From d69b6167c7432af5fe86c878decd76a540e76a94 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 10:46:51 +0100 Subject: [PATCH 01/22] Move some utilities to go SDK --- go.mod | 4 +- go.sum | 6 + pkg/errors/error.go | 7 +- pkg/github/context_tools.go | 124 ++++++----- pkg/github/server.go | 187 ++++++++-------- pkg/github/tools.go | 414 ++++++++++++++++++------------------ pkg/utils/result.go | 49 +++++ 7 files changed, 436 insertions(+), 355 deletions(-) create mode 100644 pkg/utils/result.go diff --git a/go.mod b/go.mod index eea55c143..008787299 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect github.com/google/go-github/v71 v71.0.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect @@ -40,6 +41,7 @@ require ( github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect @@ -52,7 +54,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/go.sum b/go.sum index 72ef812df..cd568cb51 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/google/go-github/v77 v77.0.0 h1:9DsKKbZqil5y/4Z9mNpZDQnpli6PJbqipSuuN github.com/google/go-github/v77 v77.0.0/go.mod h1:c8VmGXRUmaZUqbctUcGEDWYnMrtzZfJhDSylEf1wfmA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -63,6 +65,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= @@ -112,6 +116,8 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 72bbeed53..1d0680a0d 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -4,8 +4,9 @@ import ( "context" "fmt" + "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" ) type GitHubAPIError struct { @@ -112,7 +113,7 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } - return mcp.NewToolResultErrorFromErr(message, err) + return utils.NewToolResultErrorFromErr(message, err) } // NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware @@ -121,5 +122,5 @@ func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err erro if ctx != nil { _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling } - return mcp.NewToolResultErrorFromErr(message, err) + return utils.NewToolResultErrorFromErr(message, err) } diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 06642aa15..6b5f1311a 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -6,8 +6,9 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -34,20 +35,20 @@ type UserDetails struct { } // GetMe creates a tool to get details of the authenticated user. -func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - tool := mcp.NewTool("get_me", - mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandler) { + tool := mcp.Tool{ + Name: "get_me", + Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), - ReadOnlyHint: ToBoolPtr(true), - }), - ) + ReadOnlyHint: true, + }, + } - type args struct{} - handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, _ args) (*mcp.CallToolResult, error) { + handler := mcp.ToolHandler(func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), err } user, res, err := client.Users.Get(ctx, "") @@ -56,7 +57,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too "failed to get user", res, err, - ), nil + ), err } // Create minimal user representation instead of returning full user object @@ -103,21 +104,30 @@ type OrganizationTeams struct { Teams []TeamInfo `json:"teams"` } -func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("get_teams", - mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")), - mcp.WithString("user", - mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), - ), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + // return mcp.NewTool("get_teams", + // mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")), + // mcp.WithString("user", + // mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), + // ), + // mcp.WithToolAnnotation(mcp.ToolAnnotation{ + // Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), + // ReadOnlyHint: ToBoolPtr(true), + // }), + // ), + return mcp.Tool{ + Name: "get_teams", + Description: t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials"), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), - ReadOnlyHint: ToBoolPtr(true), - }), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - user, err := OptionalParam[string](request, "user") + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{}, + }, + func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + user, err := OptionalParam[string](args, "user") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } var username string @@ -126,7 +136,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations } else { client, err := getClient(ctx) if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } userResp, res, err := client.Users.Get(ctx, "") @@ -135,14 +145,14 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations "failed to get user", res, err, - ), nil + ), nil, nil } username = userResp.GetLogin() } gqlClient, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil } var q struct { @@ -165,7 +175,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations "login": githubv4.String(username), } if err := gqlClient.Query(ctx, &q, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil, nil } var organizations []OrganizationTeams @@ -186,40 +196,46 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations organizations = append(organizations, orgTeams) } - return MarshalledTextResult(organizations), nil + return MarshalledTextResult(organizations), nil, nil } } -func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("get_team_members", - mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials")), - mcp.WithString("org", - mcp.Description(t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team.")), - mcp.Required(), - ), - mcp.WithString("team_slug", - mcp.Description(t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug")), - mcp.Required(), - ), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "get_team_members", + Description: t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials"), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"), - ReadOnlyHint: ToBoolPtr(true), - }), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "org": &jsonschema.Schema{ + Type: "string", + Description: t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), + }, + "team_slug": &jsonschema.Schema{ + Type: "string", + Description: t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), + }, + }, + Required: []string{"org", "team_slug"}, + }, + }, + func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - teamSlug, err := RequiredParam[string](request, "team_slug") + teamSlug, err := RequiredParam[string](args, "team_slug") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } gqlClient, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil } var q struct { @@ -238,7 +254,7 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe "teamSlug": githubv4.String(teamSlug), } if err := gqlClient.Query(ctx, &q, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil, nil } var members []string @@ -246,6 +262,6 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe members = append(members, string(member.Login)) } - return MarshalledTextResult(members), nil + return MarshalledTextResult(members), nil, nil } } diff --git a/pkg/github/server.go b/pkg/github/server.go index ddf3b0f86..a5f6bc4d5 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -6,36 +6,37 @@ import ( "fmt" "strconv" + "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // NewServer creates a new GitHub MCP server with the specified GH client and logger. -func NewServer(version string, opts ...server.ServerOption) *server.MCPServer { +func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server { // Add default options - defaultOpts := []server.ServerOption{ - server.WithToolCapabilities(true), - server.WithResourceCapabilities(true, true), - server.WithLogging(), + opts = &mcp.ServerOptions{ + HasTools: true, + HasResources: true, + Logger: opts.Logger, } - opts = append(defaultOpts, opts...) // Create a new MCP server - s := server.NewMCPServer( - "github-mcp-server", - version, - opts..., - ) + s := mcp.NewServer(&mcp.Implementation{ + Name: "github-mcp-server", + Title: "GitHub MCP Server", + Version: version, + }, opts) + return s } // OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request. // It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong. -func OptionalParamOK[T any](r mcp.CallToolRequest, p string) (value T, ok bool, err error) { +func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) { // Check if the parameter is present in the request - val, exists := r.GetArguments()[p] + val, exists := args[p] if !exists { // Not present, return zero value, false, no error return @@ -66,16 +67,16 @@ func isAcceptedError(err error) bool { // 1. Checks if the parameter is present in the request. // 2. Checks if the parameter is of the expected type. // 3. Checks if the parameter is not empty, i.e: non-zero value -func RequiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) { +func RequiredParam[T comparable](args map[string]any, p string) (T, error) { var zero T // Check if the parameter is present in the request - if _, ok := r.GetArguments()[p]; !ok { + if _, ok := args[p]; !ok { return zero, fmt.Errorf("missing required parameter: %s", p) } // Check if the parameter is of the expected type - val, ok := r.GetArguments()[p].(T) + val, ok := args[p].(T) if !ok { return zero, fmt.Errorf("parameter %s is not of type %T", p, zero) } @@ -92,8 +93,8 @@ func RequiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) { // 1. Checks if the parameter is present in the request. // 2. Checks if the parameter is of the expected type. // 3. Checks if the parameter is not empty, i.e: non-zero value -func RequiredInt(r mcp.CallToolRequest, p string) (int, error) { - v, err := RequiredParam[float64](r, p) +func RequiredInt(args map[string]any, p string) (int, error) { + v, err := RequiredParam[float64](args, p) if err != nil { return 0, err } @@ -106,8 +107,8 @@ func RequiredInt(r mcp.CallToolRequest, p string) (int, error) { // 2. Checks if the parameter is of the expected type (float64). // 3. Checks if the parameter is not empty, i.e: non-zero value. // 4. Validates that the float64 value can be safely converted to int64 without truncation. -func RequiredBigInt(r mcp.CallToolRequest, p string) (int64, error) { - v, err := RequiredParam[float64](r, p) +func RequiredBigInt(args map[string]any, p string) (int64, error) { + v, err := RequiredParam[float64](args, p) if err != nil { return 0, err } @@ -124,28 +125,28 @@ func RequiredBigInt(r mcp.CallToolRequest, p string) (int64, error) { // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value // 2. If it is present, it checks if the parameter is of the expected type and returns it -func OptionalParam[T any](r mcp.CallToolRequest, p string) (T, error) { +func OptionalParam[T any](args map[string]any, p string) (T, error) { var zero T // Check if the parameter is present in the request - if _, ok := r.GetArguments()[p]; !ok { + if _, ok := args[p]; !ok { return zero, nil } // Check if the parameter is of the expected type - if _, ok := r.GetArguments()[p].(T); !ok { - return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, r.GetArguments()[p]) + if _, ok := args[p].(T); !ok { + return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p]) } - return r.GetArguments()[p].(T), nil + return args[p].(T), nil } // OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value // 2. If it is present, it checks if the parameter is of the expected type and returns it -func OptionalIntParam(r mcp.CallToolRequest, p string) (int, error) { - v, err := OptionalParam[float64](r, p) +func OptionalIntParam(args map[string]any, p string) (int, error) { + v, err := OptionalParam[float64](args, p) if err != nil { return 0, err } @@ -154,8 +155,8 @@ func OptionalIntParam(r mcp.CallToolRequest, p string) (int, error) { // OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request // similar to optionalIntParam, but it also takes a default value. -func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, error) { - v, err := OptionalIntParam(r, p) +func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) { + v, err := OptionalIntParam(args, p) if err != nil { return 0, err } @@ -167,10 +168,9 @@ func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, e // OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request // similar to optionalBoolParam, but it also takes a default value. -func OptionalBoolParamWithDefault(r mcp.CallToolRequest, p string, d bool) (bool, error) { - args := r.GetArguments() +func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) { _, ok := args[p] - v, err := OptionalParam[bool](r, p) + v, err := OptionalParam[bool](args, p) if err != nil { return false, err } @@ -184,13 +184,13 @@ func OptionalBoolParamWithDefault(r mcp.CallToolRequest, p string, d bool) (bool // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value // 2. If it is present, iterates the elements and checks each is a string -func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) { +func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) { // Check if the parameter is present in the request - if _, ok := r.GetArguments()[p]; !ok { + if _, ok := args[p]; !ok { return []string{}, nil } - switch v := r.GetArguments()[p].(type) { + switch v := args[p].(type) { case nil: return []string{}, nil case []string: @@ -206,7 +206,7 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) } return strSlice, nil default: - return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, r.GetArguments()[p]) + return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p]) } } @@ -234,13 +234,13 @@ func convertStringToBigInt(s string, def int64) (int64, error) { // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns an empty slice // 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values -func OptionalBigIntArrayParam(r mcp.CallToolRequest, p string) ([]int64, error) { +func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) { // Check if the parameter is present in the request - if _, ok := r.GetArguments()[p]; !ok { + if _, ok := args[p]; !ok { return []int64{}, nil } - switch v := r.GetArguments()[p].(type) { + switch v := args[p].(type) { case nil: return []int64{}, nil case []string: @@ -260,61 +260,68 @@ func OptionalBigIntArrayParam(r mcp.CallToolRequest, p string) ([]int64, error) } return int64Slice, nil default: - return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, r.GetArguments()[p]) + return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p]) } } // WithPagination adds REST API pagination parameters to a tool. // https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api -func WithPagination() mcp.ToolOption { - return func(tool *mcp.Tool) { - mcp.WithNumber("page", - mcp.Description("Page number for pagination (min 1)"), - mcp.Min(1), - )(tool) - - mcp.WithNumber("perPage", - mcp.Description("Results per page for pagination (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - )(tool) +func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["page"] = &jsonschema.Schema{ + Type: "Number", + Description: "Page number for pagination (min 1)", + Minimum: jsonschema.Ptr(1.0), + } + + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "Number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), } + + return schema } // WithUnifiedPagination adds REST API pagination parameters to a tool. // GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. -func WithUnifiedPagination() mcp.ToolOption { - return func(tool *mcp.Tool) { - mcp.WithNumber("page", - mcp.Description("Page number for pagination (min 1)"), - mcp.Min(1), - )(tool) - - mcp.WithNumber("perPage", - mcp.Description("Results per page for pagination (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - )(tool) - - mcp.WithString("after", - mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."), - )(tool) +func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["page"] = &jsonschema.Schema{ + Type: "Number", + Description: "Page number for pagination (min 1)", + Minimum: jsonschema.Ptr(1.0), + } + + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "Number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), } + + schema.Properties["after"] = &jsonschema.Schema{ + Type: "String", + Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + } + + return schema } // WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter). -func WithCursorPagination() mcp.ToolOption { - return func(tool *mcp.Tool) { - mcp.WithNumber("perPage", - mcp.Description("Results per page for pagination (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - )(tool) - - mcp.WithString("after", - mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."), - )(tool) +func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "Number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), } + + schema.Properties["after"] = &jsonschema.Schema{ + Type: "String", + Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + } + + return schema } type PaginationParams struct { @@ -328,16 +335,16 @@ type PaginationParams struct { // In future, we may want to make the default values configurable, or even have this // function returned from `withPagination`, where the defaults are provided alongside // the min/max values. -func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) { - page, err := OptionalIntParamWithDefault(r, "page", 1) +func OptionalPaginationParams(args map[string]any) (PaginationParams, error) { + page, err := OptionalIntParamWithDefault(args, "page", 1) if err != nil { return PaginationParams{}, err } - perPage, err := OptionalIntParamWithDefault(r, "perPage", 30) + perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) if err != nil { return PaginationParams{}, err } - after, err := OptionalParam[string](r, "after") + after, err := OptionalParam[string](args, "after") if err != nil { return PaginationParams{}, err } @@ -350,12 +357,12 @@ func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) { // OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request, // without the "page" parameter, suitable for cursor-based pagination only. -func OptionalCursorPaginationParams(r mcp.CallToolRequest) (CursorPaginationParams, error) { - perPage, err := OptionalIntParamWithDefault(r, "perPage", 30) +func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) { + perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) if err != nil { return CursorPaginationParams{}, err } - after, err := OptionalParam[string](r, "after") + after, err := OptionalParam[string](args, "after") if err != nil { return CursorPaginationParams{}, err } @@ -411,8 +418,8 @@ func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { func MarshalledTextResult(v any) *mcp.CallToolResult { data, err := json.Marshal(v) if err != nil { - return mcp.NewToolResultErrorFromErr("failed to marshal text result to json", err) + return utils.NewToolResultErrorFromErr("failed to marshal text result to json", err) } - return mcp.NewToolResultText(string(data)) + return utils.NewToolResultText(string(data)) } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 36c22e7a8..22841a289 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -164,220 +164,220 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // Define all available features with their default state (disabled) // Create toolsets - repos := toolsets.NewToolset(ToolsetMetadataRepos.ID, ToolsetMetadataRepos.Description). - AddReadTools( - toolsets.NewServerTool(SearchRepositories(getClient, t)), - toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)), - toolsets.NewServerTool(ListCommits(getClient, t)), - toolsets.NewServerTool(SearchCode(getClient, t)), - toolsets.NewServerTool(GetCommit(getClient, t)), - toolsets.NewServerTool(ListBranches(getClient, t)), - toolsets.NewServerTool(ListTags(getClient, t)), - toolsets.NewServerTool(GetTag(getClient, t)), - toolsets.NewServerTool(ListReleases(getClient, t)), - toolsets.NewServerTool(GetLatestRelease(getClient, t)), - toolsets.NewServerTool(GetReleaseByTag(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), - toolsets.NewServerTool(CreateRepository(getClient, t)), - toolsets.NewServerTool(ForkRepository(getClient, t)), - toolsets.NewServerTool(CreateBranch(getClient, t)), - toolsets.NewServerTool(PushFiles(getClient, t)), - toolsets.NewServerTool(DeleteFile(getClient, t)), - ). - AddResourceTemplates( - toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceBranchContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceCommitContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)), - ) - git := toolsets.NewToolset(ToolsetMetadataGit.ID, ToolsetMetadataGit.Description). - AddReadTools( - toolsets.NewServerTool(GetRepositoryTree(getClient, t)), - ) - issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). - AddReadTools( - toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t, flags)), - toolsets.NewServerTool(SearchIssues(getClient, t)), - toolsets.NewServerTool(ListIssues(getGQLClient, t)), - toolsets.NewServerTool(ListIssueTypes(getClient, t)), - toolsets.NewServerTool(GetLabel(getGQLClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), - toolsets.NewServerTool(AddIssueComment(getClient, t)), - toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), - toolsets.NewServerTool(SubIssueWrite(getClient, t)), - ).AddPrompts( - toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), - toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), - ) - users := toolsets.NewToolset(ToolsetMetadataUsers.ID, ToolsetMetadataUsers.Description). - AddReadTools( - toolsets.NewServerTool(SearchUsers(getClient, t)), - ) - orgs := toolsets.NewToolset(ToolsetMetadataOrgs.ID, ToolsetMetadataOrgs.Description). - AddReadTools( - toolsets.NewServerTool(SearchOrgs(getClient, t)), - ) - pullRequests := toolsets.NewToolset(ToolsetMetadataPullRequests.ID, ToolsetMetadataPullRequests.Description). - AddReadTools( - toolsets.NewServerTool(PullRequestRead(getClient, t, flags)), - toolsets.NewServerTool(ListPullRequests(getClient, t)), - toolsets.NewServerTool(SearchPullRequests(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(MergePullRequest(getClient, t)), - toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)), - toolsets.NewServerTool(CreatePullRequest(getClient, t)), - toolsets.NewServerTool(UpdatePullRequest(getClient, getGQLClient, t)), - toolsets.NewServerTool(RequestCopilotReview(getClient, t)), - - // Reviews - toolsets.NewServerTool(PullRequestReviewWrite(getGQLClient, t)), - toolsets.NewServerTool(AddCommentToPendingReview(getGQLClient, t)), - ) - codeSecurity := toolsets.NewToolset(ToolsetMetadataCodeSecurity.ID, ToolsetMetadataCodeSecurity.Description). - AddReadTools( - toolsets.NewServerTool(GetCodeScanningAlert(getClient, t)), - toolsets.NewServerTool(ListCodeScanningAlerts(getClient, t)), - ) - secretProtection := toolsets.NewToolset(ToolsetMetadataSecretProtection.ID, ToolsetMetadataSecretProtection.Description). - AddReadTools( - toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), - toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), - ) - dependabot := toolsets.NewToolset(ToolsetMetadataDependabot.ID, ToolsetMetadataDependabot.Description). - AddReadTools( - toolsets.NewServerTool(GetDependabotAlert(getClient, t)), - toolsets.NewServerTool(ListDependabotAlerts(getClient, t)), - ) - - notifications := toolsets.NewToolset(ToolsetMetadataNotifications.ID, ToolsetMetadataNotifications.Description). - AddReadTools( - toolsets.NewServerTool(ListNotifications(getClient, t)), - toolsets.NewServerTool(GetNotificationDetails(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(DismissNotification(getClient, t)), - toolsets.NewServerTool(MarkAllNotificationsRead(getClient, t)), - toolsets.NewServerTool(ManageNotificationSubscription(getClient, t)), - toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), - ) - - discussions := toolsets.NewToolset(ToolsetMetadataDiscussions.ID, ToolsetMetadataDiscussions.Description). - AddReadTools( - toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), - toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), - toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), - toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), - ) - - actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). - AddReadTools( - toolsets.NewServerTool(ListWorkflows(getClient, t)), - toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), - toolsets.NewServerTool(GetWorkflowRun(getClient, t)), - toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), - toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), - toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), - toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), - toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), - toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(RunWorkflow(getClient, t)), - toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), - toolsets.NewServerTool(RerunFailedJobs(getClient, t)), - toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), - toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), - ) - - securityAdvisories := toolsets.NewToolset(ToolsetMetadataSecurityAdvisories.ID, ToolsetMetadataSecurityAdvisories.Description). - AddReadTools( - toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)), - toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)), - toolsets.NewServerTool(ListRepositorySecurityAdvisories(getClient, t)), - toolsets.NewServerTool(ListOrgRepositorySecurityAdvisories(getClient, t)), - ) - - // Keep experiments alive so the system doesn't error out when it's always enabled - experiments := toolsets.NewToolset(ToolsetMetadataExperiments.ID, ToolsetMetadataExperiments.Description) + // repos := toolsets.NewToolset(ToolsetMetadataRepos.ID, ToolsetMetadataRepos.Description). + // AddReadTools( + // toolsets.NewServerTool(SearchRepositories(getClient, t)), + // toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)), + // toolsets.NewServerTool(ListCommits(getClient, t)), + // toolsets.NewServerTool(SearchCode(getClient, t)), + // toolsets.NewServerTool(GetCommit(getClient, t)), + // toolsets.NewServerTool(ListBranches(getClient, t)), + // toolsets.NewServerTool(ListTags(getClient, t)), + // toolsets.NewServerTool(GetTag(getClient, t)), + // toolsets.NewServerTool(ListReleases(getClient, t)), + // toolsets.NewServerTool(GetLatestRelease(getClient, t)), + // toolsets.NewServerTool(GetReleaseByTag(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), + // toolsets.NewServerTool(CreateRepository(getClient, t)), + // toolsets.NewServerTool(ForkRepository(getClient, t)), + // toolsets.NewServerTool(CreateBranch(getClient, t)), + // toolsets.NewServerTool(PushFiles(getClient, t)), + // toolsets.NewServerTool(DeleteFile(getClient, t)), + // ). + // AddResourceTemplates( + // toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)), + // toolsets.NewServerResourceTemplate(GetRepositoryResourceBranchContent(getClient, getRawClient, t)), + // toolsets.NewServerResourceTemplate(GetRepositoryResourceCommitContent(getClient, getRawClient, t)), + // toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)), + // toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)), + // ) + // git := toolsets.NewToolset(ToolsetMetadataGit.ID, ToolsetMetadataGit.Description). + // AddReadTools( + // toolsets.NewServerTool(GetRepositoryTree(getClient, t)), + // ) + // issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). + // AddReadTools( + // toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t, flags)), + // toolsets.NewServerTool(SearchIssues(getClient, t)), + // toolsets.NewServerTool(ListIssues(getGQLClient, t)), + // toolsets.NewServerTool(ListIssueTypes(getClient, t)), + // toolsets.NewServerTool(GetLabel(getGQLClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), + // toolsets.NewServerTool(AddIssueComment(getClient, t)), + // toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), + // toolsets.NewServerTool(SubIssueWrite(getClient, t)), + // ).AddPrompts( + // toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), + // toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), + // ) + // users := toolsets.NewToolset(ToolsetMetadataUsers.ID, ToolsetMetadataUsers.Description). + // AddReadTools( + // toolsets.NewServerTool(SearchUsers(getClient, t)), + // ) + // orgs := toolsets.NewToolset(ToolsetMetadataOrgs.ID, ToolsetMetadataOrgs.Description). + // AddReadTools( + // toolsets.NewServerTool(SearchOrgs(getClient, t)), + // ) + // pullRequests := toolsets.NewToolset(ToolsetMetadataPullRequests.ID, ToolsetMetadataPullRequests.Description). + // AddReadTools( + // toolsets.NewServerTool(PullRequestRead(getClient, t, flags)), + // toolsets.NewServerTool(ListPullRequests(getClient, t)), + // toolsets.NewServerTool(SearchPullRequests(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(MergePullRequest(getClient, t)), + // toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)), + // toolsets.NewServerTool(CreatePullRequest(getClient, t)), + // toolsets.NewServerTool(UpdatePullRequest(getClient, getGQLClient, t)), + // toolsets.NewServerTool(RequestCopilotReview(getClient, t)), + + // // Reviews + // toolsets.NewServerTool(PullRequestReviewWrite(getGQLClient, t)), + // toolsets.NewServerTool(AddCommentToPendingReview(getGQLClient, t)), + // ) + // codeSecurity := toolsets.NewToolset(ToolsetMetadataCodeSecurity.ID, ToolsetMetadataCodeSecurity.Description). + // AddReadTools( + // toolsets.NewServerTool(GetCodeScanningAlert(getClient, t)), + // toolsets.NewServerTool(ListCodeScanningAlerts(getClient, t)), + // ) + // secretProtection := toolsets.NewToolset(ToolsetMetadataSecretProtection.ID, ToolsetMetadataSecretProtection.Description). + // AddReadTools( + // toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), + // toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), + // ) + // dependabot := toolsets.NewToolset(ToolsetMetadataDependabot.ID, ToolsetMetadataDependabot.Description). + // AddReadTools( + // toolsets.NewServerTool(GetDependabotAlert(getClient, t)), + // toolsets.NewServerTool(ListDependabotAlerts(getClient, t)), + // ) + + // notifications := toolsets.NewToolset(ToolsetMetadataNotifications.ID, ToolsetMetadataNotifications.Description). + // AddReadTools( + // toolsets.NewServerTool(ListNotifications(getClient, t)), + // toolsets.NewServerTool(GetNotificationDetails(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(DismissNotification(getClient, t)), + // toolsets.NewServerTool(MarkAllNotificationsRead(getClient, t)), + // toolsets.NewServerTool(ManageNotificationSubscription(getClient, t)), + // toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), + // ) + + // discussions := toolsets.NewToolset(ToolsetMetadataDiscussions.ID, ToolsetMetadataDiscussions.Description). + // AddReadTools( + // toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), + // toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), + // toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), + // toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + // ) + + // actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). + // AddReadTools( + // toolsets.NewServerTool(ListWorkflows(getClient, t)), + // toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRun(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), + // toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), + // toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), + // toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), + // toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(RunWorkflow(getClient, t)), + // toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), + // toolsets.NewServerTool(RerunFailedJobs(getClient, t)), + // toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), + // toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), + // ) + + // securityAdvisories := toolsets.NewToolset(ToolsetMetadataSecurityAdvisories.ID, ToolsetMetadataSecurityAdvisories.Description). + // AddReadTools( + // toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)), + // toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)), + // toolsets.NewServerTool(ListRepositorySecurityAdvisories(getClient, t)), + // toolsets.NewServerTool(ListOrgRepositorySecurityAdvisories(getClient, t)), + // ) + + // // Keep experiments alive so the system doesn't error out when it's always enabled + // experiments := toolsets.NewToolset(ToolsetMetadataExperiments.ID, ToolsetMetadataExperiments.Description) contextTools := toolsets.NewToolset(ToolsetMetadataContext.ID, ToolsetMetadataContext.Description). AddReadTools( toolsets.NewServerTool(GetMe(getClient, t)), - toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), - toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), + // toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), + // toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), ) - gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). - AddReadTools( - toolsets.NewServerTool(ListGists(getClient, t)), - toolsets.NewServerTool(GetGist(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(CreateGist(getClient, t)), - toolsets.NewServerTool(UpdateGist(getClient, t)), - ) - - projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description). - AddReadTools( - toolsets.NewServerTool(ListProjects(getClient, t)), - toolsets.NewServerTool(GetProject(getClient, t)), - toolsets.NewServerTool(ListProjectFields(getClient, t)), - toolsets.NewServerTool(GetProjectField(getClient, t)), - toolsets.NewServerTool(ListProjectItems(getClient, t)), - toolsets.NewServerTool(GetProjectItem(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(AddProjectItem(getClient, t)), - toolsets.NewServerTool(DeleteProjectItem(getClient, t)), - toolsets.NewServerTool(UpdateProjectItem(getClient, t)), - ).AddPrompts( - toolsets.NewServerPrompt(ManageProjectItemsPrompt(t)), - ) - stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description). - AddReadTools( - toolsets.NewServerTool(ListStarredRepositories(getClient, t)), - ). - AddWriteTools( - toolsets.NewServerTool(StarRepository(getClient, t)), - toolsets.NewServerTool(UnstarRepository(getClient, t)), - ) - labels := toolsets.NewToolset(ToolsetLabels.ID, ToolsetLabels.Description). - AddReadTools( - // get - toolsets.NewServerTool(GetLabel(getGQLClient, t)), - // list labels on repo or issue - toolsets.NewServerTool(ListLabels(getGQLClient, t)), - ). - AddWriteTools( - // create or update - toolsets.NewServerTool(LabelWrite(getGQLClient, t)), - ) - // Add toolsets to the group + // gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). + // AddReadTools( + // toolsets.NewServerTool(ListGists(getClient, t)), + // toolsets.NewServerTool(GetGist(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(CreateGist(getClient, t)), + // toolsets.NewServerTool(UpdateGist(getClient, t)), + // ) + + // projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description). + // AddReadTools( + // toolsets.NewServerTool(ListProjects(getClient, t)), + // toolsets.NewServerTool(GetProject(getClient, t)), + // toolsets.NewServerTool(ListProjectFields(getClient, t)), + // toolsets.NewServerTool(GetProjectField(getClient, t)), + // toolsets.NewServerTool(ListProjectItems(getClient, t)), + // toolsets.NewServerTool(GetProjectItem(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(AddProjectItem(getClient, t)), + // toolsets.NewServerTool(DeleteProjectItem(getClient, t)), + // toolsets.NewServerTool(UpdateProjectItem(getClient, t)), + // ).AddPrompts( + // toolsets.NewServerPrompt(ManageProjectItemsPrompt(t)), + // ) + // stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description). + // AddReadTools( + // toolsets.NewServerTool(ListStarredRepositories(getClient, t)), + // ). + // AddWriteTools( + // toolsets.NewServerTool(StarRepository(getClient, t)), + // toolsets.NewServerTool(UnstarRepository(getClient, t)), + // ) + // labels := toolsets.NewToolset(ToolsetLabels.ID, ToolsetLabels.Description). + // AddReadTools( + // // get + // toolsets.NewServerTool(GetLabel(getGQLClient, t)), + // // list labels on repo or issue + // toolsets.NewServerTool(ListLabels(getGQLClient, t)), + // ). + // AddWriteTools( + // // create or update + // toolsets.NewServerTool(LabelWrite(getGQLClient, t)), + // ) + // // Add toolsets to the group tsg.AddToolset(contextTools) - tsg.AddToolset(repos) - tsg.AddToolset(git) - tsg.AddToolset(issues) - tsg.AddToolset(orgs) - tsg.AddToolset(users) - tsg.AddToolset(pullRequests) - tsg.AddToolset(actions) - tsg.AddToolset(codeSecurity) - tsg.AddToolset(secretProtection) - tsg.AddToolset(dependabot) - tsg.AddToolset(notifications) - tsg.AddToolset(experiments) - tsg.AddToolset(discussions) - tsg.AddToolset(gists) - tsg.AddToolset(securityAdvisories) - tsg.AddToolset(projects) - tsg.AddToolset(stargazers) - tsg.AddToolset(labels) + // tsg.AddToolset(repos) + // tsg.AddToolset(git) + // tsg.AddToolset(issues) + // tsg.AddToolset(orgs) + // tsg.AddToolset(users) + // tsg.AddToolset(pullRequests) + // tsg.AddToolset(actions) + // tsg.AddToolset(codeSecurity) + // tsg.AddToolset(secretProtection) + // tsg.AddToolset(dependabot) + // tsg.AddToolset(notifications) + // tsg.AddToolset(experiments) + // tsg.AddToolset(discussions) + // tsg.AddToolset(gists) + // tsg.AddToolset(securityAdvisories) + // tsg.AddToolset(projects) + // tsg.AddToolset(stargazers) + // tsg.AddToolset(labels) return tsg } diff --git a/pkg/utils/result.go b/pkg/utils/result.go new file mode 100644 index 000000000..c90a911de --- /dev/null +++ b/pkg/utils/result.go @@ -0,0 +1,49 @@ +package utils + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func NewToolResultText(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + } +} + +func NewToolResultError(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + IsError: true, + } +} + +func NewToolResultErrorFromErr(message string, err error) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message + ": " + err.Error(), + }, + }, + IsError: true, + } +} + +func NewToolResultResource(message string, contents *mcp.ResourceContents) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + &mcp.EmbeddedResource{ + Resource: contents, + }, + }, + IsError: false, + } +} From 8084335eb01e0c23766a16a9cec0cff657b03720 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 10:47:36 +0100 Subject: [PATCH 02/22] Remove commented out tool --- pkg/github/context_tools.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 6b5f1311a..f538121ba 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -105,16 +105,6 @@ type OrganizationTeams struct { } func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { - // return mcp.NewTool("get_teams", - // mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")), - // mcp.WithString("user", - // mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), - // ), - // mcp.WithToolAnnotation(mcp.ToolAnnotation{ - // Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), - // ReadOnlyHint: ToBoolPtr(true), - // }), - // ), return mcp.Tool{ Name: "get_teams", Description: t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials"), From 5a92f9ca0b977e48d27afaddd40cf34934e6051b Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 17:05:08 +0100 Subject: [PATCH 03/22] Add intermediate structs for toolsets --- pkg/github/tools.go | 14 ++++---- pkg/toolsets/toolsets.go | 74 ++++++++++++++++++++++++---------------- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 22841a289..b334929a0 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -309,8 +309,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG contextTools := toolsets.NewToolset(ToolsetMetadataContext.ID, ToolsetMetadataContext.Description). AddReadTools( toolsets.NewServerTool(GetMe(getClient, t)), - // toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), - // toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), + toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), + toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), ) // gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). @@ -383,14 +383,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG } // InitDynamicToolset creates a dynamic toolset that can be used to enable other toolsets, and so requires the server and toolset group as arguments -func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) *toolsets.Toolset { +func InitDynamicToolset(s *mcp.Server, tsg *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) *toolsets.Toolset { // Create a new dynamic toolset // Need to add the dynamic toolset last so it can be used to enable other toolsets dynamicToolSelection := toolsets.NewToolset(ToolsetMetadataDynamic.ID, ToolsetMetadataDynamic.Description). AddReadTools( - toolsets.NewServerTool(ListAvailableToolsets(tsg, t)), - toolsets.NewServerTool(GetToolsetsTools(tsg, t)), - toolsets.NewServerTool(EnableToolset(s, tsg, t)), + // toolsets.NewServerTool(ListAvailableToolsets(tsg, t)), + // toolsets.NewServerTool(GetToolsetsTools(tsg, t)), + // toolsets.NewServerTool(EnableToolset(s, tsg, t)), ) dynamicToolSelection.Enabled = true diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 96f1fc3ca..4fc25e1f4 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -3,8 +3,7 @@ package toolsets import ( "fmt" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" ) type ToolsetDoesNotExistError struct { @@ -29,19 +28,34 @@ func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError { return &ToolsetDoesNotExistError{Name: name} } -func NewServerTool(tool mcp.Tool, handler server.ToolHandlerFunc) server.ServerTool { - return server.ServerTool{Tool: tool, Handler: handler} +type ServerTool struct { + Tool mcp.Tool + Handler mcp.ToolHandlerFor[map[string]any, any] } -func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler server.ResourceTemplateHandlerFunc) server.ServerResourceTemplate { - return server.ServerResourceTemplate{ +func NewServerTool(tool mcp.Tool, handler mcp.ToolHandlerFor[map[string]any, any]) ServerTool { + return ServerTool{Tool: tool, Handler: handler} +} + +type ServerResourceTemplate struct { + Template mcp.ResourceTemplate + Handler mcp.ResourceHandler +} + +func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler mcp.ResourceHandler) ServerResourceTemplate { + return ServerResourceTemplate{ Template: resourceTemplate, Handler: handler, } } -func NewServerPrompt(prompt mcp.Prompt, handler server.PromptHandlerFunc) server.ServerPrompt { - return server.ServerPrompt{ +type ServerPrompt struct { + Prompt mcp.Prompt + Handler mcp.PromptHandler +} + +func NewServerPrompt(prompt mcp.Prompt, handler mcp.PromptHandler) ServerPrompt { + return ServerPrompt{ Prompt: prompt, Handler: handler, } @@ -53,16 +67,16 @@ type Toolset struct { Description string Enabled bool readOnly bool - writeTools []server.ServerTool - readTools []server.ServerTool + writeTools []ServerTool + readTools []ServerTool // resources are not tools, but the community seems to be moving towards namespaces as a broader concept // and in order to have multiple servers running concurrently, we want to avoid overlapping resources too. - resourceTemplates []server.ServerResourceTemplate + resourceTemplates []ServerResourceTemplate // prompts are also not tools but are namespaced similarly - prompts []server.ServerPrompt + prompts []ServerPrompt } -func (t *Toolset) GetActiveTools() []server.ServerTool { +func (t *Toolset) GetActiveTools() []ServerTool { if t.Enabled { if t.readOnly { return t.readTools @@ -72,63 +86,63 @@ func (t *Toolset) GetActiveTools() []server.ServerTool { return nil } -func (t *Toolset) GetAvailableTools() []server.ServerTool { +func (t *Toolset) GetAvailableTools() []ServerTool { if t.readOnly { return t.readTools } return append(t.readTools, t.writeTools...) } -func (t *Toolset) RegisterTools(s *server.MCPServer) { +func (t *Toolset) RegisterTools(s *mcp.Server) { if !t.Enabled { return } for _, tool := range t.readTools { - s.AddTool(tool.Tool, tool.Handler) + mcp.AddTool(s, &tool.Tool, tool.Handler) } if !t.readOnly { for _, tool := range t.writeTools { - s.AddTool(tool.Tool, tool.Handler) + mcp.AddTool(s, &tool.Tool, tool.Handler) } } } -func (t *Toolset) AddResourceTemplates(templates ...server.ServerResourceTemplate) *Toolset { +func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Toolset { t.resourceTemplates = append(t.resourceTemplates, templates...) return t } -func (t *Toolset) AddPrompts(prompts ...server.ServerPrompt) *Toolset { +func (t *Toolset) AddPrompts(prompts ...ServerPrompt) *Toolset { t.prompts = append(t.prompts, prompts...) return t } -func (t *Toolset) GetActiveResourceTemplates() []server.ServerResourceTemplate { +func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate { if !t.Enabled { return nil } return t.resourceTemplates } -func (t *Toolset) GetAvailableResourceTemplates() []server.ServerResourceTemplate { +func (t *Toolset) GetAvailableResourceTemplates() []ServerResourceTemplate { return t.resourceTemplates } -func (t *Toolset) RegisterResourcesTemplates(s *server.MCPServer) { +func (t *Toolset) RegisterResourcesTemplates(s *mcp.Server) { if !t.Enabled { return } for _, resource := range t.resourceTemplates { - s.AddResourceTemplate(resource.Template, resource.Handler) + s.AddResourceTemplate(&resource.Template, resource.Handler) } } -func (t *Toolset) RegisterPrompts(s *server.MCPServer) { +func (t *Toolset) RegisterPrompts(s *mcp.Server) { if !t.Enabled { return } for _, prompt := range t.prompts { - s.AddPrompt(prompt.Prompt, prompt.Handler) + s.AddPrompt(&prompt.Prompt, prompt.Handler) } } @@ -137,10 +151,10 @@ func (t *Toolset) SetReadOnly() { t.readOnly = true } -func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset { +func (t *Toolset) AddWriteTools(tools ...ServerTool) *Toolset { // Silently ignore if the toolset is read-only to avoid any breach of that contract for _, tool := range tools { - if *tool.Tool.Annotations.ReadOnlyHint { + if tool.Tool.Annotations.ReadOnlyHint { panic(fmt.Sprintf("tool (%s) is incorrectly annotated as read-only", tool.Tool.Name)) } } @@ -150,9 +164,9 @@ func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset { return t } -func (t *Toolset) AddReadTools(tools ...server.ServerTool) *Toolset { +func (t *Toolset) AddReadTools(tools ...ServerTool) *Toolset { for _, tool := range tools { - if !*tool.Tool.Annotations.ReadOnlyHint { + if !tool.Tool.Annotations.ReadOnlyHint { panic(fmt.Sprintf("tool (%s) must be annotated as read-only", tool.Tool.Name)) } } @@ -248,7 +262,7 @@ func (tg *ToolsetGroup) EnableToolset(name string) error { return nil } -func (tg *ToolsetGroup) RegisterAll(s *server.MCPServer) { +func (tg *ToolsetGroup) RegisterAll(s *mcp.Server) { for _, toolset := range tg.Toolsets { toolset.RegisterTools(s) toolset.RegisterResourcesTemplates(s) From 385dd8d69d4593cdae56eb5f3bed0b7562ba3e8e Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 17:06:00 +0100 Subject: [PATCH 04/22] Move to go-sdk IOTransport --- internal/ghmcp/server.go | 134 ++++++++++++++++++++++----------------- pkg/log/io.go | 2 + 2 files changed, 79 insertions(+), 57 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 0e338cfd9..594ba42ab 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log" "log/slog" "net/http" "net/url" @@ -20,8 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -54,11 +52,14 @@ type MCPServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool + + // Logger is used for logging within the server + Logger *slog.Logger } const stdioServerLogPrefix = "stdioserver" -func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { +func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) @@ -81,34 +82,6 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { } // We're going to wrap the Transport later in beforeInit gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) - // When a client send an initialize request, update the user agent to include the client info. - beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { - userAgent := fmt.Sprintf( - "github-mcp-server/%s (%s/%s)", - cfg.Version, - message.Params.ClientInfo.Name, - message.Params.ClientInfo.Version, - ) - - restClient.UserAgent = userAgent - - gqlHTTPClient.Transport = &userAgentTransport{ - transport: gqlHTTPClient.Transport, - agent: userAgent, - } - } - - hooks := &server.Hooks{ - OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit}, - OnBeforeAny: []server.BeforeAnyHookFunc{ - func(ctx context.Context, _ any, _ mcp.MCPMethod, _ any) { - // Ensure the context is cleared of any previous errors - // as context isn't propagated through middleware - errors.ContextWithGitHubErrors(ctx) - }, - }, - } - enabledToolsets := cfg.EnabledToolsets // If dynamic toolsets are enabled, remove "all" from the enabled toolsets @@ -135,10 +108,14 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { // Generate instructions based on enabled toolsets instructions := github.GenerateInstructions(enabledToolsets) - ghServer := github.NewServer(cfg.Version, - server.WithInstructions(instructions), - server.WithHooks(hooks), - ) + ghServer := github.NewServer(cfg.Version, &mcp.ServerOptions{ + Instructions: instructions, + Logger: cfg.Logger, + }) + + // Add middlewares + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, restClient, gqlHTTPClient)) getClient := func(_ context.Context) (*gogithub.Client, error) { return restClient, nil // closing over client @@ -229,23 +206,6 @@ func RunStdioServer(cfg StdioServerConfig) error { t, dumpTranslations := translations.TranslationHelper() - ghServer, err := NewMCPServer(MCPServerConfig{ - Version: cfg.Version, - Host: cfg.Host, - Token: cfg.Token, - EnabledToolsets: cfg.EnabledToolsets, - DynamicToolsets: cfg.DynamicToolsets, - ReadOnly: cfg.ReadOnly, - Translator: t, - ContentWindowSize: cfg.ContentWindowSize, - LockdownMode: cfg.LockdownMode, - }) - if err != nil { - return fmt.Errorf("failed to create MCP server: %w", err) - } - - stdioServer := server.NewStdioServer(ghServer) - var slogHandler slog.Handler var logOutput io.Writer if cfg.LogFilePath != "" { @@ -261,8 +221,22 @@ func RunStdioServer(cfg StdioServerConfig) error { } logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - stdLogger := log.New(logOutput, stdioServerLogPrefix, 0) - stdioServer.SetErrorLogger(stdLogger) + + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + Logger: logger, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } if cfg.ExportTranslations { // Once server is initialized, all translations are loaded @@ -272,15 +246,20 @@ func RunStdioServer(cfg StdioServerConfig) error { // Start listening for messages errC := make(chan error, 1) go func() { - in, out := io.Reader(os.Stdin), io.Writer(os.Stdout) + var in io.ReadCloser + var out io.WriteCloser + + in = os.Stdin + out = os.Stdout if cfg.EnableCommandLogging { loggedIO := mcplog.NewIOLogger(in, out, logger) in, out = loggedIO, loggedIO } + // enable GitHub errors in the context ctx := errors.ContextWithGitHubErrors(ctx) - errC <- stdioServer.Listen(ctx, in, out) + errC <- ghServer.Run(ctx, &mcp.IOTransport{Reader: in, Writer: out}) }() // Output github-mcp-server string @@ -497,3 +476,44 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Header.Set("Authorization", "Bearer "+t.token) return t.transport.RoundTrip(req) } + +func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { + // Ensure the context is cleared of any previous errors + // as context isn't propagated through middleware + ctx = errors.ContextWithGitHubErrors(ctx) + return next(ctx, method, req) + } +} + +func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { + if method != "initialize" { + return next(ctx, method, request) + } + + initializeRequest, ok := request.(*mcp.InitializeRequest) + if !ok { + return next(ctx, method, request) + } + + message := initializeRequest + userAgent := fmt.Sprintf( + "github-mcp-server/%s (%s/%s)", + cfg.Version, + message.Params.ClientInfo.Name, + message.Params.ClientInfo.Version, + ) + + restClient.UserAgent = userAgent + + gqlHTTPClient.Transport = &userAgentTransport{ + transport: gqlHTTPClient.Transport, + agent: userAgent, + } + + return next(ctx, method, request) + } + } +} diff --git a/pkg/log/io.go b/pkg/log/io.go index 44b8dc17a..0f034c2a4 100644 --- a/pkg/log/io.go +++ b/pkg/log/io.go @@ -9,6 +9,8 @@ import ( // IOLogger is a wrapper around io.Reader and io.Writer that can be used // to log the data being read and written from the underlying streams type IOLogger struct { + io.ReadWriteCloser + reader io.Reader writer io.Writer logger *slog.Logger From 28bc3f45666b176e7364af3c154eedbef173e59a Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 17:07:08 +0100 Subject: [PATCH 05/22] Update context tools and tests to use ToolHandlerFor with typed arguments and return values. --- pkg/github/context_tools.go | 14 ++++---- pkg/github/context_tools_test.go | 12 +++---- pkg/github/helper_test.go | 56 ++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index f538121ba..433b30216 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -35,7 +35,7 @@ type UserDetails struct { } // GetMe creates a tool to get details of the authenticated user. -func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandler) { +func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := mcp.Tool{ Name: "get_me", Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."), @@ -45,10 +45,10 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too }, } - handler := mcp.ToolHandler(func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { client, err := getClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), err + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err } user, res, err := client.Users.Get(ctx, "") @@ -57,7 +57,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too "failed to get user", res, err, - ), err + ), nil, err } // Create minimal user representation instead of returning full user object @@ -87,7 +87,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too }, } - return MarshalledTextResult(minimalUser), nil + return MarshalledTextResult(minimalUser), nil, nil }) return tool, handler @@ -200,11 +200,11 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe }, InputSchema: &jsonschema.Schema{ Properties: map[string]*jsonschema.Schema{ - "org": &jsonschema.Schema{ + "org": { Type: "string", Description: t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), }, - "team_slug": &jsonschema.Schema{ + "team_slug": { Type: "string", Description: t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), }, diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 880d9d98c..7573da5fd 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -25,7 +25,7 @@ func Test_GetMe(t *testing.T) { // Verify some basic very important properties assert.Equal(t, "get_me", tool.Name) - assert.True(t, *tool.Annotations.ReadOnlyHint, "get_me tool should be read-only") + assert.True(t, tool.Annotations.ReadOnlyHint, "get_me tool should be read-only") // Setup mock user response mockUser := &github.User{ @@ -111,7 +111,7 @@ func Test_GetMe(t *testing.T) { _, handler := GetMe(tc.stubbedGetClientFn, translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) textContent := getTextResult(t, result) @@ -150,7 +150,7 @@ func Test_GetTeams(t *testing.T) { require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_teams", tool.Name) - assert.True(t, *tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") + assert.True(t, tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") mockUser := &github.User{ Login: github.Ptr("testuser"), @@ -335,7 +335,7 @@ func Test_GetTeams(t *testing.T) { _, handler := GetTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) textContent := getTextResult(t, result) @@ -377,7 +377,7 @@ func Test_GetTeamMembers(t *testing.T) { require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_team_members", tool.Name) - assert.True(t, *tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") + assert.True(t, tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") mockTeamMembersResponse := githubv4mock.DataResponse(map[string]any{ "organization": map[string]any{ @@ -471,7 +471,7 @@ func Test_GetTeamMembers(t *testing.T) { _, handler := GetTeamMembers(tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) textContent := getTextResult(t, result) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index bc1ae412f..a869dd0bb 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -110,56 +110,66 @@ func mockResponse(t *testing.T, code int, body interface{}) http.HandlerFunc { // createMCPRequest is a helper function to create a MCP request with the given arguments. func createMCPRequest(args any) mcp.CallToolRequest { + // convert args to map[string]interface{} and serialize to JSON + argsMap, ok := args.(map[string]interface{}) + if !ok { + argsMap = make(map[string]interface{}) + } + + argsJSON, err := json.Marshal(argsMap) + require.NoError(nil, err) + + jsonRawMessage := json.RawMessage(argsJSON) + return mcp.CallToolRequest{ - Params: struct { - Name string `json:"name"` - Arguments any `json:"arguments,omitempty"` - Meta *mcp.Meta `json:"_meta,omitempty"` - }{ - Arguments: args, + Params: &mcp.CallToolParamsRaw{ + Arguments: jsonRawMessage, }, } } // getTextResult is a helper function that returns a text result from a tool call. -func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent { +func getTextResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent { t.Helper() assert.NotNil(t, result) require.Len(t, result.Content, 1) require.IsType(t, mcp.TextContent{}, result.Content[0]) - textContent := result.Content[0].(mcp.TextContent) - assert.Equal(t, "text", textContent.Type) + textContent := result.Content[0].(*mcp.TextContent) return textContent } -func getErrorResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent { +func getErrorResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent { res := getTextResult(t, result) require.True(t, result.IsError, "expected tool call result to be an error") return res } // getTextResourceResult is a helper function that returns a text result from a tool call. -func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.TextResourceContents { +func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceContents { t.Helper() assert.NotNil(t, result) require.Len(t, result.Content, 2) content := result.Content[1] require.IsType(t, mcp.EmbeddedResource{}, content) - resource := content.(mcp.EmbeddedResource) - require.IsType(t, mcp.TextResourceContents{}, resource.Resource) - return resource.Resource.(mcp.TextResourceContents) + resource := content.(*mcp.EmbeddedResource) + + require.IsType(t, mcp.ResourceContents{}, resource.Resource) + require.NotEmpty(t, resource.Resource.Text) + return resource.Resource } // getBlobResourceResult is a helper function that returns a blob result from a tool call. -func getBlobResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.BlobResourceContents { +func getBlobResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceContents { t.Helper() assert.NotNil(t, result) require.Len(t, result.Content, 2) content := result.Content[1] require.IsType(t, mcp.EmbeddedResource{}, content) - resource := content.(mcp.EmbeddedResource) - require.IsType(t, mcp.BlobResourceContents{}, resource.Resource) - return resource.Resource.(mcp.BlobResourceContents) + + resource := content.(*mcp.EmbeddedResource) + require.IsType(t, mcp.ResourceContents{}, resource.Resource) + require.NotEmpty(t, resource.Resource.Blob) + return resource.Resource } func TestOptionalParamOK(t *testing.T) { @@ -226,11 +236,9 @@ func TestOptionalParamOK(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.args) - // Test with string type assertion if _, isString := tc.expectedVal.(string); isString || tc.errorMsg == "parameter myParam is not of type string, is bool" { - val, ok, err := OptionalParamOK[string](request, tc.paramName) + val, ok, err := OptionalParamOK[string, map[string]any](tc.args, tc.paramName) if tc.expectError { require.Error(t, err) assert.Contains(t, err.Error(), tc.errorMsg) @@ -245,7 +253,7 @@ func TestOptionalParamOK(t *testing.T) { // Test with bool type assertion if _, isBool := tc.expectedVal.(bool); isBool || tc.errorMsg == "parameter myParam is not of type bool, is string" { - val, ok, err := OptionalParamOK[bool](request, tc.paramName) + val, ok, err := OptionalParamOK[bool, map[string]any](tc.args, tc.paramName) if tc.expectError { require.Error(t, err) assert.Contains(t, err.Error(), tc.errorMsg) @@ -260,7 +268,7 @@ func TestOptionalParamOK(t *testing.T) { // Test with float64 type assertion (for number case) if _, isFloat := tc.expectedVal.(float64); isFloat { - val, ok, err := OptionalParamOK[float64](request, tc.paramName) + val, ok, err := OptionalParamOK[float64, map[string]any](tc.args, tc.paramName) if tc.expectError { // This case shouldn't happen for float64 in the defined tests require.Fail(t, "Unexpected error case for float64") From 42ada5f005b5e9940c5811c82695a4d641aa9c8b Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 17:07:59 +0100 Subject: [PATCH 06/22] comment out broken tools for now, we'll tackle them one by one --- pkg/github/actions.go | 2444 ++++---- pkg/github/actions_test.go | 2638 ++++----- pkg/github/code_scanning.go | 310 +- pkg/github/code_scanning_test.go | 494 +- pkg/github/dependabot.go | 292 +- pkg/github/dependabot_test.go | 502 +- pkg/github/discussions.go | 1058 ++-- pkg/github/discussions_test.go | 1552 +++--- pkg/github/dynamic_tools.go | 272 +- pkg/github/gists.go | 628 +-- pkg/github/gists_test.go | 1186 ++-- pkg/github/git.go | 292 +- pkg/github/issues.go | 3318 +++++------ pkg/github/issues_test.go | 7038 ++++++++++++------------ pkg/github/labels.go | 794 +-- pkg/github/labels_test.go | 928 ++-- pkg/github/notifications.go | 1046 ++-- pkg/github/notifications_test.go | 1526 ++--- pkg/github/projects.go | 2280 ++++---- pkg/github/projects_test.go | 3294 +++++------ pkg/github/pullrequests.go | 3256 +++++------ pkg/github/pullrequests_test.go | 5882 ++++++++++---------- pkg/github/repositories.go | 3852 ++++++------- pkg/github/repositories_test.go | 6824 +++++++++++------------ pkg/github/search.go | 668 +-- pkg/github/search_test.go | 1482 ++--- pkg/github/search_utils.go | 226 +- pkg/github/search_utils_test.go | 682 +-- pkg/github/secret_scanning.go | 298 +- pkg/github/secret_scanning_test.go | 494 +- pkg/github/security_advisories.go | 790 +-- pkg/github/security_advisories_test.go | 1048 ++-- pkg/github/server_test.go | 24 +- 33 files changed, 28705 insertions(+), 28713 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index ecf538323..cdabea9bd 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -1,1224 +1,1224 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/github/github-mcp-server/internal/profiler" - buffer "github.com/github/github-mcp-server/pkg/buffer" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -const ( - DescriptionRepositoryOwner = "Repository owner" - DescriptionRepositoryName = "Repository name" -) - -// ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflows", - mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - 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 - } - - // Get optional pagination parameters - 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) - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - - workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflows: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflows) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_runs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithString("workflow_id", - mcp.Required(), - mcp.Description("The workflow ID or workflow file name"), - ), - mcp.WithString("actor", - mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), - ), - mcp.WithString("branch", - mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), - ), - mcp.WithString("event", - mcp.Description("Returns workflow runs for a specific event type"), - mcp.Enum( - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run", - ), - ), - mcp.WithString("status", - mcp.Description("Returns workflow runs with the check run status"), - mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), - ), - 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 - } - workflowID, err := RequiredParam[string](request, "workflow_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional filtering parameters - actor, err := OptionalParam[string](request, "actor") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - branch, err := OptionalParam[string](request, "branch") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - event, err := OptionalParam[string](request, "event") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - status, err := OptionalParam[string](request, "status") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional pagination parameters - 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) - } - - // Set up list options - opts := &github.ListWorkflowRunsOptions{ - Actor: actor, - Branch: branch, - Event: event, - Status: status, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow runs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// RunWorkflow creates a tool to run an Actions workflow -func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("run_workflow", - mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithString("workflow_id", - mcp.Required(), - mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"), - ), - mcp.WithString("ref", - mcp.Required(), - mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), - ), - mcp.WithObject("inputs", - mcp.Description("Inputs the workflow accepts"), - ), - ), - 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 - } - workflowID, err := RequiredParam[string](request, "workflow_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ref, err := RequiredParam[string](request, "ref") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := request.GetArguments()["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); ok { - inputs = inputsMap - } - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - event := github.CreateWorkflowDispatchEventRequest{ - Ref: ref, - Inputs: inputs, - } - - var resp *github.Response - var workflowType string - - if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) - workflowType = "workflow_id" - } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) - workflowType = "workflow_file" - } - - if err != nil { - return nil, fmt.Errorf("failed to run workflow: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_type": workflowType, - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - return nil, fmt.Errorf("failed to get workflow run: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRun) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run_logs", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the download URL for the logs - url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) - if err != nil { - return nil, fmt.Errorf("failed to get workflow run logs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Create response with the logs URL and information - result := map[string]any{ - "logs_url": url.String(), - "message": "Workflow run logs are available for download", - "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", - "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", - "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_jobs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - mcp.WithString("filter", - mcp.Description("Filters jobs by their completed_at timestamp"), - mcp.Enum("latest", "all"), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional filtering parameters - filter, err := OptionalParam[string](request, "filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional pagination parameters - 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) - } - - // Set up list options - opts := &github.ListWorkflowJobsOptions{ - Filter: filter, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow jobs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Add optimization tip for failed job debugging - response := map[string]any{ - "jobs": jobs, - "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", - } - - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run -func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_job_logs", - mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("job_id", - mcp.Description("The unique identifier of the workflow job (required for single job logs)"), - ), - mcp.WithNumber("run_id", - mcp.Description("Workflow run ID (required when using failed_only)"), - ), - mcp.WithBoolean("failed_only", - mcp.Description("When true, gets logs for all failed jobs in run_id"), - ), - mcp.WithBoolean("return_content", - mcp.Description("Returns actual log content instead of URLs"), - ), - mcp.WithNumber("tail_lines", - mcp.Description("Number of lines to return from the end of the log"), - mcp.DefaultNumber(500), - ), - ), - 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 - } - - // Get optional parameters - jobID, err := OptionalIntParam(request, "job_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID, err := OptionalIntParam(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - failedOnly, err := OptionalParam[bool](request, "failed_only") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - returnContent, err := OptionalParam[bool](request, "return_content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - tailLines, err := OptionalIntParam(request, "tail_lines") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - // Default to 500 lines if not specified - if tailLines == 0 { - tailLines = 500 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Validate parameters - if failedOnly && runID == 0 { - return mcp.NewToolResultError("run_id is required when failed_only is true"), nil - } - if !failedOnly && jobID == 0 { - return mcp.NewToolResultError("job_id is required when failed_only is false"), nil - } - - if failedOnly && runID > 0 { - // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize) - } else if jobID > 0 { - // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize) - } - - return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil - } -} - -// handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { - // First, get all jobs for the workflow run - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ - Filter: "latest", - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - // Filter for failed jobs - var failedJobs []*github.WorkflowJob - for _, job := range jobs.Jobs { - if job.GetConclusion() == "failure" { - failedJobs = append(failedJobs, job) - } - } - - if len(failedJobs) == 0 { - result := map[string]any{ - "message": "No failed jobs found in this workflow run", - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": 0, - } - r, _ := json.Marshal(result) - return mcp.NewToolResultText(string(r)), nil - } - - // Collect logs for all failed jobs - var logResults []map[string]any - for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) - if err != nil { - // Continue with other jobs even if one fails - jobResult = map[string]any{ - "job_id": job.GetID(), - "job_name": job.GetName(), - "error": err.Error(), - } - // Enable reporting of status codes and error causes - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling - } - - logResults = append(logResults, jobResult) - } - - result := map[string]any{ - "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": len(failedJobs), - "logs": logResults, - "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -// handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil - } - - r, err := json.Marshal(jobResult) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -// getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { - // Get the download URL for the job logs - url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) - if err != nil { - return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - - if returnContent { - // Download and return the actual log content - content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp - if err != nil { - // To keep the return value consistent wrap the response as a GitHub Response - ghRes := &github.Response{ - Response: httpResp, - } - return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) - } - result["logs_content"] = content - result["message"] = "Job logs content retrieved successfully" - result["original_length"] = originalLength - } else { - // Return just the URL - result["logs_url"] = url.String() - result["message"] = "Job logs are available for download" - result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." - } - - return result, resp, nil -} - -func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { - prof := profiler.New(nil, profiler.IsProfilingEnabled()) - finish := prof.Start(ctx, "log_buffer_processing") - - httpResp, err := http.Get(logURL) //nolint:gosec - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) - } - defer func() { _ = httpResp.Body.Close() }() - - if httpResp.StatusCode != http.StatusOK { - return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) - } - - bufferSize := tailLines - if bufferSize > maxLines { - bufferSize = maxLines - } - - processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) - } - - lines := strings.Split(processedInput, "\n") - if len(lines) > tailLines { - lines = lines[len(lines)-tailLines:] - } - finalResult := strings.Join(lines, "\n") - - _ = finish(len(lines), int64(len(finalResult))) - - return finalResult, totalLines, httpResp, nil -} - -// RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_workflow_run", - mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_failed_jobs", - mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("cancel_workflow_run", - mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil - } - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_run_artifacts", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional pagination parameters - 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) - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - - artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(artifacts) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("download_workflow_run_artifact", - mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("artifact_id", - mcp.Required(), - mcp.Description("The unique identifier of the artifact"), - ), - ), - 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 - } - artifactIDInt, err := RequiredInt(request, "artifact_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - artifactID := int64(artifactIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the download URL for the artifact - url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - // Create response with the download URL and information - result := map[string]any{ - "download_url": url.String(), - "message": "Artifact is available for download", - "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", - "artifact_id": artifactID, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run -func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_workflow_run_logs", - mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), - ReadOnlyHint: ToBoolPtr(false), - DestructiveHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run_usage", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(usage) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "net/http" +// "strconv" +// "strings" + +// "github.com/github/github-mcp-server/internal/profiler" +// buffer "github.com/github/github-mcp-server/pkg/buffer" +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// const ( +// DescriptionRepositoryOwner = "Repository owner" +// DescriptionRepositoryName = "Repository name" +// ) + +// // ListWorkflows creates a tool to list workflows in a repository +// func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_workflows", +// mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// 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 +// } + +// // Get optional pagination parameters +// 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) +// } + +// // Set up list options +// opts := &github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// } + +// workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list workflows: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(workflows) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow +// func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_workflow_runs", +// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithString("workflow_id", +// mcp.Required(), +// mcp.Description("The workflow ID or workflow file name"), +// ), +// mcp.WithString("actor", +// mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), +// ), +// mcp.WithString("branch", +// mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), +// ), +// mcp.WithString("event", +// mcp.Description("Returns workflow runs for a specific event type"), +// mcp.Enum( +// "branch_protection_rule", +// "check_run", +// "check_suite", +// "create", +// "delete", +// "deployment", +// "deployment_status", +// "discussion", +// "discussion_comment", +// "fork", +// "gollum", +// "issue_comment", +// "issues", +// "label", +// "merge_group", +// "milestone", +// "page_build", +// "public", +// "pull_request", +// "pull_request_review", +// "pull_request_review_comment", +// "pull_request_target", +// "push", +// "registry_package", +// "release", +// "repository_dispatch", +// "schedule", +// "status", +// "watch", +// "workflow_call", +// "workflow_dispatch", +// "workflow_run", +// ), +// ), +// mcp.WithString("status", +// mcp.Description("Returns workflow runs with the check run status"), +// mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), +// ), +// 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 +// } +// workflowID, err := RequiredParam[string](request, "workflow_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional filtering parameters +// actor, err := OptionalParam[string](request, "actor") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// branch, err := OptionalParam[string](request, "branch") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// event, err := OptionalParam[string](request, "event") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// status, err := OptionalParam[string](request, "status") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional pagination parameters +// 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) +// } + +// // Set up list options +// opts := &github.ListWorkflowRunsOptions{ +// Actor: actor, +// Branch: branch, +// Event: event, +// Status: status, +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } + +// workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list workflow runs: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(workflowRuns) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // RunWorkflow creates a tool to run an Actions workflow +// func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("run_workflow", +// mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithString("workflow_id", +// mcp.Required(), +// mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"), +// ), +// mcp.WithString("ref", +// mcp.Required(), +// mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), +// ), +// mcp.WithObject("inputs", +// mcp.Description("Inputs the workflow accepts"), +// ), +// ), +// 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 +// } +// workflowID, err := RequiredParam[string](request, "workflow_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ref, err := RequiredParam[string](request, "ref") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional inputs parameter +// var inputs map[string]interface{} +// if requestInputs, ok := request.GetArguments()["inputs"]; ok { +// if inputsMap, ok := requestInputs.(map[string]interface{}); ok { +// inputs = inputsMap +// } +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// event := github.CreateWorkflowDispatchEventRequest{ +// Ref: ref, +// Inputs: inputs, +// } + +// var resp *github.Response +// var workflowType string + +// if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { +// resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) +// workflowType = "workflow_id" +// } else { +// resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) +// workflowType = "workflow_file" +// } + +// if err != nil { +// return nil, fmt.Errorf("failed to run workflow: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "message": "Workflow run has been queued", +// "workflow_type": workflowType, +// "workflow_id": workflowID, +// "ref": ref, +// "inputs": inputs, +// "status": resp.Status, +// "status_code": resp.StatusCode, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetWorkflowRun creates a tool to get details of a specific workflow run +// func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_workflow_run", +// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) +// if err != nil { +// return nil, fmt.Errorf("failed to get workflow run: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(workflowRun) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run +// func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_workflow_run_logs", +// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// // Get the download URL for the logs +// url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) +// if err != nil { +// return nil, fmt.Errorf("failed to get workflow run logs: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// // Create response with the logs URL and information +// result := map[string]any{ +// "logs_url": url.String(), +// "message": "Workflow run logs are available for download", +// "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", +// "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", +// "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListWorkflowJobs creates a tool to list jobs for a specific workflow run +// func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_workflow_jobs", +// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// mcp.WithString("filter", +// mcp.Description("Filters jobs by their completed_at timestamp"), +// mcp.Enum("latest", "all"), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// // Get optional filtering parameters +// filter, err := OptionalParam[string](request, "filter") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional pagination parameters +// 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) +// } + +// // Set up list options +// opts := &github.ListWorkflowJobsOptions{ +// Filter: filter, +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } + +// jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list workflow jobs: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// // Add optimization tip for failed job debugging +// response := map[string]any{ +// "jobs": jobs, +// "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", +// } + +// r, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run +// func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_job_logs", +// mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("job_id", +// mcp.Description("The unique identifier of the workflow job (required for single job logs)"), +// ), +// mcp.WithNumber("run_id", +// mcp.Description("Workflow run ID (required when using failed_only)"), +// ), +// mcp.WithBoolean("failed_only", +// mcp.Description("When true, gets logs for all failed jobs in run_id"), +// ), +// mcp.WithBoolean("return_content", +// mcp.Description("Returns actual log content instead of URLs"), +// ), +// mcp.WithNumber("tail_lines", +// mcp.Description("Number of lines to return from the end of the log"), +// mcp.DefaultNumber(500), +// ), +// ), +// 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 +// } + +// // Get optional parameters +// jobID, err := OptionalIntParam(request, "job_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID, err := OptionalIntParam(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// failedOnly, err := OptionalParam[bool](request, "failed_only") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// returnContent, err := OptionalParam[bool](request, "return_content") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// tailLines, err := OptionalIntParam(request, "tail_lines") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// // Default to 500 lines if not specified +// if tailLines == 0 { +// tailLines = 500 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// // Validate parameters +// if failedOnly && runID == 0 { +// return mcp.NewToolResultError("run_id is required when failed_only is true"), nil +// } +// if !failedOnly && jobID == 0 { +// return mcp.NewToolResultError("job_id is required when failed_only is false"), nil +// } + +// if failedOnly && runID > 0 { +// // Handle failed-only mode: get logs for all failed jobs in the workflow run +// return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize) +// } else if jobID > 0 { +// // Handle single job mode +// return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize) +// } + +// return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil +// } +// } + +// // handleFailedJobLogs gets logs for all failed jobs in a workflow run +// func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { +// // First, get all jobs for the workflow run +// jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ +// Filter: "latest", +// }) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Filter for failed jobs +// var failedJobs []*github.WorkflowJob +// for _, job := range jobs.Jobs { +// if job.GetConclusion() == "failure" { +// failedJobs = append(failedJobs, job) +// } +// } + +// if len(failedJobs) == 0 { +// result := map[string]any{ +// "message": "No failed jobs found in this workflow run", +// "run_id": runID, +// "total_jobs": len(jobs.Jobs), +// "failed_jobs": 0, +// } +// r, _ := json.Marshal(result) +// return mcp.NewToolResultText(string(r)), nil +// } + +// // Collect logs for all failed jobs +// var logResults []map[string]any +// for _, job := range failedJobs { +// jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) +// if err != nil { +// // Continue with other jobs even if one fails +// jobResult = map[string]any{ +// "job_id": job.GetID(), +// "job_name": job.GetName(), +// "error": err.Error(), +// } +// // Enable reporting of status codes and error causes +// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling +// } + +// logResults = append(logResults, jobResult) +// } + +// result := map[string]any{ +// "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), +// "run_id": runID, +// "total_jobs": len(jobs.Jobs), +// "failed_jobs": len(failedJobs), +// "logs": logResults, +// "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// // handleSingleJobLogs gets logs for a single job +// func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { +// jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil +// } + +// r, err := json.Marshal(jobResult) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// // getJobLogData retrieves log data for a single job, either as URL or content +// func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { +// // Get the download URL for the job logs +// url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) +// if err != nil { +// return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "job_id": jobID, +// } +// if jobName != "" { +// result["job_name"] = jobName +// } + +// if returnContent { +// // Download and return the actual log content +// content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp +// if err != nil { +// // To keep the return value consistent wrap the response as a GitHub Response +// ghRes := &github.Response{ +// Response: httpResp, +// } +// return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) +// } +// result["logs_content"] = content +// result["message"] = "Job logs content retrieved successfully" +// result["original_length"] = originalLength +// } else { +// // Return just the URL +// result["logs_url"] = url.String() +// result["message"] = "Job logs are available for download" +// result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." +// } + +// return result, resp, nil +// } + +// func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { +// prof := profiler.New(nil, profiler.IsProfilingEnabled()) +// finish := prof.Start(ctx, "log_buffer_processing") + +// httpResp, err := http.Get(logURL) //nolint:gosec +// if err != nil { +// return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) +// } +// defer func() { _ = httpResp.Body.Close() }() + +// if httpResp.StatusCode != http.StatusOK { +// return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) +// } + +// bufferSize := tailLines +// if bufferSize > maxLines { +// bufferSize = maxLines +// } + +// processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) +// if err != nil { +// return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) +// } + +// lines := strings.Split(processedInput, "\n") +// if len(lines) > tailLines { +// lines = lines[len(lines)-tailLines:] +// } +// finalResult := strings.Join(lines, "\n") + +// _ = finish(len(lines), int64(len(finalResult))) + +// return finalResult, totalLines, httpResp, nil +// } + +// // RerunWorkflowRun creates a tool to re-run an entire workflow run +// func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("rerun_workflow_run", +// mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "message": "Workflow run has been queued for re-run", +// "run_id": runID, +// "status": resp.Status, +// "status_code": resp.StatusCode, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run +// func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("rerun_failed_jobs", +// mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "message": "Failed jobs have been queued for re-run", +// "run_id": runID, +// "status": resp.Status, +// "status_code": resp.StatusCode, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // CancelWorkflowRun creates a tool to cancel a workflow run +// func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("cancel_workflow_run", +// mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) +// if err != nil { +// if _, ok := err.(*github.AcceptedError); !ok { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil +// } +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "message": "Workflow run has been cancelled", +// "run_id": runID, +// "status": resp.Status, +// "status_code": resp.StatusCode, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run +// func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_workflow_run_artifacts", +// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// // Get optional pagination parameters +// 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) +// } + +// // Set up list options +// opts := &github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// } + +// artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(artifacts) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact +// func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("download_workflow_run_artifact", +// mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("artifact_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the artifact"), +// ), +// ), +// 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 +// } +// artifactIDInt, err := RequiredInt(request, "artifact_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// artifactID := int64(artifactIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// // Get the download URL for the artifact +// url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Create response with the download URL and information +// result := map[string]any{ +// "download_url": url.String(), +// "message": "Artifact is available for download", +// "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", +// "artifact_id": artifactID, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run +// func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("delete_workflow_run_logs", +// mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), +// ReadOnlyHint: ToBoolPtr(false), +// DestructiveHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// result := map[string]any{ +// "message": "Workflow run logs have been deleted", +// "run_id": runID, +// "status": resp.Status, +// "status_code": resp.StatusCode, +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run +// func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_workflow_run_usage", +// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryOwner), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description(DescriptionRepositoryName), +// ), +// mcp.WithNumber("run_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the workflow run"), +// ), +// ), +// 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 +// } +// runIDInt, err := RequiredInt(request, "run_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// runID := int64(runIDInt) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(usage) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 1738bc8e5..a4a5c4281 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1,1321 +1,1321 @@ package github -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "runtime" - "runtime/debug" - "strings" - "testing" - - "github.com/github/github-mcp-server/internal/profiler" - buffer "github.com/github/github-mcp-server/pkg/buffer" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ListWorkflows(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflows", 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, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), - NodeID: github.Ptr("W_123"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), - NodeID: github.Ptr("W_456"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.Workflows - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, 0) - assert.NotEmpty(t, response.Workflows) - }) - } -} - -func Test_RunWorkflow(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "run_workflow", 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, "workflow_id") - assert.Contains(t, tool.InputSchema.Properties, "ref") - assert.Contains(t, tool.InputSchema.Properties, "inputs") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_RunWorkflow_WithFilename(t *testing.T) { - // Test the unified RunWorkflow function with filenames - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run by filename", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "ci.yml", - "ref": "main", - }, - expectError: false, - }, - { - name: "successful workflow run by numeric ID as string", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_CancelWorkflowRun(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "cancel_workflow_run", 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, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run cancellation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "conflict when cancelling a workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: true, - expectedErrMsg: "failed to cancel workflow run", - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Contains(t, textContent.Text, tc.expectedErrMsg) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been cancelled", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifacts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - artifacts := &github.ArtifactList{ - TotalCount: github.Ptr(int64(2)), - Artifacts: []*github.Artifact{ - { - ID: github.Ptr(int64(1)), - NodeID: github.Ptr("A_1"), - Name: github.Ptr("build-artifacts"), - SizeInBytes: github.Ptr(int64(1024)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - { - ID: github.Ptr(int64(2)), - NodeID: github.Ptr("A_2"), - Name: github.Ptr("test-results"), - SizeInBytes: github.Ptr(int64(512)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(artifacts) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.ArtifactList - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, int64(0)) - assert.NotEmpty(t, response.Artifacts) - }) - } -} - -func Test_DownloadWorkflowRunArtifact(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifact download URL", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/artifacts/123/zip", - Method: "GET", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // GitHub returns a 302 redirect to the download URL - w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") - w.WriteHeader(http.StatusFound) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "artifact_id": float64(123), - }, - expectError: false, - }, - { - name: "missing required parameter artifact_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "download_url") - assert.Contains(t, response, "message") - assert.Equal(t, "Artifact is available for download", response["message"]) - assert.Equal(t, float64(123), response["artifact_id"]) - }) - } -} - -func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "delete_workflow_run_logs", 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, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful logs deletion", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run logs have been deleted", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_workflow_run_usage", 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, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run usage", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - usage := &github.WorkflowRunUsage{ - Billable: &github.WorkflowRunBillMap{ - "UBUNTU": &github.WorkflowRunBill{ - TotalMS: github.Ptr(int64(120000)), - Jobs: github.Ptr(2), - JobRuns: []*github.WorkflowRunJobRun{ - { - JobID: github.Ptr(1), - DurationMS: github.Ptr(int64(60000)), - }, - { - JobID: github.Ptr(2), - DurationMS: github.Ptr(int64(60000)), - }, - }, - }, - }, - RunDurationMS: github.Ptr(int64(120000)), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(usage) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.WorkflowRunUsage - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.RunDurationMS) - assert.NotNil(t, response.Billable) - }) - } -} - -func Test_GetJobLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) - - assert.Equal(t, "get_job_logs", 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, "job_id") - assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "failed_only") - assert.Contains(t, tool.InputSchema.Properties, "return_content") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - checkResponse func(t *testing.T, response map[string]any) - }{ - { - name: "successful single job logs with URL", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(123), response["job_id"]) - assert.Contains(t, response, "logs_url") - assert.Equal(t, "Job logs are available for download", response["message"]) - assert.Contains(t, response, "note") - }, - }, - { - name: "successful failed jobs logs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(3), response["total_jobs"]) - assert.Equal(t, float64(2), response["failed_jobs"]) - assert.Contains(t, response, "logs") - assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) - - logs, ok := response["logs"].([]interface{}) - assert.True(t, ok) - assert.Len(t, logs, 2) - }, - }, - { - name: "no failed jobs found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(2), response["total_jobs"]) - assert.Equal(t, float64(0), response["failed_jobs"]) - }, - }, - { - name: "missing job_id when not using failed_only", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "job_id is required when failed_only is false", - }, - { - name: "missing run_id when using failed_only", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "failed_only": true, - }, - expectError: true, - expectedErrMsg: "run_id is required when failed_only is true", - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "repo": "repo", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter repo", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "owner", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: repo", - }, - { - name: "API error when getting single job logs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(999), - }, - expectError: true, - }, - { - name: "API error when listing workflow jobs for failed_only", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(999), - "failed_only": true, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - if tc.expectError { - // For API errors, just verify we got an error - assert.True(t, result.IsError) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - if tc.checkResponse != nil { - tc.checkResponse(t, response) - } - }) - } -} - -func Test_GetJobLogs_WithContentReturn(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - - // Create a test server to serve log content - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - ), - ) - - client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - }) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.Equal(t, logContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -} - -func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" - - // Create a test server to serve log content - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - ), - ) - - client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(1), // Requesting last 1 line - }) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.Equal(t, float64(3), response["original_length"]) - assert.Equal(t, expectedLogContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -} - -func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { - logContent := "Line 1\nLine 2\nLine 3" - expectedLogContent := "Line 1\nLine 2\nLine 3" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - ), - ) - - client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(100), - }) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - assert.Equal(t, float64(123), response["job_id"]) - assert.Equal(t, float64(3), response["original_length"]) - assert.Equal(t, expectedLogContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") -} - -func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping memory profiling test in short mode") - } - - const logLines = 100000 - const bufferSize = 5000 - largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(largeLogContent)) - })) - defer testServer.Close() - - os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") - defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") - - profiler.InitFromEnv(nil) - ctx := context.Background() - - debug.SetGCPercent(-1) - defer debug.SetGCPercent(100) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - var baselineStats runtime.MemStats - runtime.ReadMemStats(&baselineStats) - - profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { - resp1, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp1.Body.Close() //nolint:bodyclose - content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose - return totalLines, int64(len(content)), err - }) - require.NoError(t, err1) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { - resp2, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp2.Body.Close() //nolint:bodyclose - - allContent, err := io.ReadAll(resp2.Body) - if err != nil { - return 0, 0, err - } - - allLines := strings.Split(string(allContent), "\n") - var nonEmptyLines []string - for _, line := range allLines { - if line != "" { - nonEmptyLines = append(nonEmptyLines, line) - } - } - totalLines := len(nonEmptyLines) - - var resultLines []string - if totalLines > bufferSize { - resultLines = nonEmptyLines[totalLines-bufferSize:] - } else { - resultLines = nonEmptyLines - } - - result := strings.Join(resultLines, "\n") - return totalLines, int64(len(result)), nil - }) - require.NoError(t, err2) - - assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, - "Sliding window should use less memory than reading all into memory") - - assert.Equal(t, profile1.LinesCount, profile2.LinesCount, - "Both approaches should count the same number of input lines") - assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, - "Both approaches should produce similar output sizes (within 100 bytes)") - - memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 - t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", - memoryReduction, - float64(profile2.MemoryDelta)/1024/1024, - float64(profile1.MemoryDelta)/1024/1024) - - t.Logf("Baseline: %d bytes", baselineStats.Alloc) - t.Logf("Sliding window: %s", profile1.String()) - t.Logf("No window: %s", profile2.String()) -} +// import ( +// "context" +// "encoding/json" +// "io" +// "net/http" +// "net/http/httptest" +// "os" +// "runtime" +// "runtime/debug" +// "strings" +// "testing" + +// "github.com/github/github-mcp-server/internal/profiler" +// buffer "github.com/github/github-mcp-server/pkg/buffer" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_ListWorkflows(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_workflows", 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, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful workflow listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsWorkflowsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// workflows := &github.Workflows{ +// TotalCount: github.Ptr(2), +// Workflows: []*github.Workflow{ +// { +// ID: github.Ptr(int64(123)), +// Name: github.Ptr("CI"), +// Path: github.Ptr(".github/workflows/ci.yml"), +// State: github.Ptr("active"), +// CreatedAt: &github.Timestamp{}, +// UpdatedAt: &github.Timestamp{}, +// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), +// BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), +// NodeID: github.Ptr("W_123"), +// }, +// { +// ID: github.Ptr(int64(456)), +// Name: github.Ptr("Deploy"), +// Path: github.Ptr(".github/workflows/deploy.yml"), +// State: github.Ptr("active"), +// CreatedAt: &github.Timestamp{}, +// UpdatedAt: &github.Timestamp{}, +// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), +// BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), +// NodeID: github.Ptr("W_456"), +// }, +// }, +// } +// w.WriteHeader(http.StatusOK) +// _ = json.NewEncoder(w).Encode(workflows) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: owner", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response github.Workflows +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.NotNil(t, response.TotalCount) +// assert.Greater(t, *response.TotalCount, 0) +// assert.NotEmpty(t, response.Workflows) +// }) +// } +// } + +// func Test_RunWorkflow(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "run_workflow", 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, "workflow_id") +// assert.Contains(t, tool.InputSchema.Properties, "ref") +// assert.Contains(t, tool.InputSchema.Properties, "inputs") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful workflow run", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "workflow_id": "12345", +// "ref": "main", +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter workflow_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "ref": "main", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: workflow_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Equal(t, "Workflow run has been queued", response["message"]) +// assert.Contains(t, response, "workflow_type") +// }) +// } +// } + +// func Test_RunWorkflow_WithFilename(t *testing.T) { +// // Test the unified RunWorkflow function with filenames +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful workflow run by filename", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "workflow_id": "ci.yml", +// "ref": "main", +// }, +// expectError: false, +// }, +// { +// name: "successful workflow run by numeric ID as string", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "workflow_id": "12345", +// "ref": "main", +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter workflow_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "ref": "main", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: workflow_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Equal(t, "Workflow run has been queued", response["message"]) +// assert.Contains(t, response, "workflow_type") +// }) +// } +// } + +// func Test_CancelWorkflowRun(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "cancel_workflow_run", 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, "run_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful workflow run cancellation", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/repos/owner/repo/actions/runs/12345/cancel", +// Method: "POST", +// }, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusAccepted) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(12345), +// }, +// expectError: false, +// }, +// { +// name: "conflict when cancelling a workflow run", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/repos/owner/repo/actions/runs/12345/cancel", +// Method: "POST", +// }, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusConflict) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(12345), +// }, +// expectError: true, +// expectedErrMsg: "failed to cancel workflow run", +// }, +// { +// name: "missing required parameter run_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: run_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Contains(t, textContent.Text, tc.expectedErrMsg) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Equal(t, "Workflow run has been cancelled", response["message"]) +// assert.Equal(t, float64(12345), response["run_id"]) +// }) +// } +// } + +// func Test_ListWorkflowRunArtifacts(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful artifacts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// artifacts := &github.ArtifactList{ +// TotalCount: github.Ptr(int64(2)), +// Artifacts: []*github.Artifact{ +// { +// ID: github.Ptr(int64(1)), +// NodeID: github.Ptr("A_1"), +// Name: github.Ptr("build-artifacts"), +// SizeInBytes: github.Ptr(int64(1024)), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), +// ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), +// Expired: github.Ptr(false), +// CreatedAt: &github.Timestamp{}, +// UpdatedAt: &github.Timestamp{}, +// ExpiresAt: &github.Timestamp{}, +// WorkflowRun: &github.ArtifactWorkflowRun{ +// ID: github.Ptr(int64(12345)), +// RepositoryID: github.Ptr(int64(1)), +// HeadRepositoryID: github.Ptr(int64(1)), +// HeadBranch: github.Ptr("main"), +// HeadSHA: github.Ptr("abc123"), +// }, +// }, +// { +// ID: github.Ptr(int64(2)), +// NodeID: github.Ptr("A_2"), +// Name: github.Ptr("test-results"), +// SizeInBytes: github.Ptr(int64(512)), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), +// ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), +// Expired: github.Ptr(false), +// CreatedAt: &github.Timestamp{}, +// UpdatedAt: &github.Timestamp{}, +// ExpiresAt: &github.Timestamp{}, +// WorkflowRun: &github.ArtifactWorkflowRun{ +// ID: github.Ptr(int64(12345)), +// RepositoryID: github.Ptr(int64(1)), +// HeadRepositoryID: github.Ptr(int64(1)), +// HeadBranch: github.Ptr("main"), +// HeadSHA: github.Ptr("abc123"), +// }, +// }, +// }, +// } +// w.WriteHeader(http.StatusOK) +// _ = json.NewEncoder(w).Encode(artifacts) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(12345), +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter run_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: run_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response github.ArtifactList +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.NotNil(t, response.TotalCount) +// assert.Greater(t, *response.TotalCount, int64(0)) +// assert.NotEmpty(t, response.Artifacts) +// }) +// } +// } + +// func Test_DownloadWorkflowRunArtifact(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful artifact download URL", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/repos/owner/repo/actions/artifacts/123/zip", +// Method: "GET", +// }, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// // GitHub returns a 302 redirect to the download URL +// w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "artifact_id": float64(123), +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter artifact_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: artifact_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Contains(t, response, "download_url") +// assert.Contains(t, response, "message") +// assert.Equal(t, "Artifact is available for download", response["message"]) +// assert.Equal(t, float64(123), response["artifact_id"]) +// }) +// } +// } + +// func Test_DeleteWorkflowRunLogs(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "delete_workflow_run_logs", 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, "run_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful logs deletion", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(12345), +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter run_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: run_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Equal(t, "Workflow run logs have been deleted", response["message"]) +// assert.Equal(t, float64(12345), response["run_id"]) +// }) +// } +// } + +// func Test_GetWorkflowRunUsage(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "get_workflow_run_usage", 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, "run_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful workflow run usage", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// usage := &github.WorkflowRunUsage{ +// Billable: &github.WorkflowRunBillMap{ +// "UBUNTU": &github.WorkflowRunBill{ +// TotalMS: github.Ptr(int64(120000)), +// Jobs: github.Ptr(2), +// JobRuns: []*github.WorkflowRunJobRun{ +// { +// JobID: github.Ptr(1), +// DurationMS: github.Ptr(int64(60000)), +// }, +// { +// JobID: github.Ptr(2), +// DurationMS: github.Ptr(int64(60000)), +// }, +// }, +// }, +// }, +// RunDurationMS: github.Ptr(int64(120000)), +// } +// w.WriteHeader(http.StatusOK) +// _ = json.NewEncoder(w).Encode(usage) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(12345), +// }, +// expectError: false, +// }, +// { +// name: "missing required parameter run_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: run_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// // Unmarshal and verify the result +// var response github.WorkflowRunUsage +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.NotNil(t, response.RunDurationMS) +// assert.NotNil(t, response.Billable) +// }) +// } +// } + +// func Test_GetJobLogs(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) + +// assert.Equal(t, "get_job_logs", 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, "job_id") +// assert.Contains(t, tool.InputSchema.Properties, "run_id") +// assert.Contains(t, tool.InputSchema.Properties, "failed_only") +// assert.Contains(t, tool.InputSchema.Properties, "return_content") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// checkResponse func(t *testing.T, response map[string]any) +// }{ +// { +// name: "successful single job logs with URL", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Location", "https://github.com/logs/job/123") +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "job_id": float64(123), +// }, +// expectError: false, +// checkResponse: func(t *testing.T, response map[string]any) { +// assert.Equal(t, float64(123), response["job_id"]) +// assert.Contains(t, response, "logs_url") +// assert.Equal(t, "Job logs are available for download", response["message"]) +// assert.Contains(t, response, "note") +// }, +// }, +// { +// name: "successful failed jobs logs", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// jobs := &github.Jobs{ +// TotalCount: github.Ptr(3), +// Jobs: []*github.WorkflowJob{ +// { +// ID: github.Ptr(int64(1)), +// Name: github.Ptr("test-job-1"), +// Conclusion: github.Ptr("success"), +// }, +// { +// ID: github.Ptr(int64(2)), +// Name: github.Ptr("test-job-2"), +// Conclusion: github.Ptr("failure"), +// }, +// { +// ID: github.Ptr(int64(3)), +// Name: github.Ptr("test-job-3"), +// Conclusion: github.Ptr("failure"), +// }, +// }, +// } +// w.WriteHeader(http.StatusOK) +// _ = json.NewEncoder(w).Encode(jobs) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(456), +// "failed_only": true, +// }, +// expectError: false, +// checkResponse: func(t *testing.T, response map[string]any) { +// assert.Equal(t, float64(456), response["run_id"]) +// assert.Equal(t, float64(3), response["total_jobs"]) +// assert.Equal(t, float64(2), response["failed_jobs"]) +// assert.Contains(t, response, "logs") +// assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) + +// logs, ok := response["logs"].([]interface{}) +// assert.True(t, ok) +// assert.Len(t, logs, 2) +// }, +// }, +// { +// name: "no failed jobs found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// jobs := &github.Jobs{ +// TotalCount: github.Ptr(2), +// Jobs: []*github.WorkflowJob{ +// { +// ID: github.Ptr(int64(1)), +// Name: github.Ptr("test-job-1"), +// Conclusion: github.Ptr("success"), +// }, +// { +// ID: github.Ptr(int64(2)), +// Name: github.Ptr("test-job-2"), +// Conclusion: github.Ptr("success"), +// }, +// }, +// } +// w.WriteHeader(http.StatusOK) +// _ = json.NewEncoder(w).Encode(jobs) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(456), +// "failed_only": true, +// }, +// expectError: false, +// checkResponse: func(t *testing.T, response map[string]any) { +// assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) +// assert.Equal(t, float64(456), response["run_id"]) +// assert.Equal(t, float64(2), response["total_jobs"]) +// assert.Equal(t, float64(0), response["failed_jobs"]) +// }, +// }, +// { +// name: "missing job_id when not using failed_only", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "job_id is required when failed_only is false", +// }, +// { +// name: "missing run_id when using failed_only", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "failed_only": true, +// }, +// expectError: true, +// expectedErrMsg: "run_id is required when failed_only is true", +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "repo": "repo", +// "job_id": float64(123), +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing required parameter repo", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "owner", +// "job_id": float64(123), +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: repo", +// }, +// { +// name: "API error when getting single job logs", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _ = json.NewEncoder(w).Encode(map[string]string{ +// "message": "Not Found", +// }) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "job_id": float64(999), +// }, +// expectError: true, +// }, +// { +// name: "API error when listing workflow jobs for failed_only", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _ = json.NewEncoder(w).Encode(map[string]string{ +// "message": "Not Found", +// }) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "run_id": float64(999), +// "failed_only": true, +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// require.Equal(t, tc.expectError, result.IsError) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// if tc.expectedErrMsg != "" { +// assert.Equal(t, tc.expectedErrMsg, textContent.Text) +// return +// } + +// if tc.expectError { +// // For API errors, just verify we got an error +// assert.True(t, result.IsError) +// return +// } + +// // Unmarshal and verify the result +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) + +// if tc.checkResponse != nil { +// tc.checkResponse(t, response) +// } +// }) +// } +// } + +// func Test_GetJobLogs_WithContentReturn(t *testing.T) { +// // Test the return_content functionality with a mock HTTP server +// logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + +// // Create a test server to serve log content +// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(logContent)) +// })) +// defer testServer.Close() + +// mockedClient := mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Location", testServer.URL) +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ) + +// client := github.NewClient(mockedClient) +// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + +// request := createMCPRequest(map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "job_id": float64(123), +// "return_content": true, +// }) + +// result, err := handler(context.Background(), request) +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) + +// assert.Equal(t, float64(123), response["job_id"]) +// assert.Equal(t, logContent, response["logs_content"]) +// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) +// assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +// } + +// func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { +// // Test the return_content functionality with a mock HTTP server +// logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" +// expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" + +// // Create a test server to serve log content +// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(logContent)) +// })) +// defer testServer.Close() + +// mockedClient := mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Location", testServer.URL) +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ) + +// client := github.NewClient(mockedClient) +// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + +// request := createMCPRequest(map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "job_id": float64(123), +// "return_content": true, +// "tail_lines": float64(1), // Requesting last 1 line +// }) + +// result, err := handler(context.Background(), request) +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) + +// assert.Equal(t, float64(123), response["job_id"]) +// assert.Equal(t, float64(3), response["original_length"]) +// assert.Equal(t, expectedLogContent, response["logs_content"]) +// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) +// assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +// } + +// func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { +// logContent := "Line 1\nLine 2\nLine 3" +// expectedLogContent := "Line 1\nLine 2\nLine 3" + +// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(logContent)) +// })) +// defer testServer.Close() + +// mockedClient := mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Location", testServer.URL) +// w.WriteHeader(http.StatusFound) +// }), +// ), +// ) + +// client := github.NewClient(mockedClient) +// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + +// request := createMCPRequest(map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "job_id": float64(123), +// "return_content": true, +// "tail_lines": float64(100), +// }) + +// result, err := handler(context.Background(), request) +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) +// var response map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) + +// assert.Equal(t, float64(123), response["job_id"]) +// assert.Equal(t, float64(3), response["original_length"]) +// assert.Equal(t, expectedLogContent, response["logs_content"]) +// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) +// assert.NotContains(t, response, "logs_url") +// } + +// func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { +// if testing.Short() { +// t.Skip("Skipping memory profiling test in short mode") +// } + +// const logLines = 100000 +// const bufferSize = 5000 +// largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" + +// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(largeLogContent)) +// })) +// defer testServer.Close() + +// os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") +// defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") + +// profiler.InitFromEnv(nil) +// ctx := context.Background() + +// debug.SetGCPercent(-1) +// defer debug.SetGCPercent(100) + +// for i := 0; i < 3; i++ { +// runtime.GC() +// } + +// var baselineStats runtime.MemStats +// runtime.ReadMemStats(&baselineStats) + +// profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { +// resp1, err := http.Get(testServer.URL) +// if err != nil { +// return 0, 0, err +// } +// defer resp1.Body.Close() //nolint:bodyclose +// content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose +// return totalLines, int64(len(content)), err +// }) +// require.NoError(t, err1) + +// for i := 0; i < 3; i++ { +// runtime.GC() +// } + +// profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { +// resp2, err := http.Get(testServer.URL) +// if err != nil { +// return 0, 0, err +// } +// defer resp2.Body.Close() //nolint:bodyclose + +// allContent, err := io.ReadAll(resp2.Body) +// if err != nil { +// return 0, 0, err +// } + +// allLines := strings.Split(string(allContent), "\n") +// var nonEmptyLines []string +// for _, line := range allLines { +// if line != "" { +// nonEmptyLines = append(nonEmptyLines, line) +// } +// } +// totalLines := len(nonEmptyLines) + +// var resultLines []string +// if totalLines > bufferSize { +// resultLines = nonEmptyLines[totalLines-bufferSize:] +// } else { +// resultLines = nonEmptyLines +// } + +// result := strings.Join(resultLines, "\n") +// return totalLines, int64(len(result)), nil +// }) +// require.NoError(t, err2) + +// assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, +// "Sliding window should use less memory than reading all into memory") + +// assert.Equal(t, profile1.LinesCount, profile2.LinesCount, +// "Both approaches should count the same number of input lines") +// assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, +// "Both approaches should produce similar output sizes (within 100 bytes)") + +// memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 +// t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", +// memoryReduction, +// float64(profile2.MemoryDelta)/1024/1024, +// float64(profile1.MemoryDelta)/1024/1024) + +// t.Logf("Baseline: %d bytes", baselineStats.Alloc) +// t.Logf("Sliding window: %s", profile1.String()) +// t.Logf("No window: %s", profile2.String()) +// } diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index aa39cfc35..0feca2b36 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -1,169 +1,169 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) -func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_code_scanning_alert", - mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithNumber("alertNumber", - mcp.Required(), - mcp.Description("The number of the alert."), - ), - ), - 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 - } - alertNumber, err := RequiredInt(request, "alertNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_code_scanning_alert", +// mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithNumber("alertNumber", +// mcp.Required(), +// mcp.Description("The number of the alert."), +// ), +// ), +// 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 +// } +// alertNumber, err := RequiredInt(request, "alertNumber") +// 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) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get alert", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get alert", +// 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 get alert: %s", string(body))), 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 alert: %s", string(body))), nil +// } - r, err := json.Marshal(alert) - if err != nil { - return nil, fmt.Errorf("failed to marshal alert: %w", err) - } +// r, err := json.Marshal(alert) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alert: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } -func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_code_scanning_alerts", - mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithString("state", - mcp.Description("Filter code scanning alerts by state. Defaults to open"), - mcp.DefaultString("open"), - mcp.Enum("open", "closed", "dismissed", "fixed"), - ), - mcp.WithString("ref", - mcp.Description("The Git reference for the results you want to list."), - ), - mcp.WithString("severity", - mcp.Description("Filter code scanning alerts by severity"), - mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"), - ), - mcp.WithString("tool_name", - mcp.Description("The name of the tool used for code scanning."), - ), - ), - 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 - } - ref, err := OptionalParam[string](request, "ref") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - severity, err := OptionalParam[string](request, "severity") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - toolName, err := OptionalParam[string](request, "tool_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_code_scanning_alerts", +// mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithString("state", +// mcp.Description("Filter code scanning alerts by state. Defaults to open"), +// mcp.DefaultString("open"), +// mcp.Enum("open", "closed", "dismissed", "fixed"), +// ), +// mcp.WithString("ref", +// mcp.Description("The Git reference for the results you want to list."), +// ), +// mcp.WithString("severity", +// mcp.Description("Filter code scanning alerts by severity"), +// mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"), +// ), +// mcp.WithString("tool_name", +// mcp.Description("The name of the tool used for code scanning."), +// ), +// ), +// 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 +// } +// ref, err := OptionalParam[string](request, "ref") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// state, err := OptionalParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// severity, err := OptionalParam[string](request, "severity") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// toolName, err := OptionalParam[string](request, "tool_name") +// 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) - } - alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list alerts", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list alerts", +// 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 alerts: %s", string(body))), 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 list alerts: %s", string(body))), nil +// } - r, err := json.Marshal(alerts) - if err != nil { - return nil, fmt.Errorf("failed to marshal alerts: %w", err) - } +// r, err := json.Marshal(alerts) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alerts: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 874d1eeda..95197a708 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -1,249 +1,249 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetCodeScanningAlert(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetCodeScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_code_scanning_alert", 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, "alertNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - - // Setup mock alert for success case - mockAlert := &github.Alert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - Rule: &github.Rule{ID: github.Ptr("test-rule"), Description: github.Ptr("Test Rule Description")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlert *github.Alert - expectedErrMsg string - }{ - { - name: "successful alert fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, - mockAlert, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(42), - }, - expectError: false, - expectedAlert: mockAlert, - }, - { - name: "alert fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(9999), - }, - expectError: true, - expectedErrMsg: "failed to get alert", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetCodeScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedAlert github.Alert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) - assert.NoError(t, err) - assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) - assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) - assert.Equal(t, *tc.expectedAlert.Rule.ID, *returnedAlert.Rule.ID) - assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) - - }) - } -} - -func Test_ListCodeScanningAlerts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_code_scanning_alerts", 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, "ref") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.Contains(t, tool.InputSchema.Properties, "severity") - assert.Contains(t, tool.InputSchema.Properties, "tool_name") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock alerts for success case - mockAlerts := []*github.Alert{ - { - Number: github.Ptr(42), - State: github.Ptr("open"), - Rule: &github.Rule{ID: github.Ptr("test-rule-1"), Description: github.Ptr("Test Rule 1")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), - }, - { - Number: github.Ptr(43), - State: github.Ptr("fixed"), - Rule: &github.Rule{ID: github.Ptr("test-rule-2"), Description: github.Ptr("Test Rule 2")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/43"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlerts []*github.Alert - expectedErrMsg string - }{ - { - name: "successful alerts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCodeScanningAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "ref": "main", - "state": "open", - "severity": "high", - "tool_name": "codeql", - }).andThen( - mockResponse(t, http.StatusOK, mockAlerts), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "ref": "main", - "state": "open", - "severity": "high", - "tool_name": "codeql", - }, - expectError: false, - expectedAlerts: mockAlerts, - }, - { - name: "alerts listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCodeScanningAlertsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list alerts", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedAlerts []*github.Alert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) - assert.NoError(t, err) - assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) - for i, alert := range returnedAlerts { - assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) - assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) - assert.Equal(t, *tc.expectedAlerts[i].Rule.ID, *alert.Rule.ID) - assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) - } - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_GetCodeScanningAlert(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetCodeScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_code_scanning_alert", 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, "alertNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + +// // Setup mock alert for success case +// mockAlert := &github.Alert{ +// Number: github.Ptr(42), +// State: github.Ptr("open"), +// Rule: &github.Rule{ID: github.Ptr("test-rule"), Description: github.Ptr("Test Rule Description")}, +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlert *github.Alert +// expectedErrMsg string +// }{ +// { +// name: "successful alert fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, +// mockAlert, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(42), +// }, +// expectError: false, +// expectedAlert: mockAlert, +// }, +// { +// name: "alert fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(9999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get alert", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetCodeScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedAlert github.Alert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) +// assert.NoError(t, err) +// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) +// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) +// assert.Equal(t, *tc.expectedAlert.Rule.ID, *returnedAlert.Rule.ID) +// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + +// }) +// } +// } + +// func Test_ListCodeScanningAlerts(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_code_scanning_alerts", 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, "ref") +// assert.Contains(t, tool.InputSchema.Properties, "state") +// assert.Contains(t, tool.InputSchema.Properties, "severity") +// assert.Contains(t, tool.InputSchema.Properties, "tool_name") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock alerts for success case +// mockAlerts := []*github.Alert{ +// { +// Number: github.Ptr(42), +// State: github.Ptr("open"), +// Rule: &github.Rule{ID: github.Ptr("test-rule-1"), Description: github.Ptr("Test Rule 1")}, +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), +// }, +// { +// Number: github.Ptr(43), +// State: github.Ptr("fixed"), +// Rule: &github.Rule{ID: github.Ptr("test-rule-2"), Description: github.Ptr("Test Rule 2")}, +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/43"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlerts []*github.Alert +// expectedErrMsg string +// }{ +// { +// name: "successful alerts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCodeScanningAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "ref": "main", +// "state": "open", +// "severity": "high", +// "tool_name": "codeql", +// }).andThen( +// mockResponse(t, http.StatusOK, mockAlerts), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "ref": "main", +// "state": "open", +// "severity": "high", +// "tool_name": "codeql", +// }, +// expectError: false, +// expectedAlerts: mockAlerts, +// }, +// { +// name: "alerts listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCodeScanningAlertsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list alerts", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedAlerts []*github.Alert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) +// assert.NoError(t, err) +// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) +// for i, alert := range returnedAlerts { +// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) +// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) +// assert.Equal(t, *tc.expectedAlerts[i].Rule.ID, *alert.Rule.ID) +// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) +// } +// }) +// } +// } diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index e21562c02..f43da8287 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -1,161 +1,161 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) -func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool( - "get_dependabot_alert", - mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithNumber("alertNumber", - mcp.Required(), - mcp.Description("The number of the alert."), - ), - ), - 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 - } - alertNumber, err := RequiredInt(request, "alertNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool( +// "get_dependabot_alert", +// mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithNumber("alertNumber", +// mcp.Required(), +// mcp.Description("The number of the alert."), +// ), +// ), +// 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 +// } +// alertNumber, err := RequiredInt(request, "alertNumber") +// 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) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to get alert with number '%d'", alertNumber), +// 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 get alert: %s", string(body))), 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 alert: %s", string(body))), nil +// } - r, err := json.Marshal(alert) - if err != nil { - return nil, fmt.Errorf("failed to marshal alert: %w", err) - } +// r, err := json.Marshal(alert) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alert: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } -func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool( - "list_dependabot_alerts", - mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithString("state", - mcp.Description("Filter dependabot alerts by state. Defaults to open"), - mcp.DefaultString("open"), - mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), - ), - mcp.WithString("severity", - mcp.Description("Filter dependabot alerts by severity"), - mcp.Enum("low", "medium", "high", "critical"), - ), - ), - 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 - } - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - severity, err := OptionalParam[string](request, "severity") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool( +// "list_dependabot_alerts", +// mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithString("state", +// mcp.Description("Filter dependabot alerts by state. Defaults to open"), +// mcp.DefaultString("open"), +// mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), +// ), +// mcp.WithString("severity", +// mcp.Description("Filter dependabot alerts by severity"), +// mcp.Enum("low", "medium", "high", "critical"), +// ), +// ), +// 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 +// } +// state, err := OptionalParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// severity, err := OptionalParam[string](request, "severity") +// 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) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ - State: ToStringPtr(state), - Severity: ToStringPtr(severity), - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ +// State: ToStringPtr(state), +// Severity: ToStringPtr(severity), +// }) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), +// 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 alerts: %s", string(body))), 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 list alerts: %s", string(body))), nil +// } - r, err := json.Marshal(alerts) - if err != nil { - return nil, fmt.Errorf("failed to marshal alerts: %w", err) - } +// r, err := json.Marshal(alerts) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alerts: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 302692a3a..ab879ace1 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -1,276 +1,276 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) -func Test_GetDependabotAlert(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// func Test_GetDependabotAlert(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - // Validate tool schema - assert.Equal(t, "get_dependabot_alert", 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, "alertNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) +// // Validate tool schema +// assert.Equal(t, "get_dependabot_alert", 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, "alertNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - // Setup mock alert for success case - mockAlert := &github.DependabotAlert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), - } +// // Setup mock alert for success case +// mockAlert := &github.DependabotAlert{ +// Number: github.Ptr(42), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), +// } - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlert *github.DependabotAlert - expectedErrMsg string - }{ - { - name: "successful alert fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, - mockAlert, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(42), - }, - expectError: false, - expectedAlert: mockAlert, - }, - { - name: "alert fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(9999), - }, - expectError: true, - expectedErrMsg: "failed to get alert", - }, - } +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlert *github.DependabotAlert +// expectedErrMsg string +// }{ +// { +// name: "successful alert fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, +// mockAlert, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(42), +// }, +// expectError: false, +// expectedAlert: mockAlert, +// }, +// { +// name: "alert fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(9999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get alert", +// }, +// } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper) +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper) - // Create call request - request := createMCPRequest(tc.requestArgs) +// // Create call request +// request := createMCPRequest(tc.requestArgs) - // Call handler - result, err := handler(context.Background(), request) +// // Call handler +// result, err := handler(context.Background(), request) - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } - require.NoError(t, err) - require.False(t, result.IsError) +// require.NoError(t, err) +// require.False(t, result.IsError) - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedAlert github.DependabotAlert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) - assert.NoError(t, err) - assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) - assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) - assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) - }) - } -} +// // Unmarshal and verify the result +// var returnedAlert github.DependabotAlert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) +// assert.NoError(t, err) +// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) +// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) +// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) +// }) +// } +// } -func Test_ListDependabotAlerts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// func Test_ListDependabotAlerts(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_dependabot_alerts", 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, "state") - assert.Contains(t, tool.InputSchema.Properties, "severity") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) +// assert.Equal(t, "list_dependabot_alerts", 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, "state") +// assert.Contains(t, tool.InputSchema.Properties, "severity") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - // Setup mock alerts for success case - criticalAlert := github.DependabotAlert{ - Number: github.Ptr(1), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), - State: github.Ptr("open"), - SecurityAdvisory: &github.DependabotSecurityAdvisory{ - Severity: github.Ptr("critical"), - }, - } - highSeverityAlert := github.DependabotAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), - State: github.Ptr("fixed"), - SecurityAdvisory: &github.DependabotSecurityAdvisory{ - Severity: github.Ptr("high"), - }, - } +// // Setup mock alerts for success case +// criticalAlert := github.DependabotAlert{ +// Number: github.Ptr(1), +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), +// State: github.Ptr("open"), +// SecurityAdvisory: &github.DependabotSecurityAdvisory{ +// Severity: github.Ptr("critical"), +// }, +// } +// highSeverityAlert := github.DependabotAlert{ +// Number: github.Ptr(2), +// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), +// State: github.Ptr("fixed"), +// SecurityAdvisory: &github.DependabotSecurityAdvisory{ +// Severity: github.Ptr("high"), +// }, +// } - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlerts []*github.DependabotAlert - expectedErrMsg string - }{ - { - name: "successful open alerts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposDependabotAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "open", - }).andThen( - mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "open", - }, - expectError: false, - expectedAlerts: []*github.DependabotAlert{&criticalAlert}, - }, - { - name: "successful severity filtered listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposDependabotAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "severity": "high", - }).andThen( - mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "severity": "high", - }, - expectError: false, - expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, - }, - { - name: "successful all alerts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposDependabotAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, - }, - { - name: "alerts listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposDependabotAlertsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list alerts", - }, - } +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlerts []*github.DependabotAlert +// expectedErrMsg string +// }{ +// { +// name: "successful open alerts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposDependabotAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "state": "open", +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "open", +// }, +// expectError: false, +// expectedAlerts: []*github.DependabotAlert{&criticalAlert}, +// }, +// { +// name: "successful severity filtered listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposDependabotAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "severity": "high", +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "severity": "high", +// }, +// expectError: false, +// expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, +// }, +// { +// name: "successful all alerts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposDependabotAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{}).andThen( +// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, +// }, +// { +// name: "alerts listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposDependabotAlertsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list alerts", +// }, +// } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) +// request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) +// result, err := handler(context.Background(), request) - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } - require.NoError(t, err) - require.False(t, result.IsError) +// require.NoError(t, err) +// require.False(t, result.IsError) - textContent := getTextResult(t, result) +// textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedAlerts []*github.DependabotAlert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) - assert.NoError(t, err) - assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) - for i, alert := range returnedAlerts { - assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) - assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) - assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) - if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && - alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { - assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) - } - } - }) - } -} +// // Unmarshal and verify the result +// var returnedAlerts []*github.DependabotAlert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) +// assert.NoError(t, err) +// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) +// for i, alert := range returnedAlerts { +// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) +// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) +// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) +// if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && +// alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { +// assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) +// } +// } +// }) +// } +// } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 3aa92f05c..5a4148511 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -1,531 +1,531 @@ package github -import ( - "context" - "encoding/json" - "fmt" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/shurcooL/githubv4" -) - -const DefaultGraphQLPageSize = 30 - -// Common interface for all discussion query types -type DiscussionQueryResult interface { - GetDiscussionFragment() DiscussionFragment -} - -// Implement the interface for all query types -func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { - return q.Repository.Discussions -} - -func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { - return q.Repository.Discussions -} - -func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { - return q.Repository.Discussions -} - -func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { - return q.Repository.Discussions -} - -type DiscussionFragment struct { - Nodes []NodeFragment - PageInfo PageInfoFragment - TotalCount githubv4.Int -} - -type NodeFragment struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - UpdatedAt githubv4.DateTime - Author struct { - Login githubv4.String - } - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` -} - -type PageInfoFragment struct { - HasNextPage bool - HasPreviousPage bool - StartCursor githubv4.String - EndCursor githubv4.String -} - -type BasicNoOrder struct { - Repository struct { - Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type BasicWithOrder struct { - Repository struct { - Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type WithCategoryAndOrder struct { - Repository struct { - Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -type WithCategoryNoOrder struct { - Repository struct { - Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { - return &github.Discussion{ - Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(string(fragment.Title)), - HTMLURL: github.Ptr(string(fragment.URL)), - CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, - UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, - User: &github.User{ - Login: github.Ptr(string(fragment.Author.Login)), - }, - DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr(string(fragment.Category.Name)), - }, - } -} - -func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { - if categoryID != nil && useOrdering { - return &WithCategoryAndOrder{} - } - if categoryID != nil && !useOrdering { - return &WithCategoryNoOrder{} - } - if categoryID == nil && useOrdering { - return &BasicWithOrder{} - } - return &BasicNoOrder{} -} - -func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_discussions", - mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), - ), - mcp.WithString("category", - mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), - ), - mcp.WithString("orderBy", - mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), - mcp.Enum("CREATED_AT", "UPDATED_AT"), - ), - mcp.WithString("direction", - mcp.Description("Order direction."), - mcp.Enum("ASC", "DESC"), - ), - WithCursorPagination(), - ), - 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 := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - // when not provided, default to the .github repository - // this will query discussions at the organisation level - if repo == "" { - repo = ".github" - } - - category, err := OptionalParam[string](request, "category") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - orderBy, err := OptionalParam[string](request, "orderBy") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) - if err != nil { - return nil, err - } - paginationParams, err := pagination.ToGraphQLParams() - if err != nil { - return nil, err - } - - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - var categoryID *githubv4.ID - if category != "" { - id := githubv4.ID(category) - categoryID = &id - } - - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "first": githubv4.Int(*paginationParams.First), - } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - vars["after"] = (*githubv4.String)(nil) - } - - // this is an extra check in case the tool description is misinterpreted, because - // we shouldn't use ordering unless both a 'field' and 'direction' are provided - useOrdering := orderBy != "" && direction != "" - if useOrdering { - vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) - vars["orderByDirection"] = githubv4.OrderDirection(direction) - } - - if categoryID != nil { - vars["categoryId"] = *categoryID - } - - discussionQuery := getQueryType(useOrdering, categoryID) - if err := client.Query(ctx, discussionQuery, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Extract and convert all discussion nodes using the common interface - var discussions []*github.Discussion - var pageInfo PageInfoFragment - var totalCount githubv4.Int - if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { - fragment := queryResult.GetDiscussionFragment() - for _, node := range fragment.Nodes { - discussions = append(discussions, fragmentToDiscussion(node)) - } - pageInfo = fragment.PageInfo - totalCount = fragment.TotalCount - } - - // Create response with pagination info - response := map[string]interface{}{ - "discussions": discussions, - "pageInfo": map[string]interface{}{ - "hasNextPage": pageInfo.HasNextPage, - "hasPreviousPage": pageInfo.HasPreviousPage, - "startCursor": string(pageInfo.StartCursor), - "endCursor": string(pageInfo.EndCursor), - }, - "totalCount": totalCount, - } - - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussions: %w", err) - } - return mcp.NewToolResultText(string(out)), nil - } -} - -func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_discussion", - mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("discussionNumber", - mcp.Required(), - mcp.Description("Discussion Number"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - var q struct { - Repository struct { - Discussion struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - CreatedAt githubv4.DateTime - URL githubv4.String `graphql:"url"` - Category struct { - Name githubv4.String - } `graphql:"category"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(params.Owner), - "repo": githubv4.String(params.Repo), - "discussionNumber": githubv4.Int(params.DiscussionNumber), - } - if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - d := q.Repository.Discussion - discussion := &github.Discussion{ - Number: github.Ptr(int(d.Number)), - Title: github.Ptr(string(d.Title)), - Body: github.Ptr(string(d.Body)), - HTMLURL: github.Ptr(string(d.URL)), - CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, - DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr(string(d.Category.Name)), - }, - } - out, err := json.Marshal(discussion) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussion: %w", err) - } - - return mcp.NewToolResultText(string(out)), nil - } -} - -func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_discussion_comments", - mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), - mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), - mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), - WithCursorPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) - if err != nil { - return nil, err - } - - // Check if pagination parameters were explicitly provided - _, perPageProvided := request.GetArguments()["perPage"] - paginationExplicit := perPageProvided - - paginationParams, err := pagination.ToGraphQLParams() - if err != nil { - return nil, err - } - - // Use default of 30 if pagination was not explicitly provided - if !paginationExplicit { - defaultFirst := int32(DefaultGraphQLPageSize) - paginationParams.First = &defaultFirst - } - - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - var q struct { - Repository struct { - Discussion struct { - Comments struct { - Nodes []struct { - Body githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"comments(first: $first, after: $after)"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(params.Owner), - "repo": githubv4.String(params.Repo), - "discussionNumber": githubv4.Int(params.DiscussionNumber), - "first": githubv4.Int(*paginationParams.First), - } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - vars["after"] = (*githubv4.String)(nil) - } - if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var comments []*github.IssueComment - for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) - } - - // Create response with pagination info - response := map[string]interface{}{ - "comments": comments, - "pageInfo": map[string]interface{}{ - "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), - "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), - }, - "totalCount": q.Repository.Discussion.Comments.TotalCount, - } - - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal comments: %w", err) - } - - return mcp.NewToolResultText(string(out)), nil - } -} - -func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_discussion_categories", - mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), - ), - ), - 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 := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - // when not provided, default to the .github repository - // this will query discussion categories at the organisation level - if repo == "" { - repo = ".github" - } - - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - var q struct { - Repository struct { - DiscussionCategories struct { - Nodes []struct { - ID githubv4.ID - Name githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"discussionCategories(first: $first)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "first": githubv4.Int(25), - } - if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var categories []map[string]string - for _, c := range q.Repository.DiscussionCategories.Nodes { - categories = append(categories, map[string]string{ - "id": fmt.Sprint(c.ID), - "name": string(c.Name), - }) - } - - // Create response with pagination info - response := map[string]interface{}{ - "categories": categories, - "pageInfo": map[string]interface{}{ - "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), - "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), - }, - "totalCount": q.Repository.DiscussionCategories.TotalCount, - } - - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) - } - return mcp.NewToolResultText(string(out)), nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/go-viper/mapstructure/v2" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// "github.com/shurcooL/githubv4" +// ) + +// const DefaultGraphQLPageSize = 30 + +// // Common interface for all discussion query types +// type DiscussionQueryResult interface { +// GetDiscussionFragment() DiscussionFragment +// } + +// // Implement the interface for all query types +// func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { +// return q.Repository.Discussions +// } + +// func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { +// return q.Repository.Discussions +// } + +// func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { +// return q.Repository.Discussions +// } + +// func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { +// return q.Repository.Discussions +// } + +// type DiscussionFragment struct { +// Nodes []NodeFragment +// PageInfo PageInfoFragment +// TotalCount githubv4.Int +// } + +// type NodeFragment struct { +// Number githubv4.Int +// Title githubv4.String +// CreatedAt githubv4.DateTime +// UpdatedAt githubv4.DateTime +// Author struct { +// Login githubv4.String +// } +// Category struct { +// Name githubv4.String +// } `graphql:"category"` +// URL githubv4.String `graphql:"url"` +// } + +// type PageInfoFragment struct { +// HasNextPage bool +// HasPreviousPage bool +// StartCursor githubv4.String +// EndCursor githubv4.String +// } + +// type BasicNoOrder struct { +// Repository struct { +// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// type BasicWithOrder struct { +// Repository struct { +// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// type WithCategoryAndOrder struct { +// Repository struct { +// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// type WithCategoryNoOrder struct { +// Repository struct { +// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { +// return &github.Discussion{ +// Number: github.Ptr(int(fragment.Number)), +// Title: github.Ptr(string(fragment.Title)), +// HTMLURL: github.Ptr(string(fragment.URL)), +// CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, +// UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, +// User: &github.User{ +// Login: github.Ptr(string(fragment.Author.Login)), +// }, +// DiscussionCategory: &github.DiscussionCategory{ +// Name: github.Ptr(string(fragment.Category.Name)), +// }, +// } +// } + +// func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { +// if categoryID != nil && useOrdering { +// return &WithCategoryAndOrder{} +// } +// if categoryID != nil && !useOrdering { +// return &WithCategoryNoOrder{} +// } +// if categoryID == nil && useOrdering { +// return &BasicWithOrder{} +// } +// return &BasicNoOrder{} +// } + +// func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_discussions", +// mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), +// ), +// mcp.WithString("category", +// mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), +// ), +// mcp.WithString("orderBy", +// mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), +// mcp.Enum("CREATED_AT", "UPDATED_AT"), +// ), +// mcp.WithString("direction", +// mcp.Description("Order direction."), +// mcp.Enum("ASC", "DESC"), +// ), +// WithCursorPagination(), +// ), +// 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 := OptionalParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// // when not provided, default to the .github repository +// // this will query discussions at the organisation level +// if repo == "" { +// repo = ".github" +// } + +// category, err := OptionalParam[string](request, "category") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// orderBy, err := OptionalParam[string](request, "orderBy") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get pagination parameters and convert to GraphQL format +// pagination, err := OptionalCursorPaginationParams(request) +// if err != nil { +// return nil, err +// } +// paginationParams, err := pagination.ToGraphQLParams() +// if err != nil { +// return nil, err +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// var categoryID *githubv4.ID +// if category != "" { +// id := githubv4.ID(category) +// categoryID = &id +// } + +// vars := map[string]interface{}{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "first": githubv4.Int(*paginationParams.First), +// } +// if paginationParams.After != nil { +// vars["after"] = githubv4.String(*paginationParams.After) +// } else { +// vars["after"] = (*githubv4.String)(nil) +// } + +// // this is an extra check in case the tool description is misinterpreted, because +// // we shouldn't use ordering unless both a 'field' and 'direction' are provided +// useOrdering := orderBy != "" && direction != "" +// if useOrdering { +// vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) +// vars["orderByDirection"] = githubv4.OrderDirection(direction) +// } + +// if categoryID != nil { +// vars["categoryId"] = *categoryID +// } + +// discussionQuery := getQueryType(useOrdering, categoryID) +// if err := client.Query(ctx, discussionQuery, vars); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Extract and convert all discussion nodes using the common interface +// var discussions []*github.Discussion +// var pageInfo PageInfoFragment +// var totalCount githubv4.Int +// if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { +// fragment := queryResult.GetDiscussionFragment() +// for _, node := range fragment.Nodes { +// discussions = append(discussions, fragmentToDiscussion(node)) +// } +// pageInfo = fragment.PageInfo +// totalCount = fragment.TotalCount +// } + +// // Create response with pagination info +// response := map[string]interface{}{ +// "discussions": discussions, +// "pageInfo": map[string]interface{}{ +// "hasNextPage": pageInfo.HasNextPage, +// "hasPreviousPage": pageInfo.HasPreviousPage, +// "startCursor": string(pageInfo.StartCursor), +// "endCursor": string(pageInfo.EndCursor), +// }, +// "totalCount": totalCount, +// } + +// out, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal discussions: %w", err) +// } +// return mcp.NewToolResultText(string(out)), nil +// } +// } + +// func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_discussion", +// mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("discussionNumber", +// mcp.Required(), +// mcp.Description("Discussion Number"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // Decode params +// var params struct { +// Owner string +// Repo string +// DiscussionNumber int32 +// } +// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// var q struct { +// Repository struct { +// Discussion struct { +// Number githubv4.Int +// Title githubv4.String +// Body githubv4.String +// CreatedAt githubv4.DateTime +// URL githubv4.String `graphql:"url"` +// Category struct { +// Name githubv4.String +// } `graphql:"category"` +// } `graphql:"discussion(number: $discussionNumber)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } +// vars := map[string]interface{}{ +// "owner": githubv4.String(params.Owner), +// "repo": githubv4.String(params.Repo), +// "discussionNumber": githubv4.Int(params.DiscussionNumber), +// } +// if err := client.Query(ctx, &q, vars); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// d := q.Repository.Discussion +// discussion := &github.Discussion{ +// Number: github.Ptr(int(d.Number)), +// Title: github.Ptr(string(d.Title)), +// Body: github.Ptr(string(d.Body)), +// HTMLURL: github.Ptr(string(d.URL)), +// CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, +// DiscussionCategory: &github.DiscussionCategory{ +// Name: github.Ptr(string(d.Category.Name)), +// }, +// } +// out, err := json.Marshal(discussion) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal discussion: %w", err) +// } + +// return mcp.NewToolResultText(string(out)), nil +// } +// } + +// func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_discussion_comments", +// mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), +// mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), +// mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), +// WithCursorPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // Decode params +// var params struct { +// Owner string +// Repo string +// DiscussionNumber int32 +// } +// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get pagination parameters and convert to GraphQL format +// pagination, err := OptionalCursorPaginationParams(request) +// if err != nil { +// return nil, err +// } + +// // Check if pagination parameters were explicitly provided +// _, perPageProvided := request.GetArguments()["perPage"] +// paginationExplicit := perPageProvided + +// paginationParams, err := pagination.ToGraphQLParams() +// if err != nil { +// return nil, err +// } + +// // Use default of 30 if pagination was not explicitly provided +// if !paginationExplicit { +// defaultFirst := int32(DefaultGraphQLPageSize) +// paginationParams.First = &defaultFirst +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// var q struct { +// Repository struct { +// Discussion struct { +// Comments struct { +// Nodes []struct { +// Body githubv4.String +// } +// PageInfo struct { +// HasNextPage githubv4.Boolean +// HasPreviousPage githubv4.Boolean +// StartCursor githubv4.String +// EndCursor githubv4.String +// } +// TotalCount int +// } `graphql:"comments(first: $first, after: $after)"` +// } `graphql:"discussion(number: $discussionNumber)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } +// vars := map[string]interface{}{ +// "owner": githubv4.String(params.Owner), +// "repo": githubv4.String(params.Repo), +// "discussionNumber": githubv4.Int(params.DiscussionNumber), +// "first": githubv4.Int(*paginationParams.First), +// } +// if paginationParams.After != nil { +// vars["after"] = githubv4.String(*paginationParams.After) +// } else { +// vars["after"] = (*githubv4.String)(nil) +// } +// if err := client.Query(ctx, &q, vars); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var comments []*github.IssueComment +// for _, c := range q.Repository.Discussion.Comments.Nodes { +// comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) +// } + +// // Create response with pagination info +// response := map[string]interface{}{ +// "comments": comments, +// "pageInfo": map[string]interface{}{ +// "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, +// "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, +// "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), +// "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), +// }, +// "totalCount": q.Repository.Discussion.Comments.TotalCount, +// } + +// out, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal comments: %w", err) +// } + +// return mcp.NewToolResultText(string(out)), nil +// } +// } + +// func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_discussion_categories", +// mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), +// ), +// ), +// 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 := OptionalParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// // when not provided, default to the .github repository +// // this will query discussion categories at the organisation level +// if repo == "" { +// repo = ".github" +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// var q struct { +// Repository struct { +// DiscussionCategories struct { +// Nodes []struct { +// ID githubv4.ID +// Name githubv4.String +// } +// PageInfo struct { +// HasNextPage githubv4.Boolean +// HasPreviousPage githubv4.Boolean +// StartCursor githubv4.String +// EndCursor githubv4.String +// } +// TotalCount int +// } `graphql:"discussionCategories(first: $first)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } +// vars := map[string]interface{}{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "first": githubv4.Int(25), +// } +// if err := client.Query(ctx, &q, vars); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var categories []map[string]string +// for _, c := range q.Repository.DiscussionCategories.Nodes { +// categories = append(categories, map[string]string{ +// "id": fmt.Sprint(c.ID), +// "name": string(c.Name), +// }) +// } + +// // Create response with pagination info +// response := map[string]interface{}{ +// "categories": categories, +// "pageInfo": map[string]interface{}{ +// "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, +// "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, +// "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), +// "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), +// }, +// "totalCount": q.Repository.DiscussionCategories.TotalCount, +// } + +// out, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) +// } +// return mcp.NewToolResultText(string(out)), nil +// } +// } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 0930b1421..2742fc02c 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -1,778 +1,778 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/shurcooL/githubv4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ( - discussionsGeneral = []map[string]any{ - {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, - {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, - } - discussionsAll = []map[string]any{ - { - "number": 1, - "title": "Discussion 1 title", - "createdAt": "2023-01-01T00:00:00Z", - "updatedAt": "2023-01-01T00:00:00Z", - "author": map[string]any{"login": "user1"}, - "url": "https://github.com/owner/repo/discussions/1", - "category": map[string]any{"name": "General"}, - }, - { - "number": 2, - "title": "Discussion 2 title", - "createdAt": "2023-02-01T00:00:00Z", - "updatedAt": "2023-02-01T00:00:00Z", - "author": map[string]any{"login": "user2"}, - "url": "https://github.com/owner/repo/discussions/2", - "category": map[string]any{"name": "Questions"}, - }, - { - "number": 3, - "title": "Discussion 3 title", - "createdAt": "2023-03-01T00:00:00Z", - "updatedAt": "2023-03-01T00:00:00Z", - "author": map[string]any{"login": "user3"}, - "url": "https://github.com/owner/repo/discussions/3", - "category": map[string]any{"name": "General"}, - }, - } - - discussionsOrgLevel = []map[string]any{ - { - "number": 1, - "title": "Org Discussion 1 - Community Guidelines", - "createdAt": "2023-01-15T00:00:00Z", - "updatedAt": "2023-01-15T00:00:00Z", - "author": map[string]any{"login": "org-admin"}, - "url": "https://github.com/owner/.github/discussions/1", - "category": map[string]any{"name": "Announcements"}, - }, - { - "number": 2, - "title": "Org Discussion 2 - Roadmap 2023", - "createdAt": "2023-02-20T00:00:00Z", - "updatedAt": "2023-02-20T00:00:00Z", - "author": map[string]any{"login": "org-admin"}, - "url": "https://github.com/owner/.github/discussions/2", - "category": map[string]any{"name": "General"}, - }, - { - "number": 3, - "title": "Org Discussion 3 - Roadmap 2024", - "createdAt": "2023-02-20T00:00:00Z", - "updatedAt": "2023-02-20T00:00:00Z", - "author": map[string]any{"login": "org-admin"}, - "url": "https://github.com/owner/.github/discussions/3", - "category": map[string]any{"name": "General"}, - }, - { - "number": 4, - "title": "Org Discussion 4 - Roadmap 2025", - "createdAt": "2023-02-20T00:00:00Z", - "updatedAt": "2023-02-20T00:00:00Z", - "author": map[string]any{"login": "org-admin"}, - "url": "https://github.com/owner/.github/discussions/4", - "category": map[string]any{"name": "General"}, - }, - } - - // Ordered mock responses - discussionsOrderedCreatedAsc = []map[string]any{ - discussionsAll[0], // Discussion 1 (created 2023-01-01) - discussionsAll[1], // Discussion 2 (created 2023-02-01) - discussionsAll[2], // Discussion 3 (created 2023-03-01) - } - - discussionsOrderedUpdatedDesc = []map[string]any{ - discussionsAll[2], // Discussion 3 (updated 2023-03-01) - discussionsAll[1], // Discussion 2 (updated 2023-02-01) - discussionsAll[0], // Discussion 1 (updated 2023-01-01) - } - - // only 'General' category discussions ordered by created date descending - discussionsGeneralOrderedDesc = []map[string]any{ - discussionsGeneral[1], // Discussion 3 (created 2023-03-01) - discussionsGeneral[0], // Discussion 1 (created 2023-01-01) - } - - mockResponseListAll = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsAll, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 3, - }, - }, - }) - mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsGeneral, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }) - mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsOrderedCreatedAsc, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 3, - }, - }, - }) - mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsOrderedUpdatedDesc, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 3, - }, - }, - }) - mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsGeneralOrderedDesc, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }) - - mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussions": map[string]any{ - "nodes": discussionsOrgLevel, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 4, - }, - }, - }) - - mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") -) - -func Test_ListDiscussions(t *testing.T) { - mockClient := githubv4.NewClient(nil) - toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "list_discussions", toolDef.Name) - assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") - assert.Contains(t, toolDef.InputSchema.Properties, "direction") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) - - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling - varsListAll := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "first": float64(30), - "after": (*string)(nil), - } - - varsRepoNotFound := map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - "first": float64(30), - "after": (*string)(nil), - } - - varsDiscussionsFiltered := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "categoryId": "DIC_kwDOABC123", - "first": float64(30), - "after": (*string)(nil), - } - - varsOrderByCreatedAsc := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "orderByField": "CREATED_AT", - "orderByDirection": "ASC", - "first": float64(30), - "after": (*string)(nil), - } - - varsOrderByUpdatedDesc := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "orderByField": "UPDATED_AT", - "orderByDirection": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsCategoryWithOrder := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "categoryId": "DIC_kwDOABC123", - "orderByField": "CREATED_AT", - "orderByDirection": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsOrgLevel := map[string]interface{}{ - "owner": "owner", - "repo": ".github", // This is what gets set when repo is not provided - "first": float64(30), - "after": (*string)(nil), - } - - tests := []struct { - name string - reqParams map[string]interface{} - expectError bool - errContains string - expectedCount int - verifyOrder func(t *testing.T, discussions []*github.Discussion) - }{ - { - name: "list all discussions without category filter", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedCount: 3, // All discussions - }, - { - name: "filter by category ID", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "category": "DIC_kwDOABC123", - }, - expectError: false, - expectedCount: 2, // Only General discussions (matching the category ID) - }, - { - name: "order by created at ascending", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "orderBy": "CREATED_AT", - "direction": "ASC", - }, - expectError: false, - expectedCount: 3, - verifyOrder: func(t *testing.T, discussions []*github.Discussion) { - // Verify discussions are ordered by created date ascending - require.Len(t, discussions, 3) - assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") - assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") - assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") - }, - }, - { - name: "order by updated at descending", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "orderBy": "UPDATED_AT", - "direction": "DESC", - }, - expectError: false, - expectedCount: 3, - verifyOrder: func(t *testing.T, discussions []*github.Discussion) { - // Verify discussions are ordered by updated date descending - require.Len(t, discussions, 3) - assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") - assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") - assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") - }, - }, - { - name: "filter by category with order", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "category": "DIC_kwDOABC123", - "orderBy": "CREATED_AT", - "direction": "DESC", - }, - expectError: false, - expectedCount: 2, - verifyOrder: func(t *testing.T, discussions []*github.Discussion) { - // Verify only General discussions, ordered by created date descending - require.Len(t, discussions, 2) - assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") - assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") - }, - }, - { - name: "order by without direction (should not use ordering)", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "orderBy": "CREATED_AT", - }, - expectError: false, - expectedCount: 3, - }, - { - name: "direction without order by (should not use ordering)", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "direction": "DESC", - }, - expectError: false, - expectedCount: 3, - }, - { - name: "repository not found error", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - }, - expectError: true, - errContains: "repository not found", - }, - { - name: "list org-level discussions (no repo provided)", - reqParams: map[string]interface{}{ - "owner": "owner", - // repo is not provided, it will default to ".github" - }, - expectError: false, - expectedCount: 4, - }, - } - - // Define the actual query strings that match the implementation - qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var httpClient *http.Client - - switch tc.name { - case "list all discussions without category filter": - matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by category ID": - matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "order by created at ascending": - matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "order by updated at descending": - matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by category with order": - matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "order by without direction (should not use ordering)": - matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "direction without order by (should not use ordering)": - matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "repository not found error": - matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "list org-level discussions (no repo provided)": - matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - } - - gqlClient := githubv4.NewClient(httpClient) - _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) - text := getTextResult(t, res).Text - - if tc.expectError { - require.True(t, res.IsError) - assert.Contains(t, text, tc.errContains) - return - } - require.NoError(t, err) - - // Parse the structured response with pagination info - var response struct { - Discussions []*github.Discussion `json:"discussions"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } - err = json.Unmarshal([]byte(text), &response) - require.NoError(t, err) - - assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) - - // Verify order if verifyOrder function is provided - if tc.verifyOrder != nil { - tc.verifyOrder(t, response.Discussions) - } - - // Verify that all returned discussions have a category if filtered - if _, hasCategory := tc.reqParams["category"]; hasCategory { - for _, discussion := range response.Discussions { - require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") - assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") - } - } - }) - } -} - -func Test_GetDiscussion(t *testing.T) { - // Verify tool definition and schema - toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) - assert.Equal(t, "get_discussion", toolDef.Name) - assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - - // Use exact string query that matches implementation output - qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" - - vars := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "discussionNumber": float64(1), - } - tests := []struct { - name string - response githubv4mock.GQLResponse - expectError bool - expected *github.Discussion - errContains string - }{ - { - name: "successful retrieval", - response: githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{"discussion": map[string]any{ - "number": 1, - "title": "Test Discussion Title", - "body": "This is a test discussion", - "url": "https://github.com/owner/repo/discussions/1", - "createdAt": "2025-04-25T12:00:00Z", - "category": map[string]any{"name": "General"}, - }}, - }), - expectError: false, - expected: &github.Discussion{ - HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), - Number: github.Ptr(1), - Title: github.Ptr("Test Discussion Title"), - Body: github.Ptr("This is a test discussion"), - CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, - DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr("General"), - }, - }, - }, - { - name: "discussion not found", - response: githubv4mock.ErrorResponse("discussion not found"), - expectError: true, - errContains: "discussion not found", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) - httpClient := githubv4mock.NewMockedHTTPClient(matcher) - gqlClient := githubv4.NewClient(httpClient) - _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) - res, err := handler(context.Background(), req) - text := getTextResult(t, res).Text - - if tc.expectError { - require.True(t, res.IsError) - assert.Contains(t, text, tc.errContains) - return - } - - require.NoError(t, err) - var out github.Discussion - require.NoError(t, json.Unmarshal([]byte(text), &out)) - assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) - assert.Equal(t, *tc.expected.Number, *out.Number) - assert.Equal(t, *tc.expected.Title, *out.Title) - assert.Equal(t, *tc.expected.Body, *out.Body) - // Check category label - assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) - }) - } -} - -func Test_GetDiscussionComments(t *testing.T) { - // Verify tool definition and schema - toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) - assert.Equal(t, "get_discussion_comments", toolDef.Name) - assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - - // Use exact string query that matches implementation output - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" - - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling - vars := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "discussionNumber": float64(1), - "first": float64(30), - "after": (*string)(nil), - } - - mockResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussion": map[string]any{ - "comments": map[string]any{ - "nodes": []map[string]any{ - {"body": "This is the first comment"}, - {"body": "This is the second comment"}, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }, - }) - matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) - httpClient := githubv4mock.NewMockedHTTPClient(matcher) - gqlClient := githubv4.NewClient(httpClient) - _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - request := createMCPRequest(map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "discussionNumber": int32(1), - }) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - // (Lines removed) - - var response struct { - Comments []*github.IssueComment `json:"comments"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Len(t, response.Comments, 2) - expectedBodies := []string{"This is the first comment", "This is the second comment"} - for i, comment := range response.Comments { - assert.Equal(t, expectedBodies[i], *comment.Body) - } -} - -func Test_ListDiscussionCategories(t *testing.T) { - mockClient := githubv4.NewClient(nil) - toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "list_discussion_categories", toolDef.Name) - assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.Description, "or organisation") - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) - - // Use exact string query that matches implementation output - qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - - // Variables for repository-level categories - varsRepo := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "first": float64(25), - } - - // Variables for organization-level categories (using .github repo) - varsOrg := map[string]interface{}{ - "owner": "owner", - "repo": ".github", - "first": float64(25), - } - - mockRespRepo := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussionCategories": map[string]any{ - "nodes": []map[string]any{ - {"id": "123", "name": "CategoryOne"}, - {"id": "456", "name": "CategoryTwo"}, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }) - - mockRespOrg := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussionCategories": map[string]any{ - "nodes": []map[string]any{ - {"id": "789", "name": "Announcements"}, - {"id": "101", "name": "General"}, - {"id": "112", "name": "Ideas"}, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 3, - }, - }, - }) - - tests := []struct { - name string - reqParams map[string]interface{} - vars map[string]interface{} - mockResponse githubv4mock.GQLResponse - expectError bool - expectedCount int - expectedCategories []map[string]string - }{ - { - name: "list repository-level discussion categories", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - vars: varsRepo, - mockResponse: mockRespRepo, - expectError: false, - expectedCount: 2, - expectedCategories: []map[string]string{ - {"id": "123", "name": "CategoryOne"}, - {"id": "456", "name": "CategoryTwo"}, - }, - }, - { - name: "list org-level discussion categories (no repo provided)", - reqParams: map[string]interface{}{ - "owner": "owner", - // repo is not provided, it will default to ".github" - }, - vars: varsOrg, - mockResponse: mockRespOrg, - expectError: false, - expectedCount: 3, - expectedCategories: []map[string]string{ - {"id": "789", "name": "Announcements"}, - {"id": "101", "name": "General"}, - {"id": "112", "name": "Ideas"}, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse) - httpClient := githubv4mock.NewMockedHTTPClient(matcher) - gqlClient := githubv4.NewClient(httpClient) - - _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) - text := getTextResult(t, res).Text - - if tc.expectError { - require.True(t, res.IsError) - return - } - require.NoError(t, err) - - var response struct { - Categories []map[string]string `json:"categories"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } - require.NoError(t, json.Unmarshal([]byte(text), &response)) - assert.Equal(t, tc.expectedCategories, response.Categories) - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" +// "time" + +// "github.com/github/github-mcp-server/internal/githubv4mock" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/shurcooL/githubv4" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// var ( +// discussionsGeneral = []map[string]any{ +// {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, +// {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, +// } +// discussionsAll = []map[string]any{ +// { +// "number": 1, +// "title": "Discussion 1 title", +// "createdAt": "2023-01-01T00:00:00Z", +// "updatedAt": "2023-01-01T00:00:00Z", +// "author": map[string]any{"login": "user1"}, +// "url": "https://github.com/owner/repo/discussions/1", +// "category": map[string]any{"name": "General"}, +// }, +// { +// "number": 2, +// "title": "Discussion 2 title", +// "createdAt": "2023-02-01T00:00:00Z", +// "updatedAt": "2023-02-01T00:00:00Z", +// "author": map[string]any{"login": "user2"}, +// "url": "https://github.com/owner/repo/discussions/2", +// "category": map[string]any{"name": "Questions"}, +// }, +// { +// "number": 3, +// "title": "Discussion 3 title", +// "createdAt": "2023-03-01T00:00:00Z", +// "updatedAt": "2023-03-01T00:00:00Z", +// "author": map[string]any{"login": "user3"}, +// "url": "https://github.com/owner/repo/discussions/3", +// "category": map[string]any{"name": "General"}, +// }, +// } + +// discussionsOrgLevel = []map[string]any{ +// { +// "number": 1, +// "title": "Org Discussion 1 - Community Guidelines", +// "createdAt": "2023-01-15T00:00:00Z", +// "updatedAt": "2023-01-15T00:00:00Z", +// "author": map[string]any{"login": "org-admin"}, +// "url": "https://github.com/owner/.github/discussions/1", +// "category": map[string]any{"name": "Announcements"}, +// }, +// { +// "number": 2, +// "title": "Org Discussion 2 - Roadmap 2023", +// "createdAt": "2023-02-20T00:00:00Z", +// "updatedAt": "2023-02-20T00:00:00Z", +// "author": map[string]any{"login": "org-admin"}, +// "url": "https://github.com/owner/.github/discussions/2", +// "category": map[string]any{"name": "General"}, +// }, +// { +// "number": 3, +// "title": "Org Discussion 3 - Roadmap 2024", +// "createdAt": "2023-02-20T00:00:00Z", +// "updatedAt": "2023-02-20T00:00:00Z", +// "author": map[string]any{"login": "org-admin"}, +// "url": "https://github.com/owner/.github/discussions/3", +// "category": map[string]any{"name": "General"}, +// }, +// { +// "number": 4, +// "title": "Org Discussion 4 - Roadmap 2025", +// "createdAt": "2023-02-20T00:00:00Z", +// "updatedAt": "2023-02-20T00:00:00Z", +// "author": map[string]any{"login": "org-admin"}, +// "url": "https://github.com/owner/.github/discussions/4", +// "category": map[string]any{"name": "General"}, +// }, +// } + +// // Ordered mock responses +// discussionsOrderedCreatedAsc = []map[string]any{ +// discussionsAll[0], // Discussion 1 (created 2023-01-01) +// discussionsAll[1], // Discussion 2 (created 2023-02-01) +// discussionsAll[2], // Discussion 3 (created 2023-03-01) +// } + +// discussionsOrderedUpdatedDesc = []map[string]any{ +// discussionsAll[2], // Discussion 3 (updated 2023-03-01) +// discussionsAll[1], // Discussion 2 (updated 2023-02-01) +// discussionsAll[0], // Discussion 1 (updated 2023-01-01) +// } + +// // only 'General' category discussions ordered by created date descending +// discussionsGeneralOrderedDesc = []map[string]any{ +// discussionsGeneral[1], // Discussion 3 (created 2023-03-01) +// discussionsGeneral[0], // Discussion 1 (created 2023-01-01) +// } + +// mockResponseListAll = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsAll, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 3, +// }, +// }, +// }) +// mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsGeneral, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }) +// mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsOrderedCreatedAsc, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 3, +// }, +// }, +// }) +// mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsOrderedUpdatedDesc, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 3, +// }, +// }, +// }) +// mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsGeneralOrderedDesc, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }) + +// mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussions": map[string]any{ +// "nodes": discussionsOrgLevel, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 4, +// }, +// }, +// }) + +// mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") +// ) + +// func Test_ListDiscussions(t *testing.T) { +// mockClient := githubv4.NewClient(nil) +// toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// assert.Equal(t, "list_discussions", toolDef.Name) +// assert.NotEmpty(t, toolDef.Description) +// assert.Contains(t, toolDef.InputSchema.Properties, "owner") +// assert.Contains(t, toolDef.InputSchema.Properties, "repo") +// assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") +// assert.Contains(t, toolDef.InputSchema.Properties, "direction") +// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + +// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling +// varsListAll := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsRepoNotFound := map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsDiscussionsFiltered := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "categoryId": "DIC_kwDOABC123", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsOrderByCreatedAsc := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "orderByField": "CREATED_AT", +// "orderByDirection": "ASC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsOrderByUpdatedDesc := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "orderByField": "UPDATED_AT", +// "orderByDirection": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsCategoryWithOrder := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "categoryId": "DIC_kwDOABC123", +// "orderByField": "CREATED_AT", +// "orderByDirection": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsOrgLevel := map[string]interface{}{ +// "owner": "owner", +// "repo": ".github", // This is what gets set when repo is not provided +// "first": float64(30), +// "after": (*string)(nil), +// } + +// tests := []struct { +// name string +// reqParams map[string]interface{} +// expectError bool +// errContains string +// expectedCount int +// verifyOrder func(t *testing.T, discussions []*github.Discussion) +// }{ +// { +// name: "list all discussions without category filter", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedCount: 3, // All discussions +// }, +// { +// name: "filter by category ID", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "category": "DIC_kwDOABC123", +// }, +// expectError: false, +// expectedCount: 2, // Only General discussions (matching the category ID) +// }, +// { +// name: "order by created at ascending", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "orderBy": "CREATED_AT", +// "direction": "ASC", +// }, +// expectError: false, +// expectedCount: 3, +// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { +// // Verify discussions are ordered by created date ascending +// require.Len(t, discussions, 3) +// assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") +// assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") +// assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") +// }, +// }, +// { +// name: "order by updated at descending", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "orderBy": "UPDATED_AT", +// "direction": "DESC", +// }, +// expectError: false, +// expectedCount: 3, +// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { +// // Verify discussions are ordered by updated date descending +// require.Len(t, discussions, 3) +// assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") +// assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") +// assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") +// }, +// }, +// { +// name: "filter by category with order", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "category": "DIC_kwDOABC123", +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// }, +// expectError: false, +// expectedCount: 2, +// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { +// // Verify only General discussions, ordered by created date descending +// require.Len(t, discussions, 2) +// assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") +// assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") +// }, +// }, +// { +// name: "order by without direction (should not use ordering)", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "orderBy": "CREATED_AT", +// }, +// expectError: false, +// expectedCount: 3, +// }, +// { +// name: "direction without order by (should not use ordering)", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "direction": "DESC", +// }, +// expectError: false, +// expectedCount: 3, +// }, +// { +// name: "repository not found error", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// }, +// expectError: true, +// errContains: "repository not found", +// }, +// { +// name: "list org-level discussions (no repo provided)", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// // repo is not provided, it will default to ".github" +// }, +// expectError: false, +// expectedCount: 4, +// }, +// } + +// // Define the actual query strings that match the implementation +// qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" +// qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" +// qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" +// qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// var httpClient *http.Client + +// switch tc.name { +// case "list all discussions without category filter": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "filter by category ID": +// matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "order by created at ascending": +// matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "order by updated at descending": +// matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "filter by category with order": +// matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "order by without direction (should not use ordering)": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "direction without order by (should not use ordering)": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "repository not found error": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "list org-level discussions (no repo provided)": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// } + +// gqlClient := githubv4.NewClient(httpClient) +// _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// req := createMCPRequest(tc.reqParams) +// res, err := handler(context.Background(), req) +// text := getTextResult(t, res).Text + +// if tc.expectError { +// require.True(t, res.IsError) +// assert.Contains(t, text, tc.errContains) +// return +// } +// require.NoError(t, err) + +// // Parse the structured response with pagination info +// var response struct { +// Discussions []*github.Discussion `json:"discussions"` +// PageInfo struct { +// HasNextPage bool `json:"hasNextPage"` +// HasPreviousPage bool `json:"hasPreviousPage"` +// StartCursor string `json:"startCursor"` +// EndCursor string `json:"endCursor"` +// } `json:"pageInfo"` +// TotalCount int `json:"totalCount"` +// } +// err = json.Unmarshal([]byte(text), &response) +// require.NoError(t, err) + +// assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) + +// // Verify order if verifyOrder function is provided +// if tc.verifyOrder != nil { +// tc.verifyOrder(t, response.Discussions) +// } + +// // Verify that all returned discussions have a category if filtered +// if _, hasCategory := tc.reqParams["category"]; hasCategory { +// for _, discussion := range response.Discussions { +// require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") +// assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") +// } +// } +// }) +// } +// } + +// func Test_GetDiscussion(t *testing.T) { +// // Verify tool definition and schema +// toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) +// assert.Equal(t, "get_discussion", toolDef.Name) +// assert.NotEmpty(t, toolDef.Description) +// assert.Contains(t, toolDef.InputSchema.Properties, "owner") +// assert.Contains(t, toolDef.InputSchema.Properties, "repo") +// assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") +// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + +// // Use exact string query that matches implementation output +// qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" + +// vars := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "discussionNumber": float64(1), +// } +// tests := []struct { +// name string +// response githubv4mock.GQLResponse +// expectError bool +// expected *github.Discussion +// errContains string +// }{ +// { +// name: "successful retrieval", +// response: githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{"discussion": map[string]any{ +// "number": 1, +// "title": "Test Discussion Title", +// "body": "This is a test discussion", +// "url": "https://github.com/owner/repo/discussions/1", +// "createdAt": "2025-04-25T12:00:00Z", +// "category": map[string]any{"name": "General"}, +// }}, +// }), +// expectError: false, +// expected: &github.Discussion{ +// HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), +// Number: github.Ptr(1), +// Title: github.Ptr("Test Discussion Title"), +// Body: github.Ptr("This is a test discussion"), +// CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, +// DiscussionCategory: &github.DiscussionCategory{ +// Name: github.Ptr("General"), +// }, +// }, +// }, +// { +// name: "discussion not found", +// response: githubv4mock.ErrorResponse("discussion not found"), +// expectError: true, +// errContains: "discussion not found", +// }, +// } +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) +// httpClient := githubv4mock.NewMockedHTTPClient(matcher) +// gqlClient := githubv4.NewClient(httpClient) +// _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) +// res, err := handler(context.Background(), req) +// text := getTextResult(t, res).Text + +// if tc.expectError { +// require.True(t, res.IsError) +// assert.Contains(t, text, tc.errContains) +// return +// } + +// require.NoError(t, err) +// var out github.Discussion +// require.NoError(t, json.Unmarshal([]byte(text), &out)) +// assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) +// assert.Equal(t, *tc.expected.Number, *out.Number) +// assert.Equal(t, *tc.expected.Title, *out.Title) +// assert.Equal(t, *tc.expected.Body, *out.Body) +// // Check category label +// assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) +// }) +// } +// } + +// func Test_GetDiscussionComments(t *testing.T) { +// // Verify tool definition and schema +// toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) +// assert.Equal(t, "get_discussion_comments", toolDef.Name) +// assert.NotEmpty(t, toolDef.Description) +// assert.Contains(t, toolDef.InputSchema.Properties, "owner") +// assert.Contains(t, toolDef.InputSchema.Properties, "repo") +// assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") +// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + +// // Use exact string query that matches implementation output +// qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + +// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling +// vars := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "discussionNumber": float64(1), +// "first": float64(30), +// "after": (*string)(nil), +// } + +// mockResponse := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussion": map[string]any{ +// "comments": map[string]any{ +// "nodes": []map[string]any{ +// {"body": "This is the first comment"}, +// {"body": "This is the second comment"}, +// }, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }, +// }) +// matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) +// httpClient := githubv4mock.NewMockedHTTPClient(matcher) +// gqlClient := githubv4.NewClient(httpClient) +// _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// request := createMCPRequest(map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "discussionNumber": int32(1), +// }) + +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// // (Lines removed) + +// var response struct { +// Comments []*github.IssueComment `json:"comments"` +// PageInfo struct { +// HasNextPage bool `json:"hasNextPage"` +// HasPreviousPage bool `json:"hasPreviousPage"` +// StartCursor string `json:"startCursor"` +// EndCursor string `json:"endCursor"` +// } `json:"pageInfo"` +// TotalCount int `json:"totalCount"` +// } +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) +// assert.Len(t, response.Comments, 2) +// expectedBodies := []string{"This is the first comment", "This is the second comment"} +// for i, comment := range response.Comments { +// assert.Equal(t, expectedBodies[i], *comment.Body) +// } +// } + +// func Test_ListDiscussionCategories(t *testing.T) { +// mockClient := githubv4.NewClient(nil) +// toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// assert.Equal(t, "list_discussion_categories", toolDef.Name) +// assert.NotEmpty(t, toolDef.Description) +// assert.Contains(t, toolDef.Description, "or organisation") +// assert.Contains(t, toolDef.InputSchema.Properties, "owner") +// assert.Contains(t, toolDef.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + +// // Use exact string query that matches implementation output +// qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + +// // Variables for repository-level categories +// varsRepo := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "first": float64(25), +// } + +// // Variables for organization-level categories (using .github repo) +// varsOrg := map[string]interface{}{ +// "owner": "owner", +// "repo": ".github", +// "first": float64(25), +// } + +// mockRespRepo := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussionCategories": map[string]any{ +// "nodes": []map[string]any{ +// {"id": "123", "name": "CategoryOne"}, +// {"id": "456", "name": "CategoryTwo"}, +// }, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }) + +// mockRespOrg := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "discussionCategories": map[string]any{ +// "nodes": []map[string]any{ +// {"id": "789", "name": "Announcements"}, +// {"id": "101", "name": "General"}, +// {"id": "112", "name": "Ideas"}, +// }, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 3, +// }, +// }, +// }) + +// tests := []struct { +// name string +// reqParams map[string]interface{} +// vars map[string]interface{} +// mockResponse githubv4mock.GQLResponse +// expectError bool +// expectedCount int +// expectedCategories []map[string]string +// }{ +// { +// name: "list repository-level discussion categories", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// vars: varsRepo, +// mockResponse: mockRespRepo, +// expectError: false, +// expectedCount: 2, +// expectedCategories: []map[string]string{ +// {"id": "123", "name": "CategoryOne"}, +// {"id": "456", "name": "CategoryTwo"}, +// }, +// }, +// { +// name: "list org-level discussion categories (no repo provided)", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// // repo is not provided, it will default to ".github" +// }, +// vars: varsOrg, +// mockResponse: mockRespOrg, +// expectError: false, +// expectedCount: 3, +// expectedCategories: []map[string]string{ +// {"id": "789", "name": "Announcements"}, +// {"id": "101", "name": "General"}, +// {"id": "112", "name": "Ideas"}, +// }, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse) +// httpClient := githubv4mock.NewMockedHTTPClient(matcher) +// gqlClient := githubv4.NewClient(httpClient) + +// _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// req := createMCPRequest(tc.reqParams) +// res, err := handler(context.Background(), req) +// text := getTextResult(t, res).Text + +// if tc.expectError { +// require.True(t, res.IsError) +// return +// } +// require.NoError(t, err) + +// var response struct { +// Categories []map[string]string `json:"categories"` +// PageInfo struct { +// HasNextPage bool `json:"hasNextPage"` +// HasPreviousPage bool `json:"hasPreviousPage"` +// StartCursor string `json:"startCursor"` +// EndCursor string `json:"endCursor"` +// } `json:"pageInfo"` +// TotalCount int `json:"totalCount"` +// } +// require.NoError(t, json.Unmarshal([]byte(text), &response)) +// assert.Equal(t, tc.expectedCategories, response.Categories) +// }) +// } +// } diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go index e703a885e..284962615 100644 --- a/pkg/github/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -1,138 +1,138 @@ package github -import ( - "context" - "encoding/json" - "fmt" - - "github.com/github/github-mcp-server/pkg/toolsets" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption { - toolsetNames := make([]string, 0, len(toolsetGroup.Toolsets)) - for name := range toolsetGroup.Toolsets { - toolsetNames = append(toolsetNames, name) - } - return mcp.Enum(toolsetNames...) -} - -func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("enable_toolset", - mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), - // Not modifying GitHub data so no need to show a warning - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("toolset", - mcp.Required(), - mcp.Description("The name of the toolset to enable"), - ToolsetEnum(toolsetGroup), - ), - ), - func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // We need to convert the toolsets back to a map for JSON serialization - toolsetName, err := RequiredParam[string](request, "toolset") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - toolset := toolsetGroup.Toolsets[toolsetName] - if toolset == nil { - return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil - } - if toolset.Enabled { - return mcp.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil - } - - toolset.Enabled = true - - // caution: this currently affects the global tools and notifies all clients: - // - // Send notification to all initialized sessions - // s.sendNotificationToAllClients("notifications/tools/list_changed", nil) - s.AddTools(toolset.GetActiveTools()...) - - return mcp.NewToolResultText(fmt.Sprintf("Toolset %s enabled", toolsetName)), nil - } -} - -func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_available_toolsets", - mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), - ReadOnlyHint: ToBoolPtr(true), - }), - ), - func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // We need to convert the toolsetGroup back to a map for JSON serialization - - payload := []map[string]string{} - - for name, ts := range toolsetGroup.Toolsets { - { - t := map[string]string{ - "name": name, - "description": ts.Description, - "can_enable": "true", - "currently_enabled": fmt.Sprintf("%t", ts.Enabled), - } - payload = append(payload, t) - } - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_toolset_tools", - mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("toolset", - mcp.Required(), - mcp.Description("The name of the toolset you want to get the tools for"), - ToolsetEnum(toolsetGroup), - ), - ), - func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // We need to convert the toolsetGroup back to a map for JSON serialization - toolsetName, err := RequiredParam[string](request, "toolset") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - toolset := toolsetGroup.Toolsets[toolsetName] - if toolset == nil { - return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil - } - payload := []map[string]string{} - - for _, st := range toolset.GetAvailableTools() { - tool := map[string]string{ - "name": st.Tool.Name, - "description": st.Tool.Description, - "can_enable": "true", - "toolset": toolsetName, - } - payload = append(payload, tool) - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" + +// "github.com/github/github-mcp-server/pkg/toolsets" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption { +// toolsetNames := make([]string, 0, len(toolsetGroup.Toolsets)) +// for name := range toolsetGroup.Toolsets { +// toolsetNames = append(toolsetNames, name) +// } +// return mcp.Enum(toolsetNames...) +// } + +// func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("enable_toolset", +// mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), +// // Not modifying GitHub data so no need to show a warning +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("toolset", +// mcp.Required(), +// mcp.Description("The name of the toolset to enable"), +// ToolsetEnum(toolsetGroup), +// ), +// ), +// func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // We need to convert the toolsets back to a map for JSON serialization +// toolsetName, err := RequiredParam[string](request, "toolset") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// toolset := toolsetGroup.Toolsets[toolsetName] +// if toolset == nil { +// return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil +// } +// if toolset.Enabled { +// return mcp.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil +// } + +// toolset.Enabled = true + +// // caution: this currently affects the global tools and notifies all clients: +// // +// // Send notification to all initialized sessions +// // s.sendNotificationToAllClients("notifications/tools/list_changed", nil) +// s.AddTools(toolset.GetActiveTools()...) + +// return mcp.NewToolResultText(fmt.Sprintf("Toolset %s enabled", toolsetName)), nil +// } +// } + +// func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_available_toolsets", +// mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// ), +// func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // We need to convert the toolsetGroup back to a map for JSON serialization + +// payload := []map[string]string{} + +// for name, ts := range toolsetGroup.Toolsets { +// { +// t := map[string]string{ +// "name": name, +// "description": ts.Description, +// "can_enable": "true", +// "currently_enabled": fmt.Sprintf("%t", ts.Enabled), +// } +// payload = append(payload, t) +// } +// } + +// r, err := json.Marshal(payload) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal features: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_toolset_tools", +// mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("toolset", +// mcp.Required(), +// mcp.Description("The name of the toolset you want to get the tools for"), +// ToolsetEnum(toolsetGroup), +// ), +// ), +// func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // We need to convert the toolsetGroup back to a map for JSON serialization +// toolsetName, err := RequiredParam[string](request, "toolset") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// toolset := toolsetGroup.Toolsets[toolsetName] +// if toolset == nil { +// return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil +// } +// payload := []map[string]string{} + +// for _, st := range toolset.GetAvailableTools() { +// tool := map[string]string{ +// "name": st.Tool.Name, +// "description": st.Tool.Description, +// "can_enable": "true", +// "toolset": toolsetName, +// } +// payload = append(payload, tool) +// } + +// r, err := json.Marshal(payload) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal features: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 7168f8c0e..9bb51ec2f 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -1,316 +1,316 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// ListGists creates a tool to list gists for a user -func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_gists", - mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_GISTS", "List Gists"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("username", - mcp.Description("GitHub username (omit for authenticated user's gists)"), - ), - mcp.WithString("since", - mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - username, err := OptionalParam[string](request, "username") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.GistListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } - - // Parse since timestamp if provided - if since != "" { - sinceTime, err := parseISOTimestamp(since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil - } - opts.Since = sinceTime - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - gists, resp, err := client.Gists.List(ctx, username, opts) - if err != nil { - return nil, fmt.Errorf("failed to list gists: %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 list gists: %s", string(body))), nil - } - - r, err := json.Marshal(gists) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetGist creates a tool to get the content of a gist -func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_gist", - mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_GIST", "Get Gist Content"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("gist_id", - mcp.Required(), - mcp.Description("The ID of the gist"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - gistID, err := RequiredParam[string](request, "gist_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) - } - - gist, resp, err := client.Gists.Get(ctx, gistID) - if err != nil { - return nil, fmt.Errorf("failed to get gist: %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 gist: %s", string(body))), nil - } - - r, err := json.Marshal(gist) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// CreateGist creates a tool to create a new gist -func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_gist", - mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_GIST", "Create Gist"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("description", - mcp.Description("Description of the gist"), - ), - mcp.WithString("filename", - mcp.Required(), - mcp.Description("Filename for simple single-file gist creation"), - ), - mcp.WithString("content", - mcp.Required(), - mcp.Description("Content for simple single-file gist creation"), - ), - mcp.WithBoolean("public", - mcp.Description("Whether the gist is public"), - mcp.DefaultBool(false), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - description, err := OptionalParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - filename, err := RequiredParam[string](request, "filename") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - content, err := RequiredParam[string](request, "content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - public, err := OptionalParam[bool](request, "public") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ - Filename: github.Ptr(filename), - Content: github.Ptr(content), - } - - gist := &github.Gist{ - Files: files, - Public: github.Ptr(public), - Description: github.Ptr(description), - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - createdGist, resp, err := client.Gists.Create(ctx, gist) - if err != nil { - return nil, fmt.Errorf("failed to create gist: %w", err) - } - 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 gist: %s", string(body))), nil - } - - minimalResponse := MinimalResponse{ - ID: createdGist.GetID(), - URL: createdGist.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 - } -} - -// UpdateGist creates a tool to edit an existing gist -func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("update_gist", - mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_GIST", "Update Gist"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("gist_id", - mcp.Required(), - mcp.Description("ID of the gist to update"), - ), - mcp.WithString("description", - mcp.Description("Updated description of the gist"), - ), - mcp.WithString("filename", - mcp.Required(), - mcp.Description("Filename to update or create"), - ), - mcp.WithString("content", - mcp.Required(), - mcp.Description("Content for the file"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - gistID, err := RequiredParam[string](request, "gist_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - description, err := OptionalParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - filename, err := RequiredParam[string](request, "filename") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - content, err := RequiredParam[string](request, "content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ - Filename: github.Ptr(filename), - Content: github.Ptr(content), - } - - gist := &github.Gist{ - Files: files, - Description: github.Ptr(description), - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) - if err != nil { - return nil, fmt.Errorf("failed to update gist: %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 update gist: %s", string(body))), nil - } - - minimalResponse := MinimalResponse{ - ID: updatedGist.GetID(), - URL: updatedGist.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 - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// // ListGists creates a tool to list gists for a user +// func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_gists", +// mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_GISTS", "List Gists"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("username", +// mcp.Description("GitHub username (omit for authenticated user's gists)"), +// ), +// mcp.WithString("since", +// mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), +// ), +// WithPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// username, err := OptionalParam[string](request, "username") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// since, err := OptionalParam[string](request, "since") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &github.GistListOptions{ +// ListOptions: github.ListOptions{ +// Page: pagination.Page, +// PerPage: pagination.PerPage, +// }, +// } + +// // Parse since timestamp if provided +// if since != "" { +// sinceTime, err := parseISOTimestamp(since) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil +// } +// opts.Since = sinceTime +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// gists, resp, err := client.Gists.List(ctx, username, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list gists: %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 list gists: %s", string(body))), nil +// } + +// r, err := json.Marshal(gists) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetGist creates a tool to get the content of a gist +// func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_gist", +// mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_GIST", "Get Gist Content"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("gist_id", +// mcp.Required(), +// mcp.Description("The ID of the gist"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// gistID, err := RequiredParam[string](request, "gist_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) +// } + +// gist, resp, err := client.Gists.Get(ctx, gistID) +// if err != nil { +// return nil, fmt.Errorf("failed to get gist: %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 gist: %s", string(body))), nil +// } + +// r, err := json.Marshal(gist) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // CreateGist creates a tool to create a new gist +// func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("create_gist", +// mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CREATE_GIST", "Create Gist"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("description", +// mcp.Description("Description of the gist"), +// ), +// mcp.WithString("filename", +// mcp.Required(), +// mcp.Description("Filename for simple single-file gist creation"), +// ), +// mcp.WithString("content", +// mcp.Required(), +// mcp.Description("Content for simple single-file gist creation"), +// ), +// mcp.WithBoolean("public", +// mcp.Description("Whether the gist is public"), +// mcp.DefaultBool(false), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// description, err := OptionalParam[string](request, "description") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// filename, err := RequiredParam[string](request, "filename") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// content, err := RequiredParam[string](request, "content") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// public, err := OptionalParam[bool](request, "public") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// files := make(map[github.GistFilename]github.GistFile) +// files[github.GistFilename(filename)] = github.GistFile{ +// Filename: github.Ptr(filename), +// Content: github.Ptr(content), +// } + +// gist := &github.Gist{ +// Files: files, +// Public: github.Ptr(public), +// Description: github.Ptr(description), +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// createdGist, resp, err := client.Gists.Create(ctx, gist) +// if err != nil { +// return nil, fmt.Errorf("failed to create gist: %w", err) +// } +// 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 gist: %s", string(body))), nil +// } + +// minimalResponse := MinimalResponse{ +// ID: createdGist.GetID(), +// URL: createdGist.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 +// } +// } + +// // UpdateGist creates a tool to edit an existing gist +// func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("update_gist", +// mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_UPDATE_GIST", "Update Gist"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("gist_id", +// mcp.Required(), +// mcp.Description("ID of the gist to update"), +// ), +// mcp.WithString("description", +// mcp.Description("Updated description of the gist"), +// ), +// mcp.WithString("filename", +// mcp.Required(), +// mcp.Description("Filename to update or create"), +// ), +// mcp.WithString("content", +// mcp.Required(), +// mcp.Description("Content for the file"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// gistID, err := RequiredParam[string](request, "gist_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// description, err := OptionalParam[string](request, "description") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// filename, err := RequiredParam[string](request, "filename") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// content, err := RequiredParam[string](request, "content") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// files := make(map[github.GistFilename]github.GistFile) +// files[github.GistFilename(filename)] = github.GistFile{ +// Filename: github.Ptr(filename), +// Content: github.Ptr(content), +// } + +// gist := &github.Gist{ +// Files: files, +// Description: github.Ptr(description), +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) +// if err != nil { +// return nil, fmt.Errorf("failed to update gist: %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 update gist: %s", string(body))), nil +// } + +// minimalResponse := MinimalResponse{ +// ID: updatedGist.GetID(), +// URL: updatedGist.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 +// } +// } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index e8eb6d7f4..e810f2499 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -1,595 +1,595 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ListGists(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_gists", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "username") - assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Empty(t, tool.InputSchema.Required) - - // Setup mock gists for success case - mockGists := []*github.Gist{ - { - ID: github.Ptr("gist1"), - Description: github.Ptr("First Gist"), - HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), - Public: github.Ptr(true), - CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, - Owner: &github.User{Login: github.Ptr("user")}, - Files: map[github.GistFilename]github.GistFile{ - "file1.txt": { - Filename: github.Ptr("file1.txt"), - Content: github.Ptr("content of file 1"), - }, - }, - }, - { - ID: github.Ptr("gist2"), - Description: github.Ptr("Second Gist"), - HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"), - Public: github.Ptr(false), - CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, - Owner: &github.User{Login: github.Ptr("testuser")}, - Files: map[github.GistFilename]github.GistFile{ - "file2.js": { - Filename: github.Ptr("file2.js"), - Content: github.Ptr("console.log('hello');"), - }, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedGists []*github.Gist - expectedErrMsg string - }{ - { - name: "list authenticated user's gists", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetGists, - mockGists, - ), - ), - requestArgs: map[string]interface{}{}, - expectError: false, - expectedGists: mockGists, - }, - { - name: "list specific user's gists", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUsersGistsByUsername, - mockResponse(t, http.StatusOK, mockGists), - ), - ), - requestArgs: map[string]interface{}{ - "username": "testuser", - }, - expectError: false, - expectedGists: mockGists, - }, - { - name: "list gists with pagination and since parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetGists, - expectQueryParams(t, map[string]string{ - "since": "2023-01-01T00:00:00Z", - "page": "2", - "per_page": "5", - }).andThen( - mockResponse(t, http.StatusOK, mockGists), - ), - ), - ), - requestArgs: map[string]interface{}{ - "since": "2023-01-01T00:00:00Z", - "page": float64(2), - "perPage": float64(5), - }, - expectError: false, - expectedGists: mockGists, - }, - { - name: "invalid since parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetGists, - mockGists, - ), - ), - requestArgs: map[string]interface{}{ - "since": "invalid-date", - }, - expectError: true, - expectedErrMsg: "invalid since timestamp", - }, - { - name: "list gists fails with error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetGists, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "failed to list gists", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } else { - // For errors returned as part of the result, not as an error - assert.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 returnedGists []*github.Gist - err = json.Unmarshal([]byte(textContent.Text), &returnedGists) - require.NoError(t, err) - - assert.Len(t, returnedGists, len(tc.expectedGists)) - for i, gist := range returnedGists { - assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID) - assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description) - assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL) - assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public) - } - }) - } -} - -func Test_GetGist(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_gist", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "gist_id") - - assert.Contains(t, tool.InputSchema.Required, "gist_id") - - // Setup mock gist for success case - mockGist := github.Gist{ - ID: github.Ptr("gist1"), - Description: github.Ptr("First Gist"), - HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), - Public: github.Ptr(true), - CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, - Owner: &github.User{Login: github.Ptr("user")}, - Files: map[github.GistFilename]github.GistFile{ - github.GistFilename("file1.txt"): { - Filename: github.Ptr("file1.txt"), - Content: github.Ptr("content of file 1"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedGists github.Gist - expectedErrMsg string - }{ - { - name: "Successful fetching different gist", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetGistsByGistId, - mockResponse(t, http.StatusOK, mockGist), - ), - ), - requestArgs: map[string]interface{}{ - "gist_id": "gist1", - }, - expectError: false, - expectedGists: mockGist, - }, - { - name: "gist_id parameter missing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetGistsByGistId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid Request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "missing required parameter: gist_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetGist(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } else { - // For errors returned as part of the result, not as an error - assert.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 returnedGists github.Gist - err = json.Unmarshal([]byte(textContent.Text), &returnedGists) - require.NoError(t, err) - - assert.Equal(t, *tc.expectedGists.ID, *returnedGists.ID) - assert.Equal(t, *tc.expectedGists.Description, *returnedGists.Description) - assert.Equal(t, *tc.expectedGists.HTMLURL, *returnedGists.HTMLURL) - assert.Equal(t, *tc.expectedGists.Public, *returnedGists.Public) - }) - } -} - -func Test_CreateGist(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "create_gist", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.Contains(t, tool.InputSchema.Properties, "filename") - assert.Contains(t, tool.InputSchema.Properties, "content") - assert.Contains(t, tool.InputSchema.Properties, "public") - - // Verify required parameters - assert.Contains(t, tool.InputSchema.Required, "filename") - assert.Contains(t, tool.InputSchema.Required, "content") - - // Setup mock data for test cases - createdGist := &github.Gist{ - ID: github.Ptr("new-gist-id"), - Description: github.Ptr("Test Gist"), - HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"), - Public: github.Ptr(false), - CreatedAt: &github.Timestamp{Time: time.Now()}, - Owner: &github.User{Login: github.Ptr("user")}, - Files: map[github.GistFilename]github.GistFile{ - "test.go": { - Filename: github.Ptr("test.go"), - Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - expectedGist *github.Gist - }{ - { - name: "create gist successfully", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostGists, - mockResponse(t, http.StatusCreated, createdGist), - ), - ), - requestArgs: map[string]interface{}{ - "filename": "test.go", - "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", - "description": "Test Gist", - "public": false, - }, - expectError: false, - expectedGist: createdGist, - }, - { - name: "missing required filename", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "content": "test content", - "description": "Test Gist", - }, - expectError: true, - expectedErrMsg: "missing required parameter: filename", - }, - { - name: "missing required content", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "filename": "test.go", - "description": "Test Gist", - }, - expectError: true, - expectedErrMsg: "missing required parameter: content", - }, - { - name: "api returns error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostGists, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "filename": "test.go", - "content": "package main", - "description": "Test Gist", - }, - expectError: true, - expectedErrMsg: "failed to create gist", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } else { - // For errors returned as part of the result, not as an error - assert.NotNil(t, result) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - assert.NotNil(t, result) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var gist MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &gist) - require.NoError(t, err) - - assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) - }) - } -} - -func Test_UpdateGist(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "update_gist", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "gist_id") - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.Contains(t, tool.InputSchema.Properties, "filename") - assert.Contains(t, tool.InputSchema.Properties, "content") - - // Verify required parameters - assert.Contains(t, tool.InputSchema.Required, "gist_id") - assert.Contains(t, tool.InputSchema.Required, "filename") - assert.Contains(t, tool.InputSchema.Required, "content") - - // Setup mock data for test cases - updatedGist := &github.Gist{ - ID: github.Ptr("existing-gist-id"), - Description: github.Ptr("Updated Test Gist"), - HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), - Public: github.Ptr(true), - UpdatedAt: &github.Timestamp{Time: time.Now()}, - Owner: &github.User{Login: github.Ptr("user")}, - Files: map[github.GistFilename]github.GistFile{ - "updated.go": { - Filename: github.Ptr("updated.go"), - Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - expectedGist *github.Gist - }{ - { - name: "update gist successfully", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchGistsByGistId, - mockResponse(t, http.StatusOK, updatedGist), - ), - ), - requestArgs: map[string]interface{}{ - "gist_id": "existing-gist-id", - "filename": "updated.go", - "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", - "description": "Updated Test Gist", - }, - expectError: false, - expectedGist: updatedGist, - }, - { - name: "missing required gist_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "filename": "updated.go", - "content": "updated content", - "description": "Updated Test Gist", - }, - expectError: true, - expectedErrMsg: "missing required parameter: gist_id", - }, - { - name: "missing required filename", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "gist_id": "existing-gist-id", - "content": "updated content", - "description": "Updated Test Gist", - }, - expectError: true, - expectedErrMsg: "missing required parameter: filename", - }, - { - name: "missing required content", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "gist_id": "existing-gist-id", - "filename": "updated.go", - "description": "Updated Test Gist", - }, - expectError: true, - expectedErrMsg: "missing required parameter: content", - }, - { - name: "api returns error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchGistsByGistId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "gist_id": "nonexistent-gist-id", - "filename": "updated.go", - "content": "package main", - "description": "Updated Test Gist", - }, - expectError: true, - expectedErrMsg: "failed to update gist", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } else { - // For errors returned as part of the result, not as an error - assert.NotNil(t, result) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - assert.NotNil(t, result) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var updateResp MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &updateResp) - require.NoError(t, err) - - assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" +// "time" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_ListGists(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_gists", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "username") +// assert.Contains(t, tool.InputSchema.Properties, "since") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Empty(t, tool.InputSchema.Required) + +// // Setup mock gists for success case +// mockGists := []*github.Gist{ +// { +// ID: github.Ptr("gist1"), +// Description: github.Ptr("First Gist"), +// HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), +// Public: github.Ptr(true), +// CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, +// Owner: &github.User{Login: github.Ptr("user")}, +// Files: map[github.GistFilename]github.GistFile{ +// "file1.txt": { +// Filename: github.Ptr("file1.txt"), +// Content: github.Ptr("content of file 1"), +// }, +// }, +// }, +// { +// ID: github.Ptr("gist2"), +// Description: github.Ptr("Second Gist"), +// HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"), +// Public: github.Ptr(false), +// CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, +// Owner: &github.User{Login: github.Ptr("testuser")}, +// Files: map[github.GistFilename]github.GistFile{ +// "file2.js": { +// Filename: github.Ptr("file2.js"), +// Content: github.Ptr("console.log('hello');"), +// }, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedGists []*github.Gist +// expectedErrMsg string +// }{ +// { +// name: "list authenticated user's gists", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetGists, +// mockGists, +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: false, +// expectedGists: mockGists, +// }, +// { +// name: "list specific user's gists", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetUsersGistsByUsername, +// mockResponse(t, http.StatusOK, mockGists), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "username": "testuser", +// }, +// expectError: false, +// expectedGists: mockGists, +// }, +// { +// name: "list gists with pagination and since parameter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetGists, +// expectQueryParams(t, map[string]string{ +// "since": "2023-01-01T00:00:00Z", +// "page": "2", +// "per_page": "5", +// }).andThen( +// mockResponse(t, http.StatusOK, mockGists), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "since": "2023-01-01T00:00:00Z", +// "page": float64(2), +// "perPage": float64(5), +// }, +// expectError: false, +// expectedGists: mockGists, +// }, +// { +// name: "invalid since parameter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetGists, +// mockGists, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "since": "invalid-date", +// }, +// expectError: true, +// expectedErrMsg: "invalid since timestamp", +// }, +// { +// name: "list gists fails with error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetGists, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "failed to list gists", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// } else { +// // For errors returned as part of the result, not as an error +// assert.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 returnedGists []*github.Gist +// err = json.Unmarshal([]byte(textContent.Text), &returnedGists) +// require.NoError(t, err) + +// assert.Len(t, returnedGists, len(tc.expectedGists)) +// for i, gist := range returnedGists { +// assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID) +// assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description) +// assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL) +// assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public) +// } +// }) +// } +// } + +// func Test_GetGist(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "get_gist", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "gist_id") + +// assert.Contains(t, tool.InputSchema.Required, "gist_id") + +// // Setup mock gist for success case +// mockGist := github.Gist{ +// ID: github.Ptr("gist1"), +// Description: github.Ptr("First Gist"), +// HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), +// Public: github.Ptr(true), +// CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, +// Owner: &github.User{Login: github.Ptr("user")}, +// Files: map[github.GistFilename]github.GistFile{ +// github.GistFilename("file1.txt"): { +// Filename: github.Ptr("file1.txt"), +// Content: github.Ptr("content of file 1"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedGists github.Gist +// expectedErrMsg string +// }{ +// { +// name: "Successful fetching different gist", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetGistsByGistId, +// mockResponse(t, http.StatusOK, mockGist), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "gist_id": "gist1", +// }, +// expectError: false, +// expectedGists: mockGist, +// }, +// { +// name: "gist_id parameter missing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetGistsByGistId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Invalid Request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "missing required parameter: gist_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetGist(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// } else { +// // For errors returned as part of the result, not as an error +// assert.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 returnedGists github.Gist +// err = json.Unmarshal([]byte(textContent.Text), &returnedGists) +// require.NoError(t, err) + +// assert.Equal(t, *tc.expectedGists.ID, *returnedGists.ID) +// assert.Equal(t, *tc.expectedGists.Description, *returnedGists.Description) +// assert.Equal(t, *tc.expectedGists.HTMLURL, *returnedGists.HTMLURL) +// assert.Equal(t, *tc.expectedGists.Public, *returnedGists.Public) +// }) +// } +// } + +// func Test_CreateGist(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "create_gist", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "description") +// assert.Contains(t, tool.InputSchema.Properties, "filename") +// assert.Contains(t, tool.InputSchema.Properties, "content") +// assert.Contains(t, tool.InputSchema.Properties, "public") + +// // Verify required parameters +// assert.Contains(t, tool.InputSchema.Required, "filename") +// assert.Contains(t, tool.InputSchema.Required, "content") + +// // Setup mock data for test cases +// createdGist := &github.Gist{ +// ID: github.Ptr("new-gist-id"), +// Description: github.Ptr("Test Gist"), +// HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"), +// Public: github.Ptr(false), +// CreatedAt: &github.Timestamp{Time: time.Now()}, +// Owner: &github.User{Login: github.Ptr("user")}, +// Files: map[github.GistFilename]github.GistFile{ +// "test.go": { +// Filename: github.Ptr("test.go"), +// Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// expectedGist *github.Gist +// }{ +// { +// name: "create gist successfully", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostGists, +// mockResponse(t, http.StatusCreated, createdGist), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "filename": "test.go", +// "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", +// "description": "Test Gist", +// "public": false, +// }, +// expectError: false, +// expectedGist: createdGist, +// }, +// { +// name: "missing required filename", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "content": "test content", +// "description": "Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: filename", +// }, +// { +// name: "missing required content", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "filename": "test.go", +// "description": "Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: content", +// }, +// { +// name: "api returns error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostGists, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "filename": "test.go", +// "content": "package main", +// "description": "Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "failed to create gist", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// } else { +// // For errors returned as part of the result, not as an error +// assert.NotNil(t, result) +// textContent := getTextResult(t, result) +// assert.Contains(t, textContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// assert.NotNil(t, result) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var gist MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &gist) +// require.NoError(t, err) + +// assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) +// }) +// } +// } + +// func Test_UpdateGist(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "update_gist", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "gist_id") +// assert.Contains(t, tool.InputSchema.Properties, "description") +// assert.Contains(t, tool.InputSchema.Properties, "filename") +// assert.Contains(t, tool.InputSchema.Properties, "content") + +// // Verify required parameters +// assert.Contains(t, tool.InputSchema.Required, "gist_id") +// assert.Contains(t, tool.InputSchema.Required, "filename") +// assert.Contains(t, tool.InputSchema.Required, "content") + +// // Setup mock data for test cases +// updatedGist := &github.Gist{ +// ID: github.Ptr("existing-gist-id"), +// Description: github.Ptr("Updated Test Gist"), +// HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), +// Public: github.Ptr(true), +// UpdatedAt: &github.Timestamp{Time: time.Now()}, +// Owner: &github.User{Login: github.Ptr("user")}, +// Files: map[github.GistFilename]github.GistFile{ +// "updated.go": { +// Filename: github.Ptr("updated.go"), +// Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// expectedGist *github.Gist +// }{ +// { +// name: "update gist successfully", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchGistsByGistId, +// mockResponse(t, http.StatusOK, updatedGist), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "gist_id": "existing-gist-id", +// "filename": "updated.go", +// "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", +// "description": "Updated Test Gist", +// }, +// expectError: false, +// expectedGist: updatedGist, +// }, +// { +// name: "missing required gist_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "filename": "updated.go", +// "content": "updated content", +// "description": "Updated Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: gist_id", +// }, +// { +// name: "missing required filename", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "gist_id": "existing-gist-id", +// "content": "updated content", +// "description": "Updated Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: filename", +// }, +// { +// name: "missing required content", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "gist_id": "existing-gist-id", +// "filename": "updated.go", +// "description": "Updated Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: content", +// }, +// { +// name: "api returns error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchGistsByGistId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "gist_id": "nonexistent-gist-id", +// "filename": "updated.go", +// "content": "package main", +// "description": "Updated Test Gist", +// }, +// expectError: true, +// expectedErrMsg: "failed to update gist", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// } else { +// // For errors returned as part of the result, not as an error +// assert.NotNil(t, result) +// textContent := getTextResult(t, result) +// assert.Contains(t, textContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// assert.NotNil(t, result) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var updateResp MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &updateResp) +// require.NoError(t, err) + +// assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) +// }) +// } +// } diff --git a/pkg/github/git.go b/pkg/github/git.go index 5dfc8e0e8..07cbb25f2 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -1,160 +1,160 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "strings" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) -// TreeEntryResponse represents a single entry in a Git tree. -type TreeEntryResponse struct { - Path string `json:"path"` - Type string `json:"type"` - Size *int `json:"size,omitempty"` - Mode string `json:"mode"` - SHA string `json:"sha"` - URL string `json:"url"` -} +// // TreeEntryResponse represents a single entry in a Git tree. +// type TreeEntryResponse struct { +// Path string `json:"path"` +// Type string `json:"type"` +// Size *int `json:"size,omitempty"` +// Mode string `json:"mode"` +// SHA string `json:"sha"` +// URL string `json:"url"` +// } -// TreeResponse represents the response structure for a Git tree. -type TreeResponse struct { - SHA string `json:"sha"` - Truncated bool `json:"truncated"` - Tree []TreeEntryResponse `json:"tree"` - TreeSHA string `json:"tree_sha"` - Owner string `json:"owner"` - Repo string `json:"repo"` - Recursive bool `json:"recursive"` - Count int `json:"count"` -} +// // TreeResponse represents the response structure for a Git tree. +// type TreeResponse struct { +// SHA string `json:"sha"` +// Truncated bool `json:"truncated"` +// Tree []TreeEntryResponse `json:"tree"` +// TreeSHA string `json:"tree_sha"` +// Owner string `json:"owner"` +// Repo string `json:"repo"` +// Recursive bool `json:"recursive"` +// Count int `json:"count"` +// } -// GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. -func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_repository_tree", - mcp.WithDescription(t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("tree_sha", - mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"), - ), - mcp.WithBoolean("recursive", - mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"), - mcp.DefaultBool(false), - ), - mcp.WithString("path_filter", - mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"), - ), - ), - 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 - } - treeSHA, err := OptionalParam[string](request, "tree_sha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - recursive, err := OptionalBoolParamWithDefault(request, "recursive", false) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pathFilter, err := OptionalParam[string](request, "path_filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// // GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. +// func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_repository_tree", +// mcp.WithDescription(t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("tree_sha", +// mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"), +// ), +// mcp.WithBoolean("recursive", +// mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"), +// mcp.DefaultBool(false), +// ), +// mcp.WithString("path_filter", +// mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"), +// ), +// ), +// 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 +// } +// treeSHA, err := OptionalParam[string](request, "tree_sha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// recursive, err := OptionalBoolParamWithDefault(request, "recursive", false) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pathFilter, err := OptionalParam[string](request, "path_filter") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError("failed to get GitHub client"), nil - } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError("failed to get GitHub client"), nil +// } - // If no tree_sha is provided, use the repository's default branch - if treeSHA == "" { - repoInfo, repoResp, err := client.Repositories.Get(ctx, owner, repo) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get repository info", - repoResp, - err, - ), nil - } - treeSHA = *repoInfo.DefaultBranch - } +// // If no tree_sha is provided, use the repository's default branch +// if treeSHA == "" { +// repoInfo, repoResp, err := client.Repositories.Get(ctx, owner, repo) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get repository info", +// repoResp, +// err, +// ), nil +// } +// treeSHA = *repoInfo.DefaultBranch +// } - // Get the tree using the GitHub Git Tree API - tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get repository tree", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// // Get the tree using the GitHub Git Tree API +// tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get repository tree", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() - // Filter tree entries if path_filter is provided - var filteredEntries []*github.TreeEntry - if pathFilter != "" { - for _, entry := range tree.Entries { - if strings.HasPrefix(entry.GetPath(), pathFilter) { - filteredEntries = append(filteredEntries, entry) - } - } - } else { - filteredEntries = tree.Entries - } +// // Filter tree entries if path_filter is provided +// var filteredEntries []*github.TreeEntry +// if pathFilter != "" { +// for _, entry := range tree.Entries { +// if strings.HasPrefix(entry.GetPath(), pathFilter) { +// filteredEntries = append(filteredEntries, entry) +// } +// } +// } else { +// filteredEntries = tree.Entries +// } - treeEntries := make([]TreeEntryResponse, len(filteredEntries)) - for i, entry := range filteredEntries { - treeEntries[i] = TreeEntryResponse{ - Path: entry.GetPath(), - Type: entry.GetType(), - Mode: entry.GetMode(), - SHA: entry.GetSHA(), - URL: entry.GetURL(), - } - if entry.Size != nil { - treeEntries[i].Size = entry.Size - } - } +// treeEntries := make([]TreeEntryResponse, len(filteredEntries)) +// for i, entry := range filteredEntries { +// treeEntries[i] = TreeEntryResponse{ +// Path: entry.GetPath(), +// Type: entry.GetType(), +// Mode: entry.GetMode(), +// SHA: entry.GetSHA(), +// URL: entry.GetURL(), +// } +// if entry.Size != nil { +// treeEntries[i].Size = entry.Size +// } +// } - response := TreeResponse{ - SHA: *tree.SHA, - Truncated: *tree.Truncated, - Tree: treeEntries, - TreeSHA: treeSHA, - Owner: owner, - Repo: repo, - Recursive: recursive, - Count: len(filteredEntries), - } +// response := TreeResponse{ +// SHA: *tree.SHA, +// Truncated: *tree.Truncated, +// Tree: treeEntries, +// TreeSHA: treeSHA, +// Owner: owner, +// Repo: repo, +// Recursive: recursive, +// Count: len(filteredEntries), +// } - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } +// r, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1032d4d04..3de63c075 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1,1661 +1,1661 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/shurcooL/githubv4" -) - -// CloseIssueInput represents the input for closing an issue via the GraphQL API. -// Used to extend the functionality of the githubv4 library to support closing issues as duplicates. -type CloseIssueInput struct { - IssueID githubv4.ID `json:"issueId"` - ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` - StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` - DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"` -} - -// IssueClosedStateReason represents the reason an issue was closed. -// Used to extend the functionality of the githubv4 library to support closing issues as duplicates. -type IssueClosedStateReason string - -const ( - IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" - IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" - IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED" -) - -// fetchIssueIDs retrieves issue IDs via the GraphQL API. -// When duplicateOf is 0, it fetches only the main issue ID. -// When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. -func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { - // Build query variables common to both cases - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers - } - - if duplicateOf == 0 { - // Only fetch the main issue ID - var query struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") - } - - return query.Repository.Issue.ID, "", nil - } - - // Fetch both issue IDs in a single query - var query struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - DuplicateIssue struct { - ID githubv4.ID - } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - // Add duplicate issue number to variables - vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers - - if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") - } - - return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil -} - -// getCloseStateReason converts a string state reason to the appropriate enum value -func getCloseStateReason(stateReason string) IssueClosedStateReason { - switch stateReason { - case "not_planned": - return IssueClosedStateReasonNotPlanned - case "duplicate": - return IssueClosedStateReasonDuplicate - default: // Default to "completed" for empty or "completed" values - return IssueClosedStateReasonCompleted - } -} - -// IssueFragment represents a fragment of an issue node in the GraphQL API. -type IssueFragment struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - State githubv4.String - DatabaseID int64 - - Author struct { - Login githubv4.String - } - CreatedAt githubv4.DateTime - UpdatedAt githubv4.DateTime - Labels struct { - Nodes []struct { - Name githubv4.String - ID githubv4.String - Description githubv4.String - } - } `graphql:"labels(first: 100)"` - Comments struct { - TotalCount githubv4.Int - } `graphql:"comments"` -} - -// Common interface for all issue query types -type IssueQueryResult interface { - GetIssueFragment() IssueQueryFragment -} - -type IssueQueryFragment struct { - Nodes []IssueFragment `graphql:"nodes"` - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int -} - -// ListIssuesQuery is the root query structure for fetching issues with optional label filtering. -type ListIssuesQuery struct { - Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -// ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. -type ListIssuesQueryTypeWithLabels struct { - Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -// ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. -type ListIssuesQueryWithSince struct { - Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -// ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. -type ListIssuesQueryTypeWithLabelsWithSince struct { - Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -// Implement the interface for all query types -func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { - return q.Repository.Issues -} - -func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { - return q.Repository.Issues -} - -func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { - return q.Repository.Issues -} - -func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { - return q.Repository.Issues -} - -func getIssueQueryType(hasLabels bool, hasSince bool) any { - switch { - case hasLabels && hasSince: - return &ListIssuesQueryTypeWithLabelsWithSince{} - case hasLabels: - return &ListIssuesQueryTypeWithLabels{} - case hasSince: - return &ListIssuesQueryWithSince{} - default: - return &ListIssuesQuery{} - } -} - -func fragmentToIssue(fragment IssueFragment) *github.Issue { - // Convert GraphQL labels to GitHub API labels format - var foundLabels []*github.Label - for _, labelNode := range fragment.Labels.Nodes { - foundLabels = append(foundLabels, &github.Label{ - Name: github.Ptr(string(labelNode.Name)), - NodeID: github.Ptr(string(labelNode.ID)), - Description: github.Ptr(string(labelNode.Description)), - }) - } - - return &github.Issue{ - Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), - CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, - UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, - User: &github.User{ - Login: github.Ptr(string(fragment.Author.Login)), - }, - State: github.Ptr(string(fragment.State)), - ID: github.Ptr(fragment.DatabaseID), - Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), - Labels: foundLabels, - Comments: github.Ptr(int(fragment.Comments.TotalCount)), - } -} - -// GetIssue creates a tool to get details of a specific issue in a GitHub repository. -func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (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_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"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository"), - ), - mcp.WithNumber("issue_number", - 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 - } - 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - gqlClient, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) - } - - switch method { - case "get": - return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) - case "get_comments": - return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) - case "get_sub_issues": - return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) - case "get_labels": - return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) - default: - return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil - } - } -} - -func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, flags FeatureFlags) (*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() }() - - 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 - } - - if flags.LockdownMode { - if issue.User != nil { - shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil - } - if shouldRemoveContent { - return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil - } - } - } - - // Sanitize title/body on response - if issue != nil { - if issue.Title != nil { - issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) - } - if issue.Body != nil { - issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) - } - } - - 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, _ FeatureFlags) (*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, _ FeatureFlags) (*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, _ FeatureFlags) (*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. -func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - - return mcp.NewTool("list_issue_types", - mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The organization owner of the repository"), - ), - ), - 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) - if err != nil { - return nil, fmt.Errorf("failed to list issue types: %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 list issue types: %s", string(body))), nil - } - - r, err := json.Marshal(issueTypes) - if err != nil { - return nil, fmt.Errorf("failed to marshal issue types: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// 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. 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), - }), - 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 comment on"), - ), - mcp.WithString("body", - mcp.Required(), - mcp.Description("Comment content"), - ), - ), - 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 - } - body, err := RequiredParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - comment := &github.IssueComment{ - Body: github.Ptr(body), - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) - if err != nil { - return nil, fmt.Errorf("failed to create comment: %w", err) - } - 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 comment: %s", string(body))), nil - } - - r, err := json.Marshal(createdComment) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// 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_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"), - ), - 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 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. 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 - } - 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 - } - replaceParent, err := OptionalParam[bool](request, "replace_parent") - 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) - } - - 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 - } - } -} - -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), - } - - 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 - } - - 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 add 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 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 -} - -// 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") - } -} - -// 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"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("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. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter."), - ), - 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) { - 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 - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - title, err := OptionalParam[string](request, "title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Optional parameters - body, err := OptionalParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get assignees - assignees, err := OptionalStringArrayParam(request, "assignees") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional milestone - milestone, err := OptionalIntParam(request, "milestone") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var milestoneNum int - if milestone != 0 { - milestoneNum = milestone - } - - // Get optional type - issueType, err := OptionalParam[string](request, "type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - gqlClient, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GraphQL client: %w", err) - } - - 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 mcp.NewToolResultError(err.Error()), nil - } - 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 - } - } -} - -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 - } - - // Create the issue request - issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, - } - - if milestoneNum != 0 { - issueRequest.Milestone = &milestoneNum - } - - if issueType != "" { - issueRequest.Type = github.Ptr(issueType) - } - - 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 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 - } - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", issue.GetID()), - URL: issue.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 -} - -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{} - - // Set optional parameters if provided - if title != "" { - issueRequest.Title = github.Ptr(title) - } - - if body != "" { - issueRequest.Body = github.Ptr(body) - } - - if len(labels) > 0 { - issueRequest.Labels = &labels - } - - if len(assignees) > 0 { - issueRequest.Assignees = &assignees - } - - if milestoneNum != 0 { - issueRequest.Milestone = &milestoneNum - } - - if issueType != "" { - issueRequest.Type = github.Ptr(issueType) - } - - 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 != "" { - // 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 -} - -// ListIssues creates a tool to list and filter repository issues -func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_issues", - mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("state", - mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), - mcp.Enum("OPEN", "CLOSED"), - ), - mcp.WithArray("labels", - mcp.Description("Filter by labels"), - mcp.Items( - map[string]interface{}{ - "type": "string", - }, - ), - ), - mcp.WithString("orderBy", - mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), - mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), - ), - mcp.WithString("direction", - mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), - mcp.Enum("ASC", "DESC"), - ), - mcp.WithString("since", - mcp.Description("Filter by date (ISO 8601 timestamp)"), - ), - WithCursorPagination(), - ), - 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 - } - - // Set optional parameters if provided - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // If the state has a value, cast into an array of strings - var states []githubv4.IssueState - if state != "" { - states = append(states, githubv4.IssueState(state)) - } else { - states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} - } - - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - orderBy, err := OptionalParam[string](request, "orderBy") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // These variables are required for the GraphQL query to be set by default - // If orderBy is empty, default to CREATED_AT - if orderBy == "" { - orderBy = "CREATED_AT" - } - // If direction is empty, default to DESC - if direction == "" { - direction = "DESC" - } - - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // There are two optional parameters: since and labels. - var sinceTime time.Time - var hasSince bool - if since != "" { - sinceTime, err = parseISOTimestamp(since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil - } - hasSince = true - } - hasLabels := len(labels) > 0 - - // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) - if err != nil { - return nil, err - } - - // Check if someone tried to use page-based pagination instead of cursor-based - if _, pageProvided := request.GetArguments()["page"]; pageProvided { - return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil - } - - // Check if pagination parameters were explicitly provided - _, perPageProvided := request.GetArguments()["perPage"] - paginationExplicit := perPageProvided - - paginationParams, err := pagination.ToGraphQLParams() - if err != nil { - return nil, err - } - - // Use default of 30 if pagination was not explicitly provided - if !paginationExplicit { - defaultFirst := int32(DefaultGraphQLPageSize) - paginationParams.First = &defaultFirst - } - - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), - } - - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - // Used within query, therefore must be set to nil and provided as $after - vars["after"] = (*githubv4.String)(nil) - } - - // Ensure optional parameters are set - if hasLabels { - // Use query with labels filtering - convert string labels to githubv4.String slice - labelStrings := make([]githubv4.String, len(labels)) - for i, label := range labels { - labelStrings[i] = githubv4.String(label) - } - vars["labels"] = labelStrings - } - - if hasSince { - vars["since"] = githubv4.DateTime{Time: sinceTime} - } - - issueQuery := getIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Extract and convert all issue nodes using the common interface - var issues []*github.Issue - var pageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - var totalCount int - - if queryResult, ok := issueQuery.(IssueQueryResult); ok { - fragment := queryResult.GetIssueFragment() - for _, issue := range fragment.Nodes { - issues = append(issues, fragmentToIssue(issue)) - } - pageInfo = fragment.PageInfo - totalCount = fragment.TotalCount - } - - // Create response with issues - response := map[string]interface{}{ - "issues": issues, - "pageInfo": map[string]interface{}{ - "hasNextPage": pageInfo.HasNextPage, - "hasPreviousPage": pageInfo.HasPreviousPage, - "startCursor": string(pageInfo.StartCursor), - "endCursor": string(pageInfo.EndCursor), - }, - "totalCount": totalCount, - } - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal issues: %w", err) - } - return mcp.NewToolResultText(string(out)), 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 { - summary string - outcomes []string - referenceLinks []string -} - -func (d *mvpDescription) String() string { - var sb strings.Builder - sb.WriteString(d.summary) - if len(d.outcomes) > 0 { - sb.WriteString("\n\n") - sb.WriteString("This tool can help with the following outcomes:\n") - for _, outcome := range d.outcomes { - sb.WriteString(fmt.Sprintf("- %s\n", outcome)) - } - } - - if len(d.referenceLinks) > 0 { - sb.WriteString("\n\n") - sb.WriteString("More information can be found at:\n") - for _, link := range d.referenceLinks { - sb.WriteString(fmt.Sprintf("- %s\n", link)) - } - } - - return sb.String() -} - -func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - description := mvpDescription{ - summary: "Assign Copilot to a specific issue in a GitHub repository.", - outcomes: []string{ - "a Pull Request created with source code changes to resolve the issue", - }, - referenceLinks: []string{ - "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", - }, - } - - return mcp.NewTool("assign_copilot_to_issue", - mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), - ReadOnlyHint: ToBoolPtr(false), - IdempotentHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issueNumber", - mcp.Required(), - mcp.Description("Issue number"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var params struct { - Owner string - Repo string - IssueNumber int32 - } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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) - } - - // Firstly, we try to find the copilot bot in the suggested actors for the repository. - // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe - // it will not be on the first page of responses, thus we will keep paginating until we find it. - type botAssignee struct { - ID githubv4.ID - Login string - TypeName string `graphql:"__typename"` - } - - type suggestedActorsQuery struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot botAssignee `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "endCursor": (*githubv4.String)(nil), - } - - var copilotAssignee *botAssignee - for { - var query suggestedActorsQuery - err := client.Query(ctx, &query, variables) - if err != nil { - return nil, err - } - - // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the - // same name on each host. We need this in order to get the ID for later assignment. - for _, node := range query.Repository.SuggestedActors.Nodes { - if node.Bot.Login == "copilot-swe-agent" { - copilotAssignee = &node.Bot - break - } - } - - if !query.Repository.SuggestedActors.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) - } - - // If we didn't find the copilot bot, we can't proceed any further. - if copilotAssignee == nil { - // The e2e tests depend upon this specific message to skip the test. - return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil - } - - // Next let's get the GQL Node ID and current assignees for this issue because the only way to - // assign copilot is to use replaceActorsForAssignable which requires the full list. - var getIssueQuery struct { - Repository struct { - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables = map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "number": githubv4.Int(params.IssueNumber), - } - - if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil - } - - // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already - // assigned to seems to have no impact (which is a good thing). - var assignCopilotMutation struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors - } `graphql:"replaceActorsForAssignable(input: $input)"` - } - - actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) - for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { - actorIDs[i] = node.ID - } - actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID - - if err := client.Mutate( - ctx, - &assignCopilotMutation, - ReplaceActorsForAssignableInput{ - AssignableID: getIssueQuery.Repository.Issue.ID, - ActorIDs: actorIDs, - }, - nil, - ); err != nil { - return nil, fmt.Errorf("failed to replace actors for assignable: %w", err) - } - - return mcp.NewToolResultText("successfully assigned copilot to issue"), nil - } -} - -type ReplaceActorsForAssignableInput struct { - AssignableID githubv4.ID `json:"assignableId"` - ActorIDs []githubv4.ID `json:"actorIds"` -} - -// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. -// Returns the parsed time or an error if parsing fails. -// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" -func parseISOTimestamp(timestamp string) (time.Time, error) { - if timestamp == "" { - return time.Time{}, fmt.Errorf("empty timestamp") - } - - // Try RFC3339 format (standard ISO 8601 with time) - t, err := time.Parse(time.RFC3339, timestamp) - if err == nil { - return t, nil - } - - // Try simple date format (YYYY-MM-DD) - t, err = time.Parse("2006-01-02", timestamp) - if err == nil { - return t, nil - } - - // Return error with supported formats - return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) -} - -func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { - return mcp.NewPrompt("AssignCodingAgent", - mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), - mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), - ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - repo := request.Params.Arguments["repo"] - - messages := []mcp.PromptMessage{ - { - Role: "user", - Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), - }, - { - Role: "user", - Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)), - }, - { - Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)), - }, - { - Role: "user", - Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."), - }, - { - Role: "assistant", - Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."), - }, - { - Role: "user", - Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."), - }, - } - return &mcp.GetPromptResult{ - Messages: messages, - }, nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" +// "strings" +// "time" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/lockdown" +// "github.com/github/github-mcp-server/pkg/sanitize" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/go-viper/mapstructure/v2" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// "github.com/shurcooL/githubv4" +// ) + +// // CloseIssueInput represents the input for closing an issue via the GraphQL API. +// // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. +// type CloseIssueInput struct { +// IssueID githubv4.ID `json:"issueId"` +// ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +// StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` +// DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"` +// } + +// // IssueClosedStateReason represents the reason an issue was closed. +// // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. +// type IssueClosedStateReason string + +// const ( +// IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" +// IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" +// IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED" +// ) + +// // fetchIssueIDs retrieves issue IDs via the GraphQL API. +// // When duplicateOf is 0, it fetches only the main issue ID. +// // When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. +// func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { +// // Build query variables common to both cases +// vars := map[string]interface{}{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers +// } + +// if duplicateOf == 0 { +// // Only fetch the main issue ID +// var query struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// if err := gqlClient.Query(ctx, &query, vars); err != nil { +// return "", "", fmt.Errorf("failed to get issue ID") +// } + +// return query.Repository.Issue.ID, "", nil +// } + +// // Fetch both issue IDs in a single query +// var query struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// DuplicateIssue struct { +// ID githubv4.ID +// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// // Add duplicate issue number to variables +// vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers + +// if err := gqlClient.Query(ctx, &query, vars); err != nil { +// return "", "", fmt.Errorf("failed to get issue ID") +// } + +// return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil +// } + +// // getCloseStateReason converts a string state reason to the appropriate enum value +// func getCloseStateReason(stateReason string) IssueClosedStateReason { +// switch stateReason { +// case "not_planned": +// return IssueClosedStateReasonNotPlanned +// case "duplicate": +// return IssueClosedStateReasonDuplicate +// default: // Default to "completed" for empty or "completed" values +// return IssueClosedStateReasonCompleted +// } +// } + +// // IssueFragment represents a fragment of an issue node in the GraphQL API. +// type IssueFragment struct { +// Number githubv4.Int +// Title githubv4.String +// Body githubv4.String +// State githubv4.String +// DatabaseID int64 + +// Author struct { +// Login githubv4.String +// } +// CreatedAt githubv4.DateTime +// UpdatedAt githubv4.DateTime +// Labels struct { +// Nodes []struct { +// Name githubv4.String +// ID githubv4.String +// Description githubv4.String +// } +// } `graphql:"labels(first: 100)"` +// Comments struct { +// TotalCount githubv4.Int +// } `graphql:"comments"` +// } + +// // Common interface for all issue query types +// type IssueQueryResult interface { +// GetIssueFragment() IssueQueryFragment +// } + +// type IssueQueryFragment struct { +// Nodes []IssueFragment `graphql:"nodes"` +// PageInfo struct { +// HasNextPage githubv4.Boolean +// HasPreviousPage githubv4.Boolean +// StartCursor githubv4.String +// EndCursor githubv4.String +// } +// TotalCount int +// } + +// // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. +// type ListIssuesQuery struct { +// Repository struct { +// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. +// type ListIssuesQueryTypeWithLabels struct { +// Repository struct { +// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. +// type ListIssuesQueryWithSince struct { +// Repository struct { +// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. +// type ListIssuesQueryTypeWithLabelsWithSince struct { +// Repository struct { +// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// // Implement the interface for all query types +// func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { +// return q.Repository.Issues +// } + +// func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { +// return q.Repository.Issues +// } + +// func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { +// return q.Repository.Issues +// } + +// func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { +// return q.Repository.Issues +// } + +// func getIssueQueryType(hasLabels bool, hasSince bool) any { +// switch { +// case hasLabels && hasSince: +// return &ListIssuesQueryTypeWithLabelsWithSince{} +// case hasLabels: +// return &ListIssuesQueryTypeWithLabels{} +// case hasSince: +// return &ListIssuesQueryWithSince{} +// default: +// return &ListIssuesQuery{} +// } +// } + +// func fragmentToIssue(fragment IssueFragment) *github.Issue { +// // Convert GraphQL labels to GitHub API labels format +// var foundLabels []*github.Label +// for _, labelNode := range fragment.Labels.Nodes { +// foundLabels = append(foundLabels, &github.Label{ +// Name: github.Ptr(string(labelNode.Name)), +// NodeID: github.Ptr(string(labelNode.ID)), +// Description: github.Ptr(string(labelNode.Description)), +// }) +// } + +// return &github.Issue{ +// Number: github.Ptr(int(fragment.Number)), +// Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), +// CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, +// UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, +// User: &github.User{ +// Login: github.Ptr(string(fragment.Author.Login)), +// }, +// State: github.Ptr(string(fragment.State)), +// ID: github.Ptr(fragment.DatabaseID), +// Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), +// Labels: foundLabels, +// Comments: github.Ptr(int(fragment.Comments.TotalCount)), +// } +// } + +// // GetIssue creates a tool to get details of a specific issue in a GitHub repository. +// func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (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_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"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository"), +// ), +// mcp.WithNumber("issue_number", +// 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 +// } +// 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// gqlClient, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) +// } + +// switch method { +// case "get": +// return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) +// case "get_comments": +// return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) +// case "get_sub_issues": +// return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) +// case "get_labels": +// return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) +// default: +// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil +// } +// } +// } + +// func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, flags FeatureFlags) (*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() }() + +// 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 +// } + +// if flags.LockdownMode { +// if issue.User != nil { +// shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil +// } +// if shouldRemoveContent { +// return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil +// } +// } +// } + +// // Sanitize title/body on response +// if issue != nil { +// if issue.Title != nil { +// issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) +// } +// if issue.Body != nil { +// issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) +// } +// } + +// 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, _ FeatureFlags) (*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, _ FeatureFlags) (*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, _ FeatureFlags) (*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. +// func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + +// return mcp.NewTool("list_issue_types", +// mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The organization owner of the repository"), +// ), +// ), +// 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) +// if err != nil { +// return nil, fmt.Errorf("failed to list issue types: %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 list issue types: %s", string(body))), nil +// } + +// r, err := json.Marshal(issueTypes) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal issue types: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // 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. 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), +// }), +// 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 comment on"), +// ), +// mcp.WithString("body", +// mcp.Required(), +// mcp.Description("Comment content"), +// ), +// ), +// 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 +// } +// body, err := RequiredParam[string](request, "body") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// comment := &github.IssueComment{ +// Body: github.Ptr(body), +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) +// if err != nil { +// return nil, fmt.Errorf("failed to create comment: %w", err) +// } +// 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 comment: %s", string(body))), nil +// } + +// r, err := json.Marshal(createdComment) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // 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_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"), +// ), +// 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 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. 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 +// } +// 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 +// } +// replaceParent, err := OptionalParam[bool](request, "replace_parent") +// 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) +// } + +// 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 +// } +// } +// } + +// 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), +// } + +// 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 +// } + +// 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 add 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 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 +// } + +// // 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") +// } +// } + +// // 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"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("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. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter."), +// ), +// 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) { +// 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 +// } +// repo, err := RequiredParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// title, err := OptionalParam[string](request, "title") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Optional parameters +// body, err := OptionalParam[string](request, "body") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get assignees +// assignees, err := OptionalStringArrayParam(request, "assignees") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get labels +// labels, err := OptionalStringArrayParam(request, "labels") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional milestone +// milestone, err := OptionalIntParam(request, "milestone") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var milestoneNum int +// if milestone != 0 { +// milestoneNum = milestone +// } + +// // Get optional type +// issueType, err := OptionalParam[string](request, "type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// gqlClient, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GraphQL client: %w", err) +// } + +// 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 mcp.NewToolResultError(err.Error()), nil +// } +// 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 +// } +// } +// } + +// 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 +// } + +// // Create the issue request +// issueRequest := &github.IssueRequest{ +// Title: github.Ptr(title), +// Body: github.Ptr(body), +// Assignees: &assignees, +// Labels: &labels, +// } + +// if milestoneNum != 0 { +// issueRequest.Milestone = &milestoneNum +// } + +// if issueType != "" { +// issueRequest.Type = github.Ptr(issueType) +// } + +// 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 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 +// } + +// // Return minimal response with just essential information +// minimalResponse := MinimalResponse{ +// ID: fmt.Sprintf("%d", issue.GetID()), +// URL: issue.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 +// } + +// 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{} + +// // Set optional parameters if provided +// if title != "" { +// issueRequest.Title = github.Ptr(title) +// } + +// if body != "" { +// issueRequest.Body = github.Ptr(body) +// } + +// if len(labels) > 0 { +// issueRequest.Labels = &labels +// } + +// if len(assignees) > 0 { +// issueRequest.Assignees = &assignees +// } + +// if milestoneNum != 0 { +// issueRequest.Milestone = &milestoneNum +// } + +// if issueType != "" { +// issueRequest.Type = github.Ptr(issueType) +// } + +// 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 != "" { +// // 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 +// } + +// // ListIssues creates a tool to list and filter repository issues +// func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_issues", +// mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("state", +// mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), +// mcp.Enum("OPEN", "CLOSED"), +// ), +// mcp.WithArray("labels", +// mcp.Description("Filter by labels"), +// mcp.Items( +// map[string]interface{}{ +// "type": "string", +// }, +// ), +// ), +// mcp.WithString("orderBy", +// mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), +// mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), +// ), +// mcp.WithString("direction", +// mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), +// mcp.Enum("ASC", "DESC"), +// ), +// mcp.WithString("since", +// mcp.Description("Filter by date (ISO 8601 timestamp)"), +// ), +// WithCursorPagination(), +// ), +// 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 +// } + +// // Set optional parameters if provided +// state, err := OptionalParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // If the state has a value, cast into an array of strings +// var states []githubv4.IssueState +// if state != "" { +// states = append(states, githubv4.IssueState(state)) +// } else { +// states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} +// } + +// // Get labels +// labels, err := OptionalStringArrayParam(request, "labels") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// orderBy, err := OptionalParam[string](request, "orderBy") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // These variables are required for the GraphQL query to be set by default +// // If orderBy is empty, default to CREATED_AT +// if orderBy == "" { +// orderBy = "CREATED_AT" +// } +// // If direction is empty, default to DESC +// if direction == "" { +// direction = "DESC" +// } + +// since, err := OptionalParam[string](request, "since") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // There are two optional parameters: since and labels. +// var sinceTime time.Time +// var hasSince bool +// if since != "" { +// sinceTime, err = parseISOTimestamp(since) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil +// } +// hasSince = true +// } +// hasLabels := len(labels) > 0 + +// // Get pagination parameters and convert to GraphQL format +// pagination, err := OptionalCursorPaginationParams(request) +// if err != nil { +// return nil, err +// } + +// // Check if someone tried to use page-based pagination instead of cursor-based +// if _, pageProvided := request.GetArguments()["page"]; pageProvided { +// return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil +// } + +// // Check if pagination parameters were explicitly provided +// _, perPageProvided := request.GetArguments()["perPage"] +// paginationExplicit := perPageProvided + +// paginationParams, err := pagination.ToGraphQLParams() +// if err != nil { +// return nil, err +// } + +// // Use default of 30 if pagination was not explicitly provided +// if !paginationExplicit { +// defaultFirst := int32(DefaultGraphQLPageSize) +// paginationParams.First = &defaultFirst +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// vars := map[string]interface{}{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "states": states, +// "orderBy": githubv4.IssueOrderField(orderBy), +// "direction": githubv4.OrderDirection(direction), +// "first": githubv4.Int(*paginationParams.First), +// } + +// if paginationParams.After != nil { +// vars["after"] = githubv4.String(*paginationParams.After) +// } else { +// // Used within query, therefore must be set to nil and provided as $after +// vars["after"] = (*githubv4.String)(nil) +// } + +// // Ensure optional parameters are set +// if hasLabels { +// // Use query with labels filtering - convert string labels to githubv4.String slice +// labelStrings := make([]githubv4.String, len(labels)) +// for i, label := range labels { +// labelStrings[i] = githubv4.String(label) +// } +// vars["labels"] = labelStrings +// } + +// if hasSince { +// vars["since"] = githubv4.DateTime{Time: sinceTime} +// } + +// issueQuery := getIssueQueryType(hasLabels, hasSince) +// if err := client.Query(ctx, issueQuery, vars); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Extract and convert all issue nodes using the common interface +// var issues []*github.Issue +// var pageInfo struct { +// HasNextPage githubv4.Boolean +// HasPreviousPage githubv4.Boolean +// StartCursor githubv4.String +// EndCursor githubv4.String +// } +// var totalCount int + +// if queryResult, ok := issueQuery.(IssueQueryResult); ok { +// fragment := queryResult.GetIssueFragment() +// for _, issue := range fragment.Nodes { +// issues = append(issues, fragmentToIssue(issue)) +// } +// pageInfo = fragment.PageInfo +// totalCount = fragment.TotalCount +// } + +// // Create response with issues +// response := map[string]interface{}{ +// "issues": issues, +// "pageInfo": map[string]interface{}{ +// "hasNextPage": pageInfo.HasNextPage, +// "hasPreviousPage": pageInfo.HasPreviousPage, +// "startCursor": string(pageInfo.StartCursor), +// "endCursor": string(pageInfo.EndCursor), +// }, +// "totalCount": totalCount, +// } +// out, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal issues: %w", err) +// } +// return mcp.NewToolResultText(string(out)), 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 { +// summary string +// outcomes []string +// referenceLinks []string +// } + +// func (d *mvpDescription) String() string { +// var sb strings.Builder +// sb.WriteString(d.summary) +// if len(d.outcomes) > 0 { +// sb.WriteString("\n\n") +// sb.WriteString("This tool can help with the following outcomes:\n") +// for _, outcome := range d.outcomes { +// sb.WriteString(fmt.Sprintf("- %s\n", outcome)) +// } +// } + +// if len(d.referenceLinks) > 0 { +// sb.WriteString("\n\n") +// sb.WriteString("More information can be found at:\n") +// for _, link := range d.referenceLinks { +// sb.WriteString(fmt.Sprintf("- %s\n", link)) +// } +// } + +// return sb.String() +// } + +// func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// description := mvpDescription{ +// summary: "Assign Copilot to a specific issue in a GitHub repository.", +// outcomes: []string{ +// "a Pull Request created with source code changes to resolve the issue", +// }, +// referenceLinks: []string{ +// "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", +// }, +// } + +// return mcp.NewTool("assign_copilot_to_issue", +// mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), +// ReadOnlyHint: ToBoolPtr(false), +// IdempotentHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("issueNumber", +// mcp.Required(), +// mcp.Description("Issue number"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// var params struct { +// Owner string +// Repo string +// IssueNumber int32 +// } +// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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) +// } + +// // Firstly, we try to find the copilot bot in the suggested actors for the repository. +// // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe +// // it will not be on the first page of responses, thus we will keep paginating until we find it. +// type botAssignee struct { +// ID githubv4.ID +// Login string +// TypeName string `graphql:"__typename"` +// } + +// type suggestedActorsQuery struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot botAssignee `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// } + +// variables := map[string]any{ +// "owner": githubv4.String(params.Owner), +// "name": githubv4.String(params.Repo), +// "endCursor": (*githubv4.String)(nil), +// } + +// var copilotAssignee *botAssignee +// for { +// var query suggestedActorsQuery +// err := client.Query(ctx, &query, variables) +// if err != nil { +// return nil, err +// } + +// // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the +// // same name on each host. We need this in order to get the ID for later assignment. +// for _, node := range query.Repository.SuggestedActors.Nodes { +// if node.Bot.Login == "copilot-swe-agent" { +// copilotAssignee = &node.Bot +// break +// } +// } + +// if !query.Repository.SuggestedActors.PageInfo.HasNextPage { +// break +// } +// variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) +// } + +// // If we didn't find the copilot bot, we can't proceed any further. +// if copilotAssignee == nil { +// // The e2e tests depend upon this specific message to skip the test. +// return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil +// } + +// // Next let's get the GQL Node ID and current assignees for this issue because the only way to +// // assign copilot is to use replaceActorsForAssignable which requires the full list. +// var getIssueQuery struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// Assignees struct { +// Nodes []struct { +// ID githubv4.ID +// } +// } `graphql:"assignees(first: 100)"` +// } `graphql:"issue(number: $number)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// } + +// variables = map[string]any{ +// "owner": githubv4.String(params.Owner), +// "name": githubv4.String(params.Repo), +// "number": githubv4.Int(params.IssueNumber), +// } + +// if err := client.Query(ctx, &getIssueQuery, variables); err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil +// } + +// // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already +// // assigned to seems to have no impact (which is a good thing). +// var assignCopilotMutation struct { +// ReplaceActorsForAssignable struct { +// Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors +// } `graphql:"replaceActorsForAssignable(input: $input)"` +// } + +// actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) +// for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { +// actorIDs[i] = node.ID +// } +// actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + +// if err := client.Mutate( +// ctx, +// &assignCopilotMutation, +// ReplaceActorsForAssignableInput{ +// AssignableID: getIssueQuery.Repository.Issue.ID, +// ActorIDs: actorIDs, +// }, +// nil, +// ); err != nil { +// return nil, fmt.Errorf("failed to replace actors for assignable: %w", err) +// } + +// return mcp.NewToolResultText("successfully assigned copilot to issue"), nil +// } +// } + +// type ReplaceActorsForAssignableInput struct { +// AssignableID githubv4.ID `json:"assignableId"` +// ActorIDs []githubv4.ID `json:"actorIds"` +// } + +// // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. +// // Returns the parsed time or an error if parsing fails. +// // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" +// func parseISOTimestamp(timestamp string) (time.Time, error) { +// if timestamp == "" { +// return time.Time{}, fmt.Errorf("empty timestamp") +// } + +// // Try RFC3339 format (standard ISO 8601 with time) +// t, err := time.Parse(time.RFC3339, timestamp) +// if err == nil { +// return t, nil +// } + +// // Try simple date format (YYYY-MM-DD) +// t, err = time.Parse("2006-01-02", timestamp) +// if err == nil { +// return t, nil +// } + +// // Return error with supported formats +// return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) +// } + +// func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { +// return mcp.NewPrompt("AssignCodingAgent", +// mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), +// mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), +// ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { +// repo := request.Params.Arguments["repo"] + +// messages := []mcp.PromptMessage{ +// { +// Role: "user", +// Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."), +// }, +// } +// return &mcp.GetPromptResult{ +// Messages: messages, +// }, nil +// } +// } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d13b93e4b..569b41bf6 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1,3521 +1,3521 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" - "time" - - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/shurcooL/githubv4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetIssue(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - defaultGQLClient := githubv4.NewClient(nil) - tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(defaultGQLClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - 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"}) - - // Setup mock issue for success case - mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("Test Issue"), - Body: github.Ptr("This is a test issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - Repository: &github.Repository{ - Name: github.Ptr("repo"), - Owner: &github.User{ - Login: github.Ptr("owner"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - gqlHTTPClient *http.Client - requestArgs map[string]interface{} - expectHandlerError bool - expectResultError bool - expectedIssue *github.Issue - expectedErrMsg string - lockdownEnabled bool - }{ - { - name: "successful issue retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectedIssue: mockIssue, - }, - { - name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - }, - expectHandlerError: true, - expectedErrMsg: "failed to get issue", - }, - { - name: "lockdown enabled - private repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), - gqlHTTPClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "username": githubv4.String("testuser"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "isPrivate": true, - "collaborators": map[string]any{ - "edges": []any{}, - }, - }, - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectedIssue: mockIssue, - lockdownEnabled: true, - }, - { - name: "lockdown enabled - user lacks push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), - gqlHTTPClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "username": githubv4.String("testuser"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "isPrivate": false, - "collaborators": map[string]any{ - "edges": []any{ - map[string]any{ - "permission": "READ", - "node": map[string]any{ - "login": "testuser", - }, - }, - }, - }, - }, - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectResultError: true, - expectedErrMsg: "access to issue details is restricted by lockdown mode", - lockdownEnabled: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - - var gqlClient *githubv4.Client - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - } else { - gqlClient = defaultGQLClient - } - - flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) - _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, flags) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectHandlerError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - if tc.expectResultError { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - textContent := getTextResult(t, result) - - var returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) - require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) - }) - } -} - -func Test_AddIssueComment(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"}) - - // Setup mock comment for success case - mockComment := &github.IssueComment{ - ID: github.Ptr(int64(123)), - Body: github.Ptr("This is a test comment"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedComment *github.IssueComment - expectedErrMsg string - }{ - { - name: "successful comment creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockComment), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "body": "This is a test comment", - }, - expectError: false, - expectedComment: mockComment, - }, - { - name: "comment creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "body": "", - }, - expectError: false, - expectedErrMsg: "missing required parameter: body", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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 returnedComment github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComment) - require.NoError(t, err) - assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) - assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) - assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) - - }) - } -} - -func Test_SearchIssues(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "search_issues", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - // Setup mock search results - mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - Issues: []*github.Issue{ - { - Number: github.Ptr(42), - Title: github.Ptr("Bug: Something is broken"), - Body: github.Ptr("This is a bug report"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - Comments: github.Ptr(5), - User: &github.User{ - Login: github.Ptr("user1"), - }, - }, - { - Number: github.Ptr(43), - Title: github.Ptr("Feature: Add new functionality"), - Body: github.Ptr("This is a feature request"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), - Comments: github.Ptr(3), - User: &github.User{ - Login: github.Ptr("user2"), - }, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.IssuesSearchResult - expectedErrMsg string - }{ - { - name: "successful issues search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": float64(1), - "perPage": float64(30), - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "issues search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:issue is:open", - "sort": "created", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:open", - "owner": "test-owner", - "repo": "test-repo", - "sort": "created", - "order": "asc", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "issues search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "bug", - "owner": "test-owner", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "issues search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "feature", - "repo": "test-repo", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "issues search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:issue repo:owner/repo is:open", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing is:issue filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:github/github-mcp-server critical", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:github/github-mcp-server critical", - "owner": "different-owner", - "repo": "different-repo", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with both is: and repo: filters already present", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:octocat/Hello-World bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:issue repo:octocat/Hello-World bug", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "complex query with multiple OR operators and existing filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "search issues fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search issues", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) - for i, issue := range returnedResult.Issues { - assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) - assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) - assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) - assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) - assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) - } - }) - } -} - -func Test_CreateIssue(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - mockGQLClient := githubv4.NewClient(nil) - tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "assignees") - 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{"method", "owner", "repo"}) - - // Setup mock issue for success case - mockIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Test Issue"), - Body: github.Ptr("This is a test issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "successful issue creation with all fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - expectRequestBody(t, map[string]any{ - "title": "Test Issue", - "body": "This is a test issue", - "labels": []any{"bug", "help wanted"}, - "assignees": []any{"user1", "user2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), - ), - requestArgs: map[string]interface{}{ - "method": "create", - "owner": "owner", - "repo": "repo", - "title": "Test Issue", - "body": "This is a test issue", - "assignees": []any{"user1", "user2"}, - "labels": []any{"bug", "help wanted"}, - "milestone": float64(5), - "type": "Bug", - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "successful issue creation with minimal fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(124), - Title: github.Ptr("Minimal Issue"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - State: github.Ptr("open"), - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "create", - "owner": "owner", - "repo": "repo", - "title": "Minimal Issue", - "assignees": nil, // Expect no failure with nil optional value. - }, - expectError: false, - expectedIssue: &github.Issue{ - Number: github.Ptr(124), - Title: github.Ptr("Minimal Issue"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - State: github.Ptr("open"), - }, - }, - { - name: "issue creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "create", - "owner": "owner", - "repo": "repo", - "title": "", - }, - expectError: false, - expectedErrMsg: "missing required parameter: title", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - gqlClient := githubv4.NewClient(nil) - _, handler := IssueWrite(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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) - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var returnedIssue MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) - require.NoError(t, err) - - assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) - }) - } -} - -func Test_ListIssues(t *testing.T) { - // Verify tool definition - mockClient := githubv4.NewClient(nil) - tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_issues", 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, "state") - assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "orderBy") - assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "after") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Mock issues data - mockIssuesAll := []map[string]any{ - { - "number": 123, - "title": "First Issue", - "body": "This is the first test issue", - "state": "OPEN", - "databaseId": 1001, - "createdAt": "2023-01-01T00:00:00Z", - "updatedAt": "2023-01-01T00:00:00Z", - "author": map[string]any{"login": "user1"}, - "labels": map[string]any{ - "nodes": []map[string]any{ - {"name": "bug", "id": "label1", "description": "Bug label"}, - }, - }, - "comments": map[string]any{ - "totalCount": 5, - }, - }, - { - "number": 456, - "title": "Second Issue", - "body": "This is the second test issue", - "state": "OPEN", - "databaseId": 1002, - "createdAt": "2023-02-01T00:00:00Z", - "updatedAt": "2023-02-01T00:00:00Z", - "author": map[string]any{"login": "user2"}, - "labels": map[string]any{ - "nodes": []map[string]any{ - {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, - }, - }, - "comments": map[string]any{ - "totalCount": 3, - }, - }, - } - - mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} - mockIssuesClosed := []map[string]any{ - { - "number": 789, - "title": "Closed Issue", - "body": "This is a closed issue", - "state": "CLOSED", - "databaseId": 1003, - "createdAt": "2023-03-01T00:00:00Z", - "updatedAt": "2023-03-01T00:00:00Z", - "author": map[string]any{"login": "user3"}, - "labels": map[string]any{ - "nodes": []map[string]any{}, - }, - "comments": map[string]any{ - "totalCount": 1, - }, - }, - } - - // Mock responses - mockResponseListAll := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issues": map[string]any{ - "nodes": mockIssuesAll, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }) - - mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issues": map[string]any{ - "nodes": mockIssuesOpen, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 2, - }, - }, - }) - - mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issues": map[string]any{ - "nodes": mockIssuesClosed, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - "totalCount": 1, - }, - }, - }) - - mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") - - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling - varsListAll := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsOpenOnly := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsClosedOnly := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsWithLabels := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "labels": []interface{}{"bug", "enhancement"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - varsRepoNotFound := map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), - } - - tests := []struct { - name string - reqParams map[string]interface{} - expectError bool - errContains string - expectedCount int - verifyOrder func(t *testing.T, issues []*github.Issue) - }{ - { - name: "list all issues", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedCount: 2, - }, - { - name: "filter by open state", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "OPEN", - }, - expectError: false, - expectedCount: 2, - }, - { - name: "filter by closed state", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "CLOSED", - }, - expectError: false, - expectedCount: 1, - }, - { - name: "filter by labels", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "labels": []any{"bug", "enhancement"}, - }, - expectError: false, - expectedCount: 2, - }, - { - name: "repository not found error", - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - }, - expectError: true, - errContains: "repository not found", - }, - } - - // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var httpClient *http.Client - - switch tc.name { - case "list all issues": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by open state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by closed state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by labels": - matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "repository not found error": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - } - - gqlClient := githubv4.NewClient(httpClient) - _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) - text := getTextResult(t, res).Text - - if tc.expectError { - require.True(t, res.IsError) - assert.Contains(t, text, tc.errContains) - return - } - require.NoError(t, err) - - // Parse the structured response with pagination info - var response struct { - Issues []*github.Issue `json:"issues"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } - err = json.Unmarshal([]byte(text), &response) - require.NoError(t, err) - - assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) - - // Verify order if verifyOrder function is provided - if tc.verifyOrder != nil { - tc.verifyOrder(t, response.Issues) - } - - // Verify that returned issues have expected structure - for _, issue := range response.Issues { - assert.NotNil(t, issue.Number, "Issue should have number") - assert.NotNil(t, issue.Title, "Issue should have title") - assert.NotNil(t, issue.State, "Issue should have state") - } - }) - } -} - -func Test_UpdateIssue(t *testing.T) { - // Verify tool definition - mockClient := github.NewClient(nil) - mockGQLClient := githubv4.NewClient(nil) - tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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") - assert.Contains(t, tool.InputSchema.Properties, "title") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "assignees") - assert.Contains(t, tool.InputSchema.Properties, "milestone") - assert.Contains(t, tool.InputSchema.Properties, "type") - 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{"method", "owner", "repo"}) - - // Mock issues for reuse across test cases - mockBaseIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Title"), - Body: github.Ptr("Description"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - } - - mockUpdatedIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - State: github.Ptr("closed"), - StateReason: github.Ptr("duplicate"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - } - - mockReopenedIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Title"), - State: github.Ptr("open"), - StateReason: github.Ptr("reopened"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - } - - // Mock GraphQL responses for reuse across test cases - issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - }, - }) - - duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - "duplicateIssue": map[string]any{ - "id": "I_kwDOA0xdyM50BPbP", - }, - }, - }) - - closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ - "closeIssue": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - "number": 123, - "url": "https://github.com/owner/repo/issues/123", - "state": "CLOSED", - }, - }, - }) - - reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{ - "reopenIssue": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - "number": 123, - "url": "https://github.com/owner/repo/issues/123", - "state": "OPEN", - }, - }, - }) - - duplicateStateReason := IssueClosedStateReasonDuplicate - - tests := []struct { - name string - mockedRESTClient *http.Client - mockedGQLClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "partial update of non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedIssue), - ), - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "title": "Updated Title", - "body": "Updated Description", - }, - expectError: false, - expectedIssue: mockUpdatedIssue, - }, - { - name: "issue not found when updating non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - "title": "Updated Title", - }, - expectError: true, - expectedErrMsg: "failed to update issue", - }, - { - name: "close issue as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - DuplicateIssue struct { - ID githubv4.ID - } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - "duplicateOf": githubv4.Int(456), - }, - duplicateIssueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - }{}, - CloseIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - StateReason: &duplicateStateReason, - DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), - }, - nil, - closeSuccessResponse, - ), - ), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "state": "closed", - "state_reason": "duplicate", - "duplicate_of": float64(456), - }, - expectError: false, - expectedIssue: mockUpdatedIssue, - }, - { - name: "reopen issue", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `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), - }, - issueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - ReopenIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"reopenIssue(input: $input)"` - }{}, - githubv4.ReopenIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - }, - nil, - reopenSuccessResponse, - ), - ), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "state": "open", - }, - expectError: false, - expectedIssue: mockReopenedIssue, - }, - { - name: "main issue not found when trying to close it", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(999), - }, - githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), - ), - ), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - "state": "closed", - "state_reason": "not_planned", - }, - expectError: true, - expectedErrMsg: "Failed to find issues", - }, - { - name: "duplicate issue not found when closing as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - DuplicateIssue struct { - ID githubv4.ID - } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - "duplicateOf": githubv4.Int(999), - }, - githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), - ), - ), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "state": "closed", - "state_reason": "duplicate", - "duplicate_of": float64(999), - }, - expectError: true, - expectedErrMsg: "Failed to find issues", - }, - { - name: "close as duplicate with combined non-state updates", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - "labels": []any{"bug", "priority"}, - "assignees": []any{"assignee1", "assignee2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusOK, &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - State: github.Ptr("open"), // Still open after REST update - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - }), - ), - ), - ), - mockedGQLClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - DuplicateIssue struct { - ID githubv4.ID - } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - "duplicateOf": githubv4.Int(456), - }, - duplicateIssueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - }{}, - CloseIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - StateReason: &duplicateStateReason, - DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), - }, - nil, - closeSuccessResponse, - ), - ), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "title": "Updated Title", - "body": "Updated Description", - "labels": []any{"bug", "priority"}, - "assignees": []any{"assignee1", "assignee2"}, - "milestone": float64(5), - "type": "Bug", - "state": "closed", - "state_reason": "duplicate", - "duplicate_of": float64(456), - }, - expectError: false, - expectedIssue: mockUpdatedIssue, - }, - { - name: "duplicate_of without duplicate state_reason should fail", - mockedRESTClient: mock.NewMockedHTTPClient(), - mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "method": "update", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "state": "closed", - "state_reason": "completed", - "duplicate_of": float64(456), - }, - expectError: true, - expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup clients with mocks - restClient := github.NewClient(tc.mockedRESTClient) - gqlClient := githubv4.NewClient(tc.mockedGQLClient) - _, handler := IssueWrite(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError || tc.expectedErrMsg != "" { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - 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 - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var updateResp MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &updateResp) - require.NoError(t, err) - - assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) - }) - } -} - -func Test_ParseISOTimestamp(t *testing.T) { - tests := []struct { - name string - input string - expectedErr bool - expectedTime time.Time - }{ - { - name: "valid RFC3339 format", - input: "2023-01-15T14:30:00Z", - expectedErr: false, - expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC), - }, - { - name: "valid date only format", - input: "2023-01-15", - expectedErr: false, - expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), - }, - { - name: "empty timestamp", - input: "", - expectedErr: true, - }, - { - name: "invalid format", - input: "15/01/2023", - expectedErr: true, - }, - { - name: "invalid date", - input: "2023-13-45", - expectedErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - parsedTime, err := parseISOTimestamp(tc.input) - - if tc.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedTime, parsedTime) - } - }) - } -} - -func Test_GetIssueComments(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - gqlClient := githubv4.NewClient(nil) - tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - 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.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) - - // Setup mock comments for success case - mockComments := []*github.IssueComment{ - { - ID: github.Ptr(int64(123)), - Body: github.Ptr("This is the first comment"), - User: &github.User{ - Login: github.Ptr("user1"), - }, - CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, - }, - { - ID: github.Ptr(int64(456)), - Body: github.Ptr("This is the second comment"), - User: &github.User{ - Login: github.Ptr("user2"), - }, - CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)}, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedComments []*github.IssueComment - expectedErrMsg string - }{ - { - name: "successful comments retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockComments, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_comments", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedComments: mockComments, - }, - { - name: "successful comments retrieval with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockComments), - ), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_comments", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedComments: mockComments, - }, - { - name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_comments", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get issue comments", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - gqlClient := githubv4.NewClient(nil) - _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedComments []*github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) - require.NoError(t, err) - assert.Equal(t, len(tc.expectedComments), len(returnedComments)) - if len(returnedComments) > 0 { - assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) - assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) - } - }) - } -} - -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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - 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() - - // Verify tool definition - mockClient := githubv4.NewClient(nil) - tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "assign_copilot_to_issue", 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, "issueNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) - - var pageOfFakeBots = func(n int) []struct{} { - // We don't _really_ need real bots here, just objects that count as entries for the page - bots := make([]struct{}, n) - for i := range n { - bots[i] = struct{}{} - } - return bots - } - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful assignment when there are no existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - name: "successful assignment when there are existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("existing-assignee-id"), - }, - map[string]any{ - "id": githubv4.ID("existing-assignee-id-2"), - }, - }, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{ - githubv4.ID("existing-assignee-id"), - githubv4.ID("existing-assignee-id-2"), - githubv4.ID("copilot-swe-agent-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - name: "copilot bot not on first page of suggested actors", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - // First page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": pageOfFakeBots(100), - "pageInfo": map[string]any{ - "hasNextPage": true, - "endCursor": githubv4.String("next-page-cursor"), - }, - }, - }, - }), - ), - // Second page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": githubv4.String("next-page-cursor"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - name: "copilot not a suggested actor", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{}, - }, - }, - }), - ), - ), - expectToolError: true, - expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - - t.Parallel() - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) - require.Equal(t, textContent.Text, "successfully assigned copilot to issue") - }) - } -} - -func Test_AddSubIssue(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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{"method", "owner", "repo", "issue_number", "sub_issue_id"}) - - // Setup mock issue for success case (matches GitHub API response format) - mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("Parent Issue"), - Body: github.Ptr("This is the parent issue with a sub-issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - Labels: []*github.Label{ - { - Name: github.Ptr("enhancement"), - Color: github.Ptr("84b6eb"), - Description: github.Ptr("New feature or request"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "successful sub-issue addition with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "replace_parent": true, - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "successful sub-issue addition with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(456), - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "successful sub-issue addition with replace_parent false", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(789), - "replace_parent": false, - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "failed to add sub-issue", - }, - { - name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(999), - }, - expectError: false, - expectedErrMsg: "failed to add sub-issue", - }, - { - name: "validation failed - sub-issue cannot be parent of itself", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(42), - }, - expectError: false, - expectedErrMsg: "failed to add sub-issue", - }, - { - name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - 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), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "failed to add sub-issue", - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // 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), - }, - expectError: false, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "add", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedErrMsg: "missing required parameter: sub_issue_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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 returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) - require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) - }) - } -} - -func Test_GetSubIssues(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - gqlClient := githubv4.NewClient(nil) - tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - 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.Contains(t, tool.InputSchema.Properties, "page") - 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{ - { - Number: github.Ptr(123), - Title: github.Ptr("Sub-issue 1"), - Body: github.Ptr("This is the first sub-issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - User: &github.User{ - Login: github.Ptr("user1"), - }, - Labels: []*github.Label{ - { - Name: github.Ptr("bug"), - Color: github.Ptr("d73a4a"), - Description: github.Ptr("Something isn't working"), - }, - }, - }, - { - Number: github.Ptr(124), - Title: github.Ptr("Sub-issue 2"), - Body: github.Ptr("This is the second sub-issue"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - User: &github.User{ - Login: github.Ptr("user2"), - }, - Assignees: []*github.User{ - {Login: github.Ptr("assignee1")}, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedSubIssues []*github.Issue - expectedErrMsg string - }{ - { - name: "successful sub-issues listing with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockSubIssues, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedSubIssues: mockSubIssues, - }, - { - name: "successful sub-issues listing with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSubIssues), - ), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedSubIssues: mockSubIssues, - }, - { - name: "successful sub-issues listing with empty result", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - []*github.Issue{}, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedSubIssues: []*github.Issue{}, - }, - { - name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - }, - expectError: false, - expectedErrMsg: "failed to list sub-issues", - }, - { - name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "nonexistent", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedErrMsg: "failed to list sub-issues", - }, - { - name: "sub-issues feature gone/deprecated", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedErrMsg: "failed to list sub-issues", - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter issue_number", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "get_sub_issues", - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedErrMsg: "missing required parameter: issue_number", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - gqlClient := githubv4.NewClient(nil) - _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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 returnedSubIssues []*github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) - require.NoError(t, err) - - assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) - for i, subIssue := range returnedSubIssues { - if i < len(tc.expectedSubIssues) { - assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) - assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) - assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) - assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) - assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) - - if tc.expectedSubIssues[i].Body != nil { - assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) - } - } - } - }) - } -} - -func Test_RemoveSubIssue(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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{"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{ - Number: github.Ptr(42), - Title: github.Ptr("Parent Issue"), - Body: github.Ptr("This is the parent issue after sub-issue removal"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - Labels: []*github.Label{ - { - Name: github.Ptr("enhancement"), - Color: github.Ptr("84b6eb"), - Description: github.Ptr("New feature or request"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "successful sub-issue removal", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "failed to remove sub-issue", - }, - { - name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(999), - }, - expectError: false, - expectedErrMsg: "failed to remove sub-issue", - }, - { - name: "bad request - invalid sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(-1), - }, - expectError: false, - expectedErrMsg: "failed to remove sub-issue", - }, - { - name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "nonexistent", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "failed to remove sub-issue", - }, - { - name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - 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), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "failed to remove sub-issue", - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // 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), - }, - expectError: false, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "remove", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - }, - expectError: false, - expectedErrMsg: "missing required parameter: sub_issue_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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 returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) - require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) - }) - } -} - -func Test_ReprioritizeSubIssue(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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{"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{ - Number: github.Ptr(42), - Title: github.Ptr("Parent Issue"), - Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - Labels: []*github.Label{ - { - Name: github.Ptr("enhancement"), - Color: github.Ptr("84b6eb"), - Description: github.Ptr("New feature or request"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "successful reprioritization with after_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "after_id": float64(456), - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "successful reprioritization with before_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "before_id": float64(789), - }, - expectError: false, - expectedIssue: mockIssue, - }, - { - name: "validation error - neither after_id nor before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - }, - expectError: false, - expectedErrMsg: "either after_id or before_id must be specified", - }, - { - name: "validation error - both after_id and before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "after_id": float64(456), - "before_id": float64(789), - }, - expectError: false, - expectedErrMsg: "only one of after_id or before_id should be specified, not both", - }, - { - name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(999), - "sub_issue_id": float64(123), - "after_id": float64(456), - }, - expectError: false, - expectedErrMsg: "failed to reprioritize sub-issue", - }, - { - name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(999), - "after_id": float64(456), - }, - expectError: false, - expectedErrMsg: "failed to reprioritize sub-issue", - }, - { - name: "validation failed - positioning sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "after_id": float64(999), - }, - expectError: false, - expectedErrMsg: "failed to reprioritize sub-issue", - }, - { - name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "after_id": float64(456), - }, - expectError: false, - expectedErrMsg: "failed to reprioritize sub-issue", - }, - { - name: "service unavailable", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), - ), - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "sub_issue_id": float64(123), - "before_id": float64(456), - }, - expectError: false, - expectedErrMsg: "failed to reprioritize sub-issue", - }, - { - name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // 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), - "after_id": float64(456), - }, - expectError: false, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), - requestArgs: map[string]interface{}{ - "method": "reprioritize", - "owner": "owner", - "repo": "repo", - "issue_number": float64(42), - "after_id": float64(456), - }, - expectError: false, - expectedErrMsg: "missing required parameter: sub_issue_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), 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 returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) - require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) - }) - } -} - -func Test_ListIssueTypes(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_issue_types", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) - - // Setup mock issue types for success case - mockIssueTypes := []*github.IssueType{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("bug"), - Description: github.Ptr("Something isn't working"), - Color: github.Ptr("d73a4a"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("feature"), - Description: github.Ptr("New feature or enhancement"), - Color: github.Ptr("a2eeef"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssueTypes []*github.IssueType - expectedErrMsg string - }{ - { - name: "successful issue types retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "testorg", - }, - expectError: false, - expectedIssueTypes: mockIssueTypes, - }, - { - name: "organization not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/nonexistent/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to list issue types", - }, - { - name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: false, // This should be handled by parameter validation, error returned in result - expectedErrMsg: "missing required parameter: owner", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - // Check if error is returned as tool result error - require.NotNil(t, result) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - // Check if it's a parameter validation error (returned as tool result error) - if result != nil && result.IsError { - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { - return // This is expected for parameter validation errors - } - } - - require.NoError(t, err) - require.NotNil(t, result) - require.False(t, result.IsError) - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedIssueTypes []*github.IssueType - err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) - require.NoError(t, err) - - if tc.expectedIssueTypes != nil { - require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) - for i, expected := range tc.expectedIssueTypes { - assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) - assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) - assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) - assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) - } - } - }) - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "net/http" +// "strings" +// "testing" +// "time" + +// "github.com/github/github-mcp-server/internal/githubv4mock" +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/shurcooL/githubv4" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_GetIssue(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// defaultGQLClient := githubv4.NewClient(nil) +// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(defaultGQLClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// 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"}) + +// // Setup mock issue for success case +// mockIssue := &github.Issue{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test Issue"), +// Body: github.Ptr("This is a test issue"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// Repository: &github.Repository{ +// Name: github.Ptr("repo"), +// Owner: &github.User{ +// Login: github.Ptr("owner"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// gqlHTTPClient *http.Client +// requestArgs map[string]interface{} +// expectHandlerError bool +// expectResultError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// lockdownEnabled bool +// }{ +// { +// name: "successful issue retrieval", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesByOwnerByRepoByIssueNumber, +// mockIssue, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectedIssue: mockIssue, +// }, +// { +// name: "issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// }, +// expectHandlerError: true, +// expectedErrMsg: "failed to get issue", +// }, +// { +// name: "lockdown enabled - private repository", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesByOwnerByRepoByIssueNumber, +// mockIssue, +// ), +// ), +// gqlHTTPClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// IsPrivate githubv4.Boolean +// Collaborators struct { +// Edges []struct { +// Permission githubv4.String +// Node struct { +// Login githubv4.String +// } +// } +// } `graphql:"collaborators(query: $username, first: 1)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "username": githubv4.String("testuser"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "isPrivate": true, +// "collaborators": map[string]any{ +// "edges": []any{}, +// }, +// }, +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectedIssue: mockIssue, +// lockdownEnabled: true, +// }, +// { +// name: "lockdown enabled - user lacks push access", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesByOwnerByRepoByIssueNumber, +// mockIssue, +// ), +// ), +// gqlHTTPClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// IsPrivate githubv4.Boolean +// Collaborators struct { +// Edges []struct { +// Permission githubv4.String +// Node struct { +// Login githubv4.String +// } +// } +// } `graphql:"collaborators(query: $username, first: 1)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "username": githubv4.String("testuser"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "isPrivate": false, +// "collaborators": map[string]any{ +// "edges": []any{ +// map[string]any{ +// "permission": "READ", +// "node": map[string]any{ +// "login": "testuser", +// }, +// }, +// }, +// }, +// }, +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectResultError: true, +// expectedErrMsg: "access to issue details is restricted by lockdown mode", +// lockdownEnabled: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) + +// var gqlClient *githubv4.Client +// if tc.gqlHTTPClient != nil { +// gqlClient = githubv4.NewClient(tc.gqlHTTPClient) +// } else { +// gqlClient = defaultGQLClient +// } + +// flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) +// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, flags) + +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectHandlerError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.NotNil(t, result) + +// if tc.expectResultError { +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// textContent := getTextResult(t, result) + +// var returnedIssue github.Issue +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) +// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) +// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) +// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) +// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) +// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) +// }) +// } +// } + +// func Test_AddIssueComment(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"}) + +// // Setup mock comment for success case +// mockComment := &github.IssueComment{ +// ID: github.Ptr(int64(123)), +// Body: github.Ptr("This is a test comment"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedComment *github.IssueComment +// expectedErrMsg string +// }{ +// { +// name: "successful comment creation", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusCreated, mockComment), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "body": "This is a test comment", +// }, +// expectError: false, +// expectedComment: mockComment, +// }, +// { +// name: "comment creation fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "body": "", +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: body", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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 returnedComment github.IssueComment +// err = json.Unmarshal([]byte(textContent.Text), &returnedComment) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) +// assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) +// assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) + +// }) +// } +// } + +// func Test_SearchIssues(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "search_issues", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// // Setup mock search results +// mockSearchResult := &github.IssuesSearchResult{ +// Total: github.Ptr(2), +// IncompleteResults: github.Ptr(false), +// Issues: []*github.Issue{ +// { +// Number: github.Ptr(42), +// Title: github.Ptr("Bug: Something is broken"), +// Body: github.Ptr("This is a bug report"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), +// Comments: github.Ptr(5), +// User: &github.User{ +// Login: github.Ptr("user1"), +// }, +// }, +// { +// Number: github.Ptr(43), +// Title: github.Ptr("Feature: Add new functionality"), +// Body: github.Ptr("This is a feature request"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), +// Comments: github.Ptr(3), +// User: &github.User{ +// Login: github.Ptr("user2"), +// }, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.IssuesSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful issues search with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:issue repo:owner/repo is:open", +// "sort": "created", +// "order": "desc", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:owner/repo is:open", +// "sort": "created", +// "order": "desc", +// "page": float64(1), +// "perPage": float64(30), +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "issues search with owner and repo parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "repo:test-owner/test-repo is:issue is:open", +// "sort": "created", +// "order": "asc", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:open", +// "owner": "test-owner", +// "repo": "test-repo", +// "sort": "created", +// "order": "asc", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "issues search with only owner parameter (should ignore it)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:issue bug", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "bug", +// "owner": "test-owner", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "issues search with only repo parameter (should ignore it)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:issue feature", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "feature", +// "repo": "test-repo", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "issues search with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetSearchIssues, +// mockSearchResult, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:issue repo:owner/repo is:open", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing is:issue filter - no duplication", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:issue repo:github/github-mcp-server critical", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:github/github-mcp-server critical", +// "owner": "different-owner", +// "repo": "different-repo", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with both is: and repo: filters already present", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:issue repo:octocat/Hello-World bug", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:issue repo:octocat/Hello-World bug", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "complex query with multiple OR operators and existing filters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "search issues fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search issues", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) +// for i, issue := range returnedResult.Issues { +// assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) +// assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) +// assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) +// assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) +// assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) +// } +// }) +// } +// } + +// func Test_CreateIssue(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// mockGQLClient := githubv4.NewClient(nil) +// tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "assignees") +// 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{"method", "owner", "repo"}) + +// // Setup mock issue for success case +// mockIssue := &github.Issue{ +// Number: github.Ptr(123), +// Title: github.Ptr("Test Issue"), +// Body: github.Ptr("This is a test issue"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, +// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, +// Milestone: &github.Milestone{Number: github.Ptr(5)}, +// Type: &github.IssueType{Name: github.Ptr("Bug")}, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// }{ +// { +// name: "successful issue creation with all fields", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesByOwnerByRepo, +// expectRequestBody(t, map[string]any{ +// "title": "Test Issue", +// "body": "This is a test issue", +// "labels": []any{"bug", "help wanted"}, +// "assignees": []any{"user1", "user2"}, +// "milestone": float64(5), +// "type": "Bug", +// }).andThen( +// mockResponse(t, http.StatusCreated, mockIssue), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "title": "Test Issue", +// "body": "This is a test issue", +// "assignees": []any{"user1", "user2"}, +// "labels": []any{"bug", "help wanted"}, +// "milestone": float64(5), +// "type": "Bug", +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "successful issue creation with minimal fields", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesByOwnerByRepo, +// mockResponse(t, http.StatusCreated, &github.Issue{ +// Number: github.Ptr(124), +// Title: github.Ptr("Minimal Issue"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), +// State: github.Ptr("open"), +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "title": "Minimal Issue", +// "assignees": nil, // Expect no failure with nil optional value. +// }, +// expectError: false, +// expectedIssue: &github.Issue{ +// Number: github.Ptr(124), +// Title: github.Ptr("Minimal Issue"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), +// State: github.Ptr("open"), +// }, +// }, +// { +// name: "issue creation fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "title": "", +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: title", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// gqlClient := githubv4.NewClient(nil) +// _, handler := IssueWrite(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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) +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var returnedIssue MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) +// require.NoError(t, err) + +// assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) +// }) +// } +// } + +// func Test_ListIssues(t *testing.T) { +// // Verify tool definition +// mockClient := githubv4.NewClient(nil) +// tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_issues", 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, "state") +// assert.Contains(t, tool.InputSchema.Properties, "labels") +// assert.Contains(t, tool.InputSchema.Properties, "orderBy") +// assert.Contains(t, tool.InputSchema.Properties, "direction") +// assert.Contains(t, tool.InputSchema.Properties, "since") +// assert.Contains(t, tool.InputSchema.Properties, "after") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Mock issues data +// mockIssuesAll := []map[string]any{ +// { +// "number": 123, +// "title": "First Issue", +// "body": "This is the first test issue", +// "state": "OPEN", +// "databaseId": 1001, +// "createdAt": "2023-01-01T00:00:00Z", +// "updatedAt": "2023-01-01T00:00:00Z", +// "author": map[string]any{"login": "user1"}, +// "labels": map[string]any{ +// "nodes": []map[string]any{ +// {"name": "bug", "id": "label1", "description": "Bug label"}, +// }, +// }, +// "comments": map[string]any{ +// "totalCount": 5, +// }, +// }, +// { +// "number": 456, +// "title": "Second Issue", +// "body": "This is the second test issue", +// "state": "OPEN", +// "databaseId": 1002, +// "createdAt": "2023-02-01T00:00:00Z", +// "updatedAt": "2023-02-01T00:00:00Z", +// "author": map[string]any{"login": "user2"}, +// "labels": map[string]any{ +// "nodes": []map[string]any{ +// {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, +// }, +// }, +// "comments": map[string]any{ +// "totalCount": 3, +// }, +// }, +// } + +// mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} +// mockIssuesClosed := []map[string]any{ +// { +// "number": 789, +// "title": "Closed Issue", +// "body": "This is a closed issue", +// "state": "CLOSED", +// "databaseId": 1003, +// "createdAt": "2023-03-01T00:00:00Z", +// "updatedAt": "2023-03-01T00:00:00Z", +// "author": map[string]any{"login": "user3"}, +// "labels": map[string]any{ +// "nodes": []map[string]any{}, +// }, +// "comments": map[string]any{ +// "totalCount": 1, +// }, +// }, +// } + +// // Mock responses +// mockResponseListAll := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issues": map[string]any{ +// "nodes": mockIssuesAll, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }) + +// mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issues": map[string]any{ +// "nodes": mockIssuesOpen, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 2, +// }, +// }, +// }) + +// mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issues": map[string]any{ +// "nodes": mockIssuesClosed, +// "pageInfo": map[string]any{ +// "hasNextPage": false, +// "hasPreviousPage": false, +// "startCursor": "", +// "endCursor": "", +// }, +// "totalCount": 1, +// }, +// }, +// }) + +// mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") + +// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling +// varsListAll := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "states": []interface{}{"OPEN", "CLOSED"}, +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsOpenOnly := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "states": []interface{}{"OPEN"}, +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsClosedOnly := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "states": []interface{}{"CLOSED"}, +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsWithLabels := map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "states": []interface{}{"OPEN", "CLOSED"}, +// "labels": []interface{}{"bug", "enhancement"}, +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// varsRepoNotFound := map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// "states": []interface{}{"OPEN", "CLOSED"}, +// "orderBy": "CREATED_AT", +// "direction": "DESC", +// "first": float64(30), +// "after": (*string)(nil), +// } + +// tests := []struct { +// name string +// reqParams map[string]interface{} +// expectError bool +// errContains string +// expectedCount int +// verifyOrder func(t *testing.T, issues []*github.Issue) +// }{ +// { +// name: "list all issues", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedCount: 2, +// }, +// { +// name: "filter by open state", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "OPEN", +// }, +// expectError: false, +// expectedCount: 2, +// }, +// { +// name: "filter by closed state", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "CLOSED", +// }, +// expectError: false, +// expectedCount: 1, +// }, +// { +// name: "filter by labels", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "labels": []any{"bug", "enhancement"}, +// }, +// expectError: false, +// expectedCount: 2, +// }, +// { +// name: "repository not found error", +// reqParams: map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// }, +// expectError: true, +// errContains: "repository not found", +// }, +// } + +// // Define the actual query strings that match the implementation +// qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" +// qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// var httpClient *http.Client + +// switch tc.name { +// case "list all issues": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "filter by open state": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "filter by closed state": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "filter by labels": +// matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// case "repository not found error": +// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) +// httpClient = githubv4mock.NewMockedHTTPClient(matcher) +// } + +// gqlClient := githubv4.NewClient(httpClient) +// _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// req := createMCPRequest(tc.reqParams) +// res, err := handler(context.Background(), req) +// text := getTextResult(t, res).Text + +// if tc.expectError { +// require.True(t, res.IsError) +// assert.Contains(t, text, tc.errContains) +// return +// } +// require.NoError(t, err) + +// // Parse the structured response with pagination info +// var response struct { +// Issues []*github.Issue `json:"issues"` +// PageInfo struct { +// HasNextPage bool `json:"hasNextPage"` +// HasPreviousPage bool `json:"hasPreviousPage"` +// StartCursor string `json:"startCursor"` +// EndCursor string `json:"endCursor"` +// } `json:"pageInfo"` +// TotalCount int `json:"totalCount"` +// } +// err = json.Unmarshal([]byte(text), &response) +// require.NoError(t, err) + +// assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + +// // Verify order if verifyOrder function is provided +// if tc.verifyOrder != nil { +// tc.verifyOrder(t, response.Issues) +// } + +// // Verify that returned issues have expected structure +// for _, issue := range response.Issues { +// assert.NotNil(t, issue.Number, "Issue should have number") +// assert.NotNil(t, issue.Title, "Issue should have title") +// assert.NotNil(t, issue.State, "Issue should have state") +// } +// }) +// } +// } + +// func Test_UpdateIssue(t *testing.T) { +// // Verify tool definition +// mockClient := github.NewClient(nil) +// mockGQLClient := githubv4.NewClient(nil) +// tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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") +// assert.Contains(t, tool.InputSchema.Properties, "title") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "labels") +// assert.Contains(t, tool.InputSchema.Properties, "assignees") +// assert.Contains(t, tool.InputSchema.Properties, "milestone") +// assert.Contains(t, tool.InputSchema.Properties, "type") +// 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{"method", "owner", "repo"}) + +// // Mock issues for reuse across test cases +// mockBaseIssue := &github.Issue{ +// Number: github.Ptr(123), +// Title: github.Ptr("Title"), +// Body: github.Ptr("Description"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, +// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, +// Milestone: &github.Milestone{Number: github.Ptr(5)}, +// Type: &github.IssueType{Name: github.Ptr("Bug")}, +// } + +// mockUpdatedIssue := &github.Issue{ +// Number: github.Ptr(123), +// Title: github.Ptr("Updated Title"), +// Body: github.Ptr("Updated Description"), +// State: github.Ptr("closed"), +// StateReason: github.Ptr("duplicate"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, +// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, +// Milestone: &github.Milestone{Number: github.Ptr(5)}, +// Type: &github.IssueType{Name: github.Ptr("Bug")}, +// } + +// mockReopenedIssue := &github.Issue{ +// Number: github.Ptr(123), +// Title: github.Ptr("Title"), +// State: github.Ptr("open"), +// StateReason: github.Ptr("reopened"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// } + +// // Mock GraphQL responses for reuse across test cases +// issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issue": map[string]any{ +// "id": "I_kwDOA0xdyM50BPaO", +// }, +// }, +// }) + +// duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issue": map[string]any{ +// "id": "I_kwDOA0xdyM50BPaO", +// }, +// "duplicateIssue": map[string]any{ +// "id": "I_kwDOA0xdyM50BPbP", +// }, +// }, +// }) + +// closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ +// "closeIssue": map[string]any{ +// "issue": map[string]any{ +// "id": "I_kwDOA0xdyM50BPaO", +// "number": 123, +// "url": "https://github.com/owner/repo/issues/123", +// "state": "CLOSED", +// }, +// }, +// }) + +// reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{ +// "reopenIssue": map[string]any{ +// "issue": map[string]any{ +// "id": "I_kwDOA0xdyM50BPaO", +// "number": 123, +// "url": "https://github.com/owner/repo/issues/123", +// "state": "OPEN", +// }, +// }, +// }) + +// duplicateStateReason := IssueClosedStateReasonDuplicate + +// tests := []struct { +// name string +// mockedRESTClient *http.Client +// mockedGQLClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// }{ +// { +// name: "partial update of non-state fields only", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// expectRequestBody(t, map[string]interface{}{ +// "title": "Updated Title", +// "body": "Updated Description", +// }).andThen( +// mockResponse(t, http.StatusOK, mockUpdatedIssue), +// ), +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "title": "Updated Title", +// "body": "Updated Description", +// }, +// expectError: false, +// expectedIssue: mockUpdatedIssue, +// }, +// { +// name: "issue not found when updating non-state fields only", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// "title": "Updated Title", +// }, +// expectError: true, +// expectedErrMsg: "failed to update issue", +// }, +// { +// name: "close issue as duplicate", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// mockBaseIssue, +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// DuplicateIssue struct { +// ID githubv4.ID +// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "issueNumber": githubv4.Int(123), +// "duplicateOf": githubv4.Int(456), +// }, +// duplicateIssueIDQueryResponse, +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// CloseIssue struct { +// Issue struct { +// ID githubv4.ID +// Number githubv4.Int +// URL githubv4.String +// State githubv4.String +// } +// } `graphql:"closeIssue(input: $input)"` +// }{}, +// CloseIssueInput{ +// IssueID: "I_kwDOA0xdyM50BPaO", +// StateReason: &duplicateStateReason, +// DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), +// }, +// nil, +// closeSuccessResponse, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "state": "closed", +// "state_reason": "duplicate", +// "duplicate_of": float64(456), +// }, +// expectError: false, +// expectedIssue: mockUpdatedIssue, +// }, +// { +// name: "reopen issue", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// mockBaseIssue, +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `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), +// }, +// issueIDQueryResponse, +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// ReopenIssue struct { +// Issue struct { +// ID githubv4.ID +// Number githubv4.Int +// URL githubv4.String +// State githubv4.String +// } +// } `graphql:"reopenIssue(input: $input)"` +// }{}, +// githubv4.ReopenIssueInput{ +// IssueID: "I_kwDOA0xdyM50BPaO", +// }, +// nil, +// reopenSuccessResponse, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "state": "open", +// }, +// expectError: false, +// expectedIssue: mockReopenedIssue, +// }, +// { +// name: "main issue not found when trying to close it", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// mockBaseIssue, +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "issueNumber": githubv4.Int(999), +// }, +// githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// "state": "closed", +// "state_reason": "not_planned", +// }, +// expectError: true, +// expectedErrMsg: "Failed to find issues", +// }, +// { +// name: "duplicate issue not found when closing as duplicate", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// mockBaseIssue, +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// DuplicateIssue struct { +// ID githubv4.ID +// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "issueNumber": githubv4.Int(123), +// "duplicateOf": githubv4.Int(999), +// }, +// githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "state": "closed", +// "state_reason": "duplicate", +// "duplicate_of": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "Failed to find issues", +// }, +// { +// name: "close as duplicate with combined non-state updates", +// mockedRESTClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, +// expectRequestBody(t, map[string]interface{}{ +// "title": "Updated Title", +// "body": "Updated Description", +// "labels": []any{"bug", "priority"}, +// "assignees": []any{"assignee1", "assignee2"}, +// "milestone": float64(5), +// "type": "Bug", +// }).andThen( +// mockResponse(t, http.StatusOK, &github.Issue{ +// Number: github.Ptr(123), +// Title: github.Ptr("Updated Title"), +// Body: github.Ptr("Updated Description"), +// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, +// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, +// Milestone: &github.Milestone{Number: github.Ptr(5)}, +// Type: &github.IssueType{Name: github.Ptr("Bug")}, +// State: github.Ptr("open"), // Still open after REST update +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// }), +// ), +// ), +// ), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// } `graphql:"issue(number: $issueNumber)"` +// DuplicateIssue struct { +// ID githubv4.ID +// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "issueNumber": githubv4.Int(123), +// "duplicateOf": githubv4.Int(456), +// }, +// duplicateIssueIDQueryResponse, +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// CloseIssue struct { +// Issue struct { +// ID githubv4.ID +// Number githubv4.Int +// URL githubv4.String +// State githubv4.String +// } +// } `graphql:"closeIssue(input: $input)"` +// }{}, +// CloseIssueInput{ +// IssueID: "I_kwDOA0xdyM50BPaO", +// StateReason: &duplicateStateReason, +// DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), +// }, +// nil, +// closeSuccessResponse, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "title": "Updated Title", +// "body": "Updated Description", +// "labels": []any{"bug", "priority"}, +// "assignees": []any{"assignee1", "assignee2"}, +// "milestone": float64(5), +// "type": "Bug", +// "state": "closed", +// "state_reason": "duplicate", +// "duplicate_of": float64(456), +// }, +// expectError: false, +// expectedIssue: mockUpdatedIssue, +// }, +// { +// name: "duplicate_of without duplicate state_reason should fail", +// mockedRESTClient: mock.NewMockedHTTPClient(), +// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(123), +// "state": "closed", +// "state_reason": "completed", +// "duplicate_of": float64(456), +// }, +// expectError: true, +// expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup clients with mocks +// restClient := github.NewClient(tc.mockedRESTClient) +// gqlClient := githubv4.NewClient(tc.mockedGQLClient) +// _, handler := IssueWrite(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError || tc.expectedErrMsg != "" { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// 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 +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var updateResp MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &updateResp) +// require.NoError(t, err) + +// assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) +// }) +// } +// } + +// func Test_ParseISOTimestamp(t *testing.T) { +// tests := []struct { +// name string +// input string +// expectedErr bool +// expectedTime time.Time +// }{ +// { +// name: "valid RFC3339 format", +// input: "2023-01-15T14:30:00Z", +// expectedErr: false, +// expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC), +// }, +// { +// name: "valid date only format", +// input: "2023-01-15", +// expectedErr: false, +// expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), +// }, +// { +// name: "empty timestamp", +// input: "", +// expectedErr: true, +// }, +// { +// name: "invalid format", +// input: "15/01/2023", +// expectedErr: true, +// }, +// { +// name: "invalid date", +// input: "2023-13-45", +// expectedErr: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// parsedTime, err := parseISOTimestamp(tc.input) + +// if tc.expectedErr { +// assert.Error(t, err) +// } else { +// assert.NoError(t, err) +// assert.Equal(t, tc.expectedTime, parsedTime) +// } +// }) +// } +// } + +// func Test_GetIssueComments(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// gqlClient := githubv4.NewClient(nil) +// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// 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.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + +// // Setup mock comments for success case +// mockComments := []*github.IssueComment{ +// { +// ID: github.Ptr(int64(123)), +// Body: github.Ptr("This is the first comment"), +// User: &github.User{ +// Login: github.Ptr("user1"), +// }, +// CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, +// }, +// { +// ID: github.Ptr(int64(456)), +// Body: github.Ptr("This is the second comment"), +// User: &github.User{ +// Login: github.Ptr("user2"), +// }, +// CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)}, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedComments []*github.IssueComment +// expectedErrMsg string +// }{ +// { +// name: "successful comments retrieval", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, +// mockComments, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_comments", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedComments: mockComments, +// }, +// { +// name: "successful comments retrieval with pagination", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, +// expectQueryParams(t, map[string]string{ +// "page": "2", +// "per_page": "10", +// }).andThen( +// mockResponse(t, http.StatusOK, mockComments), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_comments", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedComments: mockComments, +// }, +// { +// name: "issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_comments", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get issue comments", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// gqlClient := githubv4.NewClient(nil) +// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedComments []*github.IssueComment +// err = json.Unmarshal([]byte(textContent.Text), &returnedComments) +// require.NoError(t, err) +// assert.Equal(t, len(tc.expectedComments), len(returnedComments)) +// if len(returnedComments) > 0 { +// assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) +// assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) +// } +// }) +// } +// } + +// 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// 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() + +// // Verify tool definition +// mockClient := githubv4.NewClient(nil) +// tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "assign_copilot_to_issue", 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, "issueNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) + +// var pageOfFakeBots = func(n int) []struct{} { +// // We don't _really_ need real bots here, just objects that count as entries for the page +// bots := make([]struct{}, n) +// for i := range n { +// bots[i] = struct{}{} +// } +// return bots +// } + +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful assignment when there are no existing assignees", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "issueNumber": float64(123), +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot struct { +// ID githubv4.ID +// Login githubv4.String +// TypeName string `graphql:"__typename"` +// } `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "endCursor": (*githubv4.String)(nil), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "suggestedActors": map[string]any{ +// "nodes": []any{ +// map[string]any{ +// "id": githubv4.ID("copilot-swe-agent-id"), +// "login": githubv4.String("copilot-swe-agent"), +// "__typename": "Bot", +// }, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// Assignees struct { +// Nodes []struct { +// ID githubv4.ID +// } +// } `graphql:"assignees(first: 100)"` +// } `graphql:"issue(number: $number)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "number": githubv4.Int(123), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issue": map[string]any{ +// "id": githubv4.ID("test-issue-id"), +// "assignees": map[string]any{ +// "nodes": []any{}, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// ReplaceActorsForAssignable struct { +// Typename string `graphql:"__typename"` +// } `graphql:"replaceActorsForAssignable(input: $input)"` +// }{}, +// ReplaceActorsForAssignableInput{ +// AssignableID: githubv4.ID("test-issue-id"), +// ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// { +// name: "successful assignment when there are existing assignees", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "issueNumber": float64(123), +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot struct { +// ID githubv4.ID +// Login githubv4.String +// TypeName string `graphql:"__typename"` +// } `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "endCursor": (*githubv4.String)(nil), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "suggestedActors": map[string]any{ +// "nodes": []any{ +// map[string]any{ +// "id": githubv4.ID("copilot-swe-agent-id"), +// "login": githubv4.String("copilot-swe-agent"), +// "__typename": "Bot", +// }, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// Assignees struct { +// Nodes []struct { +// ID githubv4.ID +// } +// } `graphql:"assignees(first: 100)"` +// } `graphql:"issue(number: $number)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "number": githubv4.Int(123), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issue": map[string]any{ +// "id": githubv4.ID("test-issue-id"), +// "assignees": map[string]any{ +// "nodes": []any{ +// map[string]any{ +// "id": githubv4.ID("existing-assignee-id"), +// }, +// map[string]any{ +// "id": githubv4.ID("existing-assignee-id-2"), +// }, +// }, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// ReplaceActorsForAssignable struct { +// Typename string `graphql:"__typename"` +// } `graphql:"replaceActorsForAssignable(input: $input)"` +// }{}, +// ReplaceActorsForAssignableInput{ +// AssignableID: githubv4.ID("test-issue-id"), +// ActorIDs: []githubv4.ID{ +// githubv4.ID("existing-assignee-id"), +// githubv4.ID("existing-assignee-id-2"), +// githubv4.ID("copilot-swe-agent-id"), +// }, +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// { +// name: "copilot bot not on first page of suggested actors", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "issueNumber": float64(123), +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// // First page of suggested actors +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot struct { +// ID githubv4.ID +// Login githubv4.String +// TypeName string `graphql:"__typename"` +// } `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "endCursor": (*githubv4.String)(nil), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "suggestedActors": map[string]any{ +// "nodes": pageOfFakeBots(100), +// "pageInfo": map[string]any{ +// "hasNextPage": true, +// "endCursor": githubv4.String("next-page-cursor"), +// }, +// }, +// }, +// }), +// ), +// // Second page of suggested actors +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot struct { +// ID githubv4.ID +// Login githubv4.String +// TypeName string `graphql:"__typename"` +// } `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "endCursor": githubv4.String("next-page-cursor"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "suggestedActors": map[string]any{ +// "nodes": []any{ +// map[string]any{ +// "id": githubv4.ID("copilot-swe-agent-id"), +// "login": githubv4.String("copilot-swe-agent"), +// "__typename": "Bot", +// }, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Issue struct { +// ID githubv4.ID +// Assignees struct { +// Nodes []struct { +// ID githubv4.ID +// } +// } `graphql:"assignees(first: 100)"` +// } `graphql:"issue(number: $number)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "number": githubv4.Int(123), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "issue": map[string]any{ +// "id": githubv4.ID("test-issue-id"), +// "assignees": map[string]any{ +// "nodes": []any{}, +// }, +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// ReplaceActorsForAssignable struct { +// Typename string `graphql:"__typename"` +// } `graphql:"replaceActorsForAssignable(input: $input)"` +// }{}, +// ReplaceActorsForAssignableInput{ +// AssignableID: githubv4.ID("test-issue-id"), +// ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// { +// name: "copilot not a suggested actor", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "issueNumber": float64(123), +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// SuggestedActors struct { +// Nodes []struct { +// Bot struct { +// ID githubv4.ID +// Login githubv4.String +// TypeName string `graphql:"__typename"` +// } `graphql:"... on Bot"` +// } +// PageInfo struct { +// HasNextPage bool +// EndCursor string +// } +// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "name": githubv4.String("repo"), +// "endCursor": (*githubv4.String)(nil), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "suggestedActors": map[string]any{ +// "nodes": []any{}, +// }, +// }, +// }), +// ), +// ), +// expectToolError: true, +// expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { + +// t.Parallel() +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) +// require.Equal(t, textContent.Text, "successfully assigned copilot to issue") +// }) +// } +// } + +// func Test_AddSubIssue(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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{"method", "owner", "repo", "issue_number", "sub_issue_id"}) + +// // Setup mock issue for success case (matches GitHub API response format) +// mockIssue := &github.Issue{ +// Number: github.Ptr(42), +// Title: github.Ptr("Parent Issue"), +// Body: github.Ptr("This is the parent issue with a sub-issue"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// Labels: []*github.Label{ +// { +// Name: github.Ptr("enhancement"), +// Color: github.Ptr("84b6eb"), +// Description: github.Ptr("New feature or request"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// }{ +// { +// name: "successful sub-issue addition with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusCreated, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "replace_parent": true, +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "successful sub-issue addition with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusCreated, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(456), +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "successful sub-issue addition with replace_parent false", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusCreated, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(789), +// "replace_parent": false, +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "parent issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "failed to add sub-issue", +// }, +// { +// name: "sub-issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(999), +// }, +// expectError: false, +// expectedErrMsg: "failed to add sub-issue", +// }, +// { +// name: "validation failed - sub-issue cannot be parent of itself", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "failed to add sub-issue", +// }, +// { +// name: "insufficient permissions", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// 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), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "failed to add sub-issue", +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient( +// // 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), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing required parameter sub_issue_id", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "add", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: sub_issue_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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 returnedIssue github.Issue +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) +// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) +// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) +// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) +// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) +// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) +// }) +// } +// } + +// func Test_GetSubIssues(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// gqlClient := githubv4.NewClient(nil) +// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// 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.Contains(t, tool.InputSchema.Properties, "page") +// 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{ +// { +// Number: github.Ptr(123), +// Title: github.Ptr("Sub-issue 1"), +// Body: github.Ptr("This is the first sub-issue"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), +// User: &github.User{ +// Login: github.Ptr("user1"), +// }, +// Labels: []*github.Label{ +// { +// Name: github.Ptr("bug"), +// Color: github.Ptr("d73a4a"), +// Description: github.Ptr("Something isn't working"), +// }, +// }, +// }, +// { +// Number: github.Ptr(124), +// Title: github.Ptr("Sub-issue 2"), +// Body: github.Ptr("This is the second sub-issue"), +// State: github.Ptr("closed"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), +// User: &github.User{ +// Login: github.Ptr("user2"), +// }, +// Assignees: []*github.User{ +// {Login: github.Ptr("assignee1")}, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedSubIssues []*github.Issue +// expectedErrMsg string +// }{ +// { +// name: "successful sub-issues listing with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockSubIssues, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedSubIssues: mockSubIssues, +// }, +// { +// name: "successful sub-issues listing with pagination", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// expectQueryParams(t, map[string]string{ +// "page": "2", +// "per_page": "10", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSubIssues), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedSubIssues: mockSubIssues, +// }, +// { +// name: "successful sub-issues listing with empty result", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// []*github.Issue{}, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedSubIssues: []*github.Issue{}, +// }, +// { +// name: "parent issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// }, +// expectError: false, +// expectedErrMsg: "failed to list sub-issues", +// }, +// { +// name: "repository not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "nonexistent", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "failed to list sub-issues", +// }, +// { +// name: "sub-issues feature gone/deprecated", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "failed to list sub-issues", +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing required parameter issue_number", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_sub_issues", +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: issue_number", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// gqlClient := githubv4.NewClient(nil) +// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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 returnedSubIssues []*github.Issue +// err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) +// require.NoError(t, err) + +// assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) +// for i, subIssue := range returnedSubIssues { +// if i < len(tc.expectedSubIssues) { +// assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) +// assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) +// assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) +// assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) +// assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) + +// if tc.expectedSubIssues[i].Body != nil { +// assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) +// } +// } +// } +// }) +// } +// } + +// func Test_RemoveSubIssue(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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{"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{ +// Number: github.Ptr(42), +// Title: github.Ptr("Parent Issue"), +// Body: github.Ptr("This is the parent issue after sub-issue removal"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// Labels: []*github.Label{ +// { +// Name: github.Ptr("enhancement"), +// Color: github.Ptr("84b6eb"), +// Description: github.Ptr("New feature or request"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// }{ +// { +// name: "successful sub-issue removal", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusOK, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "parent issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "failed to remove sub-issue", +// }, +// { +// name: "sub-issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(999), +// }, +// expectError: false, +// expectedErrMsg: "failed to remove sub-issue", +// }, +// { +// name: "bad request - invalid sub_issue_id", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(-1), +// }, +// expectError: false, +// expectedErrMsg: "failed to remove sub-issue", +// }, +// { +// name: "repository not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "nonexistent", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "failed to remove sub-issue", +// }, +// { +// name: "insufficient permissions", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, +// 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), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "failed to remove sub-issue", +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient( +// // 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), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing required parameter sub_issue_id", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "remove", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: sub_issue_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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 returnedIssue github.Issue +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) +// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) +// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) +// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) +// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) +// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) +// }) +// } +// } + +// func Test_ReprioritizeSubIssue(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// 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{"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{ +// Number: github.Ptr(42), +// Title: github.Ptr("Parent Issue"), +// Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// Labels: []*github.Label{ +// { +// Name: github.Ptr("enhancement"), +// Color: github.Ptr("84b6eb"), +// Description: github.Ptr("New feature or request"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssue *github.Issue +// expectedErrMsg string +// }{ +// { +// name: "successful reprioritization with after_id", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusOK, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "successful reprioritization with before_id", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusOK, mockIssue), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "before_id": float64(789), +// }, +// expectError: false, +// expectedIssue: mockIssue, +// }, +// { +// name: "validation error - neither after_id nor before_id specified", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// }, +// expectError: false, +// expectedErrMsg: "either after_id or before_id must be specified", +// }, +// { +// name: "validation error - both after_id and before_id specified", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "after_id": float64(456), +// "before_id": float64(789), +// }, +// expectError: false, +// expectedErrMsg: "only one of after_id or before_id should be specified, not both", +// }, +// { +// name: "parent issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(999), +// "sub_issue_id": float64(123), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "failed to reprioritize sub-issue", +// }, +// { +// name: "sub-issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(999), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "failed to reprioritize sub-issue", +// }, +// { +// name: "validation failed - positioning sub-issue not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "after_id": float64(999), +// }, +// expectError: false, +// expectedErrMsg: "failed to reprioritize sub-issue", +// }, +// { +// name: "insufficient permissions", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "failed to reprioritize sub-issue", +// }, +// { +// name: "service unavailable", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, +// mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "sub_issue_id": float64(123), +// "before_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "failed to reprioritize sub-issue", +// }, +// { +// name: "missing required parameter owner", +// mockedClient: mock.NewMockedHTTPClient( +// // 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), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing required parameter sub_issue_id", +// mockedClient: mock.NewMockedHTTPClient( +// // No mocked requests needed since validation fails before HTTP call +// ), +// requestArgs: map[string]interface{}{ +// "method": "reprioritize", +// "owner": "owner", +// "repo": "repo", +// "issue_number": float64(42), +// "after_id": float64(456), +// }, +// expectError: false, +// expectedErrMsg: "missing required parameter: sub_issue_id", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), 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 returnedIssue github.Issue +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) +// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) +// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) +// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) +// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) +// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) +// }) +// } +// } + +// func Test_ListIssueTypes(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_issue_types", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) + +// // Setup mock issue types for success case +// mockIssueTypes := []*github.IssueType{ +// { +// ID: github.Ptr(int64(1)), +// Name: github.Ptr("bug"), +// Description: github.Ptr("Something isn't working"), +// Color: github.Ptr("d73a4a"), +// }, +// { +// ID: github.Ptr(int64(2)), +// Name: github.Ptr("feature"), +// Description: github.Ptr("New feature or enhancement"), +// Color: github.Ptr("a2eeef"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedIssueTypes []*github.IssueType +// expectedErrMsg string +// }{ +// { +// name: "successful issue types retrieval", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/orgs/testorg/issue-types", +// Method: "GET", +// }, +// mockResponse(t, http.StatusOK, mockIssueTypes), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "testorg", +// }, +// expectError: false, +// expectedIssueTypes: mockIssueTypes, +// }, +// { +// name: "organization not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/orgs/nonexistent/issue-types", +// Method: "GET", +// }, +// mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "nonexistent", +// }, +// expectError: true, +// expectedErrMsg: "failed to list issue types", +// }, +// { +// name: "missing owner parameter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/orgs/testorg/issue-types", +// Method: "GET", +// }, +// mockResponse(t, http.StatusOK, mockIssueTypes), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: false, // This should be handled by parameter validation, error returned in result +// expectedErrMsg: "missing required parameter: owner", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } +// // Check if error is returned as tool result error +// require.NotNil(t, result) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// // Check if it's a parameter validation error (returned as tool result error) +// if result != nil && result.IsError { +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { +// return // This is expected for parameter validation errors +// } +// } + +// require.NoError(t, err) +// require.NotNil(t, result) +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedIssueTypes []*github.IssueType +// err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) +// require.NoError(t, err) + +// if tc.expectedIssueTypes != nil { +// require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) +// for i, expected := range tc.expectedIssueTypes { +// assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) +// assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) +// assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) +// assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) +// } +// } +// }) +// } +// } diff --git a/pkg/github/labels.go b/pkg/github/labels.go index c9be7be75..f6027c9cd 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -1,399 +1,399 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "strings" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/shurcooL/githubv4" -) - -// GetLabel retrieves a specific label by name from a GitHub repository -func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool( - "get_label", - mcp.WithDescription(t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization name)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("name", - mcp.Required(), - mcp.Description("Label name."), - ), - ), - 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 - } - - name, err := RequiredParam[string](request, "name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var query struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "name": githubv4.String(name), - } - - client, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil - } - - if query.Repository.Label.Name == "" { - return mcp.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil - } - - label := map[string]any{ - "id": fmt.Sprintf("%v", query.Repository.Label.ID), - "name": string(query.Repository.Label.Name), - "color": string(query.Repository.Label.Color), - "description": string(query.Repository.Label.Description), - } - - out, err := json.Marshal(label) - if err != nil { - return nil, fmt.Errorf("failed to marshal label: %w", err) - } - - return mcp.NewToolResultText(string(out)), nil - } -} - -// 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")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization name) - required for all operations"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name - required for all operations"), - ), - ), - 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 - } - - client, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - var query struct { - Repository 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:"repository(owner: $owner, name: $repo)"` - } - - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - } - - if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil - } - - labels := make([]map[string]any, len(query.Repository.Labels.Nodes)) - for i, labelNode := range query.Repository.Labels.Nodes { - labels[i] = map[string]any{ - "id": fmt.Sprintf("%v", labelNode.ID), - "name": string(labelNode.Name), - "color": string(labelNode.Color), - "description": string(labelNode.Description), - } - } - - response := map[string]any{ - "labels": labels, - "totalCount": int(query.Repository.Labels.TotalCount), - } - - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal labels: %w", err) - } - - return mcp.NewToolResultText(string(out)), nil - } -} - -// LabelWrite handles create, update, and delete operations for GitHub labels -func LabelWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool( - "label_write", - mcp.WithDescription(t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("method", - mcp.Required(), - mcp.Description("Operation to perform: 'create', 'update', or 'delete'"), - mcp.Enum("create", "update", "delete"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization name)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("name", - mcp.Required(), - mcp.Description("Label name - required for all operations"), - ), - mcp.WithString("new_name", - mcp.Description("New name for the label (used only with 'update' method to rename)"), - ), - mcp.WithString("color", - mcp.Description("Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."), - ), - mcp.WithString("description", - mcp.Description("Label description text. Optional for 'create' and 'update'."), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Get and validate required parameters - method, err := RequiredParam[string](request, "method") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - method = strings.ToLower(method) - - 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 - } - - name, err := RequiredParam[string](request, "name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional parameters - newName, _ := OptionalParam[string](request, "new_name") - color, _ := OptionalParam[string](request, "color") - description, _ := OptionalParam[string](request, "description") - - client, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - switch method { - case "create": - // Validate required params for create - if color == "" { - return mcp.NewToolResultError("color is required for create"), nil - } - - // Get repository ID - repoID, err := getRepositoryID(ctx, client, owner, repo) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil - } - - input := githubv4.CreateLabelInput{ - RepositoryID: repoID, - Name: githubv4.String(name), - Color: githubv4.String(color), - } - if description != "" { - d := githubv4.String(description) - input.Description = &d - } - - var mutation struct { - CreateLabel struct { - Label struct { - Name githubv4.String - ID githubv4.ID - } - } `graphql:"createLabel(input: $input)"` - } - - if err := client.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil - - case "update": - // Validate required params for update - if newName == "" && color == "" && description == "" { - return mcp.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil - } - - // Get the label ID - labelID, err := getLabelID(ctx, client, owner, repo, name) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - input := githubv4.UpdateLabelInput{ - ID: labelID, - } - if newName != "" { - n := githubv4.String(newName) - input.Name = &n - } - if color != "" { - c := githubv4.String(color) - input.Color = &c - } - if description != "" { - d := githubv4.String(description) - input.Description = &d - } - - var mutation struct { - UpdateLabel struct { - Label struct { - Name githubv4.String - ID githubv4.ID - } - } `graphql:"updateLabel(input: $input)"` - } - - if err := client.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil - - case "delete": - // Get the label ID - labelID, err := getLabelID(ctx, client, owner, repo, name) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - input := githubv4.DeleteLabelInput{ - ID: labelID, - } - - var mutation struct { - DeleteLabel struct { - ClientMutationID githubv4.String - } `graphql:"deleteLabel(input: $input)"` - } - - if err := client.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil - - default: - return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil - } - } -} - -// Helper function to get repository ID -func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) { - var repoQuery struct { - Repository struct { - ID githubv4.ID - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - } - if err := client.Query(ctx, &repoQuery, vars); err != nil { - return "", err - } - return repoQuery.Repository.ID, nil -} - -// Helper function to get label by name -func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) { - var query struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "name": githubv4.String(labelName), - } - if err := client.Query(ctx, &query, vars); err != nil { - return "", err - } - if query.Repository.Label.Name == "" { - return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo) - } - return query.Repository.Label.ID, nil -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "strings" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// "github.com/shurcooL/githubv4" +// ) + +// // GetLabel retrieves a specific label by name from a GitHub repository +// func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool( +// "get_label", +// mcp.WithDescription(t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization name)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("name", +// mcp.Required(), +// mcp.Description("Label name."), +// ), +// ), +// 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 +// } + +// name, err := RequiredParam[string](request, "name") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var query struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// Color githubv4.String +// Description githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// vars := map[string]any{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "name": githubv4.String(name), +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// if err := client.Query(ctx, &query, vars); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil +// } + +// if query.Repository.Label.Name == "" { +// return mcp.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil +// } + +// label := map[string]any{ +// "id": fmt.Sprintf("%v", query.Repository.Label.ID), +// "name": string(query.Repository.Label.Name), +// "color": string(query.Repository.Label.Color), +// "description": string(query.Repository.Label.Description), +// } + +// out, err := json.Marshal(label) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal label: %w", err) +// } + +// return mcp.NewToolResultText(string(out)), nil +// } +// } + +// // 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")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization name) - required for all operations"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name - required for all operations"), +// ), +// ), +// 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 +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// var query struct { +// Repository 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:"repository(owner: $owner, name: $repo)"` +// } + +// vars := map[string]any{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// } + +// if err := client.Query(ctx, &query, vars); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil +// } + +// labels := make([]map[string]any, len(query.Repository.Labels.Nodes)) +// for i, labelNode := range query.Repository.Labels.Nodes { +// labels[i] = map[string]any{ +// "id": fmt.Sprintf("%v", labelNode.ID), +// "name": string(labelNode.Name), +// "color": string(labelNode.Color), +// "description": string(labelNode.Description), +// } +// } + +// response := map[string]any{ +// "labels": labels, +// "totalCount": int(query.Repository.Labels.TotalCount), +// } + +// out, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal labels: %w", err) +// } + +// return mcp.NewToolResultText(string(out)), nil +// } +// } + +// // LabelWrite handles create, update, and delete operations for GitHub labels +// func LabelWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool( +// "label_write", +// mcp.WithDescription(t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("method", +// mcp.Required(), +// mcp.Description("Operation to perform: 'create', 'update', or 'delete'"), +// mcp.Enum("create", "update", "delete"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization name)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("name", +// mcp.Required(), +// mcp.Description("Label name - required for all operations"), +// ), +// mcp.WithString("new_name", +// mcp.Description("New name for the label (used only with 'update' method to rename)"), +// ), +// mcp.WithString("color", +// mcp.Description("Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."), +// ), +// mcp.WithString("description", +// mcp.Description("Label description text. Optional for 'create' and 'update'."), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// // Get and validate required parameters +// method, err := RequiredParam[string](request, "method") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// method = strings.ToLower(method) + +// 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 +// } + +// name, err := RequiredParam[string](request, "name") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Get optional parameters +// newName, _ := OptionalParam[string](request, "new_name") +// color, _ := OptionalParam[string](request, "color") +// description, _ := OptionalParam[string](request, "description") + +// client, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// switch method { +// case "create": +// // Validate required params for create +// if color == "" { +// return mcp.NewToolResultError("color is required for create"), nil +// } + +// // Get repository ID +// repoID, err := getRepositoryID(ctx, client, owner, repo) +// if err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil +// } + +// input := githubv4.CreateLabelInput{ +// RepositoryID: repoID, +// Name: githubv4.String(name), +// Color: githubv4.String(color), +// } +// if description != "" { +// d := githubv4.String(description) +// input.Description = &d +// } + +// var mutation struct { +// CreateLabel struct { +// Label struct { +// Name githubv4.String +// ID githubv4.ID +// } +// } `graphql:"createLabel(input: $input)"` +// } + +// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil + +// case "update": +// // Validate required params for update +// if newName == "" && color == "" && description == "" { +// return mcp.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil +// } + +// // Get the label ID +// labelID, err := getLabelID(ctx, client, owner, repo, name) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// input := githubv4.UpdateLabelInput{ +// ID: labelID, +// } +// if newName != "" { +// n := githubv4.String(newName) +// input.Name = &n +// } +// if color != "" { +// c := githubv4.String(color) +// input.Color = &c +// } +// if description != "" { +// d := githubv4.String(description) +// input.Description = &d +// } + +// var mutation struct { +// UpdateLabel struct { +// Label struct { +// Name githubv4.String +// ID githubv4.ID +// } +// } `graphql:"updateLabel(input: $input)"` +// } + +// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil + +// case "delete": +// // Get the label ID +// labelID, err := getLabelID(ctx, client, owner, repo, name) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// input := githubv4.DeleteLabelInput{ +// ID: labelID, +// } + +// var mutation struct { +// DeleteLabel struct { +// ClientMutationID githubv4.String +// } `graphql:"deleteLabel(input: $input)"` +// } + +// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil + +// default: +// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil +// } +// } +// } + +// // Helper function to get repository ID +// func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) { +// var repoQuery struct { +// Repository struct { +// ID githubv4.ID +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } +// vars := map[string]any{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// } +// if err := client.Query(ctx, &repoQuery, vars); err != nil { +// return "", err +// } +// return repoQuery.Repository.ID, nil +// } + +// // Helper function to get label by name +// func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) { +// var query struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } +// vars := map[string]any{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "name": githubv4.String(labelName), +// } +// if err := client.Query(ctx, &query, vars); err != nil { +// return "", err +// } +// if query.Repository.Label.Name == "" { +// return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo) +// } +// return query.Repository.Label.ID, nil +// } diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index 6bb91da26..a7d71304d 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -1,491 +1,491 @@ package github -import ( - "context" - "net/http" - "testing" +// import ( +// "context" +// "net/http" +// "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/shurcooL/githubv4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) +// "github.com/github/github-mcp-server/internal/githubv4mock" +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/shurcooL/githubv4" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) -func TestGetLabel(t *testing.T) { - t.Parallel() +// func TestGetLabel(t *testing.T) { +// t.Parallel() - // Verify tool definition - mockClient := githubv4.NewClient(nil) - tool, _ := GetLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// // Verify tool definition +// mockClient := githubv4.NewClient(nil) +// tool, _ := GetLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "get_label", 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, "name") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "name"}) +// assert.Equal(t, "get_label", 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, "name") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "name"}) - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful label retrieval", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "name": "bug", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "name": githubv4.String("bug"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID("test-label-id"), - "name": githubv4.String("bug"), - "color": githubv4.String("d73a4a"), - "description": githubv4.String("Something isn't working"), - }, - }, - }), - ), - ), - expectToolError: false, - }, - { - name: "label not found", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "name": "nonexistent", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "name": githubv4.String("nonexistent"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID(""), - "name": githubv4.String(""), - "color": githubv4.String(""), - "description": githubv4.String(""), - }, - }, - }), - ), - ), - expectToolError: true, - expectedToolErrMsg: "label 'nonexistent' not found in owner/repo", - }, - } +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful label retrieval", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "name": "bug", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// Color githubv4.String +// Description githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "name": githubv4.String("bug"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID("test-label-id"), +// "name": githubv4.String("bug"), +// "color": githubv4.String("d73a4a"), +// "description": githubv4.String("Something isn't working"), +// }, +// }, +// }), +// ), +// ), +// expectToolError: false, +// }, +// { +// name: "label not found", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "name": "nonexistent", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// Color githubv4.String +// Description githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "name": githubv4.String("nonexistent"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID(""), +// "name": githubv4.String(""), +// "color": githubv4.String(""), +// "description": githubv4.String(""), +// }, +// }, +// }), +// ), +// ), +// expectToolError: true, +// expectedToolErrMsg: "label 'nonexistent' not found in owner/repo", +// }, +// } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := githubv4.NewClient(tc.mockedClient) - _, handler := GetLabel(stubGetGQLClientFn(client), translations.NullTranslationHelper) +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := GetLabel(stubGetGQLClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) - require.NoError(t, err) - assert.NotNil(t, result) +// 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) - } - }) - } -} +// 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 TestListLabels(t *testing.T) { - t.Parallel() +// func TestListLabels(t *testing.T) { +// t.Parallel() - // Verify tool definition - mockClient := githubv4.NewClient(nil) - tool, _ := ListLabels(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// // Verify tool definition +// mockClient := githubv4.NewClient(nil) +// tool, _ := ListLabels(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_label", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) +// assert.Equal(t, "list_label", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful repository labels listing", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository 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:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": 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"), - }, - map[string]any{ - "id": githubv4.ID("label-2"), - "name": githubv4.String("enhancement"), - "color": githubv4.String("a2eeef"), - "description": githubv4.String("New feature or request"), - }, - }, - "totalCount": githubv4.Int(2), - }, - }, - }), - ), - ), - expectToolError: false, - }, - } +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful repository labels listing", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository 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:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": 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"), +// }, +// map[string]any{ +// "id": githubv4.ID("label-2"), +// "name": githubv4.String("enhancement"), +// "color": githubv4.String("a2eeef"), +// "description": githubv4.String("New feature or request"), +// }, +// }, +// "totalCount": githubv4.Int(2), +// }, +// }, +// }), +// ), +// ), +// expectToolError: false, +// }, +// } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := githubv4.NewClient(tc.mockedClient) - _, handler := ListLabels(stubGetGQLClientFn(client), translations.NullTranslationHelper) +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := ListLabels(stubGetGQLClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) - require.NoError(t, err) - assert.NotNil(t, result) +// 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) - } - }) - } -} +// 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 TestWriteLabel(t *testing.T) { - t.Parallel() +// func TestWriteLabel(t *testing.T) { +// t.Parallel() - // Verify tool definition - mockClient := githubv4.NewClient(nil) - tool, _ := LabelWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// // Verify tool definition +// mockClient := githubv4.NewClient(nil) +// tool, _ := LabelWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "label_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, "name") - assert.Contains(t, tool.InputSchema.Properties, "new_name") - assert.Contains(t, tool.InputSchema.Properties, "color") - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "name"}) +// assert.Equal(t, "label_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, "name") +// assert.Contains(t, tool.InputSchema.Properties, "new_name") +// assert.Contains(t, tool.InputSchema.Properties, "color") +// assert.Contains(t, tool.InputSchema.Properties, "description") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "name"}) - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful label creation", - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "name": "new-label", - "color": "f29513", - "description": "A new test label", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - CreateLabel struct { - Label struct { - Name githubv4.String - ID githubv4.ID - } - } `graphql:"createLabel(input: $input)"` - }{}, - githubv4.CreateLabelInput{ - RepositoryID: githubv4.ID("test-repo-id"), - Name: githubv4.String("new-label"), - Color: githubv4.String("f29513"), - Description: func() *githubv4.String { s := githubv4.String("A new test label"); return &s }(), - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "createLabel": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID("new-label-id"), - "name": githubv4.String("new-label"), - }, - }, - }), - ), - ), - expectToolError: false, - }, - { - name: "create label without color", - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "name": "new-label", - }, - mockedClient: githubv4mock.NewMockedHTTPClient(), - expectToolError: true, - expectedToolErrMsg: "color is required for create", - }, - { - name: "successful label update", - requestArgs: map[string]any{ - "method": "update", - "owner": "owner", - "repo": "repo", - "name": "bug", - "new_name": "defect", - "color": "ff0000", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "name": githubv4.String("bug"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID("bug-label-id"), - "name": githubv4.String("bug"), - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateLabel struct { - Label struct { - Name githubv4.String - ID githubv4.ID - } - } `graphql:"updateLabel(input: $input)"` - }{}, - githubv4.UpdateLabelInput{ - ID: githubv4.ID("bug-label-id"), - Name: func() *githubv4.String { s := githubv4.String("defect"); return &s }(), - Color: func() *githubv4.String { s := githubv4.String("ff0000"); return &s }(), - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateLabel": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID("bug-label-id"), - "name": githubv4.String("defect"), - }, - }, - }), - ), - ), - expectToolError: false, - }, - { - name: "update label without any changes", - requestArgs: map[string]any{ - "method": "update", - "owner": "owner", - "repo": "repo", - "name": "bug", - }, - mockedClient: githubv4mock.NewMockedHTTPClient(), - expectToolError: true, - expectedToolErrMsg: "at least one of new_name, color, or description must be provided for update", - }, - { - name: "successful label deletion", - requestArgs: map[string]any{ - "method": "delete", - "owner": "owner", - "repo": "repo", - "name": "bug", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Label struct { - ID githubv4.ID - Name githubv4.String - } `graphql:"label(name: $name)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "name": githubv4.String("bug"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "label": map[string]any{ - "id": githubv4.ID("bug-label-id"), - "name": githubv4.String("bug"), - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - DeleteLabel struct { - ClientMutationID githubv4.String - } `graphql:"deleteLabel(input: $input)"` - }{}, - githubv4.DeleteLabelInput{ - ID: githubv4.ID("bug-label-id"), - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "deleteLabel": map[string]any{ - "clientMutationId": githubv4.String("test-mutation-id"), - }, - }), - ), - ), - expectToolError: false, - }, - { - name: "invalid method", - requestArgs: map[string]any{ - "method": "invalid", - "owner": "owner", - "repo": "repo", - "name": "bug", - }, - mockedClient: githubv4mock.NewMockedHTTPClient(), - expectToolError: true, - expectedToolErrMsg: "unknown method: invalid", - }, - } +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful label creation", +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "name": "new-label", +// "color": "f29513", +// "description": "A new test label", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// ID githubv4.ID +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "id": githubv4.ID("test-repo-id"), +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// CreateLabel struct { +// Label struct { +// Name githubv4.String +// ID githubv4.ID +// } +// } `graphql:"createLabel(input: $input)"` +// }{}, +// githubv4.CreateLabelInput{ +// RepositoryID: githubv4.ID("test-repo-id"), +// Name: githubv4.String("new-label"), +// Color: githubv4.String("f29513"), +// Description: func() *githubv4.String { s := githubv4.String("A new test label"); return &s }(), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{ +// "createLabel": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID("new-label-id"), +// "name": githubv4.String("new-label"), +// }, +// }, +// }), +// ), +// ), +// expectToolError: false, +// }, +// { +// name: "create label without color", +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "name": "new-label", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient(), +// expectToolError: true, +// expectedToolErrMsg: "color is required for create", +// }, +// { +// name: "successful label update", +// requestArgs: map[string]any{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "name": "bug", +// "new_name": "defect", +// "color": "ff0000", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "name": githubv4.String("bug"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID("bug-label-id"), +// "name": githubv4.String("bug"), +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// UpdateLabel struct { +// Label struct { +// Name githubv4.String +// ID githubv4.ID +// } +// } `graphql:"updateLabel(input: $input)"` +// }{}, +// githubv4.UpdateLabelInput{ +// ID: githubv4.ID("bug-label-id"), +// Name: func() *githubv4.String { s := githubv4.String("defect"); return &s }(), +// Color: func() *githubv4.String { s := githubv4.String("ff0000"); return &s }(), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{ +// "updateLabel": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID("bug-label-id"), +// "name": githubv4.String("defect"), +// }, +// }, +// }), +// ), +// ), +// expectToolError: false, +// }, +// { +// name: "update label without any changes", +// requestArgs: map[string]any{ +// "method": "update", +// "owner": "owner", +// "repo": "repo", +// "name": "bug", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient(), +// expectToolError: true, +// expectedToolErrMsg: "at least one of new_name, color, or description must be provided for update", +// }, +// { +// name: "successful label deletion", +// requestArgs: map[string]any{ +// "method": "delete", +// "owner": "owner", +// "repo": "repo", +// "name": "bug", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// Label struct { +// ID githubv4.ID +// Name githubv4.String +// } `graphql:"label(name: $name)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "name": githubv4.String("bug"), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "label": map[string]any{ +// "id": githubv4.ID("bug-label-id"), +// "name": githubv4.String("bug"), +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// DeleteLabel struct { +// ClientMutationID githubv4.String +// } `graphql:"deleteLabel(input: $input)"` +// }{}, +// githubv4.DeleteLabelInput{ +// ID: githubv4.ID("bug-label-id"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{ +// "deleteLabel": map[string]any{ +// "clientMutationId": githubv4.String("test-mutation-id"), +// }, +// }), +// ), +// ), +// expectToolError: false, +// }, +// { +// name: "invalid method", +// requestArgs: map[string]any{ +// "method": "invalid", +// "owner": "owner", +// "repo": "repo", +// "name": "bug", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient(), +// expectToolError: true, +// expectedToolErrMsg: "unknown method: invalid", +// }, +// } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := githubv4.NewClient(tc.mockedClient) - _, handler := LabelWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := LabelWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) - require.NoError(t, err) - assert.NotNil(t, result) +// 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) - } - }) - } -} +// 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) +// } +// }) +// } +// } diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 6dca53cca..508e705ac 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -1,525 +1,525 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -const ( - FilterDefault = "default" - FilterIncludeRead = "include_read_notifications" - FilterOnlyParticipating = "only_participating" -) - -// ListNotifications creates a tool to list notifications for the current user. -func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_notifications", - mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("filter", - mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."), - mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating), - ), - mcp.WithString("since", - mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"), - ), - mcp.WithString("before", - mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"), - ), - mcp.WithString("owner", - mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."), - ), - mcp.WithString("repo", - mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - filter, err := OptionalParam[string](request, "filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - before, err := OptionalParam[string](request, "before") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - owner, err := OptionalParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - paginationParams, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Build options - opts := &github.NotificationListOptions{ - All: filter == FilterIncludeRead, - Participating: filter == FilterOnlyParticipating, - ListOptions: github.ListOptions{ - Page: paginationParams.Page, - PerPage: paginationParams.PerPage, - }, - } - - // Parse time parameters if provided - if since != "" { - sinceTime, err := time.Parse(time.RFC3339, since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil - } - opts.Since = sinceTime - } - - if before != "" { - beforeTime, err := time.Parse(time.RFC3339, before) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil - } - opts.Before = beforeTime - } - - var notifications []*github.Notification - var resp *github.Response - - if owner != "" && repo != "" { - notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts) - } else { - notifications, resp, err = client.Activity.ListNotifications(ctx, opts) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list notifications", - 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 get notifications: %s", string(body))), nil - } - - // Marshal response to JSON - r, err := json.Marshal(notifications) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// DismissNotification creates a tool to mark a notification as read/done. -func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("dismiss_notification", - mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("threadID", - mcp.Required(), - mcp.Description("The ID of the notification thread"), - ), - mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getclient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - threadID, err := RequiredParam[string](request, "threadID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - state, err := RequiredParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - switch state { - case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) - case "read": - resp, err = client.Activity.MarkThreadRead(ctx, threadID) - default: - return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to mark notification as %s", state), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusResetContent && 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 mark notification as %s: %s", state, string(body))), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil - } -} - -// MarkAllNotificationsRead creates a tool to mark all notifications as read. -func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("mark_all_notifications_read", - mcp.WithDescription(t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("lastReadAt", - mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"), - ), - mcp.WithString("owner", - mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."), - ), - mcp.WithString("repo", - mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - lastReadAt, err := OptionalParam[string](request, "lastReadAt") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - owner, err := OptionalParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var lastReadTime time.Time - if lastReadAt != "" { - lastReadTime, err = time.Parse(time.RFC3339, lastReadAt) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil - } - } else { - lastReadTime = time.Now() - } - - markReadOptions := github.Timestamp{ - Time: lastReadTime, - } - - var resp *github.Response - if owner != "" && repo != "" { - resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) - } else { - resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to mark all notifications as read", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusResetContent && 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 mark all notifications as read: %s", string(body))), nil - } - - return mcp.NewToolResultText("All notifications marked as read"), nil - } -} - -// GetNotificationDetails creates a tool to get details for a specific notification. -func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_notification_details", - mcp.WithDescription(t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("notificationID", - mcp.Required(), - mcp.Description("The ID of the notification"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - notificationID, err := RequiredParam[string](request, "notificationID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - thread, resp, err := client.Activity.GetThread(ctx, notificationID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), - 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 get notification details: %s", string(body))), nil - } - - r, err := json.Marshal(thread) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// Enum values for ManageNotificationSubscription action -const ( - NotificationActionIgnore = "ignore" - NotificationActionWatch = "watch" - NotificationActionDelete = "delete" -) - -// ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) -func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("manage_notification_subscription", - mcp.WithDescription(t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("notificationID", - mcp.Required(), - mcp.Description("The ID of the notification thread."), - ), - mcp.WithString("action", - mcp.Required(), - mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."), - mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - notificationID, err := RequiredParam[string](request, "notificationID") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - action, err := RequiredParam[string](request, "action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var ( - resp *github.Response - result any - apiErr error - ) - - switch action { - case NotificationActionIgnore: - sub := &github.Subscription{Ignored: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) - case NotificationActionWatch: - sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) - case NotificationActionDelete: - resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID) - default: - return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil - } - - if apiErr != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to %s notification subscription", action), - resp, - apiErr, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil - } - - if action == NotificationActionDelete { - // Special case for delete as there is no response body - return mcp.NewToolResultText("Notification subscription deleted"), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil - } -} - -const ( - RepositorySubscriptionActionWatch = "watch" - RepositorySubscriptionActionIgnore = "ignore" - RepositorySubscriptionActionDelete = "delete" -) - -// ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) -func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("manage_repository_notification_subscription", - mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The account owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithString("action", - mcp.Required(), - mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."), - mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - 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 - } - action, err := RequiredParam[string](request, "action") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var ( - resp *github.Response - result any - apiErr error - ) - - switch action { - case RepositorySubscriptionActionIgnore: - sub := &github.Subscription{Ignored: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) - case RepositorySubscriptionActionWatch: - sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} - result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) - case RepositorySubscriptionActionDelete: - resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) - default: - return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil - } - - if apiErr != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to %s repository subscription", action), - resp, - apiErr, - ), nil - } - if resp != nil { - defer func() { _ = resp.Body.Close() }() - } - - // Handle non-2xx status codes - if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { - body, _ := io.ReadAll(resp.Body) - return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil - } - - if action == RepositorySubscriptionActionDelete { - // Special case for delete as there is no response body - return mcp.NewToolResultText("Repository subscription deleted"), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" +// "strconv" +// "time" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// const ( +// FilterDefault = "default" +// FilterIncludeRead = "include_read_notifications" +// FilterOnlyParticipating = "only_participating" +// ) + +// // ListNotifications creates a tool to list notifications for the current user. +// func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_notifications", +// mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("filter", +// mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."), +// mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating), +// ), +// mcp.WithString("since", +// mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"), +// ), +// mcp.WithString("before", +// mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"), +// ), +// mcp.WithString("owner", +// mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."), +// ), +// mcp.WithString("repo", +// mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."), +// ), +// WithPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// filter, err := OptionalParam[string](request, "filter") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// since, err := OptionalParam[string](request, "since") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// before, err := OptionalParam[string](request, "before") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// owner, err := OptionalParam[string](request, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// repo, err := OptionalParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// paginationParams, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Build options +// opts := &github.NotificationListOptions{ +// All: filter == FilterIncludeRead, +// Participating: filter == FilterOnlyParticipating, +// ListOptions: github.ListOptions{ +// Page: paginationParams.Page, +// PerPage: paginationParams.PerPage, +// }, +// } + +// // Parse time parameters if provided +// if since != "" { +// sinceTime, err := time.Parse(time.RFC3339, since) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil +// } +// opts.Since = sinceTime +// } + +// if before != "" { +// beforeTime, err := time.Parse(time.RFC3339, before) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil +// } +// opts.Before = beforeTime +// } + +// var notifications []*github.Notification +// var resp *github.Response + +// if owner != "" && repo != "" { +// notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts) +// } else { +// notifications, resp, err = client.Activity.ListNotifications(ctx, opts) +// } +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list notifications", +// 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 get notifications: %s", string(body))), nil +// } + +// // Marshal response to JSON +// r, err := json.Marshal(notifications) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // DismissNotification creates a tool to mark a notification as read/done. +// func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("dismiss_notification", +// mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("threadID", +// mcp.Required(), +// mcp.Description("The ID of the notification thread"), +// ), +// mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getclient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// threadID, err := RequiredParam[string](request, "threadID") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// state, err := RequiredParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// switch state { +// case "done": +// // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint +// var threadIDInt int64 +// threadIDInt, err = strconv.ParseInt(threadID, 10, 64) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil +// } +// resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) +// case "read": +// resp, err = client.Activity.MarkThreadRead(ctx, threadID) +// default: +// return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to mark notification as %s", state), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusResetContent && 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 mark notification as %s: %s", state, string(body))), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil +// } +// } + +// // MarkAllNotificationsRead creates a tool to mark all notifications as read. +// func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("mark_all_notifications_read", +// mcp.WithDescription(t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("lastReadAt", +// mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"), +// ), +// mcp.WithString("owner", +// mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."), +// ), +// mcp.WithString("repo", +// mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// lastReadAt, err := OptionalParam[string](request, "lastReadAt") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// owner, err := OptionalParam[string](request, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// repo, err := OptionalParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var lastReadTime time.Time +// if lastReadAt != "" { +// lastReadTime, err = time.Parse(time.RFC3339, lastReadAt) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil +// } +// } else { +// lastReadTime = time.Now() +// } + +// markReadOptions := github.Timestamp{ +// Time: lastReadTime, +// } + +// var resp *github.Response +// if owner != "" && repo != "" { +// resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) +// } else { +// resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) +// } +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to mark all notifications as read", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusResetContent && 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 mark all notifications as read: %s", string(body))), nil +// } + +// return mcp.NewToolResultText("All notifications marked as read"), nil +// } +// } + +// // GetNotificationDetails creates a tool to get details for a specific notification. +// func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_notification_details", +// mcp.WithDescription(t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("notificationID", +// mcp.Required(), +// mcp.Description("The ID of the notification"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// notificationID, err := RequiredParam[string](request, "notificationID") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// thread, resp, err := client.Activity.GetThread(ctx, notificationID) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), +// 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 get notification details: %s", string(body))), nil +// } + +// r, err := json.Marshal(thread) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // Enum values for ManageNotificationSubscription action +// const ( +// NotificationActionIgnore = "ignore" +// NotificationActionWatch = "watch" +// NotificationActionDelete = "delete" +// ) + +// // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) +// func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("manage_notification_subscription", +// mcp.WithDescription(t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("notificationID", +// mcp.Required(), +// mcp.Description("The ID of the notification thread."), +// ), +// mcp.WithString("action", +// mcp.Required(), +// mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."), +// mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// notificationID, err := RequiredParam[string](request, "notificationID") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// action, err := RequiredParam[string](request, "action") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var ( +// resp *github.Response +// result any +// apiErr error +// ) + +// switch action { +// case NotificationActionIgnore: +// sub := &github.Subscription{Ignored: ToBoolPtr(true)} +// result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) +// case NotificationActionWatch: +// sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} +// result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) +// case NotificationActionDelete: +// resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID) +// default: +// return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil +// } + +// if apiErr != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to %s notification subscription", action), +// resp, +// apiErr, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode < 200 || resp.StatusCode >= 300 { +// body, _ := io.ReadAll(resp.Body) +// return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil +// } + +// if action == NotificationActionDelete { +// // Special case for delete as there is no response body +// return mcp.NewToolResultText("Notification subscription deleted"), nil +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// const ( +// RepositorySubscriptionActionWatch = "watch" +// RepositorySubscriptionActionIgnore = "ignore" +// RepositorySubscriptionActionDelete = "delete" +// ) + +// // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) +// func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("manage_repository_notification_subscription", +// mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The account owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithString("action", +// mcp.Required(), +// mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."), +// mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// 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 +// } +// action, err := RequiredParam[string](request, "action") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var ( +// resp *github.Response +// result any +// apiErr error +// ) + +// switch action { +// case RepositorySubscriptionActionIgnore: +// sub := &github.Subscription{Ignored: ToBoolPtr(true)} +// result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) +// case RepositorySubscriptionActionWatch: +// sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} +// result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) +// case RepositorySubscriptionActionDelete: +// resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) +// default: +// return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil +// } + +// if apiErr != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to %s repository subscription", action), +// resp, +// apiErr, +// ), nil +// } +// if resp != nil { +// defer func() { _ = resp.Body.Close() }() +// } + +// // Handle non-2xx status codes +// if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { +// body, _ := io.ReadAll(resp.Body) +// return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil +// } + +// if action == RepositorySubscriptionActionDelete { +// // Special case for delete as there is no response body +// return mcp.NewToolResultText("Repository subscription deleted"), nil +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 034d8d4e2..5825c91d3 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -1,765 +1,765 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ListNotifications(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_notifications", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "filter") - assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "before") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - // All fields are optional, so Required should be empty - assert.Empty(t, tool.InputSchema.Required) - - mockNotification := &github.Notification{ - ID: github.Ptr("123"), - Reason: github.Ptr("mention"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult []*github.Notification - expectedErrMsg string - }{ - { - name: "success default filter (no params)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetNotifications, - []*github.Notification{mockNotification}, - ), - ), - requestArgs: map[string]interface{}{}, - expectError: false, - expectedResult: []*github.Notification{mockNotification}, - }, - { - name: "success with filter=include_read_notifications", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetNotifications, - []*github.Notification{mockNotification}, - ), - ), - requestArgs: map[string]interface{}{ - "filter": "include_read_notifications", - }, - expectError: false, - expectedResult: []*github.Notification{mockNotification}, - }, - { - name: "success with filter=only_participating", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetNotifications, - []*github.Notification{mockNotification}, - ), - ), - requestArgs: map[string]interface{}{ - "filter": "only_participating", - }, - expectError: false, - expectedResult: []*github.Notification{mockNotification}, - }, - { - name: "success for repo notifications", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposNotificationsByOwnerByRepo, - []*github.Notification{mockNotification}, - ), - ), - requestArgs: map[string]interface{}{ - "filter": "default", - "since": "2024-01-01T00:00:00Z", - "before": "2024-01-02T00:00:00Z", - "owner": "octocat", - "repo": "hello-world", - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedResult: []*github.Notification{mockNotification}, - }, - { - name: "error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetNotifications, - mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "error", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - textContent := getTextResult(t, result) - t.Logf("textContent: %s", textContent.Text) - var returned []*github.Notification - err = json.Unmarshal([]byte(textContent.Text), &returned) - require.NoError(t, err) - require.NotEmpty(t, returned) - assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) - }) - } -} - -func Test_ManageNotificationSubscription(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "manage_notification_subscription", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "notificationID") - assert.Contains(t, tool.InputSchema.Properties, "action") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"}) - - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectIgnored *bool - expectDeleted bool - expectInvalid bool - expectedErrMsg string - }{ - { - name: "ignore subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutNotificationsThreadsSubscriptionByThreadId, - mockSub, - ), - ), - requestArgs: map[string]interface{}{ - "notificationID": "123", - "action": "ignore", - }, - expectError: false, - expectIgnored: github.Ptr(true), - }, - { - name: "watch subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutNotificationsThreadsSubscriptionByThreadId, - mockSubWatch, - ), - ), - requestArgs: map[string]interface{}{ - "notificationID": "123", - "action": "watch", - }, - expectError: false, - expectIgnored: github.Ptr(false), - }, - { - name: "delete subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.DeleteNotificationsThreadsSubscriptionByThreadId, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "notificationID": "123", - "action": "delete", - }, - expectError: false, - expectDeleted: true, - }, - { - name: "invalid action", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "notificationID": "123", - "action": "invalid", - }, - expectError: false, - expectInvalid: true, - }, - { - name: "missing required notificationID", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "action": "ignore", - }, - expectError: true, - }, - { - name: "missing required action", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "notificationID": "123", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.NotNil(t, result) - text := getTextResult(t, result).Text - switch { - case tc.requestArgs["notificationID"] == nil: - assert.Contains(t, text, "missing required parameter: notificationID") - case tc.requestArgs["action"] == nil: - assert.Contains(t, text, "missing required parameter: action") - default: - assert.Contains(t, text, "error") - } - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - if tc.expectIgnored != nil { - var returned github.Subscription - err = json.Unmarshal([]byte(textContent.Text), &returned) - require.NoError(t, err) - assert.Equal(t, *tc.expectIgnored, *returned.Ignored) - } - if tc.expectDeleted { - assert.Contains(t, textContent.Text, "deleted") - } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "Invalid action") - } - }) - } -} - -func Test_ManageRepositoryNotificationSubscription(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "manage_repository_notification_subscription", 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, "action") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"}) - - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectIgnored *bool - expectSubscribed *bool - expectDeleted bool - expectInvalid bool - expectedErrMsg string - }{ - { - name: "ignore subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutReposSubscriptionByOwnerByRepo, - mockSub, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "action": "ignore", - }, - expectError: false, - expectIgnored: github.Ptr(true), - }, - { - name: "watch subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutReposSubscriptionByOwnerByRepo, - mockWatchSub, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "action": "watch", - }, - expectError: false, - expectIgnored: github.Ptr(false), - expectSubscribed: github.Ptr(true), - }, - { - name: "delete subscription", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.DeleteReposSubscriptionByOwnerByRepo, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "action": "delete", - }, - expectError: false, - expectDeleted: true, - }, - { - name: "invalid action", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "action": "invalid", - }, - expectError: false, - expectInvalid: true, - }, - { - name: "missing required owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "repo": "repo", - "action": "ignore", - }, - expectError: true, - }, - { - name: "missing required repo", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "action": "ignore", - }, - expectError: true, - }, - { - name: "missing required action", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.NotNil(t, result) - text := getTextResult(t, result).Text - switch { - case tc.requestArgs["owner"] == nil: - assert.Contains(t, text, "missing required parameter: owner") - case tc.requestArgs["repo"] == nil: - assert.Contains(t, text, "missing required parameter: repo") - case tc.requestArgs["action"] == nil: - assert.Contains(t, text, "missing required parameter: action") - default: - assert.Contains(t, text, "error") - } - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - if tc.expectIgnored != nil || tc.expectSubscribed != nil { - var returned github.Subscription - err = json.Unmarshal([]byte(textContent.Text), &returned) - require.NoError(t, err) - if tc.expectIgnored != nil { - assert.Equal(t, *tc.expectIgnored, *returned.Ignored) - } - if tc.expectSubscribed != nil { - assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed) - } - } - if tc.expectDeleted { - assert.Contains(t, textContent.Text, "deleted") - } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "Invalid action") - } - }) - } -} - -func Test_DismissNotification(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "dismiss_notification", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "threadID") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectRead bool - expectDone bool - expectInvalid bool - expectedErrMsg string - }{ - { - name: "mark as read", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchNotificationsThreadsByThreadId, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "threadID": "123", - "state": "read", - }, - expectError: false, - expectRead: true, - }, - { - name: "mark as done", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.DeleteNotificationsThreadsByThreadId, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "threadID": "123", - "state": "done", - }, - expectError: false, - expectDone: true, - }, - { - name: "invalid threadID format", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, - { - name: "missing required threadID", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "state": "read", - }, - expectError: true, - }, - { - name: "missing required state", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "threadID": "123", - }, - expectError: true, - }, - { - name: "invalid state value", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "threadID": "123", - "state": "invalid", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - // The tool returns a ToolResultError with a specific message - require.NoError(t, err) - require.NotNil(t, result) - text := getTextResult(t, result).Text - switch { - case tc.requestArgs["threadID"] == nil: - assert.Contains(t, text, "missing required parameter: threadID") - case tc.requestArgs["state"] == nil: - assert.Contains(t, text, "missing required parameter: state") - case tc.name == "invalid threadID format": - assert.Contains(t, text, "invalid threadID format") - case tc.name == "invalid state value": - assert.Contains(t, text, "Invalid state. Must be one of: read, done.") - default: - // fallback for other errors - assert.Contains(t, text, "error") - } - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - if tc.expectRead { - assert.Contains(t, textContent.Text, "Notification marked as read") - } - if tc.expectDone { - assert.Contains(t, textContent.Text, "Notification marked as done") - } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "invalid threadID format") - } - }) - } -} - -func Test_MarkAllNotificationsRead(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "mark_all_notifications_read", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "lastReadAt") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Empty(t, tool.InputSchema.Required) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectMarked bool - expectedErrMsg string - }{ - { - name: "success (no params)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutNotifications, - nil, - ), - ), - requestArgs: map[string]interface{}{}, - expectError: false, - expectMarked: true, - }, - { - name: "success with lastReadAt param", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutNotifications, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "lastReadAt": "2024-01-01T00:00:00Z", - }, - expectError: false, - expectMarked: true, - }, - { - name: "success with owner and repo", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PutReposNotificationsByOwnerByRepo, - nil, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "repo": "hello-world", - }, - expectError: false, - expectMarked: true, - }, - { - name: "API error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutNotifications, - mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "error", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - textContent := getTextResult(t, result) - if tc.expectMarked { - assert.Contains(t, textContent.Text, "All notifications marked as read") - } - }) - } -} - -func Test_GetNotificationDetails(t *testing.T) { - // Verify tool definition and schema - mockClient := github.NewClient(nil) - tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_notification_details", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "notificationID") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"}) - - mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectResult *github.Notification - expectedErrMsg string - }{ - { - name: "success", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetNotificationsThreadsByThreadId, - mockThread, - ), - ), - requestArgs: map[string]interface{}{ - "notificationID": "123", - }, - expectError: false, - expectResult: mockThread, - }, - { - name: "not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetNotificationsThreadsByThreadId, - mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), - ), - ), - requestArgs: map[string]interface{}{ - "notificationID": "123", - }, - expectError: true, - expectedErrMsg: "not found", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var returned github.Notification - err = json.Unmarshal([]byte(textContent.Text), &returned) - require.NoError(t, err) - assert.Equal(t, *tc.expectResult.ID, *returned.ID) - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_ListNotifications(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_notifications", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "filter") +// assert.Contains(t, tool.InputSchema.Properties, "since") +// assert.Contains(t, tool.InputSchema.Properties, "before") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// // All fields are optional, so Required should be empty +// assert.Empty(t, tool.InputSchema.Required) + +// mockNotification := &github.Notification{ +// ID: github.Ptr("123"), +// Reason: github.Ptr("mention"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult []*github.Notification +// expectedErrMsg string +// }{ +// { +// name: "success default filter (no params)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetNotifications, +// []*github.Notification{mockNotification}, +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: false, +// expectedResult: []*github.Notification{mockNotification}, +// }, +// { +// name: "success with filter=include_read_notifications", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetNotifications, +// []*github.Notification{mockNotification}, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "filter": "include_read_notifications", +// }, +// expectError: false, +// expectedResult: []*github.Notification{mockNotification}, +// }, +// { +// name: "success with filter=only_participating", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetNotifications, +// []*github.Notification{mockNotification}, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "filter": "only_participating", +// }, +// expectError: false, +// expectedResult: []*github.Notification{mockNotification}, +// }, +// { +// name: "success for repo notifications", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposNotificationsByOwnerByRepo, +// []*github.Notification{mockNotification}, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "filter": "default", +// "since": "2024-01-01T00:00:00Z", +// "before": "2024-01-02T00:00:00Z", +// "owner": "octocat", +// "repo": "hello-world", +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedResult: []*github.Notification{mockNotification}, +// }, +// { +// name: "error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetNotifications, +// mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "error", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// t.Logf("textContent: %s", textContent.Text) +// var returned []*github.Notification +// err = json.Unmarshal([]byte(textContent.Text), &returned) +// require.NoError(t, err) +// require.NotEmpty(t, returned) +// assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) +// }) +// } +// } + +// func Test_ManageNotificationSubscription(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "manage_notification_subscription", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "notificationID") +// assert.Contains(t, tool.InputSchema.Properties, "action") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"}) + +// mockSub := &github.Subscription{Ignored: github.Ptr(true)} +// mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectIgnored *bool +// expectDeleted bool +// expectInvalid bool +// expectedErrMsg string +// }{ +// { +// name: "ignore subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutNotificationsThreadsSubscriptionByThreadId, +// mockSub, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// "action": "ignore", +// }, +// expectError: false, +// expectIgnored: github.Ptr(true), +// }, +// { +// name: "watch subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutNotificationsThreadsSubscriptionByThreadId, +// mockSubWatch, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// "action": "watch", +// }, +// expectError: false, +// expectIgnored: github.Ptr(false), +// }, +// { +// name: "delete subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.DeleteNotificationsThreadsSubscriptionByThreadId, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// "action": "delete", +// }, +// expectError: false, +// expectDeleted: true, +// }, +// { +// name: "invalid action", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// "action": "invalid", +// }, +// expectError: false, +// expectInvalid: true, +// }, +// { +// name: "missing required notificationID", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "action": "ignore", +// }, +// expectError: true, +// }, +// { +// name: "missing required action", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.NotNil(t, result) +// text := getTextResult(t, result).Text +// switch { +// case tc.requestArgs["notificationID"] == nil: +// assert.Contains(t, text, "missing required parameter: notificationID") +// case tc.requestArgs["action"] == nil: +// assert.Contains(t, text, "missing required parameter: action") +// default: +// assert.Contains(t, text, "error") +// } +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) +// if tc.expectIgnored != nil { +// var returned github.Subscription +// err = json.Unmarshal([]byte(textContent.Text), &returned) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectIgnored, *returned.Ignored) +// } +// if tc.expectDeleted { +// assert.Contains(t, textContent.Text, "deleted") +// } +// if tc.expectInvalid { +// assert.Contains(t, textContent.Text, "Invalid action") +// } +// }) +// } +// } + +// func Test_ManageRepositoryNotificationSubscription(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "manage_repository_notification_subscription", 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, "action") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"}) + +// mockSub := &github.Subscription{Ignored: github.Ptr(true)} +// mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectIgnored *bool +// expectSubscribed *bool +// expectDeleted bool +// expectInvalid bool +// expectedErrMsg string +// }{ +// { +// name: "ignore subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutReposSubscriptionByOwnerByRepo, +// mockSub, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "action": "ignore", +// }, +// expectError: false, +// expectIgnored: github.Ptr(true), +// }, +// { +// name: "watch subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutReposSubscriptionByOwnerByRepo, +// mockWatchSub, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "action": "watch", +// }, +// expectError: false, +// expectIgnored: github.Ptr(false), +// expectSubscribed: github.Ptr(true), +// }, +// { +// name: "delete subscription", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.DeleteReposSubscriptionByOwnerByRepo, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "action": "delete", +// }, +// expectError: false, +// expectDeleted: true, +// }, +// { +// name: "invalid action", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "action": "invalid", +// }, +// expectError: false, +// expectInvalid: true, +// }, +// { +// name: "missing required owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "repo": "repo", +// "action": "ignore", +// }, +// expectError: true, +// }, +// { +// name: "missing required repo", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "action": "ignore", +// }, +// expectError: true, +// }, +// { +// name: "missing required action", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.NotNil(t, result) +// text := getTextResult(t, result).Text +// switch { +// case tc.requestArgs["owner"] == nil: +// assert.Contains(t, text, "missing required parameter: owner") +// case tc.requestArgs["repo"] == nil: +// assert.Contains(t, text, "missing required parameter: repo") +// case tc.requestArgs["action"] == nil: +// assert.Contains(t, text, "missing required parameter: action") +// default: +// assert.Contains(t, text, "error") +// } +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) +// if tc.expectIgnored != nil || tc.expectSubscribed != nil { +// var returned github.Subscription +// err = json.Unmarshal([]byte(textContent.Text), &returned) +// require.NoError(t, err) +// if tc.expectIgnored != nil { +// assert.Equal(t, *tc.expectIgnored, *returned.Ignored) +// } +// if tc.expectSubscribed != nil { +// assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed) +// } +// } +// if tc.expectDeleted { +// assert.Contains(t, textContent.Text, "deleted") +// } +// if tc.expectInvalid { +// assert.Contains(t, textContent.Text, "Invalid action") +// } +// }) +// } +// } + +// func Test_DismissNotification(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "dismiss_notification", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "threadID") +// assert.Contains(t, tool.InputSchema.Properties, "state") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectRead bool +// expectDone bool +// expectInvalid bool +// expectedErrMsg string +// }{ +// { +// name: "mark as read", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PatchNotificationsThreadsByThreadId, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "threadID": "123", +// "state": "read", +// }, +// expectError: false, +// expectRead: true, +// }, +// { +// name: "mark as done", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.DeleteNotificationsThreadsByThreadId, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "threadID": "123", +// "state": "done", +// }, +// expectError: false, +// expectDone: true, +// }, +// { +// name: "invalid threadID format", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "threadID": "notanumber", +// "state": "done", +// }, +// expectError: false, +// expectInvalid: true, +// }, +// { +// name: "missing required threadID", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "state": "read", +// }, +// expectError: true, +// }, +// { +// name: "missing required state", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "threadID": "123", +// }, +// expectError: true, +// }, +// { +// name: "invalid state value", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "threadID": "123", +// "state": "invalid", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// // The tool returns a ToolResultError with a specific message +// require.NoError(t, err) +// require.NotNil(t, result) +// text := getTextResult(t, result).Text +// switch { +// case tc.requestArgs["threadID"] == nil: +// assert.Contains(t, text, "missing required parameter: threadID") +// case tc.requestArgs["state"] == nil: +// assert.Contains(t, text, "missing required parameter: state") +// case tc.name == "invalid threadID format": +// assert.Contains(t, text, "invalid threadID format") +// case tc.name == "invalid state value": +// assert.Contains(t, text, "Invalid state. Must be one of: read, done.") +// default: +// // fallback for other errors +// assert.Contains(t, text, "error") +// } +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) +// if tc.expectRead { +// assert.Contains(t, textContent.Text, "Notification marked as read") +// } +// if tc.expectDone { +// assert.Contains(t, textContent.Text, "Notification marked as done") +// } +// if tc.expectInvalid { +// assert.Contains(t, textContent.Text, "invalid threadID format") +// } +// }) +// } +// } + +// func Test_MarkAllNotificationsRead(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "mark_all_notifications_read", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "lastReadAt") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.Empty(t, tool.InputSchema.Required) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectMarked bool +// expectedErrMsg string +// }{ +// { +// name: "success (no params)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutNotifications, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: false, +// expectMarked: true, +// }, +// { +// name: "success with lastReadAt param", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutNotifications, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "lastReadAt": "2024-01-01T00:00:00Z", +// }, +// expectError: false, +// expectMarked: true, +// }, +// { +// name: "success with owner and repo", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.PutReposNotificationsByOwnerByRepo, +// nil, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octocat", +// "repo": "hello-world", +// }, +// expectError: false, +// expectMarked: true, +// }, +// { +// name: "API error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutNotifications, +// mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "error", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// if tc.expectMarked { +// assert.Contains(t, textContent.Text, "All notifications marked as read") +// } +// }) +// } +// } + +// func Test_GetNotificationDetails(t *testing.T) { +// // Verify tool definition and schema +// mockClient := github.NewClient(nil) +// tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_notification_details", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "notificationID") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"}) + +// mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectResult *github.Notification +// expectedErrMsg string +// }{ +// { +// name: "success", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetNotificationsThreadsByThreadId, +// mockThread, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// }, +// expectError: false, +// expectResult: mockThread, +// }, +// { +// name: "not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetNotificationsThreadsByThreadId, +// mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "notificationID": "123", +// }, +// expectError: true, +// expectedErrMsg: "not found", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var returned github.Notification +// err = json.Unmarshal([]byte(textContent.Text), &returned) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectResult.ID, *returned.ID) +// }) +// } +// } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 21d4c1103..d48bb8a0e 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1,1142 +1,1142 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "reflect" - "strings" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/google/go-querystring/query" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -const ( - ProjectUpdateFailedError = "failed to update a project item" - ProjectAddFailedError = "failed to add a project item" - ProjectDeleteFailedError = "failed to delete a project item" - ProjectListFailedError = "failed to list project items" -) - -func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_projects", - mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithString("query", - mcp.Description("Filter projects by a search query (matches title and description)"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - queryStr, err := OptionalParam[string](req, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - minimalProjects := []MinimalProject{} - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, - Query: queryPtr, - } - - if ownerType == "org" { - projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) - } else { - projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - for _, project := range projects { - minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) - } - - 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 projects: %s", string(body))), nil - } - r, err := json.Marshal(minimalProjects) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project", - mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number"), - ), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var project *github.ProjectV2 - - if ownerType == "org" { - project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - 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 get project: %s", string(body))), nil - } - - minimalProject := convertToMinimalProject(project) - r, err := json.Marshal(minimalProject) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_project_fields", - mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org")), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var projectFields []*github.ProjectV2Field - - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, - } - - if ownerType == "org" { - projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) - } else { - projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - 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 project fields: %s", string(body))), nil - } - r, err := json.Marshal(projectFields) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project_field", - mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), mcp.Enum("user", "org")), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number.")), - mcp.WithNumber("field_id", - mcp.Required(), - mcp.Description("The field's id."), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fieldID, err := RequiredBigInt(req, "field_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var projectField *github.ProjectV2Field - - if ownerType == "org" { - projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) - } else { - projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project field", - 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 get project field: %s", string(body))), nil - } - r, err := json.Marshal(projectField) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_project_items", - mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithString("query", - mcp.Description("Search query to filter items"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of results per page (max 100, default: 30)"), - ), - mcp.WithArray("fields", - mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), - mcp.WithStringItems(), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - queryStr, err := OptionalParam[string](req, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fields, err := OptionalBigIntArrayParam(req, "fields") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - opts := &github.ListProjectItemsOptions{ - Fields: fields, - ListProjectsOptions: github.ListProjectsOptions{ - ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, - Query: queryPtr, - }, - } - - if ownerType == "org" { - projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) - } else { - projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectListFailedError, - 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("%s: %s", ProjectListFailedError, string(body))), nil - } - - r, err := json.Marshal(projectItems) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_project_item", - mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The item's ID."), - ), - mcp.WithArray("fields", - mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), - mcp.WithStringItems(), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - itemID, err := RequiredBigInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fields, err := OptionalBigIntArrayParam(req, "fields") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var url string - if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } else { - url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } - - opts := fieldSelectionOptions{} - - if len(fields) > 0 { - opts.Fields = fields - } - - url, err = addOptions(url, opts) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - projectItem := projectV2Item{} - - httpRequest, err := client.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(ctx, httpRequest, &projectItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - 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 get project item: %s", string(body))), nil - } - r, err := json.Marshal(projectItem) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("add_project_item", - mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithString("item_type", - mcp.Required(), - mcp.Description("The item's type, either issue or pull_request."), - mcp.Enum("issue", "pull_request"), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The numeric ID of the issue or pull request to add to the project."), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - itemID, err := RequiredBigInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - itemType, err := RequiredParam[string](req, "item_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if itemType != "issue" && itemType != "pull_request" { - return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - newItem := &github.AddProjectItemOptions{ - ID: itemID, - Type: toNewProjectType(itemType), - } - - var resp *github.Response - var addedItem *github.ProjectV2Item - - if ownerType == "org" { - addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) - } else { - addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil - } - 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("%s: %s", ProjectAddFailedError, string(body))), nil - } - r, err := json.Marshal(addedItem) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("update_project_item", - mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner_type", - mcp.Required(), mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), - ), - mcp.WithObject("updated_field", - mcp.Required(), - mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - itemID, err := RequiredInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - rawUpdatedField, exists := req.GetArguments()["updated_field"] - if !exists { - return mcp.NewToolResultError("missing required parameter: updated_field"), nil - } - - fieldValue, ok := rawUpdatedField.(map[string]any) - if !ok || fieldValue == nil { - return mcp.NewToolResultError("field_value must be an object"), nil - } - - updatePayload, err := buildUpdateProjectItem(fieldValue) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var projectsURL string - if ownerType == "org" { - projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } else { - projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) - } - httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ - Fields: []updateProjectItem{*updatePayload}, - }) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - updatedItem := projectV2Item{} - - resp, err := client.Do(ctx, httpRequest, &updatedItem) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - 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("%s: %s", ProjectUpdateFailedError, string(body))), nil - } - r, err := json.Marshal(updatedItem) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_project_item", - mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner_type", - mcp.Required(), - mcp.Description("Owner type"), - mcp.Enum("user", "org"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), - ), - mcp.WithNumber("project_number", - mcp.Required(), - mcp.Description("The project's number."), - ), - mcp.WithNumber("item_id", - mcp.Required(), - mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), - ), - ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](req, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ownerType, err := RequiredParam[string](req, "owner_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - projectNumber, err := RequiredInt(req, "project_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - itemID, err := RequiredBigInt(req, "item_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - var resp *github.Response - if ownerType == "org" { - resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) - } else { - resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNoContent { - 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("%s: %s", ProjectDeleteFailedError, string(body))), nil - } - return mcp.NewToolResultText("project item successfully deleted"), nil - } -} - -type fieldSelectionOptions struct { - // Specific list of field IDs to include in the response. If not provided, only the title field is included. - // The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875 - Fields []int64 `url:"fields,omitempty,comma"` -} - -type updateProjectItemPayload struct { - Fields []updateProjectItem `json:"fields"` -} - -type updateProjectItem struct { - ID int `json:"id"` - Value any `json:"value"` -} - -type projectV2ItemFieldValue struct { - ID *int64 `json:"id,omitempty"` // The unique identifier for this field. - Name string `json:"name,omitempty"` // The display name of the field. - DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). - Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. -} - -type projectV2Item struct { - ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` - Content *projectV2ItemContent `json:"content,omitempty"` - ContentType *string `json:"content_type,omitempty"` - CreatedAt *github.Timestamp `json:"created_at,omitempty"` - Creator *github.User `json:"creator,omitempty"` - Description *string `json:"description,omitempty"` - Fields []*projectV2ItemFieldValue `json:"fields,omitempty"` - ID *int64 `json:"id,omitempty"` - ItemURL *string `json:"item_url,omitempty"` - NodeID *string `json:"node_id,omitempty"` - ProjectURL *string `json:"project_url,omitempty"` - Title *string `json:"title,omitempty"` - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` -} - -type projectV2ItemContent struct { - Body *string `json:"body,omitempty"` - ClosedAt *github.Timestamp `json:"closed_at,omitempty"` - CreatedAt *github.Timestamp `json:"created_at,omitempty"` - ID *int64 `json:"id,omitempty"` - Number *int `json:"number,omitempty"` - Repository MinimalRepository `json:"repository,omitempty"` - State *string `json:"state,omitempty"` - StateReason *string `json:"stateReason,omitempty"` - Title *string `json:"title,omitempty"` - UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` - URL *string `json:"url,omitempty"` -} - -func toNewProjectType(projType string) string { - switch strings.ToLower(projType) { - case "issue": - return "Issue" - case "pull_request": - return "PullRequest" - default: - return "" - } -} - -func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { - if input == nil { - return nil, fmt.Errorf("updated_field must be an object") - } - - idField, ok := input["id"] - if !ok { - return nil, fmt.Errorf("updated_field.id is required") - } - - idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 - if !ok { - return nil, fmt.Errorf("updated_field.id must be a number") - } - - valueField, ok := input["value"] - if !ok { - return nil, fmt.Errorf("updated_field.value is required") - } - payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} - - return payload, nil -} - -// addOptions adds the parameters in opts as URL query parameters to s. opts -// must be a struct whose fields may contain "url" tags. -func addOptions(s string, opts any) (string, error) { - v := reflect.ValueOf(opts) - if v.Kind() == reflect.Ptr && v.IsNil() { - return s, nil - } - - origURL, err := url.Parse(s) - if err != nil { - return s, err - } - - origValues := origURL.Query() - - // Use the github.com/google/go-querystring library to parse the struct - newValues, err := query.Values(opts) - if err != nil { - return s, err - } - - // Merge the values - for key, values := range newValues { - for _, value := range values { - origValues.Add(key, value) - } - } - - origURL.RawQuery = origValues.Encode() - return origURL.String(), nil -} - -func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { - return mcp.NewPrompt("ManageProjectItems", - mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), - mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), - mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), - mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), - ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - owner := request.Params.Arguments["owner"] - ownerType := request.Params.Arguments["owner_type"] - - task := "" - if t, exists := request.Params.Arguments["task"]; exists { - task = fmt.Sprintf("%v", t) - } - - messages := []mcp.PromptMessage{ - { - Role: "system", - Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + - "**Core Capabilities:**\n" + - "- Project discovery and field analysis\n" + - "- Item querying with advanced filters\n" + - "- Field value updates and management\n" + - "- Progress reporting and insights\n\n" + - "**Key Rules:**\n" + - "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + - "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + - "- Use proper field IDs (not names) when updating items\n" + - "- Provide step-by-step workflows with concrete examples\n\n" + - "**Understanding Project Items:**\n" + - "- Project items reference underlying content (issues or pull requests)\n" + - "- Project tools provide: project fields, item metadata, and basic content info\n" + - "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + - "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + - "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + - "**Available Tools:**\n" + - "- **list_projects**: Discover available projects\n" + - "- **get_project**: Get detailed project information\n" + - "- **list_project_fields**: Get field definitions and IDs\n" + - "- **list_project_items**: Query items with filters and field selection\n" + - "- **get_project_item**: Get specific item details\n" + - "- **add_project_item**: Add issues/PRs to projects\n" + - "- **update_project_item**: Update field values\n" + - "- **delete_project_item**: Remove items from projects"), - }, - { - Role: "user", - Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ - "Help me get started with project management tasks.", - owner, - ownerType, - func() string { - if task != "" { - return fmt.Sprintf(" I'm specifically interested in: %s.", task) - } - return "" - }())), - }, - { - Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ - "**🔍 Step 1: Project Discovery**\n"+ - "First, let's see what projects are available using **list_projects**.", owner)), - }, - { - Role: "user", - Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), - }, - { - Role: "assistant", - Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + - "Once you select a project, I'll help you:\n\n" + - "1. **Get field information** using **list_project_fields**\n" + - " - Find field IDs, names, and data types\n" + - " - Understand available options for select fields\n" + - " - Identify required vs. optional fields\n\n" + - "2. **Query project items** using **list_project_items**\n" + - " - Filter by assignees: query=\"assignee:@me\"\n" + - " - Filter by status: query=\"status:In Progress\"\n" + - " - Filter by labels: query=\"label:bug\"\n" + - " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + - "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), - }, - { - Role: "user", - Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), - }, - { - Role: "assistant", - Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + - "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + - "**Text fields:**\n" + - "```json\n" + - "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + - "```\n\n" + - "**Single-select fields:**\n" + - "```json\n" + - "{\"id\": 198354254, \"value\": 18498754}\n" + - "```\n" + - "*(Use option ID, not option name)*\n\n" + - "**Date fields:**\n" + - "```json\n" + - "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + - "```\n\n" + - "**Number fields:**\n" + - "```json\n" + - "{\"id\": 345678, \"value\": 5}\n" + - "```\n\n" + - "**Clear a field:**\n" + - "```json\n" + - "{\"id\": 123456, \"value\": null}\n" + - "```\n\n" + - "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), - }, - { - Role: "user", - Content: mcp.NewTextContent("Can you show me a complete workflow example?"), - }, - { - Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ - "Here's how to find and update your assigned items:\n\n"+ - "**Step 1:** Discover projects\n\n"+ - "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ - "**Step 2:** Get project fields (using project #123)\n\n"+ - "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ - "*(Note the Status field ID, e.g., 198354254)*\n\n"+ - "**Step 3:** Query your assigned items\n\n"+ - "**list_project_items**\n"+ - " owner=\"%s\"\n"+ - " owner_type=\"%s\"\n"+ - " project_number=123\n"+ - " query=\"assignee:@me\"\n"+ - " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ - "**Step 4:** Update item status\n\n"+ - "**update_project_item**\n"+ - " owner=\"%s\"\n"+ - " owner_type=\"%s\"\n"+ - " project_number=123\n"+ - " item_id=789123\n"+ - " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ - "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), - }, - { - Role: "user", - Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), - }, - { - Role: "assistant", - Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + - "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + - "**From project items, extract:**\n" + - "- content.repository.name and content.repository.owner.login\n" + - "- content.number (the issue/PR number)\n" + - "- content_type (\"Issue\" or \"PullRequest\")\n\n" + - "**Then use these tools for details:**\n\n" + - "1. **Get full issue/PR details:**\n" + - " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Returns: full body, labels, assignees, milestone, etc.\n\n" + - "2. **Get recent comments:**\n" + - " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Add since parameter to filter recent comments\n\n" + - "3. **Get issue events:**\n" + - " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + - " - Shows timeline: assignments, label changes, status updates\n\n" + - "4. **For pull requests specifically:**\n" + - " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + - " - **list_pull_request_reviews** for review status\n\n" + - "**💡 Example:** To check for blockers in comments:\n" + - "1. Get project items with query=\"assignee:@me is:open\"\n" + - "2. For each item, extract repository and issue number from content\n" + - "3. Use **list_comments** to get recent comments\n" + - "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), - }, - } - return &mcp.GetPromptResult{ - Messages: messages, - }, nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" +// "net/url" +// "reflect" +// "strings" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/google/go-querystring/query" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// const ( +// ProjectUpdateFailedError = "failed to update a project item" +// ProjectAddFailedError = "failed to add a project item" +// ProjectDeleteFailedError = "failed to delete a project item" +// ProjectListFailedError = "failed to list project items" +// ) + +// func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_projects", +// mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithString("query", +// mcp.Description("Filter projects by a search query (matches title and description)"), +// ), +// mcp.WithNumber("per_page", +// mcp.Description("Number of results per page (max 100, default: 30)"), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// queryStr, err := OptionalParam[string](req, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// var projects []*github.ProjectV2 +// var queryPtr *string + +// if queryStr != "" { +// queryPtr = &queryStr +// } + +// minimalProjects := []MinimalProject{} +// opts := &github.ListProjectsOptions{ +// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, +// Query: queryPtr, +// } + +// if ownerType == "org" { +// projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) +// } else { +// projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list projects", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// for _, project := range projects { +// minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) +// } + +// 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 projects: %s", string(body))), nil +// } +// r, err := json.Marshal(minimalProjects) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_project", +// mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number"), +// ), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), +// mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// var project *github.ProjectV2 + +// if ownerType == "org" { +// project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) +// } else { +// project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) +// } +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get project", +// 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 get project: %s", string(body))), nil +// } + +// minimalProject := convertToMinimalProject(project) +// r, err := json.Marshal(minimalProject) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_project_fields", +// mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), +// mcp.Enum("user", "org")), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithNumber("per_page", +// mcp.Description("Number of results per page (max 100, default: 30)"), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// var projectFields []*github.ProjectV2Field + +// opts := &github.ListProjectsOptions{ +// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, +// } + +// if ownerType == "org" { +// projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) +// } else { +// projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list project fields", +// 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 project fields: %s", string(body))), nil +// } +// r, err := json.Marshal(projectFields) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_project_field", +// mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), mcp.Enum("user", "org")), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number.")), +// mcp.WithNumber("field_id", +// mcp.Required(), +// mcp.Description("The field's id."), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// fieldID, err := RequiredBigInt(req, "field_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// var projectField *github.ProjectV2Field + +// if ownerType == "org" { +// projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) +// } else { +// projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get project field", +// 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 get project field: %s", string(body))), nil +// } +// r, err := json.Marshal(projectField) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_project_items", +// mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), +// mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithString("query", +// mcp.Description("Search query to filter items"), +// ), +// mcp.WithNumber("per_page", +// mcp.Description("Number of results per page (max 100, default: 30)"), +// ), +// mcp.WithArray("fields", +// mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), +// mcp.WithStringItems(), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// queryStr, err := OptionalParam[string](req, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// fields, err := OptionalBigIntArrayParam(req, "fields") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// var projectItems []*github.ProjectV2Item +// var queryPtr *string + +// if queryStr != "" { +// queryPtr = &queryStr +// } + +// opts := &github.ListProjectItemsOptions{ +// Fields: fields, +// ListProjectsOptions: github.ListProjectsOptions{ +// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, +// Query: queryPtr, +// }, +// } + +// if ownerType == "org" { +// projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) +// } else { +// projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// ProjectListFailedError, +// 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("%s: %s", ProjectListFailedError, string(body))), nil +// } + +// r, err := json.Marshal(projectItems) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_project_item", +// mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), +// mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithNumber("item_id", +// mcp.Required(), +// mcp.Description("The item's ID."), +// ), +// mcp.WithArray("fields", +// mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), +// mcp.WithStringItems(), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// itemID, err := RequiredBigInt(req, "item_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// fields, err := OptionalBigIntArrayParam(req, "fields") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var url string +// if ownerType == "org" { +// url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) +// } else { +// url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) +// } + +// opts := fieldSelectionOptions{} + +// if len(fields) > 0 { +// opts.Fields = fields +// } + +// url, err = addOptions(url, opts) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// projectItem := projectV2Item{} + +// httpRequest, err := client.NewRequest("GET", url, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to create request: %w", err) +// } + +// resp, err := client.Do(ctx, httpRequest, &projectItem) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get project item", +// 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 get project item: %s", string(body))), nil +// } +// r, err := json.Marshal(projectItem) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("add_project_item", +// mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithString("item_type", +// mcp.Required(), +// mcp.Description("The item's type, either issue or pull_request."), +// mcp.Enum("issue", "pull_request"), +// ), +// mcp.WithNumber("item_id", +// mcp.Required(), +// mcp.Description("The numeric ID of the issue or pull request to add to the project."), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// itemID, err := RequiredBigInt(req, "item_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// itemType, err := RequiredParam[string](req, "item_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// if itemType != "issue" && itemType != "pull_request" { +// return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// newItem := &github.AddProjectItemOptions{ +// ID: itemID, +// Type: toNewProjectType(itemType), +// } + +// var resp *github.Response +// var addedItem *github.ProjectV2Item + +// if ownerType == "org" { +// addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) +// } else { +// addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// ProjectAddFailedError, +// resp, +// err, +// ), nil +// } +// 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("%s: %s", ProjectAddFailedError, string(body))), nil +// } +// r, err := json.Marshal(addedItem) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("update_project_item", +// mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), mcp.Description("Owner type"), +// mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithNumber("item_id", +// mcp.Required(), +// mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), +// ), +// mcp.WithObject("updated_field", +// mcp.Required(), +// mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// itemID, err := RequiredInt(req, "item_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// rawUpdatedField, exists := req.GetArguments()["updated_field"] +// if !exists { +// return mcp.NewToolResultError("missing required parameter: updated_field"), nil +// } + +// fieldValue, ok := rawUpdatedField.(map[string]any) +// if !ok || fieldValue == nil { +// return mcp.NewToolResultError("field_value must be an object"), nil +// } + +// updatePayload, err := buildUpdateProjectItem(fieldValue) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var projectsURL string +// if ownerType == "org" { +// projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) +// } else { +// projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) +// } +// httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ +// Fields: []updateProjectItem{*updatePayload}, +// }) +// if err != nil { +// return nil, fmt.Errorf("failed to create request: %w", err) +// } +// updatedItem := projectV2Item{} + +// resp, err := client.Do(ctx, httpRequest, &updatedItem) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// ProjectUpdateFailedError, +// 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("%s: %s", ProjectUpdateFailedError, string(body))), nil +// } +// r, err := json.Marshal(updatedItem) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("delete_project_item", +// mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner_type", +// mcp.Required(), +// mcp.Description("Owner type"), +// mcp.Enum("user", "org"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), +// ), +// mcp.WithNumber("project_number", +// mcp.Required(), +// mcp.Description("The project's number."), +// ), +// mcp.WithNumber("item_id", +// mcp.Required(), +// mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), +// ), +// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// owner, err := RequiredParam[string](req, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ownerType, err := RequiredParam[string](req, "owner_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// projectNumber, err := RequiredInt(req, "project_number") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// itemID, err := RequiredBigInt(req, "item_id") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// var resp *github.Response +// if ownerType == "org" { +// resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) +// } else { +// resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// ProjectDeleteFailedError, +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusNoContent { +// 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("%s: %s", ProjectDeleteFailedError, string(body))), nil +// } +// return mcp.NewToolResultText("project item successfully deleted"), nil +// } +// } + +// type fieldSelectionOptions struct { +// // Specific list of field IDs to include in the response. If not provided, only the title field is included. +// // The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875 +// Fields []int64 `url:"fields,omitempty,comma"` +// } + +// type updateProjectItemPayload struct { +// Fields []updateProjectItem `json:"fields"` +// } + +// type updateProjectItem struct { +// ID int `json:"id"` +// Value any `json:"value"` +// } + +// type projectV2ItemFieldValue struct { +// ID *int64 `json:"id,omitempty"` // The unique identifier for this field. +// Name string `json:"name,omitempty"` // The display name of the field. +// DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). +// Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. +// } + +// type projectV2Item struct { +// ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` +// Content *projectV2ItemContent `json:"content,omitempty"` +// ContentType *string `json:"content_type,omitempty"` +// CreatedAt *github.Timestamp `json:"created_at,omitempty"` +// Creator *github.User `json:"creator,omitempty"` +// Description *string `json:"description,omitempty"` +// Fields []*projectV2ItemFieldValue `json:"fields,omitempty"` +// ID *int64 `json:"id,omitempty"` +// ItemURL *string `json:"item_url,omitempty"` +// NodeID *string `json:"node_id,omitempty"` +// ProjectURL *string `json:"project_url,omitempty"` +// Title *string `json:"title,omitempty"` +// UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +// } + +// type projectV2ItemContent struct { +// Body *string `json:"body,omitempty"` +// ClosedAt *github.Timestamp `json:"closed_at,omitempty"` +// CreatedAt *github.Timestamp `json:"created_at,omitempty"` +// ID *int64 `json:"id,omitempty"` +// Number *int `json:"number,omitempty"` +// Repository MinimalRepository `json:"repository,omitempty"` +// State *string `json:"state,omitempty"` +// StateReason *string `json:"stateReason,omitempty"` +// Title *string `json:"title,omitempty"` +// UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +// URL *string `json:"url,omitempty"` +// } + +// func toNewProjectType(projType string) string { +// switch strings.ToLower(projType) { +// case "issue": +// return "Issue" +// case "pull_request": +// return "PullRequest" +// default: +// return "" +// } +// } + +// func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { +// if input == nil { +// return nil, fmt.Errorf("updated_field must be an object") +// } + +// idField, ok := input["id"] +// if !ok { +// return nil, fmt.Errorf("updated_field.id is required") +// } + +// idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 +// if !ok { +// return nil, fmt.Errorf("updated_field.id must be a number") +// } + +// valueField, ok := input["value"] +// if !ok { +// return nil, fmt.Errorf("updated_field.value is required") +// } +// payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} + +// return payload, nil +// } + +// // addOptions adds the parameters in opts as URL query parameters to s. opts +// // must be a struct whose fields may contain "url" tags. +// func addOptions(s string, opts any) (string, error) { +// v := reflect.ValueOf(opts) +// if v.Kind() == reflect.Ptr && v.IsNil() { +// return s, nil +// } + +// origURL, err := url.Parse(s) +// if err != nil { +// return s, err +// } + +// origValues := origURL.Query() + +// // Use the github.com/google/go-querystring library to parse the struct +// newValues, err := query.Values(opts) +// if err != nil { +// return s, err +// } + +// // Merge the values +// for key, values := range newValues { +// for _, value := range values { +// origValues.Add(key, value) +// } +// } + +// origURL.RawQuery = origValues.Encode() +// return origURL.String(), nil +// } + +// func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { +// return mcp.NewPrompt("ManageProjectItems", +// mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), +// mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), +// mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), +// mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), +// ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { +// owner := request.Params.Arguments["owner"] +// ownerType := request.Params.Arguments["owner_type"] + +// task := "" +// if t, exists := request.Params.Arguments["task"]; exists { +// task = fmt.Sprintf("%v", t) +// } + +// messages := []mcp.PromptMessage{ +// { +// Role: "system", +// Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + +// "**Core Capabilities:**\n" + +// "- Project discovery and field analysis\n" + +// "- Item querying with advanced filters\n" + +// "- Field value updates and management\n" + +// "- Progress reporting and insights\n\n" + +// "**Key Rules:**\n" + +// "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + +// "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + +// "- Use proper field IDs (not names) when updating items\n" + +// "- Provide step-by-step workflows with concrete examples\n\n" + +// "**Understanding Project Items:**\n" + +// "- Project items reference underlying content (issues or pull requests)\n" + +// "- Project tools provide: project fields, item metadata, and basic content info\n" + +// "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + +// "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + +// "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + +// "**Available Tools:**\n" + +// "- **list_projects**: Discover available projects\n" + +// "- **get_project**: Get detailed project information\n" + +// "- **list_project_fields**: Get field definitions and IDs\n" + +// "- **list_project_items**: Query items with filters and field selection\n" + +// "- **get_project_item**: Get specific item details\n" + +// "- **add_project_item**: Add issues/PRs to projects\n" + +// "- **update_project_item**: Update field values\n" + +// "- **delete_project_item**: Remove items from projects"), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ +// "Help me get started with project management tasks.", +// owner, +// ownerType, +// func() string { +// if task != "" { +// return fmt.Sprintf(" I'm specifically interested in: %s.", task) +// } +// return "" +// }())), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ +// "**🔍 Step 1: Project Discovery**\n"+ +// "First, let's see what projects are available using **list_projects**.", owner)), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + +// "Once you select a project, I'll help you:\n\n" + +// "1. **Get field information** using **list_project_fields**\n" + +// " - Find field IDs, names, and data types\n" + +// " - Understand available options for select fields\n" + +// " - Identify required vs. optional fields\n\n" + +// "2. **Query project items** using **list_project_items**\n" + +// " - Filter by assignees: query=\"assignee:@me\"\n" + +// " - Filter by status: query=\"status:In Progress\"\n" + +// " - Filter by labels: query=\"label:bug\"\n" + +// " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + +// "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + +// "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + +// "**Text fields:**\n" + +// "```json\n" + +// "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + +// "```\n\n" + +// "**Single-select fields:**\n" + +// "```json\n" + +// "{\"id\": 198354254, \"value\": 18498754}\n" + +// "```\n" + +// "*(Use option ID, not option name)*\n\n" + +// "**Date fields:**\n" + +// "```json\n" + +// "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + +// "```\n\n" + +// "**Number fields:**\n" + +// "```json\n" + +// "{\"id\": 345678, \"value\": 5}\n" + +// "```\n\n" + +// "**Clear a field:**\n" + +// "```json\n" + +// "{\"id\": 123456, \"value\": null}\n" + +// "```\n\n" + +// "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("Can you show me a complete workflow example?"), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ +// "Here's how to find and update your assigned items:\n\n"+ +// "**Step 1:** Discover projects\n\n"+ +// "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ +// "**Step 2:** Get project fields (using project #123)\n\n"+ +// "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ +// "*(Note the Status field ID, e.g., 198354254)*\n\n"+ +// "**Step 3:** Query your assigned items\n\n"+ +// "**list_project_items**\n"+ +// " owner=\"%s\"\n"+ +// " owner_type=\"%s\"\n"+ +// " project_number=123\n"+ +// " query=\"assignee:@me\"\n"+ +// " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ +// "**Step 4:** Update item status\n\n"+ +// "**update_project_item**\n"+ +// " owner=\"%s\"\n"+ +// " owner_type=\"%s\"\n"+ +// " project_number=123\n"+ +// " item_id=789123\n"+ +// " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ +// "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), +// }, +// { +// Role: "user", +// Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), +// }, +// { +// Role: "assistant", +// Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + +// "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + +// "**From project items, extract:**\n" + +// "- content.repository.name and content.repository.owner.login\n" + +// "- content.number (the issue/PR number)\n" + +// "- content_type (\"Issue\" or \"PullRequest\")\n\n" + +// "**Then use these tools for details:**\n\n" + +// "1. **Get full issue/PR details:**\n" + +// " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + +// " - Returns: full body, labels, assignees, milestone, etc.\n\n" + +// "2. **Get recent comments:**\n" + +// " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + +// " - Add since parameter to filter recent comments\n\n" + +// "3. **Get issue events:**\n" + +// " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + +// " - Shows timeline: assignments, label changes, status updates\n\n" + +// "4. **For pull requests specifically:**\n" + +// " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + +// " - **list_pull_request_reviews** for review status\n\n" + +// "**💡 Example:** To check for blockers in comments:\n" + +// "1. Get project items with query=\"assignee:@me is:open\"\n" + +// "2. For each item, extract repository and issue number from content\n" + +// "3. Use **list_comments** to get recent comments\n" + +// "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), +// }, +// } +// return &mcp.GetPromptResult{ +// Messages: messages, +// }, nil +// } +// } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index ed198a97a..2a63522cd 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1,1649 +1,1649 @@ package github -import ( - "context" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - gh "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ListProjects(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_projects", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) - - orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} - userProjects := []map[string]any{{"id": 2, "title": "User Project"}} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgProjects), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userProjects), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "success organization with pagination & query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "per_page": float64(50), - "query": "roadmap", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - expectedErrMsg: "failed to list projects", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var arr []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(arr)) - }) - } -} - -func Test_GetProject(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) - - project := map[string]any{"id": 123, "title": "Project Title"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "success organization project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner": "octo-org", - "owner_type": "org", - }, - expectError: false, - }, - { - name: "success user project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), - requestArgs: map[string]interface{}{ - "project_number": float64(456), - "owner": "octocat", - "owner_type": "user", - }, - expectError: false, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]interface{}{ - "project_number": float64(999), - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - expectedErrMsg: "failed to get project", - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner": "octo-org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var arr map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) - require.NoError(t, err) - }) - } -} - -func Test_ListProjectFields(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_fields", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - - orgFields := []map[string]any{ - {"id": 101, "name": "Status", "dataType": "single_select"}, - } - userFields := []map[string]any{ - {"id": 201, "name": "Priority", "dataType": "single_select"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgFields), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - }, - expectedLength: 1, - }, - { - name: "success user fields with per_page override", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userFields)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "per_page": float64(50), - }, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - }, - expectError: true, - expectedErrMsg: "failed to list project fields", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner_type": "org", - "project_number": 10, - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "project_number": 10, - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var fields []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &fields) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(fields)) - }) - } -} - -func Test_GetProjectField(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_field", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "field_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) - - orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} - userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgField), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "field_id": float64(101), - }, - expectedID: 101, - }, - { - name: "success user field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userField), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "field_id": float64(202), - }, - expectedID: 202, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - "field_id": float64(303), - }, - expectError: true, - expectedErrMsg: "failed to get project field", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing field_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing field_id" { - assert.Contains(t, text, "missing required parameter: field_id") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var field map[string]any - err = json.Unmarshal([]byte(textContent.Text), &field) - require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), field["id"]) - } - }) - } -} - -func Test_ListProjectItems(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_items", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.Contains(t, tool.InputSchema.Properties, "fields") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - - orgItems := []map[string]any{ - {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ - {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, - {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, - }}, - } - userItems := []map[string]any{ - {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, - {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItems), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - }, - expectedLength: 1, - }, - { - name: "success organization items with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - fieldParams := q.Get("fields") - if fieldParams == "123,456,789" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "fields": []interface{}{"123", "456", "789"}, - }, - expectedLength: 1, - }, - { - name: "success user items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItems), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - }, - expectedLength: 2, - }, - { - name: "success with pagination and query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "bug" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "per_page": float64(50), - "query": "bug", - }, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - }, - expectError: true, - expectedErrMsg: ProjectListFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var items []map[string]any - err = json.Unmarshal([]byte(textContent.Text), &items) - require.NoError(t, err) - assert.Equal(t, tc.expectedLength, len(items)) - }) - } -} - -func Test_GetProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.Contains(t, tool.InputSchema.Properties, "fields") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - orgItem := map[string]any{ - "id": 301, - "content_type": "Issue", - "project_node_id": "PR_1", - "creator": map[string]any{"login": "octocat"}, - } - userItem := map[string]any{ - "id": 501, - "content_type": "PullRequest", - "project_node_id": "PR_2", - "creator": map[string]any{"login": "jane"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItem), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - }, - expectedID: 301, - }, - { - name: "success organization item with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - fieldParams := q.Get("fields") - if fieldParams == "123,456" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItem)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - "fields": []interface{}{"123", "456"}, - }, - expectedID: 301, - }, - { - name: "success user item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItem), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(501), - }, - expectedID: 501, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get project item", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing item_id" { - assert.Contains(t, text, "missing required parameter: item_id") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - err = json.Unmarshal([]byte(textContent.Text), &item) - require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_AddProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "add_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_type") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) - - orgItem := map[string]any{ - "id": 601, - "content_type": "Issue", - "creator": map[string]any{ - "login": "octocat", - "id": 1, - "html_url": "https://github.com/octocat", - "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", - }, - } - - userItem := map[string]any{ - "id": 701, - "content_type": "PullRequest", - "creator": map[string]any{ - "login": "hubot", - "id": 2, - "html_url": "https://github.com/hubot", - "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - expectedContentType string - expectedCreatorLogin string - }{ - { - name: "success organization issue", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "Issue", payload.Type) - assert.Equal(t, 9876, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(orgItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_type": "issue", - "item_id": float64(9876), - }, - expectedID: 601, - expectedContentType: "Issue", - expectedCreatorLogin: "octocat", - }, - { - name: "success user pull request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "PullRequest", payload.Type) - assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(userItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(222), - "item_type": "pull_request", - "item_id": float64(7654), - }, - expectedID: 701, - expectedContentType: "PullRequest", - expectedCreatorLogin: "hubot", - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(999), - "item_type": "issue", - "item_id": float64(8888), - }, - expectError: true, - expectedErrMsg: ProjectAddFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - require.NoError(t, err) - - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_type": - assert.Contains(t, text, "missing required parameter: item_type") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - // case "api error": - // assert.Contains(t, text, ProjectAddFailedError) - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - if tc.expectedContentType != "" { - assert.Equal(t, tc.expectedContentType, item["content_type"]) - } - if tc.expectedCreatorLogin != "" { - creator, ok := item["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) - } - }) - } -} - -func Test_UpdateProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "update_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.Contains(t, tool.InputSchema.Properties, "updated_field") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) - - orgUpdatedItem := map[string]any{ - "id": 801, - "content_type": "Issue", - } - userUpdatedItem := map[string]any{ - "id": 802, - "content_type": "PullRequest", - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 101, payload.Fields[0].ID) - assert.Equal(t, "Done", payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1001), - "item_id": float64(5555), - "updated_field": map[string]any{ - "id": float64(101), - "value": "Done", - }, - }, - expectedID: 801, - }, - { - name: "success user update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2002), - "item_id": float64(6666), - "updated_field": map[string]any{ - "id": float64(202), - "value": float64(42), - }, - }, - expectedID: 802, - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(3003), - "item_id": float64(7777), - "updated_field": map[string]any{ - "id": float64(303), - "value": "In Progress", - }, - }, - expectError: true, - expectedErrMsg: "failed to update a project item", - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "field_id": float64(1), - "new_field": map[string]any{ - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(2), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(2), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "new_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing field_value", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "field_id": float64(2), - }, - expectError: true, - }, - { - name: "new_field not object", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": "not-an-object", - }, - expectError: true, - }, - { - name: "new_field missing id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{}, - }, - expectError: true, - }, - { - name: "new_field missing value", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(9), - }, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - case "missing field_value": - assert.Contains(t, text, "missing required parameter: updated_field") - case "field_value not object": - assert.Contains(t, text, "field_value must be an object") - case "field_value missing id": - assert.Contains(t, text, "missing required parameter: field_id") - case "field_value missing value": - assert.Contains(t, text, "field_value.value is required") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_DeleteProjectItem(t *testing.T) { - mockClient := gh.NewClient(nil) - tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner_type") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "project_number") - assert.Contains(t, tool.InputSchema.Properties, "item_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedText string - }{ - { - name: "success organization delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(555), - }, - expectedText: "project item successfully deleted", - }, - { - name: "success user delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(777), - }, - expectedText: "project item successfully deleted", - }, - { - name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: ProjectDeleteFailedError, - }, - { - name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - } - return - } - - require.False(t, result.IsError) - text := getTextResult(t, result).Text - assert.Contains(t, text, tc.expectedText) - }) - } -} +// import ( +// "context" +// "encoding/json" +// "io" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// gh "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_ListProjects(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_projects", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "per_page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) + +// orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} +// userProjects := []map[string]any{{"id": 2, "title": "User Project"}} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedLength int +// expectedErrMsg string +// }{ +// { +// name: "success organization", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, orgProjects), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: false, +// expectedLength: 1, +// }, +// { +// name: "success user", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, userProjects), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octocat", +// "owner_type": "user", +// }, +// expectError: false, +// expectedLength: 1, +// }, +// { +// name: "success organization with pagination & query", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// q := r.URL.Query() +// if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(orgProjects)) +// return +// } +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "per_page": float64(50), +// "query": "roadmap", +// }, +// expectError: false, +// expectedLength: 1, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: true, +// expectedErrMsg: "failed to list projects", +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner_type": "org", +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var arr []map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &arr) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedLength, len(arr)) +// }) +// } +// } + +// func Test_GetProject(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_project", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) + +// project := map[string]any{"id": 123, "title": "Project Title"} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "success organization project fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, project), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "project_number": float64(123), +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: false, +// }, +// { +// name: "success user project fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, project), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "project_number": float64(456), +// "owner": "octocat", +// "owner_type": "user", +// }, +// expectError: false, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "project_number": float64(999), +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: true, +// expectedErrMsg: "failed to get project", +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: true, +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "project_number": float64(123), +// "owner_type": "org", +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "project_number": float64(123), +// "owner": "octo-org", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing project_number" { +// assert.Contains(t, text, "missing required parameter: project_number") +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var arr map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &arr) +// require.NoError(t, err) +// }) +// } +// } + +// func Test_ListProjectFields(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_project_fields", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "per_page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + +// orgFields := []map[string]any{ +// {"id": 101, "name": "Status", "dataType": "single_select"}, +// } +// userFields := []map[string]any{ +// {"id": 201, "name": "Priority", "dataType": "single_select"}, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedLength int +// expectedErrMsg string +// }{ +// { +// name: "success organization fields", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, orgFields), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// }, +// expectedLength: 1, +// }, +// { +// name: "success user fields with per_page override", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// q := r.URL.Query() +// if q.Get("per_page") == "50" { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(userFields)) +// return +// } +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(456), +// "per_page": float64(50), +// }, +// expectedLength: 1, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(789), +// }, +// expectError: true, +// expectedErrMsg: "failed to list project fields", +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner_type": "org", +// "project_number": 10, +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "project_number": 10, +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// if tc.name == "missing project_number" { +// assert.Contains(t, text, "missing required parameter: project_number") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var fields []map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &fields) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedLength, len(fields)) +// }) +// } +// } + +// func Test_GetProjectField(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_project_field", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "field_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) + +// orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} +// userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// expectedID int +// }{ +// { +// name: "success organization field", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, orgField), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "field_id": float64(101), +// }, +// expectedID: 101, +// }, +// { +// name: "success user field", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, userField), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(456), +// "field_id": float64(202), +// }, +// expectedID: 202, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(789), +// "field_id": float64(303), +// }, +// expectError: true, +// expectedErrMsg: "failed to get project field", +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner_type": "org", +// "project_number": float64(10), +// "field_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "project_number": float64(10), +// "field_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "field_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing field_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(10), +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// if tc.name == "missing project_number" { +// assert.Contains(t, text, "missing required parameter: project_number") +// } +// if tc.name == "missing field_id" { +// assert.Contains(t, text, "missing required parameter: field_id") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var field map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &field) +// require.NoError(t, err) +// if tc.expectedID != 0 { +// assert.Equal(t, float64(tc.expectedID), field["id"]) +// } +// }) +// } +// } + +// func Test_ListProjectItems(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_project_items", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "per_page") +// assert.Contains(t, tool.InputSchema.Properties, "fields") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + +// orgItems := []map[string]any{ +// {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ +// {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, +// {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, +// }}, +// } +// userItems := []map[string]any{ +// {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, +// {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedLength int +// expectedErrMsg string +// }{ +// { +// name: "success organization items", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, orgItems), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// }, +// expectedLength: 1, +// }, +// { +// name: "success organization items with fields", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// q := r.URL.Query() +// fieldParams := q.Get("fields") +// if fieldParams == "123,456,789" { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(orgItems)) +// return +// } +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "fields": []interface{}{"123", "456", "789"}, +// }, +// expectedLength: 1, +// }, +// { +// name: "success user items", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, userItems), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(456), +// }, +// expectedLength: 2, +// }, +// { +// name: "success with pagination and query", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// q := r.URL.Query() +// if q.Get("per_page") == "50" && q.Get("q") == "bug" { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(orgItems)) +// return +// } +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "per_page": float64(50), +// "query": "bug", +// }, +// expectedLength: 1, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(789), +// }, +// expectError: true, +// expectedErrMsg: ProjectListFailedError, +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner_type": "org", +// "project_number": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "project_number": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "octo-org", +// "owner_type": "org", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// if tc.name == "missing project_number" { +// assert.Contains(t, text, "missing required parameter: project_number") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var items []map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &items) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedLength, len(items)) +// }) +// } +// } + +// func Test_GetProjectItem(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_project_item", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "item_id") +// assert.Contains(t, tool.InputSchema.Properties, "fields") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + +// orgItem := map[string]any{ +// "id": 301, +// "content_type": "Issue", +// "project_node_id": "PR_1", +// "creator": map[string]any{"login": "octocat"}, +// } +// userItem := map[string]any{ +// "id": 501, +// "content_type": "PullRequest", +// "project_node_id": "PR_2", +// "creator": map[string]any{"login": "jane"}, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// expectedID int +// }{ +// { +// name: "success organization item", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, orgItem), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "item_id": float64(301), +// }, +// expectedID: 301, +// }, +// { +// name: "success organization item with fields", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// q := r.URL.Query() +// fieldParams := q.Get("fields") +// if fieldParams == "123,456" { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(orgItem)) +// return +// } +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "item_id": float64(301), +// "fields": []interface{}{"123", "456"}, +// }, +// expectedID: 301, +// }, +// { +// name: "success user item", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusOK, userItem), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(456), +// "item_id": float64(501), +// }, +// expectedID: 501, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(789), +// "item_id": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get project item", +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner_type": "org", +// "project_number": float64(10), +// "item_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "project_number": float64(10), +// "item_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "item_id": float64(1), +// }, +// expectError: true, +// }, +// { +// name: "missing item_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(10), +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// if tc.name == "missing owner" { +// assert.Contains(t, text, "missing required parameter: owner") +// } +// if tc.name == "missing owner_type" { +// assert.Contains(t, text, "missing required parameter: owner_type") +// } +// if tc.name == "missing project_number" { +// assert.Contains(t, text, "missing required parameter: project_number") +// } +// if tc.name == "missing item_id" { +// assert.Contains(t, text, "missing required parameter: item_id") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var item map[string]any +// err = json.Unmarshal([]byte(textContent.Text), &item) +// require.NoError(t, err) +// if tc.expectedID != 0 { +// assert.Equal(t, float64(tc.expectedID), item["id"]) +// } +// }) +// } +// } + +// func Test_AddProjectItem(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "add_project_item", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "item_type") +// assert.Contains(t, tool.InputSchema.Properties, "item_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) + +// orgItem := map[string]any{ +// "id": 601, +// "content_type": "Issue", +// "creator": map[string]any{ +// "login": "octocat", +// "id": 1, +// "html_url": "https://github.com/octocat", +// "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", +// }, +// } + +// userItem := map[string]any{ +// "id": 701, +// "content_type": "PullRequest", +// "creator": map[string]any{ +// "login": "hubot", +// "id": 2, +// "html_url": "https://github.com/hubot", +// "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// expectedID int +// expectedContentType string +// expectedCreatorLogin string +// }{ +// { +// name: "success organization issue", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// body, err := io.ReadAll(r.Body) +// assert.NoError(t, err) +// var payload struct { +// Type string `json:"type"` +// ID int `json:"id"` +// } +// assert.NoError(t, json.Unmarshal(body, &payload)) +// assert.Equal(t, "Issue", payload.Type) +// assert.Equal(t, 9876, payload.ID) +// w.WriteHeader(http.StatusCreated) +// _, _ = w.Write(mock.MustMarshal(orgItem)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(321), +// "item_type": "issue", +// "item_id": float64(9876), +// }, +// expectedID: 601, +// expectedContentType: "Issue", +// expectedCreatorLogin: "octocat", +// }, +// { +// name: "success user pull request", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// body, err := io.ReadAll(r.Body) +// assert.NoError(t, err) +// var payload struct { +// Type string `json:"type"` +// ID int `json:"id"` +// } +// assert.NoError(t, json.Unmarshal(body, &payload)) +// assert.Equal(t, "PullRequest", payload.Type) +// assert.Equal(t, 7654, payload.ID) +// w.WriteHeader(http.StatusCreated) +// _, _ = w.Write(mock.MustMarshal(userItem)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(222), +// "item_type": "pull_request", +// "item_id": float64(7654), +// }, +// expectedID: 701, +// expectedContentType: "PullRequest", +// expectedCreatorLogin: "hubot", +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(999), +// "item_type": "issue", +// "item_id": float64(8888), +// }, +// expectError: true, +// expectedErrMsg: ProjectAddFailedError, +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner_type": "org", +// "project_number": float64(1), +// "item_type": "Issue", +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "project_number": float64(1), +// "item_type": "Issue", +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "item_type": "Issue", +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing item_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing item_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_type": "Issue", +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// switch tc.name { +// case "missing owner": +// assert.Contains(t, text, "missing required parameter: owner") +// case "missing owner_type": +// assert.Contains(t, text, "missing required parameter: owner_type") +// case "missing project_number": +// assert.Contains(t, text, "missing required parameter: project_number") +// case "missing item_type": +// assert.Contains(t, text, "missing required parameter: item_type") +// case "missing item_id": +// assert.Contains(t, text, "missing required parameter: item_id") +// // case "api error": +// // assert.Contains(t, text, ProjectAddFailedError) +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var item map[string]any +// require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) +// if tc.expectedID != 0 { +// assert.Equal(t, float64(tc.expectedID), item["id"]) +// } +// if tc.expectedContentType != "" { +// assert.Equal(t, tc.expectedContentType, item["content_type"]) +// } +// if tc.expectedCreatorLogin != "" { +// creator, ok := item["creator"].(map[string]any) +// require.True(t, ok) +// assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) +// } +// }) +// } +// } + +// func Test_UpdateProjectItem(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "update_project_item", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "item_id") +// assert.Contains(t, tool.InputSchema.Properties, "updated_field") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) + +// orgUpdatedItem := map[string]any{ +// "id": 801, +// "content_type": "Issue", +// } +// userUpdatedItem := map[string]any{ +// "id": 802, +// "content_type": "PullRequest", +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// expectedID int +// }{ +// { +// name: "success organization update", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// body, err := io.ReadAll(r.Body) +// assert.NoError(t, err) +// var payload struct { +// Fields []struct { +// ID int `json:"id"` +// Value interface{} `json:"value"` +// } `json:"fields"` +// } +// assert.NoError(t, json.Unmarshal(body, &payload)) +// require.Len(t, payload.Fields, 1) +// assert.Equal(t, 101, payload.Fields[0].ID) +// assert.Equal(t, "Done", payload.Fields[0].Value) +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1001), +// "item_id": float64(5555), +// "updated_field": map[string]any{ +// "id": float64(101), +// "value": "Done", +// }, +// }, +// expectedID: 801, +// }, +// { +// name: "success user update", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// body, err := io.ReadAll(r.Body) +// assert.NoError(t, err) +// var payload struct { +// Fields []struct { +// ID int `json:"id"` +// Value interface{} `json:"value"` +// } `json:"fields"` +// } +// assert.NoError(t, json.Unmarshal(body, &payload)) +// require.Len(t, payload.Fields, 1) +// assert.Equal(t, 202, payload.Fields[0].ID) +// assert.Equal(t, 42.0, payload.Fields[0].Value) +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(2002), +// "item_id": float64(6666), +// "updated_field": map[string]any{ +// "id": float64(202), +// "value": float64(42), +// }, +// }, +// expectedID: 802, +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(3003), +// "item_id": float64(7777), +// "updated_field": map[string]any{ +// "id": float64(303), +// "value": "In Progress", +// }, +// }, +// expectError: true, +// expectedErrMsg: "failed to update a project item", +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(2), +// "field_id": float64(1), +// "new_field": map[string]any{ +// "value": "X", +// }, +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "project_number": float64(1), +// "item_id": float64(2), +// "new_field": map[string]any{ +// "id": float64(1), +// "value": "X", +// }, +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "item_id": float64(2), +// "new_field": map[string]any{ +// "id": float64(1), +// "value": "X", +// }, +// }, +// expectError: true, +// }, +// { +// name: "missing item_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "new_field": map[string]any{ +// "id": float64(1), +// "value": "X", +// }, +// }, +// expectError: true, +// }, +// { +// name: "missing field_value", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(2), +// "field_id": float64(2), +// }, +// expectError: true, +// }, +// { +// name: "new_field not object", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(2), +// "updated_field": "not-an-object", +// }, +// expectError: true, +// }, +// { +// name: "new_field missing id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(2), +// "updated_field": map[string]any{}, +// }, +// expectError: true, +// }, +// { +// name: "new_field missing value", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(2), +// "updated_field": map[string]any{ +// "id": float64(9), +// }, +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// switch tc.name { +// case "missing owner": +// assert.Contains(t, text, "missing required parameter: owner") +// case "missing owner_type": +// assert.Contains(t, text, "missing required parameter: owner_type") +// case "missing project_number": +// assert.Contains(t, text, "missing required parameter: project_number") +// case "missing item_id": +// assert.Contains(t, text, "missing required parameter: item_id") +// case "missing field_value": +// assert.Contains(t, text, "missing required parameter: updated_field") +// case "field_value not object": +// assert.Contains(t, text, "field_value must be an object") +// case "field_value missing id": +// assert.Contains(t, text, "missing required parameter: field_id") +// case "field_value missing value": +// assert.Contains(t, text, "field_value.value is required") +// } +// return +// } + +// require.False(t, result.IsError) +// textContent := getTextResult(t, result) +// var item map[string]any +// require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) +// if tc.expectedID != 0 { +// assert.Equal(t, float64(tc.expectedID), item["id"]) +// } +// }) +// } +// } + +// func Test_DeleteProjectItem(t *testing.T) { +// mockClient := gh.NewClient(nil) +// tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "delete_project_item", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner_type") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "project_number") +// assert.Contains(t, tool.InputSchema.Properties, "item_id") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// expectedText string +// }{ +// { +// name: "success organization delete", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(123), +// "item_id": float64(555), +// }, +// expectedText: "project item successfully deleted", +// }, +// { +// name: "success user delete", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octocat", +// "owner_type": "user", +// "project_number": float64(456), +// "item_id": float64(777), +// }, +// expectedText: "project item successfully deleted", +// }, +// { +// name: "api error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(321), +// "item_id": float64(999), +// }, +// expectError: true, +// expectedErrMsg: ProjectDeleteFailedError, +// }, +// { +// name: "missing owner", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner_type": "org", +// "project_number": float64(1), +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing owner_type", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "project_number": float64(1), +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing project_number", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "item_id": float64(10), +// }, +// expectError: true, +// }, +// { +// name: "missing item_id", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]any{ +// "owner": "octo-org", +// "owner_type": "org", +// "project_number": float64(1), +// }, +// expectError: true, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := gh.NewClient(tc.mockedClient) +// _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// require.NoError(t, err) +// if tc.expectError { +// require.True(t, result.IsError) +// text := getTextResult(t, result).Text +// if tc.expectedErrMsg != "" { +// assert.Contains(t, text, tc.expectedErrMsg) +// } +// switch tc.name { +// case "missing owner": +// assert.Contains(t, text, "missing required parameter: owner") +// case "missing owner_type": +// assert.Contains(t, text, "missing required parameter: owner_type") +// case "missing project_number": +// assert.Contains(t, text, "missing required parameter: project_number") +// case "missing item_id": +// assert.Contains(t, text, "missing required parameter: item_id") +// } +// return +// } + +// require.False(t, result.IsError) +// text := getTextResult(t, result).Text +// assert.Contains(t, text, tc.expectedText) +// }) +// } +// } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 117f92ecf..6fce227ae 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1,1630 +1,1630 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - "github.com/shurcooL/githubv4" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/translations" -) - -// GetPullRequest creates a tool to get details of a specific pull request. -func PullRequestRead(getClient GetClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("pull_request_read", - mcp.WithDescription(t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("method", - mcp.Required(), - mcp.Description(`Action to specify what pull request data needs to be retrieved from GitHub. -Possible options: - 1. get - Get details of a specific pull request. - 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. 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", "get_comments"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - 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 - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pullNumber, err := RequiredInt(request, "pullNumber") - if err != nil { - 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) - } - - switch method { - - case "get": - return GetPullRequest(ctx, client, owner, repo, pullNumber) - case "get_diff": - return GetPullRequestDiff(ctx, client, owner, repo, pullNumber) - case "get_status": - return GetPullRequestStatus(ctx, client, owner, repo, pullNumber) - case "get_files": - return GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) - case "get_review_comments": - 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, flags) - default: - return nil, fmt.Errorf("unknown method: %s", method) - } - } -} - -func GetPullRequest(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { - pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request", - 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 get pull request: %s", string(body))), nil - } - - // sanitize title/body on response - if pr != nil { - if pr.Title != nil { - pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) - } - if pr.Body != nil { - pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) - } - } - - r, err := json.Marshal(pr) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { - raw, resp, err := client.PullRequests.GetRaw( - ctx, - owner, - repo, - pullNumber, - github.RawOptions{Type: github.Diff}, - ) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request diff", - resp, - err, - ), 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 pull request diff: %s", string(body))), nil - } - - defer func() { _ = resp.Body.Close() }() - - // Return the raw response - return mcp.NewToolResultText(string(raw)), nil -} - -func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { - pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request", - 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 get pull request: %s", string(body))), nil - } - - // Get combined status for the head SHA - status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get combined status", - 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 get combined status: %s", string(body))), nil - } - - r, err := json.Marshal(status) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request files", - 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 get pull request files: %s", string(body))), nil - } - - r, err := json.Marshal(files) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -func GetPullRequestReviewComments(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { - opts := &github.PullRequestListCommentsOptions{ - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request review comments", - 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 get pull request review 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 GetPullRequestReviews(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { - reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request reviews", - 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 get pull request reviews: %s", string(body))), nil - } - - r, err := json.Marshal(reviews) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil -} - -// CreatePullRequest creates a tool to create a new pull request. -func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("create_pull_request", - mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), - 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("PR title"), - ), - mcp.WithString("body", - mcp.Description("PR description"), - ), - mcp.WithString("head", - mcp.Required(), - mcp.Description("Branch containing changes"), - ), - mcp.WithString("base", - mcp.Required(), - mcp.Description("Branch to merge into"), - ), - mcp.WithBoolean("draft", - mcp.Description("Create as draft PR"), - ), - mcp.WithBoolean("maintainer_can_modify", - mcp.Description("Allow maintainer edits"), - ), - ), - 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 - } - head, err := RequiredParam[string](request, "head") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - base, err := RequiredParam[string](request, "base") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - body, err := OptionalParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - draft, err := OptionalParam[bool](request, "draft") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - newPR := &github.NewPullRequest{ - Title: github.Ptr(title), - Head: github.Ptr(head), - Base: github.Ptr(base), - } - - if body != "" { - newPR.Body = github.Ptr(body) - } - - newPR.Draft = github.Ptr(draft) - newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create pull request", - resp, - err, - ), nil - } - 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 pull request: %s", string(body))), nil - } - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", pr.GetID()), - URL: pr.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 - } -} - -// UpdatePullRequest creates a tool to update an existing pull request. -func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("update_pull_request", - mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number to update"), - ), - mcp.WithString("title", - mcp.Description("New title"), - ), - mcp.WithString("body", - mcp.Description("New description"), - ), - mcp.WithString("state", - mcp.Description("New state"), - mcp.Enum("open", "closed"), - ), - mcp.WithBoolean("draft", - mcp.Description("Mark pull request as draft (true) or ready for review (false)"), - ), - mcp.WithString("base", - mcp.Description("New base branch name"), - ), - mcp.WithBoolean("maintainer_can_modify", - mcp.Description("Allow maintainer edits"), - ), - mcp.WithArray("reviewers", - mcp.Description("GitHub usernames to request reviews from"), - mcp.Items(map[string]interface{}{ - "type": "string", - }), - ), - ), - 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 - } - pullNumber, err := RequiredInt(request, "pullNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Check if draft parameter is provided - draftProvided := request.GetArguments()["draft"] != nil - var draftValue bool - if draftProvided { - draftValue, err = OptionalParam[bool](request, "draft") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - } - - // Build the update struct only with provided fields - update := &github.PullRequest{} - restUpdateNeeded := false - - if title, ok, err := OptionalParamOK[string](request, "title"); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } else if ok { - update.Title = github.Ptr(title) - restUpdateNeeded = true - } - - if body, ok, err := OptionalParamOK[string](request, "body"); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } else if ok { - update.Body = github.Ptr(body) - restUpdateNeeded = true - } - - if state, ok, err := OptionalParamOK[string](request, "state"); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } else if ok { - update.State = github.Ptr(state) - restUpdateNeeded = true - } - - if base, ok, err := OptionalParamOK[string](request, "base"); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } else if ok { - update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} - restUpdateNeeded = true - } - - if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } else if ok { - update.MaintainerCanModify = github.Ptr(maintainerCanModify) - restUpdateNeeded = true - } - - // Handle reviewers separately - reviewers, err := OptionalStringArrayParam(request, "reviewers") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // If no updates, no draft change, and no reviewers, return error early - if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { - return mcp.NewToolResultError("No update parameters provided."), nil - } - - // Handle REST API updates (title, body, state, base, maintainer_can_modify) - if restUpdateNeeded { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update pull request", - 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 pull request: %s", string(body))), nil - } - } - - // Handle draft status changes using GraphQL - if draftProvided { - gqlClient, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) - } - - var prQuery struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers - }) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil - } - - currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) - - if currentIsDraft != draftValue { - if draftValue { - // Convert to draft - var mutation struct { - ConvertPullRequestToDraft struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } - } `graphql:"convertPullRequestToDraft(input: $input)"` - } - - err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ - PullRequestID: prQuery.Repository.PullRequest.ID, - }, nil) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil - } - } else { - // Mark as ready for review - var mutation struct { - MarkPullRequestReadyForReview struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } - } `graphql:"markPullRequestReadyForReview(input: $input)"` - } - - err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ - PullRequestID: prQuery.Repository.PullRequest.ID, - }, nil) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil - } - } - } - } - - // Handle reviewer requests - if len(reviewers) > 0 { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - reviewersRequest := github.ReviewersRequest{ - Reviewers: reviewers, - } - - _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to request reviewers", - resp, - err, - ), nil - } - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - - if resp.StatusCode != http.StatusCreated && 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 request reviewers: %s", string(body))), nil - } - } - - // Get the final state of the PR to return - client, err := getClient(ctx) - if err != nil { - return nil, err - } - - finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil - } - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", finalPR.GetID()), - URL: finalPR.GetHTMLURL(), - } - - r, err := json.Marshal(minimalResponse) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListPullRequests creates a tool to list and filter repository pull requests. -func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("list_pull_requests", - mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("state", - mcp.Description("Filter by state"), - mcp.Enum("open", "closed", "all"), - ), - mcp.WithString("head", - mcp.Description("Filter by head user/org and branch"), - ), - mcp.WithString("base", - mcp.Description("Filter by base branch"), - ), - mcp.WithString("sort", - mcp.Description("Sort by"), - mcp.Enum("created", "updated", "popularity", "long-running"), - ), - mcp.WithString("direction", - mcp.Description("Sort direction"), - mcp.Enum("asc", "desc"), - ), - 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 - } - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - head, err := OptionalParam[string](request, "head") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - base, err := OptionalParam[string](request, "base") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - opts := &github.PullRequestListOptions{ - State: state, - Head: head, - Base: base, - Sort: sort, - Direction: direction, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list pull requests", - 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 pull requests: %s", string(body))), nil - } - - // sanitize title/body on each PR - for _, pr := range prs { - if pr == nil { - continue - } - if pr.Title != nil { - pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) - } - if pr.Body != nil { - pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) - } - } - - r, err := json.Marshal(prs) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// MergePullRequest creates a tool to merge a pull request. -func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("merge_pull_request", - mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - mcp.WithString("commit_title", - mcp.Description("Title for merge commit"), - ), - mcp.WithString("commit_message", - mcp.Description("Extra detail for merge commit"), - ), - mcp.WithString("merge_method", - mcp.Description("Merge method"), - mcp.Enum("merge", "squash", "rebase"), - ), - ), - 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 - } - pullNumber, err := RequiredInt(request, "pullNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - commitTitle, err := OptionalParam[string](request, "commit_title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - commitMessage, err := OptionalParam[string](request, "commit_message") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - mergeMethod, err := OptionalParam[string](request, "merge_method") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - options := &github.PullRequestOptions{ - CommitTitle: commitTitle, - MergeMethod: mergeMethod, - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to merge pull request", - 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 merge pull request: %s", string(body))), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// SearchPullRequests creates a tool to search for pull requests. -func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_pull_requests", - mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub pull request search syntax"), - ), - mcp.WithString("owner", - mcp.Description("Optional repository owner. If provided with repo, only pull requests for this repository are listed."), - ), - mcp.WithString("repo", - mcp.Description("Optional repository name. If provided with owner, only pull requests 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, "pr", "failed to search pull requests") - } -} - -// UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. -func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("update_pull_request_branch", - mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - mcp.WithString("expectedHeadSha", - mcp.Description("The expected SHA of the pull request's HEAD ref"), - ), - ), - 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 - } - pullNumber, err := RequiredInt(request, "pullNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - expectedHeadSHA, err := OptionalParam[string](request, "expectedHeadSha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - opts := &github.PullRequestBranchUpdateOptions{} - if expectedHeadSHA != "" { - opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts) - if err != nil { - // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, - // and it's not a real error. - if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return mcp.NewToolResultText("Pull request branch update is in progress"), nil - } - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update pull request branch", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusAccepted { - 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 pull request branch: %s", string(body))), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -type PullRequestReviewWriteParams struct { - Method string - Owner string - Repo string - PullNumber int32 - Body string - Event string - CommitID *string -} - -func PullRequestReviewWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("pull_request_review_write", - mcp.WithDescription(t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. - -Available methods: -- create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. -- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. -- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. -`)), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), - ReadOnlyHint: ToBoolPtr(false), - }), - // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up. - // Since our other Pull Request tools are working with the REST Client, will handle the lookup - // internally for now. - mcp.WithString("method", - mcp.Required(), - mcp.Description("The write operation to perform on pull request review."), - mcp.Enum("create", "submit_pending", "delete_pending"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - mcp.WithString("body", - mcp.Description("Review comment text"), - ), - mcp.WithString("event", - mcp.Description("Review action to perform."), - mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"), - ), - mcp.WithString("commitID", - mcp.Description("SHA of commit to review"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var params PullRequestReviewWriteParams - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Given our owner, repo and PR number, lookup the GQL ID of the PR. - client, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } - - switch params.Method { - case "create": - return CreatePullRequestReview(ctx, client, params) - case "submit_pending": - return SubmitPendingPullRequestReview(ctx, client, params) - case "delete_pending": - return DeletePendingPullRequestReview(ctx, client, params) - default: - return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil - } - } -} - -func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { - var getPullRequestQuery struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - if err := client.Query(ctx, &getPullRequestQuery, map[string]any{ - "owner": githubv4.String(params.Owner), - "repo": githubv4.String(params.Repo), - "prNum": githubv4.Int(params.PullNumber), - }); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get pull request", - err, - ), nil - } - - // Now we have the GQL ID, we can create a review - var addPullRequestReviewMutation struct { - AddPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"addPullRequestReview(input: $input)"` - } - - addPullRequestReviewInput := githubv4.AddPullRequestReviewInput{ - PullRequestID: getPullRequestQuery.Repository.PullRequest.ID, - CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID), - } - - // Event and Body are provided if we submit a review - if params.Event != "" { - addPullRequestReviewInput.Event = newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event) - addPullRequestReviewInput.Body = githubv4.NewString(githubv4.String(params.Body)) - } - - if err := client.Mutate( - ctx, - &addPullRequestReviewMutation, - addPullRequestReviewInput, - nil, - ); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Return nothing interesting, just indicate success for the time being. - // In future, we may want to return the review ID, but for the moment, we're not leaking - // API implementation details to the LLM. - if params.Event == "" { - return mcp.NewToolResultText("pending pull request created"), nil - } - return mcp.NewToolResultText("pull request review submitted successfully"), nil -} - -func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { - // First we'll get the current user - var getViewerQuery struct { - Viewer struct { - Login githubv4.String - } - } - - if err := client.Query(ctx, &getViewerQuery, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get current user", - err, - ), nil - } - - var getLatestReviewForViewerQuery struct { - Repository struct { - PullRequest struct { - Reviews struct { - Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI - } - } `graphql:"reviews(first: 1, author: $author)"` - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - vars := map[string]any{ - "author": githubv4.String(getViewerQuery.Viewer.Login), - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "prNum": githubv4.Int(params.PullNumber), - } - - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get latest review for current user", - err, - ), nil - } - - // Validate there is one review and the state is pending - if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { - return mcp.NewToolResultError("No pending review found for the viewer"), nil - } - - review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] - if review.State != githubv4.PullRequestReviewStatePending { - errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) - return mcp.NewToolResultError(errText), nil - } - - // Prepare the mutation - var submitPullRequestReviewMutation struct { - SubmitPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"submitPullRequestReview(input: $input)"` - } - - if err := client.Mutate( - ctx, - &submitPullRequestReviewMutation, - githubv4.SubmitPullRequestReviewInput{ - PullRequestReviewID: &review.ID, - Event: githubv4.PullRequestReviewEvent(params.Event), - Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), - }, - nil, - ); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to submit pull request review", - err, - ), nil - } - - // Return nothing interesting, just indicate success for the time being. - // In future, we may want to return the review ID, but for the moment, we're not leaking - // API implementation details to the LLM. - return mcp.NewToolResultText("pending pull request review successfully submitted"), nil -} - -func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { - // First we'll get the current user - var getViewerQuery struct { - Viewer struct { - Login githubv4.String - } - } - - if err := client.Query(ctx, &getViewerQuery, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get current user", - err, - ), nil - } - - var getLatestReviewForViewerQuery struct { - Repository struct { - PullRequest struct { - Reviews struct { - Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI - } - } `graphql:"reviews(first: 1, author: $author)"` - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - vars := map[string]any{ - "author": githubv4.String(getViewerQuery.Viewer.Login), - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "prNum": githubv4.Int(params.PullNumber), - } - - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get latest review for current user", - err, - ), nil - } - - // Validate there is one review and the state is pending - if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { - return mcp.NewToolResultError("No pending review found for the viewer"), nil - } - - review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] - if review.State != githubv4.PullRequestReviewStatePending { - errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) - return mcp.NewToolResultError(errText), nil - } - - // Prepare the mutation - var deletePullRequestReviewMutation struct { - DeletePullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"deletePullRequestReview(input: $input)"` - } - - if err := client.Mutate( - ctx, - &deletePullRequestReviewMutation, - githubv4.DeletePullRequestReviewInput{ - PullRequestReviewID: &review.ID, - }, - nil, - ); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Return nothing interesting, just indicate success for the time being. - // In future, we may want to return the review ID, but for the moment, we're not leaking - // API implementation details to the LLM. - return mcp.NewToolResultText("pending pull request review successfully deleted"), nil -} - -// AddCommentToPendingReview creates a tool to add a comment to a pull request review. -func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("add_comment_to_pending_review", - mcp.WithDescription(t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), - ReadOnlyHint: ToBoolPtr(false), - }), - // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to - // add a new tool to get that ID for clients that aren't in the same context as the original pending review - // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment - // the latest review from a user, since only one can be active at a time. It can later be extended with - // a pullRequestReviewID parameter if targeting other reviews is desired: - // mcp.WithString("pullRequestReviewID", - // mcp.Required(), - // mcp.Description("The ID of the pull request review to add a comment to"), - // ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("The relative path to the file that necessitates a comment"), - ), - mcp.WithString("body", - mcp.Required(), - mcp.Description("The text of the review comment"), - ), - mcp.WithString("subjectType", - mcp.Required(), - mcp.Description("The level at which the comment is targeted"), - mcp.Enum("FILE", "LINE"), - ), - mcp.WithNumber("line", - mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"), - ), - mcp.WithString("side", - mcp.Description("The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state"), - mcp.Enum("LEFT", "RIGHT"), - ), - mcp.WithNumber("startLine", - mcp.Description("For multi-line comments, the first line of the range that the comment applies to"), - ), - mcp.WithString("startSide", - mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state"), - mcp.Enum("LEFT", "RIGHT"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var params struct { - Owner string - Repo string - PullNumber int32 - Path string - Body string - SubjectType string - Line *int32 - Side *string - StartLine *int32 - StartSide *string - } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getGQLClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err) - } - - // First we'll get the current user - var getViewerQuery struct { - Viewer struct { - Login githubv4.String - } - } - - if err := client.Query(ctx, &getViewerQuery, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get current user", - err, - ), nil - } - - var getLatestReviewForViewerQuery struct { - Repository struct { - PullRequest struct { - Reviews struct { - Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI - } - } `graphql:"reviews(first: 1, author: $author)"` - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - vars := map[string]any{ - "author": githubv4.String(getViewerQuery.Viewer.Login), - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "prNum": githubv4.Int(params.PullNumber), - } - - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get latest review for current user", - err, - ), nil - } - - // Validate there is one review and the state is pending - if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { - return mcp.NewToolResultError("No pending review found for the viewer"), nil - } - - review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] - if review.State != githubv4.PullRequestReviewStatePending { - errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) - return mcp.NewToolResultError(errText), nil - } - - // Then we can create a new review thread comment on the review. - var addPullRequestReviewThreadMutation struct { - AddPullRequestReviewThread struct { - Thread struct { - ID githubv4.ID // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"addPullRequestReviewThread(input: $input)"` - } - - if err := client.Mutate( - ctx, - &addPullRequestReviewThreadMutation, - githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String(params.Path), - Body: githubv4.String(params.Body), - SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), - Line: newGQLIntPtr(params.Line), - Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), - StartLine: newGQLIntPtr(params.StartLine), - StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), - PullRequestReviewID: &review.ID, - }, - nil, - ); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Return nothing interesting, just indicate success for the time being. - // In future, we may want to return the review ID, but for the moment, we're not leaking - // API implementation details to the LLM. - return mcp.NewToolResultText("pull request review comment successfully added to pending review"), nil - } -} - -// RequestCopilotReview creates a tool to request a Copilot review for a pull request. -// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this -// tool if the configured host does not support it. -func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("request_copilot_review", - mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("pullNumber", - mcp.Required(), - mcp.Description("Pull request number"), - ), - ), - 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 - } - - pullNumber, err := RequiredInt(request, "pullNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - _, resp, err := client.PullRequests.RequestReviewers( - ctx, - owner, - repo, - pullNumber, - github.ReviewersRequest{ - // The login name of the copilot reviewer bot - Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, - }, - ) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to request copilot review", - resp, - err, - ), nil - } - 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 request copilot review: %s", string(body))), nil - } - - // Return nothing on success, as there's not much value in returning the Pull Request itself - return mcp.NewToolResultText(""), nil - } -} - -// newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) -// and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse -// params from the MCP request, we need to convert them to types that are pointers of type def strings and it's -// not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo"). -func newGQLStringlike[T ~string](s string) *T { - if s == "" { - return nil - } - stringlike := T(s) - return &stringlike -} - -func newGQLStringlikePtr[T ~string](s *string) *T { - if s == nil { - return nil - } - stringlike := T(*s) - return &stringlike -} - -func newGQLIntPtr(i *int32) *githubv4.Int { - if i == nil { - return nil - } - gi := githubv4.Int(*i) - return &gi -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" + +// "github.com/go-viper/mapstructure/v2" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// "github.com/shurcooL/githubv4" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/sanitize" +// "github.com/github/github-mcp-server/pkg/translations" +// ) + +// // GetPullRequest creates a tool to get details of a specific pull request. +// func PullRequestRead(getClient GetClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("pull_request_read", +// mcp.WithDescription(t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("method", +// mcp.Required(), +// mcp.Description(`Action to specify what pull request data needs to be retrieved from GitHub. +// Possible options: +// 1. get - Get details of a specific pull request. +// 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. 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", "get_comments"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// 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 +// } +// repo, err := RequiredParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pullNumber, err := RequiredInt(request, "pullNumber") +// if err != nil { +// 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) +// } + +// switch method { + +// case "get": +// return GetPullRequest(ctx, client, owner, repo, pullNumber) +// case "get_diff": +// return GetPullRequestDiff(ctx, client, owner, repo, pullNumber) +// case "get_status": +// return GetPullRequestStatus(ctx, client, owner, repo, pullNumber) +// case "get_files": +// return GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) +// case "get_review_comments": +// 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, flags) +// default: +// return nil, fmt.Errorf("unknown method: %s", method) +// } +// } +// } + +// func GetPullRequest(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +// pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request", +// 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 get pull request: %s", string(body))), nil +// } + +// // sanitize title/body on response +// if pr != nil { +// if pr.Title != nil { +// pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) +// } +// if pr.Body != nil { +// pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) +// } +// } + +// r, err := json.Marshal(pr) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +// raw, resp, err := client.PullRequests.GetRaw( +// ctx, +// owner, +// repo, +// pullNumber, +// github.RawOptions{Type: github.Diff}, +// ) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request diff", +// resp, +// err, +// ), 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 pull request diff: %s", string(body))), nil +// } + +// defer func() { _ = resp.Body.Close() }() + +// // Return the raw response +// return mcp.NewToolResultText(string(raw)), nil +// } + +// func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +// pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request", +// 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 get pull request: %s", string(body))), nil +// } + +// // Get combined status for the head SHA +// status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get combined status", +// 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 get combined status: %s", string(body))), nil +// } + +// r, err := json.Marshal(status) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { +// opts := &github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// } +// files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request files", +// 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 get pull request files: %s", string(body))), nil +// } + +// r, err := json.Marshal(files) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// func GetPullRequestReviewComments(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { +// opts := &github.PullRequestListCommentsOptions{ +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } + +// comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request review comments", +// 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 get pull request review 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 GetPullRequestReviews(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +// reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get pull request reviews", +// 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 get pull request reviews: %s", string(body))), nil +// } + +// r, err := json.Marshal(reviews) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } + +// // CreatePullRequest creates a tool to create a new pull request. +// func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("create_pull_request", +// mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), +// 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("PR title"), +// ), +// mcp.WithString("body", +// mcp.Description("PR description"), +// ), +// mcp.WithString("head", +// mcp.Required(), +// mcp.Description("Branch containing changes"), +// ), +// mcp.WithString("base", +// mcp.Required(), +// mcp.Description("Branch to merge into"), +// ), +// mcp.WithBoolean("draft", +// mcp.Description("Create as draft PR"), +// ), +// mcp.WithBoolean("maintainer_can_modify", +// mcp.Description("Allow maintainer edits"), +// ), +// ), +// 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 +// } +// head, err := RequiredParam[string](request, "head") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// base, err := RequiredParam[string](request, "base") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// body, err := OptionalParam[string](request, "body") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// draft, err := OptionalParam[bool](request, "draft") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// newPR := &github.NewPullRequest{ +// Title: github.Ptr(title), +// Head: github.Ptr(head), +// Base: github.Ptr(base), +// } + +// if body != "" { +// newPR.Body = github.Ptr(body) +// } + +// newPR.Draft = github.Ptr(draft) +// newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create pull request", +// resp, +// err, +// ), nil +// } +// 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 pull request: %s", string(body))), nil +// } + +// // Return minimal response with just essential information +// minimalResponse := MinimalResponse{ +// ID: fmt.Sprintf("%d", pr.GetID()), +// URL: pr.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 +// } +// } + +// // UpdatePullRequest creates a tool to update an existing pull request. +// func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("update_pull_request", +// mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number to update"), +// ), +// mcp.WithString("title", +// mcp.Description("New title"), +// ), +// mcp.WithString("body", +// mcp.Description("New description"), +// ), +// mcp.WithString("state", +// mcp.Description("New state"), +// mcp.Enum("open", "closed"), +// ), +// mcp.WithBoolean("draft", +// mcp.Description("Mark pull request as draft (true) or ready for review (false)"), +// ), +// mcp.WithString("base", +// mcp.Description("New base branch name"), +// ), +// mcp.WithBoolean("maintainer_can_modify", +// mcp.Description("Allow maintainer edits"), +// ), +// mcp.WithArray("reviewers", +// mcp.Description("GitHub usernames to request reviews from"), +// mcp.Items(map[string]interface{}{ +// "type": "string", +// }), +// ), +// ), +// 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 +// } +// pullNumber, err := RequiredInt(request, "pullNumber") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Check if draft parameter is provided +// draftProvided := request.GetArguments()["draft"] != nil +// var draftValue bool +// if draftProvided { +// draftValue, err = OptionalParam[bool](request, "draft") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// } + +// // Build the update struct only with provided fields +// update := &github.PullRequest{} +// restUpdateNeeded := false + +// if title, ok, err := OptionalParamOK[string](request, "title"); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } else if ok { +// update.Title = github.Ptr(title) +// restUpdateNeeded = true +// } + +// if body, ok, err := OptionalParamOK[string](request, "body"); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } else if ok { +// update.Body = github.Ptr(body) +// restUpdateNeeded = true +// } + +// if state, ok, err := OptionalParamOK[string](request, "state"); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } else if ok { +// update.State = github.Ptr(state) +// restUpdateNeeded = true +// } + +// if base, ok, err := OptionalParamOK[string](request, "base"); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } else if ok { +// update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} +// restUpdateNeeded = true +// } + +// if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } else if ok { +// update.MaintainerCanModify = github.Ptr(maintainerCanModify) +// restUpdateNeeded = true +// } + +// // Handle reviewers separately +// reviewers, err := OptionalStringArrayParam(request, "reviewers") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // If no updates, no draft change, and no reviewers, return error early +// if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { +// return mcp.NewToolResultError("No update parameters provided."), nil +// } + +// // Handle REST API updates (title, body, state, base, maintainer_can_modify) +// if restUpdateNeeded { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to update pull request", +// 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 pull request: %s", string(body))), nil +// } +// } + +// // Handle draft status changes using GraphQL +// if draftProvided { +// gqlClient, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) +// } + +// var prQuery struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ +// "owner": githubv4.String(owner), +// "repo": githubv4.String(repo), +// "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers +// }) +// if err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil +// } + +// currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) + +// if currentIsDraft != draftValue { +// if draftValue { +// // Convert to draft +// var mutation struct { +// ConvertPullRequestToDraft struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } +// } `graphql:"convertPullRequestToDraft(input: $input)"` +// } + +// err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ +// PullRequestID: prQuery.Repository.PullRequest.ID, +// }, nil) +// if err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil +// } +// } else { +// // Mark as ready for review +// var mutation struct { +// MarkPullRequestReadyForReview struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } +// } `graphql:"markPullRequestReadyForReview(input: $input)"` +// } + +// err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ +// PullRequestID: prQuery.Repository.PullRequest.ID, +// }, nil) +// if err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil +// } +// } +// } +// } + +// // Handle reviewer requests +// if len(reviewers) > 0 { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// reviewersRequest := github.ReviewersRequest{ +// Reviewers: reviewers, +// } + +// _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to request reviewers", +// resp, +// err, +// ), nil +// } +// defer func() { +// if resp != nil && resp.Body != nil { +// _ = resp.Body.Close() +// } +// }() + +// if resp.StatusCode != http.StatusCreated && 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 request reviewers: %s", string(body))), nil +// } +// } + +// // Get the final state of the PR to return +// client, err := getClient(ctx) +// if err != nil { +// return nil, err +// } + +// finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil +// } +// defer func() { +// if resp != nil && resp.Body != nil { +// _ = resp.Body.Close() +// } +// }() + +// // Return minimal response with just essential information +// minimalResponse := MinimalResponse{ +// ID: fmt.Sprintf("%d", finalPR.GetID()), +// URL: finalPR.GetHTMLURL(), +// } + +// r, err := json.Marshal(minimalResponse) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListPullRequests creates a tool to list and filter repository pull requests. +// func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("list_pull_requests", +// mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("state", +// mcp.Description("Filter by state"), +// mcp.Enum("open", "closed", "all"), +// ), +// mcp.WithString("head", +// mcp.Description("Filter by head user/org and branch"), +// ), +// mcp.WithString("base", +// mcp.Description("Filter by base branch"), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort by"), +// mcp.Enum("created", "updated", "popularity", "long-running"), +// ), +// mcp.WithString("direction", +// mcp.Description("Sort direction"), +// mcp.Enum("asc", "desc"), +// ), +// 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 +// } +// state, err := OptionalParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// head, err := OptionalParam[string](request, "head") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// base, err := OptionalParam[string](request, "base") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// opts := &github.PullRequestListOptions{ +// State: state, +// Head: head, +// Base: base, +// Sort: sort, +// Direction: direction, +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list pull requests", +// 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 pull requests: %s", string(body))), nil +// } + +// // sanitize title/body on each PR +// for _, pr := range prs { +// if pr == nil { +// continue +// } +// if pr.Title != nil { +// pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) +// } +// if pr.Body != nil { +// pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) +// } +// } + +// r, err := json.Marshal(prs) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // MergePullRequest creates a tool to merge a pull request. +// func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("merge_pull_request", +// mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// mcp.WithString("commit_title", +// mcp.Description("Title for merge commit"), +// ), +// mcp.WithString("commit_message", +// mcp.Description("Extra detail for merge commit"), +// ), +// mcp.WithString("merge_method", +// mcp.Description("Merge method"), +// mcp.Enum("merge", "squash", "rebase"), +// ), +// ), +// 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 +// } +// pullNumber, err := RequiredInt(request, "pullNumber") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// commitTitle, err := OptionalParam[string](request, "commit_title") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// commitMessage, err := OptionalParam[string](request, "commit_message") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// mergeMethod, err := OptionalParam[string](request, "merge_method") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// options := &github.PullRequestOptions{ +// CommitTitle: commitTitle, +// MergeMethod: mergeMethod, +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to merge pull request", +// 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 merge pull request: %s", string(body))), nil +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // SearchPullRequests creates a tool to search for pull requests. +// func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("search_pull_requests", +// mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("query", +// mcp.Required(), +// mcp.Description("Search query using GitHub pull request search syntax"), +// ), +// mcp.WithString("owner", +// mcp.Description("Optional repository owner. If provided with repo, only pull requests for this repository are listed."), +// ), +// mcp.WithString("repo", +// mcp.Description("Optional repository name. If provided with owner, only pull requests 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, "pr", "failed to search pull requests") +// } +// } + +// // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. +// func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("update_pull_request_branch", +// mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// mcp.WithString("expectedHeadSha", +// mcp.Description("The expected SHA of the pull request's HEAD ref"), +// ), +// ), +// 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 +// } +// pullNumber, err := RequiredInt(request, "pullNumber") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// expectedHeadSHA, err := OptionalParam[string](request, "expectedHeadSha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// opts := &github.PullRequestBranchUpdateOptions{} +// if expectedHeadSHA != "" { +// opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts) +// if err != nil { +// // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, +// // and it's not a real error. +// if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { +// return mcp.NewToolResultText("Pull request branch update is in progress"), nil +// } +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to update pull request branch", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusAccepted { +// 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 pull request branch: %s", string(body))), nil +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// type PullRequestReviewWriteParams struct { +// Method string +// Owner string +// Repo string +// PullNumber int32 +// Body string +// Event string +// CommitID *string +// } + +// func PullRequestReviewWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("pull_request_review_write", +// mcp.WithDescription(t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. + +// Available methods: +// - create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. +// - submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. +// - delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. +// `)), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up. +// // Since our other Pull Request tools are working with the REST Client, will handle the lookup +// // internally for now. +// mcp.WithString("method", +// mcp.Required(), +// mcp.Description("The write operation to perform on pull request review."), +// mcp.Enum("create", "submit_pending", "delete_pending"), +// ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// mcp.WithString("body", +// mcp.Description("Review comment text"), +// ), +// mcp.WithString("event", +// mcp.Description("Review action to perform."), +// mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"), +// ), +// mcp.WithString("commitID", +// mcp.Description("SHA of commit to review"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// var params PullRequestReviewWriteParams +// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Given our owner, repo and PR number, lookup the GQL ID of the PR. +// client, err := getGQLClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil +// } + +// switch params.Method { +// case "create": +// return CreatePullRequestReview(ctx, client, params) +// case "submit_pending": +// return SubmitPendingPullRequestReview(ctx, client, params) +// case "delete_pending": +// return DeletePendingPullRequestReview(ctx, client, params) +// default: +// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil +// } +// } +// } + +// func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { +// var getPullRequestQuery struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// } + +// if err := client.Query(ctx, &getPullRequestQuery, map[string]any{ +// "owner": githubv4.String(params.Owner), +// "repo": githubv4.String(params.Repo), +// "prNum": githubv4.Int(params.PullNumber), +// }); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get pull request", +// err, +// ), nil +// } + +// // Now we have the GQL ID, we can create a review +// var addPullRequestReviewMutation struct { +// AddPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. +// } +// } `graphql:"addPullRequestReview(input: $input)"` +// } + +// addPullRequestReviewInput := githubv4.AddPullRequestReviewInput{ +// PullRequestID: getPullRequestQuery.Repository.PullRequest.ID, +// CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID), +// } + +// // Event and Body are provided if we submit a review +// if params.Event != "" { +// addPullRequestReviewInput.Event = newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event) +// addPullRequestReviewInput.Body = githubv4.NewString(githubv4.String(params.Body)) +// } + +// if err := client.Mutate( +// ctx, +// &addPullRequestReviewMutation, +// addPullRequestReviewInput, +// nil, +// ); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Return nothing interesting, just indicate success for the time being. +// // In future, we may want to return the review ID, but for the moment, we're not leaking +// // API implementation details to the LLM. +// if params.Event == "" { +// return mcp.NewToolResultText("pending pull request created"), nil +// } +// return mcp.NewToolResultText("pull request review submitted successfully"), nil +// } + +// func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { +// // First we'll get the current user +// var getViewerQuery struct { +// Viewer struct { +// Login githubv4.String +// } +// } + +// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get current user", +// err, +// ), nil +// } + +// var getLatestReviewForViewerQuery struct { +// Repository struct { +// PullRequest struct { +// Reviews struct { +// Nodes []struct { +// ID githubv4.ID +// State githubv4.PullRequestReviewState +// URL githubv4.URI +// } +// } `graphql:"reviews(first: 1, author: $author)"` +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// } + +// vars := map[string]any{ +// "author": githubv4.String(getViewerQuery.Viewer.Login), +// "owner": githubv4.String(params.Owner), +// "name": githubv4.String(params.Repo), +// "prNum": githubv4.Int(params.PullNumber), +// } + +// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get latest review for current user", +// err, +// ), nil +// } + +// // Validate there is one review and the state is pending +// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { +// return mcp.NewToolResultError("No pending review found for the viewer"), nil +// } + +// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] +// if review.State != githubv4.PullRequestReviewStatePending { +// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) +// return mcp.NewToolResultError(errText), nil +// } + +// // Prepare the mutation +// var submitPullRequestReviewMutation struct { +// SubmitPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. +// } +// } `graphql:"submitPullRequestReview(input: $input)"` +// } + +// if err := client.Mutate( +// ctx, +// &submitPullRequestReviewMutation, +// githubv4.SubmitPullRequestReviewInput{ +// PullRequestReviewID: &review.ID, +// Event: githubv4.PullRequestReviewEvent(params.Event), +// Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), +// }, +// nil, +// ); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to submit pull request review", +// err, +// ), nil +// } + +// // Return nothing interesting, just indicate success for the time being. +// // In future, we may want to return the review ID, but for the moment, we're not leaking +// // API implementation details to the LLM. +// return mcp.NewToolResultText("pending pull request review successfully submitted"), nil +// } + +// func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { +// // First we'll get the current user +// var getViewerQuery struct { +// Viewer struct { +// Login githubv4.String +// } +// } + +// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get current user", +// err, +// ), nil +// } + +// var getLatestReviewForViewerQuery struct { +// Repository struct { +// PullRequest struct { +// Reviews struct { +// Nodes []struct { +// ID githubv4.ID +// State githubv4.PullRequestReviewState +// URL githubv4.URI +// } +// } `graphql:"reviews(first: 1, author: $author)"` +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// } + +// vars := map[string]any{ +// "author": githubv4.String(getViewerQuery.Viewer.Login), +// "owner": githubv4.String(params.Owner), +// "name": githubv4.String(params.Repo), +// "prNum": githubv4.Int(params.PullNumber), +// } + +// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get latest review for current user", +// err, +// ), nil +// } + +// // Validate there is one review and the state is pending +// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { +// return mcp.NewToolResultError("No pending review found for the viewer"), nil +// } + +// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] +// if review.State != githubv4.PullRequestReviewStatePending { +// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) +// return mcp.NewToolResultError(errText), nil +// } + +// // Prepare the mutation +// var deletePullRequestReviewMutation struct { +// DeletePullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. +// } +// } `graphql:"deletePullRequestReview(input: $input)"` +// } + +// if err := client.Mutate( +// ctx, +// &deletePullRequestReviewMutation, +// githubv4.DeletePullRequestReviewInput{ +// PullRequestReviewID: &review.ID, +// }, +// nil, +// ); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Return nothing interesting, just indicate success for the time being. +// // In future, we may want to return the review ID, but for the moment, we're not leaking +// // API implementation details to the LLM. +// return mcp.NewToolResultText("pending pull request review successfully deleted"), nil +// } + +// // AddCommentToPendingReview creates a tool to add a comment to a pull request review. +// func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("add_comment_to_pending_review", +// mcp.WithDescription(t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to +// // add a new tool to get that ID for clients that aren't in the same context as the original pending review +// // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment +// // the latest review from a user, since only one can be active at a time. It can later be extended with +// // a pullRequestReviewID parameter if targeting other reviews is desired: +// // mcp.WithString("pullRequestReviewID", +// // mcp.Required(), +// // mcp.Description("The ID of the pull request review to add a comment to"), +// // ), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// mcp.WithString("path", +// mcp.Required(), +// mcp.Description("The relative path to the file that necessitates a comment"), +// ), +// mcp.WithString("body", +// mcp.Required(), +// mcp.Description("The text of the review comment"), +// ), +// mcp.WithString("subjectType", +// mcp.Required(), +// mcp.Description("The level at which the comment is targeted"), +// mcp.Enum("FILE", "LINE"), +// ), +// mcp.WithNumber("line", +// mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"), +// ), +// mcp.WithString("side", +// mcp.Description("The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state"), +// mcp.Enum("LEFT", "RIGHT"), +// ), +// mcp.WithNumber("startLine", +// mcp.Description("For multi-line comments, the first line of the range that the comment applies to"), +// ), +// mcp.WithString("startSide", +// mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state"), +// mcp.Enum("LEFT", "RIGHT"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// var params struct { +// Owner string +// Repo string +// PullNumber int32 +// Path string +// Body string +// SubjectType string +// Line *int32 +// Side *string +// StartLine *int32 +// StartSide *string +// } +// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getGQLClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err) +// } + +// // First we'll get the current user +// var getViewerQuery struct { +// Viewer struct { +// Login githubv4.String +// } +// } + +// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get current user", +// err, +// ), nil +// } + +// var getLatestReviewForViewerQuery struct { +// Repository struct { +// PullRequest struct { +// Reviews struct { +// Nodes []struct { +// ID githubv4.ID +// State githubv4.PullRequestReviewState +// URL githubv4.URI +// } +// } `graphql:"reviews(first: 1, author: $author)"` +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// } + +// vars := map[string]any{ +// "author": githubv4.String(getViewerQuery.Viewer.Login), +// "owner": githubv4.String(params.Owner), +// "name": githubv4.String(params.Repo), +// "prNum": githubv4.Int(params.PullNumber), +// } + +// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { +// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, +// "failed to get latest review for current user", +// err, +// ), nil +// } + +// // Validate there is one review and the state is pending +// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { +// return mcp.NewToolResultError("No pending review found for the viewer"), nil +// } + +// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] +// if review.State != githubv4.PullRequestReviewStatePending { +// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) +// return mcp.NewToolResultError(errText), nil +// } + +// // Then we can create a new review thread comment on the review. +// var addPullRequestReviewThreadMutation struct { +// AddPullRequestReviewThread struct { +// Thread struct { +// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. +// } +// } `graphql:"addPullRequestReviewThread(input: $input)"` +// } + +// if err := client.Mutate( +// ctx, +// &addPullRequestReviewThreadMutation, +// githubv4.AddPullRequestReviewThreadInput{ +// Path: githubv4.String(params.Path), +// Body: githubv4.String(params.Body), +// SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), +// Line: newGQLIntPtr(params.Line), +// Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), +// StartLine: newGQLIntPtr(params.StartLine), +// StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), +// PullRequestReviewID: &review.ID, +// }, +// nil, +// ); err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Return nothing interesting, just indicate success for the time being. +// // In future, we may want to return the review ID, but for the moment, we're not leaking +// // API implementation details to the LLM. +// return mcp.NewToolResultText("pull request review comment successfully added to pending review"), nil +// } +// } + +// // RequestCopilotReview creates a tool to request a Copilot review for a pull request. +// // Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this +// // tool if the configured host does not support it. +// func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +// return mcp.NewTool("request_copilot_review", +// mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithNumber("pullNumber", +// mcp.Required(), +// mcp.Description("Pull request number"), +// ), +// ), +// 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 +// } + +// pullNumber, err := RequiredInt(request, "pullNumber") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// _, resp, err := client.PullRequests.RequestReviewers( +// ctx, +// owner, +// repo, +// pullNumber, +// github.ReviewersRequest{ +// // The login name of the copilot reviewer bot +// Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, +// }, +// ) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to request copilot review", +// resp, +// err, +// ), nil +// } +// 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 request copilot review: %s", string(body))), nil +// } + +// // Return nothing on success, as there's not much value in returning the Pull Request itself +// return mcp.NewToolResultText(""), nil +// } +// } + +// // newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) +// // and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse +// // params from the MCP request, we need to convert them to types that are pointers of type def strings and it's +// // not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo"). +// func newGQLStringlike[T ~string](s string) *T { +// if s == "" { +// return nil +// } +// stringlike := T(s) +// return &stringlike +// } + +// func newGQLStringlikePtr[T ~string](s *string) *T { +// if s == nil { +// return nil +// } +// stringlike := T(*s) +// return &stringlike +// } + +// func newGQLIntPtr(i *int32) *githubv4.Int { +// if i == nil { +// return nil +// } +// gi := githubv4.Int(*i) +// return &gi +// } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 4cc4480e9..be5894cae 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1,2943 +1,2943 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/shurcooL/githubv4" - - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetPullRequest(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - // Setup mock PR for success case - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - }, - Body: github.Ptr("This is a test PR"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string - }{ - { - name: "successful PR fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedPR: mockPR, - }, - { - name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get pull request", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedPR github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) - require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) - }) - } -} - -func Test_UpdatePullRequest(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "update_pull_request", 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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "draft") - assert.Contains(t, tool.InputSchema.Properties, "title") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.Contains(t, tool.InputSchema.Properties, "base") - assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") - assert.Contains(t, tool.InputSchema.Properties, "reviewers") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock PR for success case - mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Updated Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Updated test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), - Base: &github.PullRequestBranch{ - Ref: github.Ptr("develop"), - }, - } - - mockClosedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("closed"), // State updated - } - - // Mock PR for when there are no updates but we still need a response - mockPRWithReviewers := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - RequestedReviewers: []*github.User{ - {Login: github.Ptr("reviewer1")}, - {Login: github.Ptr("reviewer2")}, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string - }{ - { - name: "successful PR update (title, body, base, maintainer_can_modify)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - // Expect the flat string based on previous test failure output and API docs - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - "body": "Updated test PR body.", - "base": "develop", - "maintainer_can_modify": false, - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "title": "Updated Test PR Title", - "body": "Updated test PR body.", - "base": "develop", - "maintainer_can_modify": false, - }, - expectError: false, - expectedPR: mockUpdatedPR, - }, - { - name: "successful PR update (state)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "state": "closed", - }).andThen( - mockResponse(t, http.StatusOK, mockClosedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockClosedPR, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "state": "closed", - }, - expectError: false, - expectedPR: mockClosedPR, - }, - { - name: "successful PR update with reviewers", - mockedClient: mock.NewMockedHTTPClient( - // Mock for RequestReviewers call, returning the PR with reviewers - mock.WithRequestMatch( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "reviewers": []interface{}{"reviewer1", "reviewer2"}, - }, - expectError: false, - expectedPR: mockPRWithReviewers, - }, - { - name: "successful PR update (title only)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "title": "Updated Test PR Title", - }, - expectError: false, - expectedPR: mockUpdatedPR, - }, - { - name: "no update parameters provided", - mockedClient: mock.NewMockedHTTPClient(), // No API call expected - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - // No update fields - }, - expectError: false, // Error is returned in the result, not as Go error - expectedErrMsg: "No update parameters provided", - }, - { - name: "PR update fails (API error)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "title": "Invalid Title Causing Error", - }, - expectError: true, - expectedErrMsg: "failed to update pull request", - }, - { - name: "request reviewers fails", - mockedClient: mock.NewMockedHTTPClient( - // Then reviewer request fails - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "reviewers": []interface{}{"invalid-user"}, - }, - expectError: true, - expectedErrMsg: "failed to request reviewers", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError || tc.expectedErrMsg != "" { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var updateResp MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &updateResp) - require.NoError(t, err) - assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) - }) - } -} - -func Test_UpdatePullRequest_Draft(t *testing.T) { - // Setup mock PR for success case - mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), // Updated to ready for review - Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string - }{ - { - name: "successful draft update to ready for review", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDOA0xdyM50BPaO", - "isDraft": true, // Current state is draft - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - MarkPullRequestReadyForReview struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } - } `graphql:"markPullRequestReadyForReview(input: $input)"` - }{}, - githubv4.MarkPullRequestReadyForReviewInput{ - PullRequestID: "PR_kwDOA0xdyM50BPaO", - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "markPullRequestReadyForReview": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDOA0xdyM50BPaO", - "isDraft": false, - }, - }, - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "draft": false, - }, - expectError: false, - expectedPR: mockUpdatedPR, - }, - { - name: "successful convert pull request to draft", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDOA0xdyM50BPaO", - "isDraft": false, // Current state is draft - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ConvertPullRequestToDraft struct { - PullRequest struct { - ID githubv4.ID - IsDraft githubv4.Boolean - } - } `graphql:"convertPullRequestToDraft(input: $input)"` - }{}, - githubv4.ConvertPullRequestToDraftInput{ - PullRequestID: "PR_kwDOA0xdyM50BPaO", - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "convertPullRequestToDraft": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDOA0xdyM50BPaO", - "isDraft": true, - }, - }, - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "draft": true, - }, - expectError: false, - expectedPR: mockUpdatedPR, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - )) - gqlClient := githubv4.NewClient(tc.mockedClient) - - _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError || tc.expectedErrMsg != "" { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" { - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var updateResp MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &updateResp) - require.NoError(t, err) - assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) - }) - } -} - -func Test_ListPullRequests(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_pull_requests", 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, "state") - assert.Contains(t, tool.InputSchema.Properties, "head") - assert.Contains(t, tool.InputSchema.Properties, "base") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock PRs for success case - mockPRs := []*github.PullRequest{ - { - Number: github.Ptr(42), - Title: github.Ptr("First PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - }, - { - Number: github.Ptr(43), - Title: github.Ptr("Second PR"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPRs []*github.PullRequest - expectedErrMsg string - }{ - { - name: "successful PRs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "all", - "sort": "created", - "direction": "desc", - "per_page": "30", - "page": "1", - }).andThen( - mockResponse(t, http.StatusOK, mockPRs), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "all", - "sort": "created", - "direction": "desc", - "perPage": float64(30), - "page": float64(1), - }, - expectError: false, - expectedPRs: mockPRs, - }, - { - name: "PRs listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "invalid", - }, - expectError: true, - expectedErrMsg: "failed to list pull requests", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedPRs []*github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) - require.NoError(t, err) - assert.Len(t, returnedPRs, 2) - assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) - assert.Equal(t, *tc.expectedPRs[0].Title, *returnedPRs[0].Title) - assert.Equal(t, *tc.expectedPRs[0].State, *returnedPRs[0].State) - assert.Equal(t, *tc.expectedPRs[1].Number, *returnedPRs[1].Number) - assert.Equal(t, *tc.expectedPRs[1].Title, *returnedPRs[1].Title) - assert.Equal(t, *tc.expectedPRs[1].State, *returnedPRs[1].State) - }) - } -} - -func Test_MergePullRequest(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := MergePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "merge_pull_request", 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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "commit_title") - assert.Contains(t, tool.InputSchema.Properties, "commit_message") - assert.Contains(t, tool.InputSchema.Properties, "merge_method") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock merge result for success case - mockMergeResult := &github.PullRequestMergeResult{ - Merged: github.Ptr(true), - Message: github.Ptr("Pull Request successfully merged"), - SHA: github.Ptr("abcd1234efgh5678"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedMergeResult *github.PullRequestMergeResult - expectedErrMsg string - }{ - { - name: "successful merge", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "commit_title": "Merge PR #42", - "commit_message": "Merging awesome feature", - "merge_method": "squash", - }).andThen( - mockResponse(t, http.StatusOK, mockMergeResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "commit_title": "Merge PR #42", - "commit_message": "Merging awesome feature", - "merge_method": "squash", - }, - expectError: false, - expectedMergeResult: mockMergeResult, - }, - { - name: "merge fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: true, - expectedErrMsg: "failed to merge pull request", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := MergePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedResult github.PullRequestMergeResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedMergeResult.Merged, *returnedResult.Merged) - assert.Equal(t, *tc.expectedMergeResult.Message, *returnedResult.Message) - assert.Equal(t, *tc.expectedMergeResult.SHA, *returnedResult.SHA) - }) - } -} - -func Test_SearchPullRequests(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "search_pull_requests", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - Issues: []*github.Issue{ - { - Number: github.Ptr(42), - Title: github.Ptr("Test PR 1"), - Body: github.Ptr("Updated tests."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), - Comments: github.Ptr(5), - User: &github.User{ - Login: github.Ptr("user1"), - }, - }, - { - Number: github.Ptr(43), - Title: github.Ptr("Test PR 2"), - Body: github.Ptr("Updated build scripts."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), - Comments: github.Ptr(3), - User: &github.User{ - Login: github.Ptr("user2"), - }, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.IssuesSearchResult - expectedErrMsg string - }{ - { - name: "successful pull request search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": float64(1), - "perPage": float64(30), - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "pull request search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:pr draft:false", - "sort": "updated", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "draft:false", - "owner": "test-owner", - "repo": "test-repo", - "sort": "updated", - "order": "asc", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "pull request search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "feature", - "owner": "test-owner", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "pull request search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr review-required", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "review-required", - "repo": "test-repo", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "pull request search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:pr repo:owner/repo is:open", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing is:pr filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server is:open draft:false", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:pr repo:github/github-mcp-server is:open draft:false", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server author:octocat", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "repo:github/github-mcp-server author:octocat", - "owner": "different-owner", - "repo": "different-repo", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "complex query with existing is:pr filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "search pull requests fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search pull requests", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) - for i, issue := range returnedResult.Issues { - assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) - assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) - assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) - assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) - assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) - } - }) - } - -} - -func Test_GetPullRequestFiles(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - // Setup mock PR files for success case - mockFiles := []*github.CommitFile{ - { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Changes: github.Ptr(15), - Patch: github.Ptr("@@ -1,5 +1,10 @@"), - }, - { - Filename: github.Ptr("file2.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(0), - Changes: github.Ptr(20), - Patch: github.Ptr("@@ -0,0 +1,20 @@"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedFiles []*github.CommitFile - expectedErrMsg string - }{ - { - name: "successful files fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_files", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedFiles: mockFiles, - }, - { - name: "successful files fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_files", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedFiles: mockFiles, - }, - { - name: "files fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_files", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get pull request files", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedFiles []*github.CommitFile - err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) - require.NoError(t, err) - assert.Len(t, returnedFiles, len(tc.expectedFiles)) - for i, file := range returnedFiles { - assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) - assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) - assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) - assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) - } - }) - } -} - -func Test_GetPullRequestStatus(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - // Setup mock PR for successful PR fetch - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - } - - // Setup mock status for success case - mockStatus := &github.CombinedStatus{ - State: github.Ptr("success"), - TotalCount: github.Ptr(3), - Statuses: []*github.RepoStatus{ - { - State: github.Ptr("success"), - Context: github.Ptr("continuous-integration/travis-ci"), - Description: github.Ptr("Build succeeded"), - TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), - }, - { - State: github.Ptr("success"), - Context: github.Ptr("codecov/patch"), - Description: github.Ptr("Coverage increased"), - TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), - }, - { - State: github.Ptr("success"), - Context: github.Ptr("lint/golangci-lint"), - Description: github.Ptr("No issues found"), - TargetURL: github.Ptr("https://golangci.com/r/owner/repo/pull/42"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedStatus *github.CombinedStatus - expectedErrMsg string - }{ - { - name: "successful status fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatch( - mock.GetReposCommitsStatusByOwnerByRepoByRef, - mockStatus, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_status", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedStatus: mockStatus, - }, - { - name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_status", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get pull request", - }, - { - name: "status fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatchHandler( - mock.GetReposCommitsStatusesByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_status", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: true, - expectedErrMsg: "failed to get combined status", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedStatus github.CombinedStatus - err = json.Unmarshal([]byte(textContent.Text), &returnedStatus) - require.NoError(t, err) - assert.Equal(t, *tc.expectedStatus.State, *returnedStatus.State) - assert.Equal(t, *tc.expectedStatus.TotalCount, *returnedStatus.TotalCount) - assert.Len(t, returnedStatus.Statuses, len(tc.expectedStatus.Statuses)) - for i, status := range returnedStatus.Statuses { - assert.Equal(t, *tc.expectedStatus.Statuses[i].State, *status.State) - assert.Equal(t, *tc.expectedStatus.Statuses[i].Context, *status.Context) - assert.Equal(t, *tc.expectedStatus.Statuses[i].Description, *status.Description) - } - }) - } -} - -func Test_UpdatePullRequestBranch(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := UpdatePullRequestBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "update_pull_request_branch", 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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "expectedHeadSha") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock update result for success case - mockUpdateResult := &github.PullRequestBranchUpdateResponse{ - Message: github.Ptr("Branch was updated successfully"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedUpdateResult *github.PullRequestBranchUpdateResponse - expectedErrMsg string - }{ - { - name: "successful branch update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "expected_head_sha": "abcd1234", - }).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "expectedHeadSha": "abcd1234", - }, - expectError: false, - expectedUpdateResult: mockUpdateResult, - }, - { - name: "branch update without expected SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{}).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedUpdateResult: mockUpdateResult, - }, - { - name: "branch update fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: true, - expectedErrMsg: "failed to update pull request branch", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := UpdatePullRequestBranch(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - assert.Contains(t, textContent.Text, "is in progress") - }) - } -} - -func Test_GetPullRequestComments(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - // Setup mock PR comments for success case - mockComments := []*github.PullRequestComment{ - { - ID: github.Ptr(int64(101)), - Body: github.Ptr("This looks good"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r101"), - User: &github.User{ - Login: github.Ptr("reviewer1"), - }, - Path: github.Ptr("file1.go"), - Position: github.Ptr(5), - CommitID: github.Ptr("abcdef123456"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - UpdatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - }, - { - ID: github.Ptr(int64(102)), - Body: github.Ptr("Please fix this"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r102"), - User: &github.User{ - Login: github.Ptr("reviewer2"), - }, - Path: github.Ptr("file2.go"), - Position: github.Ptr(10), - CommitID: github.Ptr("abcdef123456"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, - UpdatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedComments []*github.PullRequestComment - expectedErrMsg string - }{ - { - name: "successful comments fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, - mockComments, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_review_comments", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedComments: mockComments, - }, - { - name: "comments fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_review_comments", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get pull request review comments", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedComments []*github.PullRequestComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) - require.NoError(t, err) - assert.Len(t, returnedComments, len(tc.expectedComments)) - for i, comment := range returnedComments { - assert.Equal(t, *tc.expectedComments[i].ID, *comment.ID) - assert.Equal(t, *tc.expectedComments[i].Body, *comment.Body) - assert.Equal(t, *tc.expectedComments[i].User.Login, *comment.User.Login) - assert.Equal(t, *tc.expectedComments[i].Path, *comment.Path) - assert.Equal(t, *tc.expectedComments[i].HTMLURL, *comment.HTMLURL) - } - }) - } -} - -func Test_GetPullRequestReviews(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - // Setup mock PR reviews for success case - mockReviews := []*github.PullRequestReview{ - { - ID: github.Ptr(int64(201)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("LGTM"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), - User: &github.User{ - Login: github.Ptr("approver"), - }, - CommitID: github.Ptr("abcdef123456"), - SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - }, - { - ID: github.Ptr(int64(202)), - State: github.Ptr("CHANGES_REQUESTED"), - Body: github.Ptr("Please address the following issues"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), - User: &github.User{ - Login: github.Ptr("reviewer"), - }, - CommitID: github.Ptr("abcdef123456"), - SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedReviews []*github.PullRequestReview - expectedErrMsg string - }{ - { - name: "successful reviews fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - mockReviews, - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_reviews", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - expectError: false, - expectedReviews: mockReviews, - }, - { - name: "reviews fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "method": "get_reviews", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get pull request reviews", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedReviews []*github.PullRequestReview - err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) - require.NoError(t, err) - assert.Len(t, returnedReviews, len(tc.expectedReviews)) - for i, review := range returnedReviews { - assert.Equal(t, *tc.expectedReviews[i].ID, *review.ID) - assert.Equal(t, *tc.expectedReviews[i].State, *review.State) - assert.Equal(t, *tc.expectedReviews[i].Body, *review.Body) - assert.Equal(t, *tc.expectedReviews[i].User.Login, *review.User.Login) - assert.Equal(t, *tc.expectedReviews[i].HTMLURL, *review.HTMLURL) - } - }) - } -} - -func Test_CreatePullRequest(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := CreatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "create_pull_request", 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, "title") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "head") - assert.Contains(t, tool.InputSchema.Properties, "base") - assert.Contains(t, tool.InputSchema.Properties, "draft") - assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title", "head", "base"}) - - // Setup mock PR for success case - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - Base: &github.PullRequestBranch{ - SHA: github.Ptr("efgh5678"), - Ref: github.Ptr("main"), - }, - Body: github.Ptr("This is a test PR"), - Draft: github.Ptr(false), - MaintainerCanModify: github.Ptr(true), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string - }{ - { - name: "successful PR creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "title": "Test PR", - "body": "This is a test PR", - "head": "feature-branch", - "base": "main", - "draft": false, - "maintainer_can_modify": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "title": "Test PR", - "body": "This is a test PR", - "head": "feature-branch", - "base": "main", - "draft": false, - "maintainer_can_modify": true, - }, - expectError: false, - expectedPR: mockPR, - }, - { - name: "missing required parameter", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - // missing title, head, base - }, - expectError: true, - expectedErrMsg: "missing required parameter: title", - }, - { - name: "PR creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "title": "Test PR", - "head": "feature-branch", - "base": "main", - }, - expectError: true, - expectedErrMsg: "failed to create pull request", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CreatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - // If no error returned but in the 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 minimal result - var returnedPR MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) - require.NoError(t, err) - assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) - }) - } -} - -func TestCreateAndSubmitPullRequestReview(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := githubv4.NewClient(nil) - tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_review_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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "event") - assert.Contains(t, tool.InputSchema.Properties, "commitID") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful review creation", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDODKw3uc6WYN1T", - }, - }, - }, - ), - ), - githubv4mock.NewMutationMatcher( - struct { - AddPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"addPullRequestReview(input: $input)"` - }{}, - githubv4.AddPullRequestReviewInput{ - PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), - Body: githubv4.NewString("This is a test review"), - Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), - CommitOID: githubv4.NewGitObjectID("abcd1234"), - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "body": "This is a test review", - "event": "COMMENT", - "commitID": "abcd1234", - }, - expectToolError: false, - }, - { - name: "failure to get pull request", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.ErrorResponse("expected test failure"), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "body": "This is a test review", - "event": "COMMENT", - "commitID": "abcd1234", - }, - expectToolError: true, - expectedToolErrMsg: "expected test failure", - }, - { - name: "failure to submit review", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDODKw3uc6WYN1T", - }, - }, - }, - ), - ), - githubv4mock.NewMutationMatcher( - struct { - AddPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"addPullRequestReview(input: $input)"` - }{}, - githubv4.AddPullRequestReviewInput{ - PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), - Body: githubv4.NewString("This is a test review"), - Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), - CommitOID: githubv4.NewGitObjectID("abcd1234"), - }, - nil, - githubv4mock.ErrorResponse("expected test failure"), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "body": "This is a test review", - "event": "COMMENT", - "commitID": "abcd1234", - }, - expectToolError: true, - expectedToolErrMsg: "expected test failure", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, textContent.Text, "pull request review submitted successfully") - }) - } -} - -func Test_RequestCopilotReview(t *testing.T) { - t.Parallel() - - mockClient := github.NewClient(nil) - tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "request_copilot_review", 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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock PR for success case - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - }, - Body: github.Ptr("This is a test PR"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(1), - }, - expectError: false, - }, - { - name: "request fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to request copilot review", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - client := github.NewClient(tc.mockedClient) - _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - assert.NotNil(t, result) - assert.Len(t, result.Content, 1) - - textContent := getTextResult(t, result) - require.Equal(t, "", textContent.Text) - }) - } -} - -func TestCreatePendingPullRequestReview(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := githubv4.NewClient(nil) - tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_review_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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "commitID") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful review creation", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDODKw3uc6WYN1T", - }, - }, - }, - ), - ), - githubv4mock.NewMutationMatcher( - struct { - AddPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"addPullRequestReview(input: $input)"` - }{}, - githubv4.AddPullRequestReviewInput{ - PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), - CommitOID: githubv4.NewGitObjectID("abcd1234"), - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "commitID": "abcd1234", - }, - expectToolError: false, - }, - { - name: "failure to get pull request", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.ErrorResponse("expected test failure"), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "commitID": "abcd1234", - }, - expectToolError: true, - expectedToolErrMsg: "expected test failure", - }, - { - name: "failure to create pending review", - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - ID githubv4.ID - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "prNum": githubv4.Int(42), - }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "id": "PR_kwDODKw3uc6WYN1T", - }, - }, - }, - ), - ), - githubv4mock.NewMutationMatcher( - struct { - AddPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"addPullRequestReview(input: $input)"` - }{}, - githubv4.AddPullRequestReviewInput{ - PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), - CommitOID: githubv4.NewGitObjectID("abcd1234"), - }, - nil, - githubv4mock.ErrorResponse("expected test failure"), - ), - ), - requestArgs: map[string]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "commitID": "abcd1234", - }, - expectToolError: true, - expectedToolErrMsg: "expected test failure", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, "pending pull request created", textContent.Text) - }) - } -} - -func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := githubv4.NewClient(nil) - tool, _ := AddCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "add_comment_to_pending_review", 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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "path") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "subjectType") - assert.Contains(t, tool.InputSchema.Properties, "line") - assert.Contains(t, tool.InputSchema.Properties, "side") - assert.Contains(t, tool.InputSchema.Properties, "startLine") - assert.Contains(t, tool.InputSchema.Properties, "startSide") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful line comment addition", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "path": "file.go", - "body": "This is a test comment", - "subjectType": "LINE", - "line": float64(10), - "side": "RIGHT", - "startLine": float64(5), - "startSide": "RIGHT", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ - { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", - }, - }, - }), - githubv4mock.NewMutationMatcher( - struct { - AddPullRequestReviewThread struct { - Thread struct { - ID githubv4.String // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"addPullRequestReviewThread(input: $input)"` - }{}, - githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String("file.go"), - Body: githubv4.String("This is a test comment"), - SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), - Line: githubv4.NewInt(10), - Side: githubv4mock.Ptr(githubv4.DiffSideRight), - StartLine: githubv4.NewInt(5), - StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), - PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := AddCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review") - }) - } -} - -func TestSubmitPendingPullRequestReview(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := githubv4.NewClient(nil) - tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_review_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, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "event") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful review submission", - requestArgs: map[string]any{ - "method": "submit_pending", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "event": "COMMENT", - "body": "This is a test review", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ - { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", - }, - }, - }), - githubv4mock.NewMutationMatcher( - struct { - SubmitPullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"submitPullRequestReview(input: $input)"` - }{}, - githubv4.SubmitPullRequestReviewInput{ - PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), - Event: githubv4.PullRequestReviewEventComment, - Body: githubv4.NewString("This is a test review"), - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, "pending pull request review successfully submitted", textContent.Text) - }) - } -} - -func TestDeletePendingPullRequestReview(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := githubv4.NewClient(nil) - tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_review_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful review deletion", - requestArgs: map[string]any{ - "method": "delete_pending", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ - { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", - }, - }, - }), - githubv4mock.NewMutationMatcher( - struct { - DeletePullRequestReview struct { - PullRequestReview struct { - ID githubv4.ID - } - } `graphql:"deletePullRequestReview(input: $input)"` - }{}, - githubv4.DeletePullRequestReviewInput{ - PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, "pending pull request review successfully deleted", textContent.Text) - }) - } -} - -func TestGetPullRequestDiff(t *testing.T) { - t.Parallel() - - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "pull_request_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, "pullNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - - stubbedDiff := `diff --git a/README.md b/README.md -index 5d6e7b2..8a4f5c3 100644 ---- a/README.md -+++ b/README.md -@@ -1,4 +1,6 @@ - # Hello-World - - Hello World project for GitHub - -+## New Section -+ -+This is a new section added in the pull request.` - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful diff retrieval", - requestArgs: map[string]any{ - "method": "get_diff", - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - }, - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - // Should also expect Accept header to be application/vnd.github.v3.diff - expectPath(t, "/repos/owner/repo/pulls/42").andThen( - mockResponse(t, http.StatusOK, stubbedDiff), - ), - ), - ), - expectToolError: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - // Parse the result and get the text content if no error - require.Equal(t, stubbedDiff, textContent.Text) - }) - } -} - -func viewerQuery(login string) githubv4mock.Matcher { - return githubv4mock.NewQueryMatcher( - struct { - Viewer struct { - Login githubv4.String - } `graphql:"viewer"` - }{}, - map[string]any{}, - githubv4mock.DataResponse(map[string]any{ - "viewer": map[string]any{ - "login": login, - }, - }), - ) -} - -type getLatestPendingReviewQueryReview struct { - id string - state string - url string -} - -type getLatestPendingReviewQueryParams struct { - author string - owner string - repo string - prNum int32 - - reviews []getLatestPendingReviewQueryReview -} - -func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher { - return githubv4mock.NewQueryMatcher( - struct { - Repository struct { - PullRequest struct { - Reviews struct { - Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI - } - } `graphql:"reviews(first: 1, author: $author)"` - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "author": githubv4.String(p.author), - "owner": githubv4.String(p.owner), - "name": githubv4.String(p.repo), - "prNum": githubv4.Int(p.prNum), - }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "reviews": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": p.reviews[0].id, - "state": p.reviews[0].state, - "url": p.reviews[0].url, - }, - }, - }, - }, - }, - }, - ), - ) -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" +// "time" + +// "github.com/github/github-mcp-server/internal/githubv4mock" +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/shurcooL/githubv4" + +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_GetPullRequest(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// // Setup mock PR for success case +// mockPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Head: &github.PullRequestBranch{ +// SHA: github.Ptr("abcd1234"), +// Ref: github.Ptr("feature-branch"), +// }, +// Base: &github.PullRequestBranch{ +// Ref: github.Ptr("main"), +// }, +// Body: github.Ptr("This is a test PR"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedPR *github.PullRequest +// expectedErrMsg string +// }{ +// { +// name: "successful PR fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockPR, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedPR: mockPR, +// }, +// { +// name: "PR fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get pull request", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedPR github.PullRequest +// err = json.Unmarshal([]byte(textContent.Text), &returnedPR) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) +// assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) +// assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) +// assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) +// }) +// } +// } + +// func Test_UpdatePullRequest(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "update_pull_request", 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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "draft") +// assert.Contains(t, tool.InputSchema.Properties, "title") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "state") +// assert.Contains(t, tool.InputSchema.Properties, "base") +// assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") +// assert.Contains(t, tool.InputSchema.Properties, "reviewers") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + +// // Setup mock PR for success case +// mockUpdatedPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Updated Test PR Title"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Body: github.Ptr("Updated test PR body."), +// MaintainerCanModify: github.Ptr(false), +// Draft: github.Ptr(false), +// Base: &github.PullRequestBranch{ +// Ref: github.Ptr("develop"), +// }, +// } + +// mockClosedPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// State: github.Ptr("closed"), // State updated +// } + +// // Mock PR for when there are no updates but we still need a response +// mockPRWithReviewers := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// State: github.Ptr("open"), +// RequestedReviewers: []*github.User{ +// {Login: github.Ptr("reviewer1")}, +// {Login: github.Ptr("reviewer2")}, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedPR *github.PullRequest +// expectedErrMsg string +// }{ +// { +// name: "successful PR update (title, body, base, maintainer_can_modify)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposPullsByOwnerByRepoByPullNumber, +// // Expect the flat string based on previous test failure output and API docs +// expectRequestBody(t, map[string]interface{}{ +// "title": "Updated Test PR Title", +// "body": "Updated test PR body.", +// "base": "develop", +// "maintainer_can_modify": false, +// }).andThen( +// mockResponse(t, http.StatusOK, mockUpdatedPR), +// ), +// ), +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockUpdatedPR, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "title": "Updated Test PR Title", +// "body": "Updated test PR body.", +// "base": "develop", +// "maintainer_can_modify": false, +// }, +// expectError: false, +// expectedPR: mockUpdatedPR, +// }, +// { +// name: "successful PR update (state)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposPullsByOwnerByRepoByPullNumber, +// expectRequestBody(t, map[string]interface{}{ +// "state": "closed", +// }).andThen( +// mockResponse(t, http.StatusOK, mockClosedPR), +// ), +// ), +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockClosedPR, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "state": "closed", +// }, +// expectError: false, +// expectedPR: mockClosedPR, +// }, +// { +// name: "successful PR update with reviewers", +// mockedClient: mock.NewMockedHTTPClient( +// // Mock for RequestReviewers call, returning the PR with reviewers +// mock.WithRequestMatch( +// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, +// mockPRWithReviewers, +// ), +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockPRWithReviewers, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "reviewers": []interface{}{"reviewer1", "reviewer2"}, +// }, +// expectError: false, +// expectedPR: mockPRWithReviewers, +// }, +// { +// name: "successful PR update (title only)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposPullsByOwnerByRepoByPullNumber, +// expectRequestBody(t, map[string]interface{}{ +// "title": "Updated Test PR Title", +// }).andThen( +// mockResponse(t, http.StatusOK, mockUpdatedPR), +// ), +// ), +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockUpdatedPR, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "title": "Updated Test PR Title", +// }, +// expectError: false, +// expectedPR: mockUpdatedPR, +// }, +// { +// name: "no update parameters provided", +// mockedClient: mock.NewMockedHTTPClient(), // No API call expected +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// // No update fields +// }, +// expectError: false, // Error is returned in the result, not as Go error +// expectedErrMsg: "No update parameters provided", +// }, +// { +// name: "PR update fails (API error)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PatchReposPullsByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "title": "Invalid Title Causing Error", +// }, +// expectError: true, +// expectedErrMsg: "failed to update pull request", +// }, +// { +// name: "request reviewers fails", +// mockedClient: mock.NewMockedHTTPClient( +// // Then reviewer request fails +// mock.WithRequestMatchHandler( +// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "reviewers": []interface{}{"invalid-user"}, +// }, +// expectError: true, +// expectedErrMsg: "failed to request reviewers", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError || tc.expectedErrMsg != "" { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var updateResp MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &updateResp) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) +// }) +// } +// } + +// func Test_UpdatePullRequest_Draft(t *testing.T) { +// // Setup mock PR for success case +// mockUpdatedPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR Title"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Body: github.Ptr("Test PR body."), +// MaintainerCanModify: github.Ptr(false), +// Draft: github.Ptr(false), // Updated to ready for review +// Base: &github.PullRequestBranch{ +// Ref: github.Ptr("main"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedPR *github.PullRequest +// expectedErrMsg string +// }{ +// { +// name: "successful draft update to ready for review", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDOA0xdyM50BPaO", +// "isDraft": true, // Current state is draft +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// MarkPullRequestReadyForReview struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } +// } `graphql:"markPullRequestReadyForReview(input: $input)"` +// }{}, +// githubv4.MarkPullRequestReadyForReviewInput{ +// PullRequestID: "PR_kwDOA0xdyM50BPaO", +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{ +// "markPullRequestReadyForReview": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDOA0xdyM50BPaO", +// "isDraft": false, +// }, +// }, +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "draft": false, +// }, +// expectError: false, +// expectedPR: mockUpdatedPR, +// }, +// { +// name: "successful convert pull request to draft", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse(map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDOA0xdyM50BPaO", +// "isDraft": false, // Current state is draft +// }, +// }, +// }), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// ConvertPullRequestToDraft struct { +// PullRequest struct { +// ID githubv4.ID +// IsDraft githubv4.Boolean +// } +// } `graphql:"convertPullRequestToDraft(input: $input)"` +// }{}, +// githubv4.ConvertPullRequestToDraftInput{ +// PullRequestID: "PR_kwDOA0xdyM50BPaO", +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{ +// "convertPullRequestToDraft": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDOA0xdyM50BPaO", +// "isDraft": true, +// }, +// }, +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "draft": true, +// }, +// expectError: false, +// expectedPR: mockUpdatedPR, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // For draft-only tests, we need to mock both GraphQL and the final REST GET call +// restClient := github.NewClient(mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockUpdatedPR, +// ), +// )) +// gqlClient := githubv4.NewClient(tc.mockedClient) + +// _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError || tc.expectedErrMsg != "" { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// if tc.expectedErrMsg != "" { +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var updateResp MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &updateResp) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) +// }) +// } +// } + +// func Test_ListPullRequests(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_pull_requests", 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, "state") +// assert.Contains(t, tool.InputSchema.Properties, "head") +// assert.Contains(t, tool.InputSchema.Properties, "base") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "direction") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock PRs for success case +// mockPRs := []*github.PullRequest{ +// { +// Number: github.Ptr(42), +// Title: github.Ptr("First PR"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// }, +// { +// Number: github.Ptr(43), +// Title: github.Ptr("Second PR"), +// State: github.Ptr("closed"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedPRs []*github.PullRequest +// expectedErrMsg string +// }{ +// { +// name: "successful PRs listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "state": "all", +// "sort": "created", +// "direction": "desc", +// "per_page": "30", +// "page": "1", +// }).andThen( +// mockResponse(t, http.StatusOK, mockPRs), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "all", +// "sort": "created", +// "direction": "desc", +// "perPage": float64(30), +// "page": float64(1), +// }, +// expectError: false, +// expectedPRs: mockPRs, +// }, +// { +// name: "PRs listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "invalid", +// }, +// expectError: true, +// expectedErrMsg: "failed to list pull requests", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedPRs []*github.PullRequest +// err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) +// require.NoError(t, err) +// assert.Len(t, returnedPRs, 2) +// assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) +// assert.Equal(t, *tc.expectedPRs[0].Title, *returnedPRs[0].Title) +// assert.Equal(t, *tc.expectedPRs[0].State, *returnedPRs[0].State) +// assert.Equal(t, *tc.expectedPRs[1].Number, *returnedPRs[1].Number) +// assert.Equal(t, *tc.expectedPRs[1].Title, *returnedPRs[1].Title) +// assert.Equal(t, *tc.expectedPRs[1].State, *returnedPRs[1].State) +// }) +// } +// } + +// func Test_MergePullRequest(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := MergePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "merge_pull_request", 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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "commit_title") +// assert.Contains(t, tool.InputSchema.Properties, "commit_message") +// assert.Contains(t, tool.InputSchema.Properties, "merge_method") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + +// // Setup mock merge result for success case +// mockMergeResult := &github.PullRequestMergeResult{ +// Merged: github.Ptr(true), +// Message: github.Ptr("Pull Request successfully merged"), +// SHA: github.Ptr("abcd1234efgh5678"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedMergeResult *github.PullRequestMergeResult +// expectedErrMsg string +// }{ +// { +// name: "successful merge", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposPullsMergeByOwnerByRepoByPullNumber, +// expectRequestBody(t, map[string]interface{}{ +// "commit_title": "Merge PR #42", +// "commit_message": "Merging awesome feature", +// "merge_method": "squash", +// }).andThen( +// mockResponse(t, http.StatusOK, mockMergeResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "commit_title": "Merge PR #42", +// "commit_message": "Merging awesome feature", +// "merge_method": "squash", +// }, +// expectError: false, +// expectedMergeResult: mockMergeResult, +// }, +// { +// name: "merge fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposPullsMergeByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusMethodNotAllowed) +// _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: true, +// expectedErrMsg: "failed to merge pull request", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := MergePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedResult github.PullRequestMergeResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedMergeResult.Merged, *returnedResult.Merged) +// assert.Equal(t, *tc.expectedMergeResult.Message, *returnedResult.Message) +// assert.Equal(t, *tc.expectedMergeResult.SHA, *returnedResult.SHA) +// }) +// } +// } + +// func Test_SearchPullRequests(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "search_pull_requests", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// mockSearchResult := &github.IssuesSearchResult{ +// Total: github.Ptr(2), +// IncompleteResults: github.Ptr(false), +// Issues: []*github.Issue{ +// { +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR 1"), +// Body: github.Ptr("Updated tests."), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), +// Comments: github.Ptr(5), +// User: &github.User{ +// Login: github.Ptr("user1"), +// }, +// }, +// { +// Number: github.Ptr(43), +// Title: github.Ptr("Test PR 2"), +// Body: github.Ptr("Updated build scripts."), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), +// Comments: github.Ptr(3), +// User: &github.User{ +// Login: github.Ptr("user2"), +// }, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.IssuesSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful pull request search with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr repo:owner/repo is:open", +// "sort": "created", +// "order": "desc", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:owner/repo is:open", +// "sort": "created", +// "order": "desc", +// "page": float64(1), +// "perPage": float64(30), +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "pull request search with owner and repo parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "repo:test-owner/test-repo is:pr draft:false", +// "sort": "updated", +// "order": "asc", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "draft:false", +// "owner": "test-owner", +// "repo": "test-repo", +// "sort": "updated", +// "order": "asc", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "pull request search with only owner parameter (should ignore it)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr feature", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "feature", +// "owner": "test-owner", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "pull request search with only repo parameter (should ignore it)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr review-required", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "review-required", +// "repo": "test-repo", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "pull request search with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetSearchIssues, +// mockSearchResult, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:pr repo:owner/repo is:open", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing is:pr filter - no duplication", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr repo:github/github-mcp-server is:open draft:false", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:pr repo:github/github-mcp-server is:open draft:false", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr repo:github/github-mcp-server author:octocat", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "repo:github/github-mcp-server author:octocat", +// "owner": "different-owner", +// "repo": "different-repo", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "complex query with existing is:pr filter and OR operators", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// expectQueryParams( +// t, +// map[string]string{ +// "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", +// "page": "1", +// "per_page": "30", +// }, +// ).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "search pull requests fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchIssues, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search pull requests", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) +// for i, issue := range returnedResult.Issues { +// assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) +// assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) +// assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) +// assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) +// assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) +// } +// }) +// } + +// } + +// func Test_GetPullRequestFiles(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// // Setup mock PR files for success case +// mockFiles := []*github.CommitFile{ +// { +// Filename: github.Ptr("file1.go"), +// Status: github.Ptr("modified"), +// Additions: github.Ptr(10), +// Deletions: github.Ptr(5), +// Changes: github.Ptr(15), +// Patch: github.Ptr("@@ -1,5 +1,10 @@"), +// }, +// { +// Filename: github.Ptr("file2.go"), +// Status: github.Ptr("added"), +// Additions: github.Ptr(20), +// Deletions: github.Ptr(0), +// Changes: github.Ptr(20), +// Patch: github.Ptr("@@ -0,0 +1,20 @@"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedFiles []*github.CommitFile +// expectedErrMsg string +// }{ +// { +// name: "successful files fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, +// mockFiles, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_files", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedFiles: mockFiles, +// }, +// { +// name: "successful files fetch with pagination", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, +// mockFiles, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_files", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedFiles: mockFiles, +// }, +// { +// name: "files fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_files", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get pull request files", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedFiles []*github.CommitFile +// err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) +// require.NoError(t, err) +// assert.Len(t, returnedFiles, len(tc.expectedFiles)) +// for i, file := range returnedFiles { +// assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) +// assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) +// assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) +// assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) +// } +// }) +// } +// } + +// func Test_GetPullRequestStatus(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// // Setup mock PR for successful PR fetch +// mockPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Head: &github.PullRequestBranch{ +// SHA: github.Ptr("abcd1234"), +// Ref: github.Ptr("feature-branch"), +// }, +// } + +// // Setup mock status for success case +// mockStatus := &github.CombinedStatus{ +// State: github.Ptr("success"), +// TotalCount: github.Ptr(3), +// Statuses: []*github.RepoStatus{ +// { +// State: github.Ptr("success"), +// Context: github.Ptr("continuous-integration/travis-ci"), +// Description: github.Ptr("Build succeeded"), +// TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), +// }, +// { +// State: github.Ptr("success"), +// Context: github.Ptr("codecov/patch"), +// Description: github.Ptr("Coverage increased"), +// TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), +// }, +// { +// State: github.Ptr("success"), +// Context: github.Ptr("lint/golangci-lint"), +// Description: github.Ptr("No issues found"), +// TargetURL: github.Ptr("https://golangci.com/r/owner/repo/pull/42"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedStatus *github.CombinedStatus +// expectedErrMsg string +// }{ +// { +// name: "successful status fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockPR, +// ), +// mock.WithRequestMatch( +// mock.GetReposCommitsStatusByOwnerByRepoByRef, +// mockStatus, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_status", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedStatus: mockStatus, +// }, +// { +// name: "PR fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_status", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get pull request", +// }, +// { +// name: "status fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// mockPR, +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsStatusesByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_status", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: true, +// expectedErrMsg: "failed to get combined status", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedStatus github.CombinedStatus +// err = json.Unmarshal([]byte(textContent.Text), &returnedStatus) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedStatus.State, *returnedStatus.State) +// assert.Equal(t, *tc.expectedStatus.TotalCount, *returnedStatus.TotalCount) +// assert.Len(t, returnedStatus.Statuses, len(tc.expectedStatus.Statuses)) +// for i, status := range returnedStatus.Statuses { +// assert.Equal(t, *tc.expectedStatus.Statuses[i].State, *status.State) +// assert.Equal(t, *tc.expectedStatus.Statuses[i].Context, *status.Context) +// assert.Equal(t, *tc.expectedStatus.Statuses[i].Description, *status.Description) +// } +// }) +// } +// } + +// func Test_UpdatePullRequestBranch(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := UpdatePullRequestBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "update_pull_request_branch", 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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "expectedHeadSha") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + +// // Setup mock update result for success case +// mockUpdateResult := &github.PullRequestBranchUpdateResponse{ +// Message: github.Ptr("Branch was updated successfully"), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedUpdateResult *github.PullRequestBranchUpdateResponse +// expectedErrMsg string +// }{ +// { +// name: "successful branch update", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, +// expectRequestBody(t, map[string]interface{}{ +// "expected_head_sha": "abcd1234", +// }).andThen( +// mockResponse(t, http.StatusAccepted, mockUpdateResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "expectedHeadSha": "abcd1234", +// }, +// expectError: false, +// expectedUpdateResult: mockUpdateResult, +// }, +// { +// name: "branch update without expected SHA", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, +// expectRequestBody(t, map[string]interface{}{}).andThen( +// mockResponse(t, http.StatusAccepted, mockUpdateResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedUpdateResult: mockUpdateResult, +// }, +// { +// name: "branch update fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusConflict) +// _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: true, +// expectedErrMsg: "failed to update pull request branch", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := UpdatePullRequestBranch(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// assert.Contains(t, textContent.Text, "is in progress") +// }) +// } +// } + +// func Test_GetPullRequestComments(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// // Setup mock PR comments for success case +// mockComments := []*github.PullRequestComment{ +// { +// ID: github.Ptr(int64(101)), +// Body: github.Ptr("This looks good"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r101"), +// User: &github.User{ +// Login: github.Ptr("reviewer1"), +// }, +// Path: github.Ptr("file1.go"), +// Position: github.Ptr(5), +// CommitID: github.Ptr("abcdef123456"), +// CreatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, +// UpdatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, +// }, +// { +// ID: github.Ptr(int64(102)), +// Body: github.Ptr("Please fix this"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r102"), +// User: &github.User{ +// Login: github.Ptr("reviewer2"), +// }, +// Path: github.Ptr("file2.go"), +// Position: github.Ptr(10), +// CommitID: github.Ptr("abcdef123456"), +// CreatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, +// UpdatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedComments []*github.PullRequestComment +// expectedErrMsg string +// }{ +// { +// name: "successful comments fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, +// mockComments, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_review_comments", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedComments: mockComments, +// }, +// { +// name: "comments fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_review_comments", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get pull request review comments", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedComments []*github.PullRequestComment +// err = json.Unmarshal([]byte(textContent.Text), &returnedComments) +// require.NoError(t, err) +// assert.Len(t, returnedComments, len(tc.expectedComments)) +// for i, comment := range returnedComments { +// assert.Equal(t, *tc.expectedComments[i].ID, *comment.ID) +// assert.Equal(t, *tc.expectedComments[i].Body, *comment.Body) +// assert.Equal(t, *tc.expectedComments[i].User.Login, *comment.User.Login) +// assert.Equal(t, *tc.expectedComments[i].Path, *comment.Path) +// assert.Equal(t, *tc.expectedComments[i].HTMLURL, *comment.HTMLURL) +// } +// }) +// } +// } + +// func Test_GetPullRequestReviews(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// // Setup mock PR reviews for success case +// mockReviews := []*github.PullRequestReview{ +// { +// ID: github.Ptr(int64(201)), +// State: github.Ptr("APPROVED"), +// Body: github.Ptr("LGTM"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), +// User: &github.User{ +// Login: github.Ptr("approver"), +// }, +// CommitID: github.Ptr("abcdef123456"), +// SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, +// }, +// { +// ID: github.Ptr(int64(202)), +// State: github.Ptr("CHANGES_REQUESTED"), +// Body: github.Ptr("Please address the following issues"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), +// User: &github.User{ +// Login: github.Ptr("reviewer"), +// }, +// CommitID: github.Ptr("abcdef123456"), +// SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedReviews []*github.PullRequestReview +// expectedErrMsg string +// }{ +// { +// name: "successful reviews fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, +// mockReviews, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_reviews", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// expectError: false, +// expectedReviews: mockReviews, +// }, +// { +// name: "reviews fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "method": "get_reviews", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get pull request reviews", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedReviews []*github.PullRequestReview +// err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) +// require.NoError(t, err) +// assert.Len(t, returnedReviews, len(tc.expectedReviews)) +// for i, review := range returnedReviews { +// assert.Equal(t, *tc.expectedReviews[i].ID, *review.ID) +// assert.Equal(t, *tc.expectedReviews[i].State, *review.State) +// assert.Equal(t, *tc.expectedReviews[i].Body, *review.Body) +// assert.Equal(t, *tc.expectedReviews[i].User.Login, *review.User.Login) +// assert.Equal(t, *tc.expectedReviews[i].HTMLURL, *review.HTMLURL) +// } +// }) +// } +// } + +// func Test_CreatePullRequest(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := CreatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "create_pull_request", 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, "title") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "head") +// assert.Contains(t, tool.InputSchema.Properties, "base") +// assert.Contains(t, tool.InputSchema.Properties, "draft") +// assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title", "head", "base"}) + +// // Setup mock PR for success case +// mockPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Head: &github.PullRequestBranch{ +// SHA: github.Ptr("abcd1234"), +// Ref: github.Ptr("feature-branch"), +// }, +// Base: &github.PullRequestBranch{ +// SHA: github.Ptr("efgh5678"), +// Ref: github.Ptr("main"), +// }, +// Body: github.Ptr("This is a test PR"), +// Draft: github.Ptr(false), +// MaintainerCanModify: github.Ptr(true), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedPR *github.PullRequest +// expectedErrMsg string +// }{ +// { +// name: "successful PR creation", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposPullsByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "title": "Test PR", +// "body": "This is a test PR", +// "head": "feature-branch", +// "base": "main", +// "draft": false, +// "maintainer_can_modify": true, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockPR), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "title": "Test PR", +// "body": "This is a test PR", +// "head": "feature-branch", +// "base": "main", +// "draft": false, +// "maintainer_can_modify": true, +// }, +// expectError: false, +// expectedPR: mockPR, +// }, +// { +// name: "missing required parameter", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// // missing title, head, base +// }, +// expectError: true, +// expectedErrMsg: "missing required parameter: title", +// }, +// { +// name: "PR creation fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposPullsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "title": "Test PR", +// "head": "feature-branch", +// "base": "main", +// }, +// expectError: true, +// expectedErrMsg: "failed to create pull request", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CreatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// if err != nil { +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// // If no error returned but in the 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 minimal result +// var returnedPR MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &returnedPR) +// require.NoError(t, err) +// assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) +// }) +// } +// } + +// func TestCreateAndSubmitPullRequestReview(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := githubv4.NewClient(nil) +// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_review_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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "event") +// assert.Contains(t, tool.InputSchema.Properties, "commitID") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful review creation", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse( +// map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDODKw3uc6WYN1T", +// }, +// }, +// }, +// ), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// AddPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"addPullRequestReview(input: $input)"` +// }{}, +// githubv4.AddPullRequestReviewInput{ +// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), +// Body: githubv4.NewString("This is a test review"), +// Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), +// CommitOID: githubv4.NewGitObjectID("abcd1234"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "body": "This is a test review", +// "event": "COMMENT", +// "commitID": "abcd1234", +// }, +// expectToolError: false, +// }, +// { +// name: "failure to get pull request", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.ErrorResponse("expected test failure"), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "body": "This is a test review", +// "event": "COMMENT", +// "commitID": "abcd1234", +// }, +// expectToolError: true, +// expectedToolErrMsg: "expected test failure", +// }, +// { +// name: "failure to submit review", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse( +// map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDODKw3uc6WYN1T", +// }, +// }, +// }, +// ), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// AddPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"addPullRequestReview(input: $input)"` +// }{}, +// githubv4.AddPullRequestReviewInput{ +// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), +// Body: githubv4.NewString("This is a test review"), +// Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), +// CommitOID: githubv4.NewGitObjectID("abcd1234"), +// }, +// nil, +// githubv4mock.ErrorResponse("expected test failure"), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "body": "This is a test review", +// "event": "COMMENT", +// "commitID": "abcd1234", +// }, +// expectToolError: true, +// expectedToolErrMsg: "expected test failure", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, textContent.Text, "pull request review submitted successfully") +// }) +// } +// } + +// func Test_RequestCopilotReview(t *testing.T) { +// t.Parallel() + +// mockClient := github.NewClient(nil) +// tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "request_copilot_review", 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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + +// // Setup mock PR for success case +// mockPR := &github.PullRequest{ +// Number: github.Ptr(42), +// Title: github.Ptr("Test PR"), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), +// Head: &github.PullRequestBranch{ +// SHA: github.Ptr("abcd1234"), +// Ref: github.Ptr("feature-branch"), +// }, +// Base: &github.PullRequestBranch{ +// Ref: github.Ptr("main"), +// }, +// Body: github.Ptr("This is a test PR"), +// User: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful request", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, +// expect(t, expectations{ +// path: "/repos/owner/repo/pulls/1/requested_reviewers", +// requestBody: map[string]any{ +// "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, +// }, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockPR), +// ), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(1), +// }, +// expectError: false, +// }, +// { +// name: "request fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(999), +// }, +// expectError: true, +// expectedErrMsg: "failed to request copilot review", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// client := github.NewClient(tc.mockedClient) +// _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) +// assert.NotNil(t, result) +// assert.Len(t, result.Content, 1) + +// textContent := getTextResult(t, result) +// require.Equal(t, "", textContent.Text) +// }) +// } +// } + +// func TestCreatePendingPullRequestReview(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := githubv4.NewClient(nil) +// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_review_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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "commitID") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful review creation", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse( +// map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDODKw3uc6WYN1T", +// }, +// }, +// }, +// ), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// AddPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"addPullRequestReview(input: $input)"` +// }{}, +// githubv4.AddPullRequestReviewInput{ +// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), +// CommitOID: githubv4.NewGitObjectID("abcd1234"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "commitID": "abcd1234", +// }, +// expectToolError: false, +// }, +// { +// name: "failure to get pull request", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.ErrorResponse("expected test failure"), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "commitID": "abcd1234", +// }, +// expectToolError: true, +// expectedToolErrMsg: "expected test failure", +// }, +// { +// name: "failure to create pending review", +// mockedClient: githubv4mock.NewMockedHTTPClient( +// githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// ID githubv4.ID +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $repo)"` +// }{}, +// map[string]any{ +// "owner": githubv4.String("owner"), +// "repo": githubv4.String("repo"), +// "prNum": githubv4.Int(42), +// }, +// githubv4mock.DataResponse( +// map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "id": "PR_kwDODKw3uc6WYN1T", +// }, +// }, +// }, +// ), +// ), +// githubv4mock.NewMutationMatcher( +// struct { +// AddPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"addPullRequestReview(input: $input)"` +// }{}, +// githubv4.AddPullRequestReviewInput{ +// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), +// CommitOID: githubv4.NewGitObjectID("abcd1234"), +// }, +// nil, +// githubv4mock.ErrorResponse("expected test failure"), +// ), +// ), +// requestArgs: map[string]any{ +// "method": "create", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "commitID": "abcd1234", +// }, +// expectToolError: true, +// expectedToolErrMsg: "expected test failure", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, "pending pull request created", textContent.Text) +// }) +// } +// } + +// func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := githubv4.NewClient(nil) +// tool, _ := AddCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "add_comment_to_pending_review", 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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "path") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.Contains(t, tool.InputSchema.Properties, "subjectType") +// assert.Contains(t, tool.InputSchema.Properties, "line") +// assert.Contains(t, tool.InputSchema.Properties, "side") +// assert.Contains(t, tool.InputSchema.Properties, "startLine") +// assert.Contains(t, tool.InputSchema.Properties, "startSide") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful line comment addition", +// requestArgs: map[string]any{ +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "path": "file.go", +// "body": "This is a test comment", +// "subjectType": "LINE", +// "line": float64(10), +// "side": "RIGHT", +// "startLine": float64(5), +// "startSide": "RIGHT", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// viewerQuery("williammartin"), +// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ +// author: "williammartin", +// owner: "owner", +// repo: "repo", +// prNum: 42, + +// reviews: []getLatestPendingReviewQueryReview{ +// { +// id: "PR_kwDODKw3uc6WYN1T", +// state: "PENDING", +// url: "https://github.com/owner/repo/pull/42", +// }, +// }, +// }), +// githubv4mock.NewMutationMatcher( +// struct { +// AddPullRequestReviewThread struct { +// Thread struct { +// ID githubv4.String // We don't need this, but a selector is required or GQL complains. +// } +// } `graphql:"addPullRequestReviewThread(input: $input)"` +// }{}, +// githubv4.AddPullRequestReviewThreadInput{ +// Path: githubv4.String("file.go"), +// Body: githubv4.String("This is a test comment"), +// SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), +// Line: githubv4.NewInt(10), +// Side: githubv4mock.Ptr(githubv4.DiffSideRight), +// StartLine: githubv4.NewInt(5), +// StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), +// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := AddCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review") +// }) +// } +// } + +// func TestSubmitPendingPullRequestReview(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := githubv4.NewClient(nil) +// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_review_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, "pullNumber") +// assert.Contains(t, tool.InputSchema.Properties, "event") +// assert.Contains(t, tool.InputSchema.Properties, "body") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]any +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful review submission", +// requestArgs: map[string]any{ +// "method": "submit_pending", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// "event": "COMMENT", +// "body": "This is a test review", +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// viewerQuery("williammartin"), +// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ +// author: "williammartin", +// owner: "owner", +// repo: "repo", +// prNum: 42, + +// reviews: []getLatestPendingReviewQueryReview{ +// { +// id: "PR_kwDODKw3uc6WYN1T", +// state: "PENDING", +// url: "https://github.com/owner/repo/pull/42", +// }, +// }, +// }), +// githubv4mock.NewMutationMatcher( +// struct { +// SubmitPullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"submitPullRequestReview(input: $input)"` +// }{}, +// githubv4.SubmitPullRequestReviewInput{ +// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), +// Event: githubv4.PullRequestReviewEventComment, +// Body: githubv4.NewString("This is a test review"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, "pending pull request review successfully submitted", textContent.Text) +// }) +// } +// } + +// func TestDeletePendingPullRequestReview(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := githubv4.NewClient(nil) +// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_review_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful review deletion", +// requestArgs: map[string]any{ +// "method": "delete_pending", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// mockedClient: githubv4mock.NewMockedHTTPClient( +// viewerQuery("williammartin"), +// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ +// author: "williammartin", +// owner: "owner", +// repo: "repo", +// prNum: 42, + +// reviews: []getLatestPendingReviewQueryReview{ +// { +// id: "PR_kwDODKw3uc6WYN1T", +// state: "PENDING", +// url: "https://github.com/owner/repo/pull/42", +// }, +// }, +// }), +// githubv4mock.NewMutationMatcher( +// struct { +// DeletePullRequestReview struct { +// PullRequestReview struct { +// ID githubv4.ID +// } +// } `graphql:"deletePullRequestReview(input: $input)"` +// }{}, +// githubv4.DeletePullRequestReviewInput{ +// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), +// }, +// nil, +// githubv4mock.DataResponse(map[string]any{}), +// ), +// ), +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := githubv4.NewClient(tc.mockedClient) +// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, "pending pull request review successfully deleted", textContent.Text) +// }) +// } +// } + +// func TestGetPullRequestDiff(t *testing.T) { +// t.Parallel() + +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "pull_request_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, "pullNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + +// stubbedDiff := `diff --git a/README.md b/README.md +// index 5d6e7b2..8a4f5c3 100644 +// --- a/README.md +// +++ b/README.md +// @@ -1,4 +1,6 @@ +// # Hello-World + +// Hello World project for GitHub + +// +## New Section +// + +// +This is a new section added in the pull request.` + +// tests := []struct { +// name string +// requestArgs map[string]any +// mockedClient *http.Client +// expectToolError bool +// expectedToolErrMsg string +// }{ +// { +// name: "successful diff retrieval", +// requestArgs: map[string]any{ +// "method": "get_diff", +// "owner": "owner", +// "repo": "repo", +// "pullNumber": float64(42), +// }, +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposPullsByOwnerByRepoByPullNumber, +// // Should also expect Accept header to be application/vnd.github.v3.diff +// expectPath(t, "/repos/owner/repo/pulls/42").andThen( +// mockResponse(t, http.StatusOK, stubbedDiff), +// ), +// ), +// ), +// expectToolError: false, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// t.Parallel() + +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// if tc.expectToolError { +// require.True(t, result.IsError) +// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) +// return +// } + +// // Parse the result and get the text content if no error +// require.Equal(t, stubbedDiff, textContent.Text) +// }) +// } +// } + +// func viewerQuery(login string) githubv4mock.Matcher { +// return githubv4mock.NewQueryMatcher( +// struct { +// Viewer struct { +// Login githubv4.String +// } `graphql:"viewer"` +// }{}, +// map[string]any{}, +// githubv4mock.DataResponse(map[string]any{ +// "viewer": map[string]any{ +// "login": login, +// }, +// }), +// ) +// } + +// type getLatestPendingReviewQueryReview struct { +// id string +// state string +// url string +// } + +// type getLatestPendingReviewQueryParams struct { +// author string +// owner string +// repo string +// prNum int32 + +// reviews []getLatestPendingReviewQueryReview +// } + +// func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher { +// return githubv4mock.NewQueryMatcher( +// struct { +// Repository struct { +// PullRequest struct { +// Reviews struct { +// Nodes []struct { +// ID githubv4.ID +// State githubv4.PullRequestReviewState +// URL githubv4.URI +// } +// } `graphql:"reviews(first: 1, author: $author)"` +// } `graphql:"pullRequest(number: $prNum)"` +// } `graphql:"repository(owner: $owner, name: $name)"` +// }{}, +// map[string]any{ +// "author": githubv4.String(p.author), +// "owner": githubv4.String(p.owner), +// "name": githubv4.String(p.repo), +// "prNum": githubv4.Int(p.prNum), +// }, +// githubv4mock.DataResponse( +// map[string]any{ +// "repository": map[string]any{ +// "pullRequest": map[string]any{ +// "reviews": map[string]any{ +// "nodes": []any{ +// map[string]any{ +// "id": p.reviews[0].id, +// "state": p.reviews[0].state, +// "url": p.reviews[0].url, +// }, +// }, +// }, +// }, +// }, +// }, +// ), +// ) +// } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 0d4d11bbf..0f866bb39 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1,1928 +1,1928 @@ package github -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_commit", - mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("sha", - mcp.Required(), - mcp.Description("Commit SHA, branch name, or tag name"), - ), - mcp.WithBoolean("include_diff", - mcp.Description("Whether to include file diffs and stats in the response. Default is true."), - mcp.DefaultBool(true), - ), - 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 - } - sha, err := RequiredParam[string](request, "sha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &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) - } - commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get commit: %s", sha), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - 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 commit: %s", string(body))), nil - } - - // Convert to minimal commit - minimalCommit := convertToMinimalCommit(commit, includeDiff) - - r, err := json.Marshal(minimalCommit) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListCommits creates a tool to get commits of a branch in a repository. -func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_commits", - mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("sha", - mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), - ), - mcp.WithString("author", - mcp.Description("Author username or email address to filter commits by"), - ), - 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 - } - sha, err := OptionalParam[string](request, "sha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - author, err := OptionalParam[string](request, "author") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - // Set default perPage to 30 if not provided - perPage := pagination.PerPage - if perPage == 0 { - perPage = 30 - } - opts := &github.CommitsListOptions{ - SHA: sha, - Author: author, - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: perPage, - }, - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list commits: %s", sha), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - 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 commits: %s", string(body))), nil - } - - // Convert to minimal commits - minimalCommits := make([]MinimalCommit, len(commits)) - for i, commit := range commits { - minimalCommits[i] = convertToMinimalCommit(commit, false) - } - - r, err := json.Marshal(minimalCommits) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListBranches creates a tool to list branches in a GitHub repository. -func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_branches", - mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - 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 - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.BranchListOptions{ - 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) - } - - branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list branches", - 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 branches: %s", string(body))), nil - } - - // Convert to minimal branches - minimalBranches := make([]MinimalBranch, 0, len(branches)) - for _, branch := range branches { - minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) - } - - r, err := json.Marshal(minimalBranches) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. -func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_or_update_file", - mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("Path where to create/update the file"), - ), - mcp.WithString("content", - mcp.Required(), - mcp.Description("Content of the file"), - ), - mcp.WithString("message", - mcp.Required(), - mcp.Description("Commit message"), - ), - mcp.WithString("branch", - mcp.Required(), - mcp.Description("Branch to create/update the file in"), - ), - mcp.WithString("sha", - mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), - ), - ), - 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 - } - path, err := RequiredParam[string](request, "path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - content, err := RequiredParam[string](request, "content") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - message, err := RequiredParam[string](request, "message") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - branch, err := RequiredParam[string](request, "branch") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // json.Marshal encodes byte arrays with base64, which is required for the API. - contentBytes := []byte(content) - - // Create the file options - opts := &github.RepositoryContentFileOptions{ - Message: github.Ptr(message), - Content: contentBytes, - Branch: github.Ptr(branch), - } - - // If SHA is provided, set it (for updates) - sha, err := OptionalParam[string](request, "sha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - if sha != "" { - opts.SHA = github.Ptr(sha) - } - - // Create or update the file - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create/update file", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 && resp.StatusCode != 201 { - 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/update file: %s", string(body))), nil - } - - r, err := json.Marshal(fileContent) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// CreateRepository creates a tool to create a new GitHub repository. -func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_repository", - mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("name", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("description", - mcp.Description("Repository description"), - ), - mcp.WithString("organization", - mcp.Description("Organization to create the repository in (omit to create in your personal account)"), - ), - mcp.WithBoolean("private", - mcp.Description("Whether repo should be private"), - ), - mcp.WithBoolean("autoInit", - mcp.Description("Initialize with README"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - name, err := RequiredParam[string](request, "name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - description, err := OptionalParam[string](request, "description") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - organization, err := OptionalParam[string](request, "organization") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - private, err := OptionalParam[bool](request, "private") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - autoInit, err := OptionalParam[bool](request, "autoInit") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - repo := &github.Repository{ - Name: github.Ptr(name), - Description: github.Ptr(description), - Private: github.Ptr(private), - AutoInit: github.Ptr(autoInit), - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create repository", - resp, - err, - ), nil - } - 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 repository: %s", string(body))), nil - } - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", createdRepo.GetID()), - URL: createdRepo.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 - } -} - -// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. -func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_file_contents", - mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("path", - mcp.Description("Path to file/directory (directories must end with a slash '/')"), - mcp.DefaultString("/"), - ), - mcp.WithString("ref", - mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), - ), - mcp.WithString("sha", - mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), - ), - ), - 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 - } - path, err := RequiredParam[string](request, "path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ref, err := OptionalParam[string](request, "ref") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sha, err := OptionalParam[string](request, "sha") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError("failed to get GitHub client"), nil - } - - rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil - } - - // If the path is (most likely) not to be a directory, we will - // first try to get the raw content from the GitHub raw content API. - - var rawAPIResponseCode int - if path != "" && !strings.HasSuffix(path, "/") { - // First, get file info from Contents API to retrieve SHA - var fileSHA string - opts := &github.RepositoryContentGetOptions{Ref: ref} - fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if respContents != nil { - defer func() { _ = respContents.Body.Close() }() - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get file SHA", - respContents, - err, - ), nil - } - if fileContent == nil || fileContent.SHA == nil { - return mcp.NewToolResultError("file content SHA is nil"), nil - } - fileSHA = *fileContent.SHA - - rawClient, err := getRawClient(ctx) - if err != nil { - return mcp.NewToolResultError("failed to get GitHub raw content client"), nil - } - resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) - if err != nil { - return mcp.NewToolResultError("failed to get raw repository content"), nil - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode == http.StatusOK { - // If the raw content is found, return it directly - body, err := io.ReadAll(resp.Body) - if err != nil { - return mcp.NewToolResultError("failed to read response body"), nil - } - contentType := resp.Header.Get("Content-Type") - - var resourceURI string - switch { - case sha != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) - if err != nil { - return nil, fmt.Errorf("failed to create resource URI: %w", err) - } - case ref != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) - if err != nil { - return nil, fmt.Errorf("failed to create resource URI: %w", err) - } - default: - resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) - if err != nil { - return nil, fmt.Errorf("failed to create resource URI: %w", err) - } - } - - // Determine if content is text or binary - isTextContent := strings.HasPrefix(contentType, "text/") || - contentType == "application/json" || - contentType == "application/xml" || - strings.HasSuffix(contentType, "+json") || - strings.HasSuffix(contentType, "+xml") - - if isTextContent { - result := mcp.TextResourceContents{ - URI: resourceURI, - Text: string(body), - MIMEType: contentType, - } - // Include SHA in the result metadata - if fileSHA != "" { - return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil - } - return mcp.NewToolResultResource("successfully downloaded text file", result), nil - } - - result := mcp.BlobResourceContents{ - URI: resourceURI, - Blob: base64.StdEncoding.EncodeToString(body), - MIMEType: contentType, - } - // Include SHA in the result metadata - if fileSHA != "" { - return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil - } - return mcp.NewToolResultResource("successfully downloaded binary file", result), nil - } - rawAPIResponseCode = resp.StatusCode - } - - if rawOpts.SHA != "" { - ref = rawOpts.SHA - } - if strings.HasSuffix(path, "/") { - opts := &github.RepositoryContentGetOptions{Ref: ref} - _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if err == nil && resp.StatusCode == http.StatusOK { - defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(dirContent) - if err != nil { - return mcp.NewToolResultError("failed to marshal response"), nil - } - return mcp.NewToolResultText(string(r)), nil - } - } - - // The path does not point to a file or directory. - // Instead let's try to find it in the Git Tree by matching the end of the path. - - // Step 1: Get Git Tree recursively - tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get git tree", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Step 2: Filter tree for matching paths - const maxMatchingFiles = 3 - matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) - if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil - } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil - } - return mcp.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil - } - - return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil - } -} - -// ForkRepository creates a tool to fork a repository. -func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("fork_repository", - mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("organization", - mcp.Description("Organization to fork to"), - ), - ), - 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 - } - org, err := OptionalParam[string](request, "organization") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.RepositoryCreateForkOptions{} - if org != "" { - opts.Organization = org - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - forkedRepo, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) - if err != nil { - // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, - // and it's not a real error. - if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return mcp.NewToolResultText("Fork is in progress"), nil - } - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to fork repository", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusAccepted { - 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 fork repository: %s", string(body))), nil - } - - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", forkedRepo.GetID()), - URL: forkedRepo.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 - } -} - -// DeleteFile creates a tool to delete a file in a GitHub repository. -// This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. -// This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, -// unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. -// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, -// both of which suit an LLM well. -func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_file", - mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), - ReadOnlyHint: ToBoolPtr(false), - DestructiveHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner (username or organization)"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("path", - mcp.Required(), - mcp.Description("Path to the file to delete"), - ), - mcp.WithString("message", - mcp.Required(), - mcp.Description("Commit message"), - ), - mcp.WithString("branch", - mcp.Required(), - mcp.Description("Branch to delete the file from"), - ), - ), - 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 - } - path, err := RequiredParam[string](request, "path") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - message, err := RequiredParam[string](request, "message") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - branch, err := RequiredParam[string](request, "branch") - 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) - } - - // Get the reference for the branch - ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) - if err != nil { - return nil, fmt.Errorf("failed to get branch reference: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Get the commit object that the branch points to - baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get base commit", - 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 get commit: %s", string(body))), nil - } - - // Create a tree entry for the file deletion by setting SHA to nil - treeEntries := []*github.TreeEntry{ - { - Path: github.Ptr(path), - Mode: github.Ptr("100644"), // Regular file mode - Type: github.Ptr("blob"), - SHA: nil, // Setting SHA to nil deletes the file - }, - } - - // Create a new tree with the deletion - newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create tree", - resp, - err, - ), nil - } - 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 tree: %s", string(body))), nil - } - - // Create a new commit with the new tree - commit := github.Commit{ - Message: github.Ptr(message), - Tree: newTree, - Parents: []*github.Commit{{SHA: baseCommit.SHA}}, - } - newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create commit", - resp, - err, - ), nil - } - 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 commit: %s", string(body))), nil - } - - // Update the branch reference to point to the new commit - ref.Object.SHA = newCommit.SHA - _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ - SHA: *newCommit.SHA, - Force: github.Ptr(false), - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update reference", - 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 reference: %s", string(body))), nil - } - - // Create a response similar to what the DeleteFile API would return - response := map[string]interface{}{ - "commit": newCommit, - "content": nil, - } - - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// CreateBranch creates a tool to create a new branch. -func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("create_branch", - mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("branch", - mcp.Required(), - mcp.Description("Name for new branch"), - ), - mcp.WithString("from_branch", - mcp.Description("Source branch (defaults to repo default)"), - ), - ), - 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 - } - branch, err := RequiredParam[string](request, "branch") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - fromBranch, err := OptionalParam[string](request, "from_branch") - 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) - } - - // Get the source branch SHA - var ref *github.Reference - - if fromBranch == "" { - // Get default branch if from_branch not specified - repository, resp, err := client.Repositories.Get(ctx, owner, repo) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get repository", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - fromBranch = *repository.DefaultBranch - } - - // Get SHA of source branch - ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get reference", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Create new branch - newRef := github.CreateRef{ - Ref: "refs/heads/" + branch, - SHA: *ref.Object.SHA, - } - - createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create branch", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(createdRef) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. -func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("push_files", - mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("branch", - mcp.Required(), - mcp.Description("Branch to push to"), - ), - mcp.WithArray("files", - mcp.Required(), - mcp.Items( - map[string]interface{}{ - "type": "object", - "additionalProperties": false, - "required": []string{"path", "content"}, - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "path to the file", - }, - "content": map[string]interface{}{ - "type": "string", - "description": "file content", - }, - }, - }), - mcp.Description("Array of file objects to push, each object with path (string) and content (string)"), - ), - mcp.WithString("message", - mcp.Required(), - mcp.Description("Commit message"), - ), - ), - 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 - } - branch, err := RequiredParam[string](request, "branch") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - message, err := RequiredParam[string](request, "message") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Parse files parameter - this should be an array of objects with path and content - filesObj, ok := request.GetArguments()["files"].([]interface{}) - if !ok { - return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the reference for the branch - ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get branch reference", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Get the commit object that the branch points to - baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get base commit", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Create tree entries for all files - var entries []*github.TreeEntry - - for _, file := range filesObj { - fileMap, ok := file.(map[string]interface{}) - if !ok { - return mcp.NewToolResultError("each file must be an object with path and content"), nil - } - - path, ok := fileMap["path"].(string) - if !ok || path == "" { - return mcp.NewToolResultError("each file must have a path"), nil - } - - content, ok := fileMap["content"].(string) - if !ok { - return mcp.NewToolResultError("each file must have content"), nil - } - - // Create a tree entry for the file - entries = append(entries, &github.TreeEntry{ - Path: github.Ptr(path), - Mode: github.Ptr("100644"), // Regular file mode - Type: github.Ptr("blob"), - Content: github.Ptr(content), - }) - } - - // Create a new tree with the file entries - newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create tree", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Create a new commit - commit := github.Commit{ - Message: github.Ptr(message), - Tree: newTree, - Parents: []*github.Commit{{SHA: baseCommit.SHA}}, - } - newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to create commit", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - // Update the reference to point to the new commit - ref.Object.SHA = newCommit.SHA - updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ - SHA: *newCommit.SHA, - Force: github.Ptr(false), - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to update reference", - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(updatedRef) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListTags creates a tool to list tags in a GitHub repository. -func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_tags", - mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - 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 - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &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) - } - - tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list tags", - 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 tags: %s", string(body))), nil - } - - r, err := json.Marshal(tags) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetTag creates a tool to get details about a specific tag in a GitHub repository. -func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_tag", - mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("tag", - mcp.Required(), - mcp.Description("Tag name"), - ), - ), - 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 - } - tag, err := RequiredParam[string](request, "tag") - 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) - } - - // First get the tag reference - ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get tag reference", - 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 get tag reference: %s", string(body))), nil - } - - // Then get the tag object - tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get tag object", - 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 get tag object: %s", string(body))), nil - } - - r, err := json.Marshal(tagObj) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// ListReleases creates a tool to list releases in a GitHub repository. -func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_releases", - mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - 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 - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &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) - } - - releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list releases: %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 list releases: %s", string(body))), nil - } - - r, err := json.Marshal(releases) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// GetLatestRelease creates a tool to get the latest release in a GitHub repository. -func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_latest_release", - mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - ), - 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) - if err != nil { - return nil, fmt.Errorf("failed to get latest release: %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 latest release: %s", string(body))), nil - } - - r, err := json.Marshal(release) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_release_by_tag", - mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("tag", - mcp.Required(), - mcp.Description("Tag name (e.g., 'v1.0.0')"), - ), - ), - 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 - } - tag, err := RequiredParam[string](request, "tag") - 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) - } - - release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get release by tag: %s", tag), - 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 get release by tag: %s", string(body))), nil - } - - r, err := json.Marshal(release) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// filterPaths filters the entries in a GitHub tree to find paths that -// match the given suffix. -// maxResults limits the number of results returned to first maxResults entries, -// a maxResults of -1 means no limit. -// It returns a slice of strings containing the matching paths. -// Directories are returned with a trailing slash. -func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { - // Remove trailing slash for matching purposes, but flag whether we - // only want directories. - dirOnly := false - if strings.HasSuffix(path, "/") { - dirOnly = true - path = strings.TrimSuffix(path, "/") - } - - matchedPaths := []string{} - for _, entry := range entries { - if len(matchedPaths) == maxResults { - break // Limit the number of results to maxResults - } - if dirOnly && entry.GetType() != "tree" { - continue // Skip non-directory entries if dirOnly is true - } - entryPath := entry.GetPath() - if entryPath == "" { - continue // Skip empty paths - } - if strings.HasSuffix(entryPath, path) { - if entry.GetType() == "tree" { - entryPath += "/" // Return directories with a trailing slash - } - matchedPaths = append(matchedPaths, entryPath) - } - } - return matchedPaths -} - -// resolveGitReference takes a user-provided ref and sha and resolves them into a -// definitive commit SHA and its corresponding fully-qualified reference. -// -// The resolution logic follows a clear priority: -// -// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, -// and all reference resolution is skipped. -// -// 2. If no `sha` is provided, the function resolves the `ref` -// string into a fully-qualified format (e.g., "refs/heads/main") by trying -// the following steps in order: -// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// qualified and used as-is. -// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// prefixed with "refs/" to make it fully-qualified. -// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// first attempts to resolve it as a branch ("refs/heads/"). If that -// returns a 404 Not Found error, it then attempts to resolve it as a tag -// ("refs/tags/"). -// -// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// is made to fetch that reference's definitive commit SHA. -// -// Any unexpected (non-404) errors during the resolution process are returned -// immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { - // 1) If SHA explicitly provided, it's the highest priority. - if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, nil - } - - originalRef := ref // Keep original ref for clearer error messages down the line. - - // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. - var reference *github.Reference - var resp *github.Response - var err error - - switch { - case originalRef == "": - // 2a) If ref is empty, determine the default branch. - repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) - return nil, fmt.Errorf("failed to get repository info: %w", err) - } - ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) - case strings.HasPrefix(originalRef, "refs/"): - // 2b) Already fully qualified. The reference will be fetched at the end. - case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): - // 2c) Partially qualified. Make it fully qualified. - ref = "refs/" + originalRef - default: - // 2d) It's a short name, so we try to resolve it to either a branch or a tag. - branchRef := "refs/heads/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) - - if err == nil { - ref = branchRef // It's a branch. - } else { - // The branch lookup failed. Check if it was a 404 Not Found error. - ghErr, isGhErr := err.(*github.ErrorResponse) - if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { - tagRef := "refs/tags/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) - if err == nil { - ref = tagRef // It's a tag. - } else { - // The tag lookup also failed. Check if it was a 404 Not Found error. - ghErr2, isGhErr2 := err.(*github.ErrorResponse) - if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) - } - // The tag lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) - return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) - } - } else { - // The branch lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) - return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) - } - } - } - - if reference == nil { - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) - } - } - - sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, nil -} - -// ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. -func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_starred_repositories", - mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("username", - mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."), - ), - mcp.WithString("sort", - mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."), - mcp.Enum("created", "updated"), - ), - mcp.WithString("direction", - mcp.Description("The direction to sort the results by."), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - username, err := OptionalParam[string](request, "username") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.ActivityListStarredOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } - if sort != "" { - opts.Sort = sort - } - if direction != "" { - opts.Direction = direction - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - var repos []*github.StarredRepository - var resp *github.Response - if username == "" { - // List starred repositories for the authenticated user - repos, resp, err = client.Activity.ListStarred(ctx, "", opts) - } else { - // List starred repositories for a specific user - repos, resp, err = client.Activity.ListStarred(ctx, username, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list starred repositories for user '%s'", username), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - 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 starred repositories: %s", string(body))), nil - } - - // Convert to minimal format - minimalRepos := make([]MinimalRepository, 0, len(repos)) - for _, starredRepo := range repos { - repo := starredRepo.Repository - minimalRepo := MinimalRepository{ - ID: repo.GetID(), - Name: repo.GetName(), - FullName: repo.GetFullName(), - Description: repo.GetDescription(), - HTMLURL: repo.GetHTMLURL(), - Language: repo.GetLanguage(), - Stars: repo.GetStargazersCount(), - Forks: repo.GetForksCount(), - OpenIssues: repo.GetOpenIssuesCount(), - Private: repo.GetPrivate(), - Fork: repo.GetFork(), - Archived: repo.GetArchived(), - DefaultBranch: repo.GetDefaultBranch(), - } - - if repo.UpdatedAt != nil { - minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") - } - - minimalRepos = append(minimalRepos, minimalRepo) - } - - r, err := json.Marshal(minimalRepos) - if err != nil { - return nil, fmt.Errorf("failed to marshal starred repositories: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// StarRepository creates a tool to star a repository. -func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("star_repository", - mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - ), - 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Activity.Star(ctx, owner, repo) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to star repository %s/%s", owner, repo), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 204 { - 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 star repository: %s", string(body))), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil - } -} - -// UnstarRepository creates a tool to unstar a repository. -func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("unstar_repository", - mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - ), - 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 - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Activity.Unstar(ctx, owner, repo) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to unstar repository %s/%s", owner, repo), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 204 { - 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 unstar repository: %s", string(body))), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil - } -} +// import ( +// "context" +// "encoding/base64" +// "encoding/json" +// "fmt" +// "io" +// "net/http" +// "net/url" +// "strings" + +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/raw" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_commit", +// mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("sha", +// mcp.Required(), +// mcp.Description("Commit SHA, branch name, or tag name"), +// ), +// mcp.WithBoolean("include_diff", +// mcp.Description("Whether to include file diffs and stats in the response. Default is true."), +// mcp.DefaultBool(true), +// ), +// 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 +// } +// sha, err := RequiredParam[string](request, "sha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &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) +// } +// commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to get commit: %s", sha), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 200 { +// 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 commit: %s", string(body))), nil +// } + +// // Convert to minimal commit +// minimalCommit := convertToMinimalCommit(commit, includeDiff) + +// r, err := json.Marshal(minimalCommit) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListCommits creates a tool to get commits of a branch in a repository. +// func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_commits", +// mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("sha", +// mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), +// ), +// mcp.WithString("author", +// mcp.Description("Author username or email address to filter commits by"), +// ), +// 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 +// } +// sha, err := OptionalParam[string](request, "sha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// author, err := OptionalParam[string](request, "author") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// // Set default perPage to 30 if not provided +// perPage := pagination.PerPage +// if perPage == 0 { +// perPage = 30 +// } +// opts := &github.CommitsListOptions{ +// SHA: sha, +// Author: author, +// ListOptions: github.ListOptions{ +// Page: pagination.Page, +// PerPage: perPage, +// }, +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to list commits: %s", sha), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 200 { +// 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 commits: %s", string(body))), nil +// } + +// // Convert to minimal commits +// minimalCommits := make([]MinimalCommit, len(commits)) +// for i, commit := range commits { +// minimalCommits[i] = convertToMinimalCommit(commit, false) +// } + +// r, err := json.Marshal(minimalCommits) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListBranches creates a tool to list branches in a GitHub repository. +// func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_branches", +// mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// 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 +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &github.BranchListOptions{ +// 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) +// } + +// branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list branches", +// 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 branches: %s", string(body))), nil +// } + +// // Convert to minimal branches +// minimalBranches := make([]MinimalBranch, 0, len(branches)) +// for _, branch := range branches { +// minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) +// } + +// r, err := json.Marshal(minimalBranches) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. +// func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("create_or_update_file", +// mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("path", +// mcp.Required(), +// mcp.Description("Path where to create/update the file"), +// ), +// mcp.WithString("content", +// mcp.Required(), +// mcp.Description("Content of the file"), +// ), +// mcp.WithString("message", +// mcp.Required(), +// mcp.Description("Commit message"), +// ), +// mcp.WithString("branch", +// mcp.Required(), +// mcp.Description("Branch to create/update the file in"), +// ), +// mcp.WithString("sha", +// mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), +// ), +// ), +// 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 +// } +// path, err := RequiredParam[string](request, "path") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// content, err := RequiredParam[string](request, "content") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// message, err := RequiredParam[string](request, "message") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// branch, err := RequiredParam[string](request, "branch") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // json.Marshal encodes byte arrays with base64, which is required for the API. +// contentBytes := []byte(content) + +// // Create the file options +// opts := &github.RepositoryContentFileOptions{ +// Message: github.Ptr(message), +// Content: contentBytes, +// Branch: github.Ptr(branch), +// } + +// // If SHA is provided, set it (for updates) +// sha, err := OptionalParam[string](request, "sha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// if sha != "" { +// opts.SHA = github.Ptr(sha) +// } + +// // Create or update the file +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create/update file", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 200 && resp.StatusCode != 201 { +// 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/update file: %s", string(body))), nil +// } + +// r, err := json.Marshal(fileContent) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // CreateRepository creates a tool to create a new GitHub repository. +// func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("create_repository", +// mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("name", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("description", +// mcp.Description("Repository description"), +// ), +// mcp.WithString("organization", +// mcp.Description("Organization to create the repository in (omit to create in your personal account)"), +// ), +// mcp.WithBoolean("private", +// mcp.Description("Whether repo should be private"), +// ), +// mcp.WithBoolean("autoInit", +// mcp.Description("Initialize with README"), +// ), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// name, err := RequiredParam[string](request, "name") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// description, err := OptionalParam[string](request, "description") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// organization, err := OptionalParam[string](request, "organization") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// private, err := OptionalParam[bool](request, "private") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// autoInit, err := OptionalParam[bool](request, "autoInit") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// repo := &github.Repository{ +// Name: github.Ptr(name), +// Description: github.Ptr(description), +// Private: github.Ptr(private), +// AutoInit: github.Ptr(autoInit), +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create repository", +// resp, +// err, +// ), nil +// } +// 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 repository: %s", string(body))), nil +// } + +// // Return minimal response with just essential information +// minimalResponse := MinimalResponse{ +// ID: fmt.Sprintf("%d", createdRepo.GetID()), +// URL: createdRepo.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 +// } +// } + +// // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. +// func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_file_contents", +// mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("path", +// mcp.Description("Path to file/directory (directories must end with a slash '/')"), +// mcp.DefaultString("/"), +// ), +// mcp.WithString("ref", +// mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), +// ), +// mcp.WithString("sha", +// mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), +// ), +// ), +// 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 +// } +// path, err := RequiredParam[string](request, "path") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// ref, err := OptionalParam[string](request, "ref") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sha, err := OptionalParam[string](request, "sha") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return mcp.NewToolResultError("failed to get GitHub client"), nil +// } + +// rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil +// } + +// // If the path is (most likely) not to be a directory, we will +// // first try to get the raw content from the GitHub raw content API. + +// var rawAPIResponseCode int +// if path != "" && !strings.HasSuffix(path, "/") { +// // First, get file info from Contents API to retrieve SHA +// var fileSHA string +// opts := &github.RepositoryContentGetOptions{Ref: ref} +// fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) +// if respContents != nil { +// defer func() { _ = respContents.Body.Close() }() +// } +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get file SHA", +// respContents, +// err, +// ), nil +// } +// if fileContent == nil || fileContent.SHA == nil { +// return mcp.NewToolResultError("file content SHA is nil"), nil +// } +// fileSHA = *fileContent.SHA + +// rawClient, err := getRawClient(ctx) +// if err != nil { +// return mcp.NewToolResultError("failed to get GitHub raw content client"), nil +// } +// resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) +// if err != nil { +// return mcp.NewToolResultError("failed to get raw repository content"), nil +// } +// defer func() { +// _ = resp.Body.Close() +// }() + +// if resp.StatusCode == http.StatusOK { +// // If the raw content is found, return it directly +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return mcp.NewToolResultError("failed to read response body"), nil +// } +// contentType := resp.Header.Get("Content-Type") + +// var resourceURI string +// switch { +// case sha != "": +// resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) +// if err != nil { +// return nil, fmt.Errorf("failed to create resource URI: %w", err) +// } +// case ref != "": +// resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) +// if err != nil { +// return nil, fmt.Errorf("failed to create resource URI: %w", err) +// } +// default: +// resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) +// if err != nil { +// return nil, fmt.Errorf("failed to create resource URI: %w", err) +// } +// } + +// // Determine if content is text or binary +// isTextContent := strings.HasPrefix(contentType, "text/") || +// contentType == "application/json" || +// contentType == "application/xml" || +// strings.HasSuffix(contentType, "+json") || +// strings.HasSuffix(contentType, "+xml") + +// if isTextContent { +// result := mcp.TextResourceContents{ +// URI: resourceURI, +// Text: string(body), +// MIMEType: contentType, +// } +// // Include SHA in the result metadata +// if fileSHA != "" { +// return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil +// } +// return mcp.NewToolResultResource("successfully downloaded text file", result), nil +// } + +// result := mcp.BlobResourceContents{ +// URI: resourceURI, +// Blob: base64.StdEncoding.EncodeToString(body), +// MIMEType: contentType, +// } +// // Include SHA in the result metadata +// if fileSHA != "" { +// return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil +// } +// return mcp.NewToolResultResource("successfully downloaded binary file", result), nil +// } +// rawAPIResponseCode = resp.StatusCode +// } + +// if rawOpts.SHA != "" { +// ref = rawOpts.SHA +// } +// if strings.HasSuffix(path, "/") { +// opts := &github.RepositoryContentGetOptions{Ref: ref} +// _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) +// if err == nil && resp.StatusCode == http.StatusOK { +// defer func() { _ = resp.Body.Close() }() +// r, err := json.Marshal(dirContent) +// if err != nil { +// return mcp.NewToolResultError("failed to marshal response"), nil +// } +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // The path does not point to a file or directory. +// // Instead let's try to find it in the Git Tree by matching the end of the path. + +// // Step 1: Get Git Tree recursively +// tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get git tree", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Step 2: Filter tree for matching paths +// const maxMatchingFiles = 3 +// matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) +// if len(matchingFiles) > 0 { +// matchingFilesJSON, err := json.Marshal(matchingFiles) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil +// } +// resolvedRefs, err := json.Marshal(rawOpts) +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil +// } +// return mcp.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil +// } + +// return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil +// } +// } + +// // ForkRepository creates a tool to fork a repository. +// func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("fork_repository", +// mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("organization", +// mcp.Description("Organization to fork to"), +// ), +// ), +// 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 +// } +// org, err := OptionalParam[string](request, "organization") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &github.RepositoryCreateForkOptions{} +// if org != "" { +// opts.Organization = org +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// forkedRepo, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) +// if err != nil { +// // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, +// // and it's not a real error. +// if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { +// return mcp.NewToolResultText("Fork is in progress"), nil +// } +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to fork repository", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusAccepted { +// 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 fork repository: %s", string(body))), nil +// } + +// // Return minimal response with just essential information +// minimalResponse := MinimalResponse{ +// ID: fmt.Sprintf("%d", forkedRepo.GetID()), +// URL: forkedRepo.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 +// } +// } + +// // DeleteFile creates a tool to delete a file in a GitHub repository. +// // This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. +// // This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, +// // unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. +// // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, +// // both of which suit an LLM well. +// func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("delete_file", +// mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), +// ReadOnlyHint: ToBoolPtr(false), +// DestructiveHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner (username or organization)"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("path", +// mcp.Required(), +// mcp.Description("Path to the file to delete"), +// ), +// mcp.WithString("message", +// mcp.Required(), +// mcp.Description("Commit message"), +// ), +// mcp.WithString("branch", +// mcp.Required(), +// mcp.Description("Branch to delete the file from"), +// ), +// ), +// 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 +// } +// path, err := RequiredParam[string](request, "path") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// message, err := RequiredParam[string](request, "message") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// branch, err := RequiredParam[string](request, "branch") +// 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) +// } + +// // Get the reference for the branch +// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) +// if err != nil { +// return nil, fmt.Errorf("failed to get branch reference: %w", err) +// } +// defer func() { _ = resp.Body.Close() }() + +// // Get the commit object that the branch points to +// baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get base commit", +// 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 get commit: %s", string(body))), nil +// } + +// // Create a tree entry for the file deletion by setting SHA to nil +// treeEntries := []*github.TreeEntry{ +// { +// Path: github.Ptr(path), +// Mode: github.Ptr("100644"), // Regular file mode +// Type: github.Ptr("blob"), +// SHA: nil, // Setting SHA to nil deletes the file +// }, +// } + +// // Create a new tree with the deletion +// newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create tree", +// resp, +// err, +// ), nil +// } +// 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 tree: %s", string(body))), nil +// } + +// // Create a new commit with the new tree +// commit := github.Commit{ +// Message: github.Ptr(message), +// Tree: newTree, +// Parents: []*github.Commit{{SHA: baseCommit.SHA}}, +// } +// newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create commit", +// resp, +// err, +// ), nil +// } +// 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 commit: %s", string(body))), nil +// } + +// // Update the branch reference to point to the new commit +// ref.Object.SHA = newCommit.SHA +// _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ +// SHA: *newCommit.SHA, +// Force: github.Ptr(false), +// }) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to update reference", +// 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 reference: %s", string(body))), nil +// } + +// // Create a response similar to what the DeleteFile API would return +// response := map[string]interface{}{ +// "commit": newCommit, +// "content": nil, +// } + +// r, err := json.Marshal(response) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // CreateBranch creates a tool to create a new branch. +// func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("create_branch", +// mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("branch", +// mcp.Required(), +// mcp.Description("Name for new branch"), +// ), +// mcp.WithString("from_branch", +// mcp.Description("Source branch (defaults to repo default)"), +// ), +// ), +// 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 +// } +// branch, err := RequiredParam[string](request, "branch") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// fromBranch, err := OptionalParam[string](request, "from_branch") +// 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) +// } + +// // Get the source branch SHA +// var ref *github.Reference + +// if fromBranch == "" { +// // Get default branch if from_branch not specified +// repository, resp, err := client.Repositories.Get(ctx, owner, repo) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get repository", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// fromBranch = *repository.DefaultBranch +// } + +// // Get SHA of source branch +// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get reference", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Create new branch +// newRef := github.CreateRef{ +// Ref: "refs/heads/" + branch, +// SHA: *ref.Object.SHA, +// } + +// createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create branch", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(createdRef) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. +// func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("push_files", +// mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("branch", +// mcp.Required(), +// mcp.Description("Branch to push to"), +// ), +// mcp.WithArray("files", +// mcp.Required(), +// mcp.Items( +// map[string]interface{}{ +// "type": "object", +// "additionalProperties": false, +// "required": []string{"path", "content"}, +// "properties": map[string]interface{}{ +// "path": map[string]interface{}{ +// "type": "string", +// "description": "path to the file", +// }, +// "content": map[string]interface{}{ +// "type": "string", +// "description": "file content", +// }, +// }, +// }), +// mcp.Description("Array of file objects to push, each object with path (string) and content (string)"), +// ), +// mcp.WithString("message", +// mcp.Required(), +// mcp.Description("Commit message"), +// ), +// ), +// 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 +// } +// branch, err := RequiredParam[string](request, "branch") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// message, err := RequiredParam[string](request, "message") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// // Parse files parameter - this should be an array of objects with path and content +// filesObj, ok := request.GetArguments()["files"].([]interface{}) +// if !ok { +// return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// // Get the reference for the branch +// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get branch reference", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Get the commit object that the branch points to +// baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get base commit", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Create tree entries for all files +// var entries []*github.TreeEntry + +// for _, file := range filesObj { +// fileMap, ok := file.(map[string]interface{}) +// if !ok { +// return mcp.NewToolResultError("each file must be an object with path and content"), nil +// } + +// path, ok := fileMap["path"].(string) +// if !ok || path == "" { +// return mcp.NewToolResultError("each file must have a path"), nil +// } + +// content, ok := fileMap["content"].(string) +// if !ok { +// return mcp.NewToolResultError("each file must have content"), nil +// } + +// // Create a tree entry for the file +// entries = append(entries, &github.TreeEntry{ +// Path: github.Ptr(path), +// Mode: github.Ptr("100644"), // Regular file mode +// Type: github.Ptr("blob"), +// Content: github.Ptr(content), +// }) +// } + +// // Create a new tree with the file entries +// newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create tree", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Create a new commit +// commit := github.Commit{ +// Message: github.Ptr(message), +// Tree: newTree, +// Parents: []*github.Commit{{SHA: baseCommit.SHA}}, +// } +// newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to create commit", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// // Update the reference to point to the new commit +// ref.Object.SHA = newCommit.SHA +// updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ +// SHA: *newCommit.SHA, +// Force: github.Ptr(false), +// }) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to update reference", +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// r, err := json.Marshal(updatedRef) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListTags creates a tool to list tags in a GitHub repository. +// func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_tags", +// mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// 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 +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &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) +// } + +// tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to list tags", +// 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 tags: %s", string(body))), nil +// } + +// r, err := json.Marshal(tags) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetTag creates a tool to get details about a specific tag in a GitHub repository. +// func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_tag", +// mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("tag", +// mcp.Required(), +// mcp.Description("Tag name"), +// ), +// ), +// 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 +// } +// tag, err := RequiredParam[string](request, "tag") +// 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) +// } + +// // First get the tag reference +// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get tag reference", +// 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 get tag reference: %s", string(body))), nil +// } + +// // Then get the tag object +// tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// "failed to get tag object", +// 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 get tag object: %s", string(body))), nil +// } + +// r, err := json.Marshal(tagObj) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // ListReleases creates a tool to list releases in a GitHub repository. +// func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_releases", +// mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// 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 +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &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) +// } + +// releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list releases: %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 list releases: %s", string(body))), nil +// } + +// r, err := json.Marshal(releases) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // GetLatestRelease creates a tool to get the latest release in a GitHub repository. +// func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_latest_release", +// mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// ), +// 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) +// if err != nil { +// return nil, fmt.Errorf("failed to get latest release: %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 latest release: %s", string(body))), nil +// } + +// r, err := json.Marshal(release) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_release_by_tag", +// mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// mcp.WithString("tag", +// mcp.Required(), +// mcp.Description("Tag name (e.g., 'v1.0.0')"), +// ), +// ), +// 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 +// } +// tag, err := RequiredParam[string](request, "tag") +// 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) +// } + +// release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to get release by tag: %s", tag), +// 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 get release by tag: %s", string(body))), nil +// } + +// r, err := json.Marshal(release) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // filterPaths filters the entries in a GitHub tree to find paths that +// // match the given suffix. +// // maxResults limits the number of results returned to first maxResults entries, +// // a maxResults of -1 means no limit. +// // It returns a slice of strings containing the matching paths. +// // Directories are returned with a trailing slash. +// func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { +// // Remove trailing slash for matching purposes, but flag whether we +// // only want directories. +// dirOnly := false +// if strings.HasSuffix(path, "/") { +// dirOnly = true +// path = strings.TrimSuffix(path, "/") +// } + +// matchedPaths := []string{} +// for _, entry := range entries { +// if len(matchedPaths) == maxResults { +// break // Limit the number of results to maxResults +// } +// if dirOnly && entry.GetType() != "tree" { +// continue // Skip non-directory entries if dirOnly is true +// } +// entryPath := entry.GetPath() +// if entryPath == "" { +// continue // Skip empty paths +// } +// if strings.HasSuffix(entryPath, path) { +// if entry.GetType() == "tree" { +// entryPath += "/" // Return directories with a trailing slash +// } +// matchedPaths = append(matchedPaths, entryPath) +// } +// } +// return matchedPaths +// } + +// // resolveGitReference takes a user-provided ref and sha and resolves them into a +// // definitive commit SHA and its corresponding fully-qualified reference. +// // +// // The resolution logic follows a clear priority: +// // +// // 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// // and all reference resolution is skipped. +// // +// // 2. If no `sha` is provided, the function resolves the `ref` +// // string into a fully-qualified format (e.g., "refs/heads/main") by trying +// // the following steps in order: +// // a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// // b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// // qualified and used as-is. +// // c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// // prefixed with "refs/" to make it fully-qualified. +// // d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// // first attempts to resolve it as a branch ("refs/heads/"). If that +// // returns a 404 Not Found error, it then attempts to resolve it as a tag +// // ("refs/tags/"). +// // +// // 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// // is made to fetch that reference's definitive commit SHA. +// // +// // Any unexpected (non-404) errors during the resolution process are returned +// // immediately. All API errors are logged with rich context to aid diagnostics. +// func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { +// // 1) If SHA explicitly provided, it's the highest priority. +// if sha != "" { +// return &raw.ContentOpts{Ref: "", SHA: sha}, nil +// } + +// originalRef := ref // Keep original ref for clearer error messages down the line. + +// // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. +// var reference *github.Reference +// var resp *github.Response +// var err error + +// switch { +// case originalRef == "": +// // 2a) If ref is empty, determine the default branch. +// repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) +// if err != nil { +// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) +// return nil, fmt.Errorf("failed to get repository info: %w", err) +// } +// ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) +// case strings.HasPrefix(originalRef, "refs/"): +// // 2b) Already fully qualified. The reference will be fetched at the end. +// case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): +// // 2c) Partially qualified. Make it fully qualified. +// ref = "refs/" + originalRef +// default: +// // 2d) It's a short name, so we try to resolve it to either a branch or a tag. +// branchRef := "refs/heads/" + originalRef +// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + +// if err == nil { +// ref = branchRef // It's a branch. +// } else { +// // The branch lookup failed. Check if it was a 404 Not Found error. +// ghErr, isGhErr := err.(*github.ErrorResponse) +// if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { +// tagRef := "refs/tags/" + originalRef +// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) +// if err == nil { +// ref = tagRef // It's a tag. +// } else { +// // The tag lookup also failed. Check if it was a 404 Not Found error. +// ghErr2, isGhErr2 := err.(*github.ErrorResponse) +// if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { +// return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) +// } +// // The tag lookup failed for a different reason. +// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) +// return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) +// } +// } else { +// // The branch lookup failed for a different reason. +// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) +// return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) +// } +// } +// } + +// if reference == nil { +// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) +// if err != nil { +// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) +// return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) +// } +// } + +// sha = reference.GetObject().GetSHA() +// return &raw.ContentOpts{Ref: ref, SHA: sha}, nil +// } + +// // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. +// func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_starred_repositories", +// mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("username", +// mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."), +// ), +// mcp.WithString("sort", +// mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."), +// mcp.Enum("created", "updated"), +// ), +// mcp.WithString("direction", +// mcp.Description("The direction to sort the results by."), +// mcp.Enum("asc", "desc"), +// ), +// WithPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// username, err := OptionalParam[string](request, "username") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &github.ActivityListStarredOptions{ +// ListOptions: github.ListOptions{ +// Page: pagination.Page, +// PerPage: pagination.PerPage, +// }, +// } +// if sort != "" { +// opts.Sort = sort +// } +// if direction != "" { +// opts.Direction = direction +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// var repos []*github.StarredRepository +// var resp *github.Response +// if username == "" { +// // List starred repositories for the authenticated user +// repos, resp, err = client.Activity.ListStarred(ctx, "", opts) +// } else { +// // List starred repositories for a specific user +// repos, resp, err = client.Activity.ListStarred(ctx, username, opts) +// } + +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to list starred repositories for user '%s'", username), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 200 { +// 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 starred repositories: %s", string(body))), nil +// } + +// // Convert to minimal format +// minimalRepos := make([]MinimalRepository, 0, len(repos)) +// for _, starredRepo := range repos { +// repo := starredRepo.Repository +// minimalRepo := MinimalRepository{ +// ID: repo.GetID(), +// Name: repo.GetName(), +// FullName: repo.GetFullName(), +// Description: repo.GetDescription(), +// HTMLURL: repo.GetHTMLURL(), +// Language: repo.GetLanguage(), +// Stars: repo.GetStargazersCount(), +// Forks: repo.GetForksCount(), +// OpenIssues: repo.GetOpenIssuesCount(), +// Private: repo.GetPrivate(), +// Fork: repo.GetFork(), +// Archived: repo.GetArchived(), +// DefaultBranch: repo.GetDefaultBranch(), +// } + +// if repo.UpdatedAt != nil { +// minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") +// } + +// minimalRepos = append(minimalRepos, minimalRepo) +// } + +// r, err := json.Marshal(minimalRepos) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal starred repositories: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// // StarRepository creates a tool to star a repository. +// func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("star_repository", +// mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// ), +// 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Activity.Star(ctx, owner, repo) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to star repository %s/%s", owner, repo), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 204 { +// 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 star repository: %s", string(body))), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil +// } +// } + +// // UnstarRepository creates a tool to unstar a repository. +// func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("unstar_repository", +// mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"), +// ReadOnlyHint: ToBoolPtr(false), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("Repository owner"), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("Repository name"), +// ), +// ), +// 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 +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// resp, err := client.Activity.Unstar(ctx, owner, repo) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to unstar repository %s/%s", owner, repo), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != 204 { +// 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 unstar repository: %s", string(body))), nil +// } + +// return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil +// } +// } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 665af6b0a..1b454bbc5 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1,3414 +1,3414 @@ package github -import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetFileContents(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - mockRawClient := raw.NewClient(mockClient, &url.URL{Scheme: "https", Host: "raw.githubusercontent.com", Path: "/"}) - tool, _ := GetFileContents(stubGetClientFn(mockClient), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_file_contents", 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, "path") - assert.Contains(t, tool.InputSchema.Properties, "ref") - assert.Contains(t, tool.InputSchema.Properties, "sha") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Mock response for raw content - mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") - - // Setup mock directory content for success case - mockDirContent := []*github.RepositoryContent{ - { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), - }, - { - Type: github.Ptr("dir"), - Name: github.Ptr("src"), - Path: github.Ptr("src"), - SHA: github.Ptr("def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult interface{} - expectedErrMsg string - expectStatus int - }{ - { - name: "successful text content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "README.md", - "ref": "refs/heads/main", - }, - expectError: false, - expectedResult: mcp.TextResourceContents{ - URI: "repo://owner/repo/refs/heads/main/contents/README.md", - Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", - }, - }, - { - name: "successful file blob content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "test.png", - "ref": "refs/heads/main", - }, - expectError: false, - expectedResult: mcp.BlobResourceContents{ - URI: "repo://owner/repo/refs/heads/main/contents/test.png", - Blob: base64.StdEncoding.EncodeToString(mockRawContent), - MIMEType: "image/png", - }, - }, - { - name: "successful PDF file content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "document.pdf", - "ref": "refs/heads/main", - }, - expectError: false, - expectedResult: mcp.BlobResourceContents{ - URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", - Blob: base64.StdEncoding.EncodeToString(mockRawContent), - MIMEType: "application/pdf", - }, - }, - { - name: "successful directory content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, mockDirContent), - ), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{ - "branch": "main", - }).andThen( - mockResponse(t, http.StatusNotFound, nil), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "src/", - }, - expectError: false, - expectedResult: mockDirContent, - }, - { - name: "content fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "nonexistent.md", - "ref": "refs/heads/main", - }, - expectError: false, - expectedResult: mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) - _, handler := GetFileContents(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - // Use the correct result helper based on the expected type - switch expected := tc.expectedResult.(type) { - case mcp.TextResourceContents: - textResource := getTextResourceResult(t, result) - assert.Equal(t, expected, textResource) - case mcp.BlobResourceContents: - blobResource := getBlobResourceResult(t, result) - assert.Equal(t, expected, blobResource) - case []*github.RepositoryContent: - // Directory content fetch returns a text result (JSON array) - textContent := getTextResult(t, result) - var returnedContents []*github.RepositoryContent - err = json.Unmarshal([]byte(textContent.Text), &returnedContents) - require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text) - assert.Len(t, returnedContents, len(expected)) - for i, content := range returnedContents { - assert.Equal(t, *expected[i].Name, *content.Name) - assert.Equal(t, *expected[i].Path, *content.Path) - assert.Equal(t, *expected[i].Type, *content.Type) - } - case mcp.TextContent: - textContent := getErrorResult(t, result) - require.Equal(t, textContent, expected) - } - }) - } -} - -func Test_ForkRepository(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ForkRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "fork_repository", 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, "organization") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock forked repo for success case - mockForkedRepo := &github.Repository{ - ID: github.Ptr(int64(123456)), - Name: github.Ptr("repo"), - FullName: github.Ptr("new-owner/repo"), - Owner: &github.User{ - Login: github.Ptr("new-owner"), - }, - HTMLURL: github.Ptr("https://github.com/new-owner/repo"), - DefaultBranch: github.Ptr("main"), - Fork: github.Ptr(true), - ForksCount: github.Ptr(0), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedRepo *github.Repository - expectedErrMsg string - }{ - { - name: "successful repository fork", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - mockResponse(t, http.StatusAccepted, mockForkedRepo), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedRepo: mockForkedRepo, - }, - { - name: "repository fork fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to fork repository", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ForkRepository(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - assert.Contains(t, textContent.Text, "Fork is in progress") - }) - } -} - -func Test_CreateBranch(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := CreateBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "create_branch", 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, "branch") - assert.Contains(t, tool.InputSchema.Properties, "from_branch") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch"}) - - // Setup mock repository for default branch test - mockRepo := &github.Repository{ - DefaultBranch: github.Ptr("main"), - } - - // Setup mock reference for from_branch tests - mockSourceRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), - }, - } - - // Setup mock created reference - mockCreatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/new-feature"), - Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedRef *github.Reference - expectedErrMsg string - }{ - { - name: "successful branch creation with from_branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatch( - mock.PostReposGitRefsByOwnerByRepo, - mockCreatedRef, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "new-feature", - "from_branch": "main", - }, - expectError: false, - expectedRef: mockCreatedRef, - }, - { - name: "successful branch creation with default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, - mockRepo, - ), - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "ref": "refs/heads/new-feature", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusCreated, mockCreatedRef), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "new-feature", - }, - expectError: false, - expectedRef: mockCreatedRef, - }, - { - name: "fail to get repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - "branch": "new-feature", - }, - expectError: true, - expectedErrMsg: "failed to get repository", - }, - { - name: "fail to get reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "new-feature", - "from_branch": "nonexistent-branch", - }, - expectError: true, - expectedErrMsg: "failed to get reference", - }, - { - name: "fail to create branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "existing-branch", - "from_branch": "main", - }, - expectError: true, - expectedErrMsg: "failed to create branch", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CreateBranch(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedRef github.Reference - err = json.Unmarshal([]byte(textContent.Text), &returnedRef) - require.NoError(t, err) - assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) - assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) - }) - } -} - -func Test_GetCommit(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_commit", 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, "sha") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"}) - - mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), - Commit: &github.Commit{ - Message: github.Ptr("First commit"), - Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, - }, - }, - Author: &github.User{ - Login: github.Ptr("testuser"), - }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), - Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Total: github.Ptr(12), - }, - Files: []*github.CommitFile{ - { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Changes: github.Ptr(12), - Patch: github.Ptr("@@ -1,2 +1,10 @@"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedCommit *github.RepositoryCommit - expectedErrMsg string - }{ - { - name: "successful commit fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - mockResponse(t, http.StatusOK, mockCommit), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "sha": "abc123def456", - }, - expectError: false, - expectedCommit: mockCommit, - }, - { - name: "commit fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "sha": "nonexistent-sha", - }, - expectError: true, - expectedErrMsg: "failed to get commit", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedCommit github.RepositoryCommit - err = json.Unmarshal([]byte(textContent.Text), &returnedCommit) - require.NoError(t, err) - - assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA) - assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message) - assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login) - assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL) - }) - } -} - -func Test_ListCommits(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_commits", 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, "sha") - assert.Contains(t, tool.InputSchema.Properties, "author") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock commits for success case - mockCommits := []*github.RepositoryCommit{ - { - SHA: github.Ptr("abc123def456"), - Commit: &github.Commit{ - Message: github.Ptr("First commit"), - Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, - }, - }, - Author: &github.User{ - Login: github.Ptr("testuser"), - ID: github.Ptr(int64(12345)), - HTMLURL: github.Ptr("https://github.com/testuser"), - AvatarURL: github.Ptr("https://github.com/testuser.png"), - }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), - Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Total: github.Ptr(15), - }, - Files: []*github.CommitFile{ - { - Filename: github.Ptr("src/main.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(8), - Deletions: github.Ptr(3), - Changes: github.Ptr(11), - }, - { - Filename: github.Ptr("README.md"), - Status: github.Ptr("added"), - Additions: github.Ptr(2), - Deletions: github.Ptr(2), - Changes: github.Ptr(4), - }, - }, - }, - { - SHA: github.Ptr("def456abc789"), - Commit: &github.Commit{ - Message: github.Ptr("Second commit"), - Author: &github.CommitAuthor{ - Name: github.Ptr("Another User"), - Email: github.Ptr("another@example.com"), - Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, - }, - }, - Author: &github.User{ - Login: github.Ptr("anotheruser"), - ID: github.Ptr(int64(67890)), - HTMLURL: github.Ptr("https://github.com/anotheruser"), - AvatarURL: github.Ptr("https://github.com/anotheruser.png"), - }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), - Stats: &github.CommitStats{ - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Total: github.Ptr(30), - }, - Files: []*github.CommitFile{ - { - Filename: github.Ptr("src/utils.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Changes: github.Ptr(30), - }, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedCommits []*github.RepositoryCommit - expectedErrMsg string - }{ - { - name: "successful commits fetch with default params", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposCommitsByOwnerByRepo, - mockCommits, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedCommits: mockCommits, - }, - { - name: "successful commits fetch with branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "author": "username", - "sha": "main", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "sha": "main", - "author": "username", - }, - expectError: false, - expectedCommits: mockCommits, - }, - { - name: "successful commits fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedCommits: mockCommits, - }, - { - name: "commits fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - }, - expectError: true, - expectedErrMsg: "failed to list commits", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListCommits(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedCommits []MinimalCommit - err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) - require.NoError(t, err) - assert.Len(t, returnedCommits, len(tc.expectedCommits)) - for i, commit := range returnedCommits { - assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) - assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) - if tc.expectedCommits[i].Commit != nil { - assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) - } - if tc.expectedCommits[i].Author != nil { - assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) - } - - // Files and stats are never included in list_commits - assert.Nil(t, commit.Files) - assert.Nil(t, commit.Stats) - } - }) - } -} - -func Test_CreateOrUpdateFile(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "create_or_update_file", 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, "path") - assert.Contains(t, tool.InputSchema.Properties, "content") - assert.Contains(t, tool.InputSchema.Properties, "message") - assert.Contains(t, tool.InputSchema.Properties, "branch") - assert.Contains(t, tool.InputSchema.Properties, "sha") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) - - // Setup mock file content response - mockFileResponse := &github.RepositoryContentResponse{ - Content: &github.RepositoryContent{ - Name: github.Ptr("example.md"), - Path: github.Ptr("docs/example.md"), - SHA: github.Ptr("abc123def456"), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), - }, - Commit: github.Commit{ - SHA: github.Ptr("def456abc789"), - Message: github.Ptr("Add example file"), - Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Date: &github.Timestamp{Time: time.Now()}, - }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedContent *github.RepositoryContentResponse - expectedErrMsg string - }{ - { - name: "successful file creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Add example file", - "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content - "branch": "main", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "docs/example.md", - "content": "# Example\n\nThis is an example file.", - "message": "Add example file", - "branch": "main", - }, - expectError: false, - expectedContent: mockFileResponse, - }, - { - name: "successful file update with SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "docs/example.md", - "content": "# Updated Example\n\nThis file has been updated.", - "message": "Update example file", - "branch": "main", - "sha": "abc123def456", - }, - expectError: false, - expectedContent: mockFileResponse, - }, - { - name: "file creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "docs/example.md", - "content": "#Invalid Content", - "message": "Invalid request", - "branch": "nonexistent-branch", - }, - expectError: true, - expectedErrMsg: "failed to create/update file", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CreateOrUpdateFile(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedContent github.RepositoryContentResponse - err = json.Unmarshal([]byte(textContent.Text), &returnedContent) - require.NoError(t, err) - - // Verify content - assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) - assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) - assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) - - // Verify commit - assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) - assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) - }) - } -} - -func Test_CreateRepository(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := CreateRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "create_repository", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "name") - assert.Contains(t, tool.InputSchema.Properties, "description") - assert.Contains(t, tool.InputSchema.Properties, "organization") - assert.Contains(t, tool.InputSchema.Properties, "private") - assert.Contains(t, tool.InputSchema.Properties, "autoInit") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) - - // Setup mock repository response - mockRepo := &github.Repository{ - Name: github.Ptr("test-repo"), - Description: github.Ptr("Test repository"), - Private: github.Ptr(true), - HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), - CreatedAt: &github.Timestamp{Time: time.Now()}, - Owner: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedRepo *github.Repository - expectedErrMsg string - }{ - { - name: "successful repository creation with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, - expectRequestBody(t, map[string]interface{}{ - "name": "test-repo", - "description": "Test repository", - "private": true, - "auto_init": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockRepo), - ), - ), - ), - requestArgs: map[string]interface{}{ - "name": "test-repo", - "description": "Test repository", - "private": true, - "autoInit": true, - }, - expectError: false, - expectedRepo: mockRepo, - }, - { - name: "successful repository creation in organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/repos", - Method: "POST", - }, - expectRequestBody(t, map[string]interface{}{ - "name": "test-repo", - "description": "Test repository", - "private": false, - "auto_init": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockRepo), - ), - ), - ), - requestArgs: map[string]interface{}{ - "name": "test-repo", - "description": "Test repository", - "organization": "testorg", - "private": false, - "autoInit": true, - }, - expectError: false, - expectedRepo: mockRepo, - }, - { - name: "successful repository creation with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, - expectRequestBody(t, map[string]interface{}{ - "name": "test-repo", - "auto_init": false, - "description": "", - "private": false, - }).andThen( - mockResponse(t, http.StatusCreated, mockRepo), - ), - ), - ), - requestArgs: map[string]interface{}{ - "name": "test-repo", - }, - expectError: false, - expectedRepo: mockRepo, - }, - { - name: "repository creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "name": "invalid-repo", - }, - expectError: true, - expectedErrMsg: "failed to create repository", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := CreateRepository(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the minimal result - var returnedRepo MinimalResponse - err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) - assert.NoError(t, err) - - // Verify repository details - assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) - }) - } -} - -func Test_PushFiles(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "push_files", 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, "branch") - assert.Contains(t, tool.InputSchema.Properties, "files") - assert.Contains(t, tool.InputSchema.Properties, "message") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch", "files", "message"}) - - // Setup mock objects - mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{ - SHA: github.Ptr("abc123"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), - }, - } - - mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), - Tree: &github.Tree{ - SHA: github.Ptr("def456"), - }, - } - - mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), - } - - mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Update multiple files"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), - } - - mockUpdatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), - }, - } - - // Define test cases - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedRef *github.Reference - expectedErrMsg string - }{ - { - name: "successful push of multiple files", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockCommit, - ), - // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "base_tree": "def456", - "tree": []interface{}{ - map[string]interface{}{ - "path": "README.md", - "mode": "100644", - "type": "blob", - "content": "# Updated README\n\nThis is an updated README file.", - }, - map[string]interface{}{ - "path": "docs/example.md", - "mode": "100644", - "type": "blob", - "content": "# Example\n\nThis is an example file.", - }, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockTree), - ), - ), - // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "message": "Update multiple files", - "tree": "ghi789", - "parents": []interface{}{"abc123"}, - }).andThen( - mockResponse(t, http.StatusCreated, mockNewCommit), - ), - ), - // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, - expectRequestBody(t, map[string]interface{}{ - "sha": "jkl012", - "force": false, - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedRef), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": []interface{}{ - map[string]interface{}{ - "path": "README.md", - "content": "# Updated README\n\nThis is an updated README file.", - }, - map[string]interface{}{ - "path": "docs/example.md", - "content": "# Example\n\nThis is an example file.", - }, - }, - "message": "Update multiple files", - }, - expectError: false, - expectedRef: mockUpdatedRef, - }, - { - name: "fails when files parameter is invalid", - mockedClient: mock.NewMockedHTTPClient( - // No requests expected - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": "invalid-files-parameter", // Not an array - "message": "Update multiple files", - }, - expectError: false, // This returns a tool error, not a Go error - expectedErrMsg: "files parameter must be an array", - }, - { - name: "fails when files contains object without path", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockCommit, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": []interface{}{ - map[string]interface{}{ - "content": "# Missing path", - }, - }, - "message": "Update file", - }, - expectError: false, // This returns a tool error, not a Go error - expectedErrMsg: "each file must have a path", - }, - { - name: "fails when files contains object without content", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockCommit, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": []interface{}{ - map[string]interface{}{ - "path": "README.md", - // Missing content - }, - }, - "message": "Update file", - }, - expectError: false, // This returns a tool error, not a Go error - expectedErrMsg: "each file must have content", - }, - { - name: "fails to get branch reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - mockResponse(t, http.StatusNotFound, nil), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "non-existent-branch", - "files": []interface{}{ - map[string]interface{}{ - "path": "README.md", - "content": "# README", - }, - }, - "message": "Update file", - }, - expectError: true, - expectedErrMsg: "failed to get branch reference", - }, - { - name: "fails to get base commit", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Fail to get commit - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockResponse(t, http.StatusNotFound, nil), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": []interface{}{ - map[string]interface{}{ - "path": "README.md", - "content": "# README", - }, - }, - "message": "Update file", - }, - expectError: true, - expectedErrMsg: "failed to get base commit", - }, - { - name: "fails to create tree", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockCommit, - ), - // Fail to create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, - mockResponse(t, http.StatusInternalServerError, nil), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "branch": "main", - "files": []interface{}{ - map[string]interface{}{ - "path": "README.md", - "content": "# README", - }, - }, - "message": "Update file", - }, - expectError: true, - expectedErrMsg: "failed to create tree", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := PushFiles(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - if tc.expectedErrMsg != "" { - require.NotNil(t, result) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedRef github.Reference - err = json.Unmarshal([]byte(textContent.Text), &returnedRef) - require.NoError(t, err) - - assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) - assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) - }) - } -} - -func Test_ListBranches(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_branches", 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, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock branches for success case - mockBranches := []*github.Branch{ - { - Name: github.Ptr("main"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, - }, - { - Name: github.Ptr("develop"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, - }, - } - - // Test cases - tests := []struct { - name string - args map[string]interface{} - mockResponses []mock.MockBackendOption - wantErr bool - errContains string - }{ - { - name: "success", - args: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "page": float64(2), - }, - mockResponses: []mock.MockBackendOption{ - mock.WithRequestMatch( - mock.GetReposBranchesByOwnerByRepo, - mockBranches, - ), - }, - wantErr: false, - }, - { - name: "missing owner", - args: map[string]interface{}{ - "repo": "repo", - }, - mockResponses: []mock.MockBackendOption{}, - wantErr: false, - errContains: "missing required parameter: owner", - }, - { - name: "missing repo", - args: map[string]interface{}{ - "owner": "owner", - }, - mockResponses: []mock.MockBackendOption{}, - wantErr: false, - errContains: "missing required parameter: repo", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock client - mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) - _, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - // Create request - request := createMCPRequest(tt.args) - - // Call handler - result, err := handler(context.Background(), request) - if tt.wantErr { - require.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - if tt.errContains != "" { - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, tt.errContains) - return - } - - textContent := getTextResult(t, result) - require.NotEmpty(t, textContent.Text) - - // Verify response - var branches []*github.Branch - err = json.Unmarshal([]byte(textContent.Text), &branches) - require.NoError(t, err) - assert.Len(t, branches, 2) - assert.Equal(t, "main", *branches[0].Name) - assert.Equal(t, "develop", *branches[1].Name) - }) - } -} - -func Test_DeleteFile(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_file", 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, "path") - assert.Contains(t, tool.InputSchema.Properties, "message") - assert.Contains(t, tool.InputSchema.Properties, "branch") - // SHA is no longer required since we're using Git Data API - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"}) - - // Setup mock objects for Git Data API - mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{ - SHA: github.Ptr("abc123"), - }, - } - - mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), - Tree: &github.Tree{ - SHA: github.Ptr("def456"), - }, - } - - mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), - } - - mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Delete example file"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedCommitSHA string - expectedErrMsg string - }{ - { - name: "successful file deletion using Git Data API", - mockedClient: mock.NewMockedHTTPClient( - // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockRef, - ), - // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, - mockCommit, - ), - // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "base_tree": "def456", - "tree": []interface{}{ - map[string]interface{}{ - "path": "docs/example.md", - "mode": "100644", - "type": "blob", - "sha": nil, - }, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockTree), - ), - ), - // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "message": "Delete example file", - "tree": "ghi789", - "parents": []interface{}{"abc123"}, - }).andThen( - mockResponse(t, http.StatusCreated, mockNewCommit), - ), - ), - // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, - expectRequestBody(t, map[string]interface{}{ - "sha": "jkl012", - "force": false, - }).andThen( - mockResponse(t, http.StatusOK, &github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), - }, - }), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "docs/example.md", - "message": "Delete example file", - "branch": "main", - }, - expectError: false, - expectedCommitSHA: "jkl012", - }, - { - name: "file deletion fails - branch not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "docs/nonexistent.md", - "message": "Delete nonexistent file", - "branch": "nonexistent-branch", - }, - expectError: true, - expectedErrMsg: "failed to get branch reference", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), 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 response map[string]interface{} - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - // Verify the response contains the expected commit - commit, ok := response["commit"].(map[string]interface{}) - require.True(t, ok) - commitSHA, ok := commit["sha"].(string) - require.True(t, ok) - assert.Equal(t, tc.expectedCommitSHA, commitSHA) - }) - } -} - -func Test_ListTags(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_tags", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock tags for success case - mockTags := []*github.RepositoryTag{ - { - Name: github.Ptr("v1.0.0"), - Commit: &github.Commit{ - SHA: github.Ptr("v1.0.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), - }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), - }, - { - Name: github.Ptr("v0.9.0"), - Commit: &github.Commit{ - SHA: github.Ptr("v0.9.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), - }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedTags []*github.RepositoryTag - expectedErrMsg string - }{ - { - name: "successful tags list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, - expectPath( - t, - "/repos/owner/repo/tags", - ).andThen( - mockResponse(t, http.StatusOK, mockTags), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedTags: mockTags, - }, - { - name: "list tags fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list tags", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Parse and verify the result - var returnedTags []*github.RepositoryTag - err = json.Unmarshal([]byte(textContent.Text), &returnedTags) - require.NoError(t, err) - - // Verify each tag - require.Equal(t, len(tc.expectedTags), len(returnedTags)) - for i, expectedTag := range tc.expectedTags { - assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) - assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) - } - }) - } -} - -func Test_GetTag(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_tag", 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, "tag") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) - - mockTagRef := &github.Reference{ - Ref: github.Ptr("refs/tags/v1.0.0"), - Object: &github.GitObject{ - SHA: github.Ptr("v1.0.0-tag-sha"), - }, - } - - mockTagObj := &github.Tag{ - SHA: github.Ptr("v1.0.0-tag-sha"), - Tag: github.Ptr("v1.0.0"), - Message: github.Ptr("Release v1.0.0"), - Object: &github.GitObject{ - Type: github.Ptr("commit"), - SHA: github.Ptr("abc123"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedTag *github.Tag - expectedErrMsg string - }{ - { - name: "successful tag retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - expectPath( - t, - "/repos/owner/repo/git/ref/tags/v1.0.0", - ).andThen( - mockResponse(t, http.StatusOK, mockTagRef), - ), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, - expectPath( - t, - "/repos/owner/repo/git/tags/v1.0.0-tag-sha", - ).andThen( - mockResponse(t, http.StatusOK, mockTagObj), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: false, - expectedTag: mockTagObj, - }, - { - name: "tag reference not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: true, - expectedErrMsg: "failed to get tag reference", - }, - { - name: "tag object not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockTagRef, - ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: true, - expectedErrMsg: "failed to get tag object", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Parse and verify the result - var returnedTag github.Tag - err = json.Unmarshal([]byte(textContent.Text), &returnedTag) - require.NoError(t, err) - - assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) - assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) - assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) - assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) - assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) - }) - } -} - -func Test_ListReleases(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_releases", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - mockReleases := []*github.RepositoryRelease{ - { - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), - Name: github.Ptr("First Release"), - }, - { - ID: github.Ptr(int64(2)), - TagName: github.Ptr("v0.9.0"), - Name: github.Ptr("Beta Release"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult []*github.RepositoryRelease - expectedErrMsg string - }{ - { - name: "successful releases list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesByOwnerByRepo, - mockReleases, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedResult: mockReleases, - }, - { - name: "releases list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list releases", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - var returnedReleases []*github.RepositoryRelease - err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) - require.NoError(t, err) - assert.Len(t, returnedReleases, len(tc.expectedResult)) - for i, rel := range returnedReleases { - assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) - } - }) - } -} -func Test_GetLatestRelease(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_latest_release", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - mockRelease := &github.RepositoryRelease{ - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), - Name: github.Ptr("First Release"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.RepositoryRelease - expectedErrMsg string - }{ - { - name: "successful latest release fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesLatestByOwnerByRepo, - mockRelease, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedResult: mockRelease, - }, - { - name: "latest release fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesLatestByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to get latest release", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) - request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) - - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - var returnedRelease github.RepositoryRelease - err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) - }) - } -} - -func Test_GetReleaseByTag(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_release_by_tag", 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, "tag") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) - - mockRelease := &github.RepositoryRelease{ - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), - Name: github.Ptr("Release v1.0.0"), - Body: github.Ptr("This is the first stable release."), - Assets: []*github.ReleaseAsset{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("release-v1.0.0.tar.gz"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.RepositoryRelease - expectedErrMsg string - }{ - { - name: "successful release by tag fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesTagsByOwnerByRepoByTag, - mockRelease, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: false, - expectedResult: mockRelease, - }, - { - name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: false, // Returns tool error, not Go error - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing repo parameter", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "tag": "v1.0.0", - }, - expectError: false, // Returns tool error, not Go error - expectedErrMsg: "missing required parameter: repo", - }, - { - name: "missing tag parameter", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, // Returns tool error, not Go error - expectedErrMsg: "missing required parameter: tag", - }, - { - name: "release by tag not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v999.0.0", - }, - expectError: false, // API errors return tool errors, not Go errors - expectedErrMsg: "failed to get release by tag: v999.0.0", - }, - { - name: "server error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "tag": "v1.0.0", - }, - expectError: false, // API errors return tool errors, not Go errors - expectedErrMsg: "failed to get release by tag: v1.0.0", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - - if tc.expectedErrMsg != "" { - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - - var returnedRelease github.RepositoryRelease - err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) - require.NoError(t, err) - - assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) - assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) - assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) - if tc.expectedResult.Body != nil { - assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) - } - if len(tc.expectedResult.Assets) > 0 { - require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) - assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) - } - }) - } -} - -func Test_filterPaths(t *testing.T) { - tests := []struct { - name string - tree []*github.TreeEntry - path string - maxResults int - expected []string - }{ - { - name: "file name", - tree: []*github.TreeEntry{ - {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, - }, - path: "foo.txt", - maxResults: -1, - expected: []string{"folder/foo.txt", "nested/folder/foo.txt"}, - }, - { - name: "dir name", - tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, - }, - path: "folder/", - maxResults: -1, - expected: []string{"folder/", "nested/folder/"}, - }, - { - name: "dir and file match", - tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, - }, - path: "name", // No trailing slash can match both files and directories - maxResults: -1, - expected: []string{"name/", "name"}, - }, - { - name: "dir only match", - tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, - }, - path: "name/", // Trialing slash ensures only directories are matched - maxResults: -1, - expected: []string{"name/"}, - }, - { - name: "max results limit 2", - tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, - }, - path: "folder/", - maxResults: 2, - expected: []string{"folder/", "nested/folder/"}, - }, - { - name: "max results limit 1", - tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, - }, - path: "folder/", - maxResults: 1, - expected: []string{"folder/"}, - }, - { - name: "max results limit 0", - tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, - }, - path: "folder/", - maxResults: 0, - expected: []string{}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result := filterPaths(tc.tree, tc.path, tc.maxResults) - assert.Equal(t, tc.expected, result) - }) - } -} - -func Test_resolveGitReference(t *testing.T) { - ctx := context.Background() - owner := "owner" - repo := "repo" - - tests := []struct { - name string - ref string - sha string - mockSetup func() *http.Client - expectedOutput *raw.ContentOpts - expectError bool - errorContains string - }{ - { - name: "sha takes precedence over ref", - ref: "refs/heads/main", - sha: "123sha456", - mockSetup: func() *http.Client { - // No API calls should be made when SHA is provided - return mock.NewMockedHTTPClient() - }, - expectedOutput: &raw.ContentOpts{ - SHA: "123sha456", - }, - expectError: false, - }, - { - name: "use default branch if ref and sha both empty", - ref: "", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/git/ref/heads/main") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/heads/main", - SHA: "main-sha", - }, - expectError: false, - }, - { - name: "fully qualified ref passed through unchanged", - ref: "refs/heads/feature-branch", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/heads/feature-branch", - SHA: "feature-sha", - }, - expectError: false, - }, - { - name: "short branch name resolves to refs/heads/", - ref: "main", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/git/ref/heads/main") { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) - } else { - t.Errorf("Unexpected path: %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/heads/main", - SHA: "main-sha", - }, - expectError: false, - }, - { - name: "short tag name falls back to refs/tags/ when branch not found", - ref: "v1.0.0", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"): - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) - default: - t.Errorf("Unexpected path: %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/tags/v1.0.0", - SHA: "tag-sha", - }, - expectError: false, - }, - { - name: "heads/ prefix gets refs/ prepended", - ref: "heads/feature-branch", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/heads/feature-branch", - SHA: "feature-sha", - }, - expectError: false, - }, - { - name: "tags/ prefix gets refs/ prepended", - ref: "tags/v1.0.0", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/tags/v1.0.0", - SHA: "tag-sha", - }, - expectError: false, - }, - { - name: "invalid short name that doesn't exist as branch or tag", - ref: "nonexistent", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // Both branch and tag attempts should return 404 - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ) - }, - expectError: true, - errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag", - }, - { - name: "fully qualified pull request ref", - ref: "refs/pull/123/head", - sha: "", - mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`)) - }), - ), - ) - }, - expectedOutput: &raw.ContentOpts{ - Ref: "refs/pull/123/head", - SHA: "pr-sha", - }, - expectError: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockSetup()) - opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) - - if tc.expectError { - require.Error(t, err) - if tc.errorContains != "" { - assert.Contains(t, err.Error(), tc.errorContains) - } - return - } - - require.NoError(t, err) - require.NotNil(t, opts) - - if tc.expectedOutput.SHA != "" { - assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) - } - if tc.expectedOutput.Ref != "" { - assert.Equal(t, tc.expectedOutput.Ref, opts.Ref) - } - }) - } -} - -func Test_ListStarredRepositories(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_starred_repositories", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "username") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Empty(t, tool.InputSchema.Required) // All parameters are optional - - // Setup mock starred repositories - starredAt := time.Now().Add(-24 * time.Hour) - updatedAt := time.Now().Add(-2 * time.Hour) - mockStarredRepos := []*github.StarredRepository{ - { - StarredAt: &github.Timestamp{Time: starredAt}, - Repository: &github.Repository{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("awesome-repo"), - FullName: github.Ptr("owner/awesome-repo"), - Description: github.Ptr("An awesome repository"), - HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), - Language: github.Ptr("Go"), - StargazersCount: github.Ptr(100), - ForksCount: github.Ptr(25), - OpenIssuesCount: github.Ptr(5), - UpdatedAt: &github.Timestamp{Time: updatedAt}, - Private: github.Ptr(false), - Fork: github.Ptr(false), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("main"), - }, - }, - { - StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, - Repository: &github.Repository{ - ID: github.Ptr(int64(67890)), - Name: github.Ptr("cool-project"), - FullName: github.Ptr("user/cool-project"), - Description: github.Ptr("A very cool project"), - HTMLURL: github.Ptr("https://github.com/user/cool-project"), - Language: github.Ptr("Python"), - StargazersCount: github.Ptr(500), - ForksCount: github.Ptr(75), - OpenIssuesCount: github.Ptr(10), - UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, - Private: github.Ptr(false), - Fork: github.Ptr(true), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("master"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - expectedCount int - }{ - { - name: "successful list for authenticated user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) - }), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: false, - expectedCount: 2, - }, - { - name: "successful list for specific user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUsersStarredByUsername, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "username": "testuser", - }, - expectError: false, - expectedCount: 2, - }, - { - name: "list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "failed to list starred repositories", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListStarredRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NotNil(t, result) - textResult, ok := result.Content[0].(mcp.TextContent) - require.True(t, ok, "Expected text content") - assert.Contains(t, textResult.Text, tc.expectedErrMsg) - } else { - require.NoError(t, err) - require.NotNil(t, result) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedRepos []MinimalRepository - err = json.Unmarshal([]byte(textContent.Text), &returnedRepos) - require.NoError(t, err) - - assert.Len(t, returnedRepos, tc.expectedCount) - if tc.expectedCount > 0 { - assert.Equal(t, "awesome-repo", returnedRepos[0].Name) - assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName) - } - } - }) - } -} - -func Test_StarRepository(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "star_repository", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful star", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "testowner", - "repo": "testrepo", - }, - expectError: false, - }, - { - name: "star fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "testowner", - "repo": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to star repository", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := StarRepository(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NotNil(t, result) - textResult, ok := result.Content[0].(mcp.TextContent) - require.True(t, ok, "Expected text content") - assert.Contains(t, textResult.Text, tc.expectedErrMsg) - } else { - require.NoError(t, err) - require.NotNil(t, result) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Successfully starred repository") - } - }) - } -} - -func Test_UnstarRepository(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "unstar_repository", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful unstar", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "testowner", - "repo": "testrepo", - }, - expectError: false, - }, - { - name: "unstar fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "testowner", - "repo": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to unstar repository", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := UnstarRepository(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NotNil(t, result) - textResult, ok := result.Content[0].(mcp.TextContent) - require.True(t, ok, "Expected text content") - assert.Contains(t, textResult.Text, tc.expectedErrMsg) - } else { - require.NoError(t, err) - require.NotNil(t, result) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Successfully unstarred repository") - } - }) - } -} - -func Test_GetRepositoryTree(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_repository_tree", 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, "tree_sha") - assert.Contains(t, tool.InputSchema.Properties, "recursive") - assert.Contains(t, tool.InputSchema.Properties, "path_filter") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock data - mockRepo := &github.Repository{ - DefaultBranch: github.Ptr("main"), - } - mockTree := &github.Tree{ - SHA: github.Ptr("abc123"), - Truncated: github.Ptr(false), - Entries: []*github.TreeEntry{ - { - Path: github.Ptr("README.md"), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - SHA: github.Ptr("file1sha"), - Size: github.Ptr(123), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"), - }, - { - Path: github.Ptr("src/main.go"), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - SHA: github.Ptr("file2sha"), - Size: github.Ptr(456), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successfully get repository tree", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - mockResponse(t, http.StatusOK, mockRepo), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitTreesByOwnerByRepoByTreeSha, - mockResponse(t, http.StatusOK, mockTree), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - }, - { - name: "successfully get repository tree with path filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - mockResponse(t, http.StatusOK, mockRepo), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitTreesByOwnerByRepoByTreeSha, - mockResponse(t, http.StatusOK, mockTree), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path_filter": "src/", - }, - }, - { - name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to get repository info", - }, - { - name: "tree not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - mockResponse(t, http.StatusOK, mockRepo), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitTreesByOwnerByRepoByTreeSha, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to get repository tree", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper) - - // Create the tool request - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } else { - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - // Parse the JSON response - var treeResponse map[string]interface{} - err := json.Unmarshal([]byte(textContent.Text), &treeResponse) - require.NoError(t, err) - - // Verify response structure - assert.Equal(t, "owner", treeResponse["owner"]) - assert.Equal(t, "repo", treeResponse["repo"]) - assert.Contains(t, treeResponse, "tree") - assert.Contains(t, treeResponse, "count") - assert.Contains(t, treeResponse, "sha") - assert.Contains(t, treeResponse, "truncated") - - // Check filtering if path_filter was provided - if pathFilter, exists := tc.requestArgs["path_filter"]; exists { - tree := treeResponse["tree"].([]interface{}) - for _, entry := range tree { - entryMap := entry.(map[string]interface{}) - path := entryMap["path"].(string) - assert.True(t, strings.HasPrefix(path, pathFilter.(string)), - "Path %s should start with filter %s", path, pathFilter) - } - } - } - }) - } -} +// import ( +// "context" +// "encoding/base64" +// "encoding/json" +// "net/http" +// "net/url" +// "strings" +// "testing" +// "time" + +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/raw" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_GetFileContents(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// mockRawClient := raw.NewClient(mockClient, &url.URL{Scheme: "https", Host: "raw.githubusercontent.com", Path: "/"}) +// tool, _ := GetFileContents(stubGetClientFn(mockClient), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_file_contents", 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, "path") +// assert.Contains(t, tool.InputSchema.Properties, "ref") +// assert.Contains(t, tool.InputSchema.Properties, "sha") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Mock response for raw content +// mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") + +// // Setup mock directory content for success case +// mockDirContent := []*github.RepositoryContent{ +// { +// Type: github.Ptr("file"), +// Name: github.Ptr("README.md"), +// Path: github.Ptr("README.md"), +// SHA: github.Ptr("abc123"), +// Size: github.Ptr(42), +// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), +// }, +// { +// Type: github.Ptr("dir"), +// Name: github.Ptr("src"), +// Path: github.Ptr("src"), +// SHA: github.Ptr("def456"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult interface{} +// expectedErrMsg string +// expectStatus int +// }{ +// { +// name: "successful text content fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// fileContent := &github.RepositoryContent{ +// Name: github.Ptr("README.md"), +// Path: github.Ptr("README.md"), +// SHA: github.Ptr("abc123"), +// Type: github.Ptr("file"), +// } +// contentBytes, _ := json.Marshal(fileContent) +// _, _ = w.Write(contentBytes) +// }), +// ), +// mock.WithRequestMatchHandler( +// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Content-Type", "text/markdown") +// _, _ = w.Write(mockRawContent) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "README.md", +// "ref": "refs/heads/main", +// }, +// expectError: false, +// expectedResult: mcp.TextResourceContents{ +// URI: "repo://owner/repo/refs/heads/main/contents/README.md", +// Text: "# Test Repository\n\nThis is a test repository.", +// MIMEType: "text/markdown", +// }, +// }, +// { +// name: "successful file blob content fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// fileContent := &github.RepositoryContent{ +// Name: github.Ptr("test.png"), +// Path: github.Ptr("test.png"), +// SHA: github.Ptr("def456"), +// Type: github.Ptr("file"), +// } +// contentBytes, _ := json.Marshal(fileContent) +// _, _ = w.Write(contentBytes) +// }), +// ), +// mock.WithRequestMatchHandler( +// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Content-Type", "image/png") +// _, _ = w.Write(mockRawContent) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "test.png", +// "ref": "refs/heads/main", +// }, +// expectError: false, +// expectedResult: mcp.BlobResourceContents{ +// URI: "repo://owner/repo/refs/heads/main/contents/test.png", +// Blob: base64.StdEncoding.EncodeToString(mockRawContent), +// MIMEType: "image/png", +// }, +// }, +// { +// name: "successful PDF file content fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// fileContent := &github.RepositoryContent{ +// Name: github.Ptr("document.pdf"), +// Path: github.Ptr("document.pdf"), +// SHA: github.Ptr("pdf123"), +// Type: github.Ptr("file"), +// } +// contentBytes, _ := json.Marshal(fileContent) +// _, _ = w.Write(contentBytes) +// }), +// ), +// mock.WithRequestMatchHandler( +// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.Header().Set("Content-Type", "application/pdf") +// _, _ = w.Write(mockRawContent) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "document.pdf", +// "ref": "refs/heads/main", +// }, +// expectError: false, +// expectedResult: mcp.BlobResourceContents{ +// URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", +// Blob: base64.StdEncoding.EncodeToString(mockRawContent), +// MIMEType: "application/pdf", +// }, +// }, +// { +// name: "successful directory content fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposContentsByOwnerByRepoByPath, +// expectQueryParams(t, map[string]string{}).andThen( +// mockResponse(t, http.StatusOK, mockDirContent), +// ), +// ), +// mock.WithRequestMatchHandler( +// raw.GetRawReposContentsByOwnerByRepoByPath, +// expectQueryParams(t, map[string]string{ +// "branch": "main", +// }).andThen( +// mockResponse(t, http.StatusNotFound, nil), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "src/", +// }, +// expectError: false, +// expectedResult: mockDirContent, +// }, +// { +// name: "content fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// raw.GetRawReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "nonexistent.md", +// "ref": "refs/heads/main", +// }, +// expectError: false, +// expectedResult: mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) +// _, handler := GetFileContents(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// // Use the correct result helper based on the expected type +// switch expected := tc.expectedResult.(type) { +// case mcp.TextResourceContents: +// textResource := getTextResourceResult(t, result) +// assert.Equal(t, expected, textResource) +// case mcp.BlobResourceContents: +// blobResource := getBlobResourceResult(t, result) +// assert.Equal(t, expected, blobResource) +// case []*github.RepositoryContent: +// // Directory content fetch returns a text result (JSON array) +// textContent := getTextResult(t, result) +// var returnedContents []*github.RepositoryContent +// err = json.Unmarshal([]byte(textContent.Text), &returnedContents) +// require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text) +// assert.Len(t, returnedContents, len(expected)) +// for i, content := range returnedContents { +// assert.Equal(t, *expected[i].Name, *content.Name) +// assert.Equal(t, *expected[i].Path, *content.Path) +// assert.Equal(t, *expected[i].Type, *content.Type) +// } +// case mcp.TextContent: +// textContent := getErrorResult(t, result) +// require.Equal(t, textContent, expected) +// } +// }) +// } +// } + +// func Test_ForkRepository(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ForkRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "fork_repository", 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, "organization") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock forked repo for success case +// mockForkedRepo := &github.Repository{ +// ID: github.Ptr(int64(123456)), +// Name: github.Ptr("repo"), +// FullName: github.Ptr("new-owner/repo"), +// Owner: &github.User{ +// Login: github.Ptr("new-owner"), +// }, +// HTMLURL: github.Ptr("https://github.com/new-owner/repo"), +// DefaultBranch: github.Ptr("main"), +// Fork: github.Ptr(true), +// ForksCount: github.Ptr(0), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedRepo *github.Repository +// expectedErrMsg string +// }{ +// { +// name: "successful repository fork", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposForksByOwnerByRepo, +// mockResponse(t, http.StatusAccepted, mockForkedRepo), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedRepo: mockForkedRepo, +// }, +// { +// name: "repository fork fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PostReposForksByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusForbidden) +// _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to fork repository", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ForkRepository(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// assert.Contains(t, textContent.Text, "Fork is in progress") +// }) +// } +// } + +// func Test_CreateBranch(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := CreateBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "create_branch", 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, "branch") +// assert.Contains(t, tool.InputSchema.Properties, "from_branch") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch"}) + +// // Setup mock repository for default branch test +// mockRepo := &github.Repository{ +// DefaultBranch: github.Ptr("main"), +// } + +// // Setup mock reference for from_branch tests +// mockSourceRef := &github.Reference{ +// Ref: github.Ptr("refs/heads/main"), +// Object: &github.GitObject{ +// SHA: github.Ptr("abc123def456"), +// }, +// } + +// // Setup mock created reference +// mockCreatedRef := &github.Reference{ +// Ref: github.Ptr("refs/heads/new-feature"), +// Object: &github.GitObject{ +// SHA: github.Ptr("abc123def456"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedRef *github.Reference +// expectedErrMsg string +// }{ +// { +// name: "successful branch creation with from_branch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockSourceRef, +// ), +// mock.WithRequestMatch( +// mock.PostReposGitRefsByOwnerByRepo, +// mockCreatedRef, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "new-feature", +// "from_branch": "main", +// }, +// expectError: false, +// expectedRef: mockCreatedRef, +// }, +// { +// name: "successful branch creation with default branch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposByOwnerByRepo, +// mockRepo, +// ), +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockSourceRef, +// ), +// mock.WithRequestMatchHandler( +// mock.PostReposGitRefsByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "ref": "refs/heads/new-feature", +// "sha": "abc123def456", +// }).andThen( +// mockResponse(t, http.StatusCreated, mockCreatedRef), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "new-feature", +// }, +// expectError: false, +// expectedRef: mockCreatedRef, +// }, +// { +// name: "fail to get repository", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// "branch": "new-feature", +// }, +// expectError: true, +// expectedErrMsg: "failed to get repository", +// }, +// { +// name: "fail to get reference", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "new-feature", +// "from_branch": "nonexistent-branch", +// }, +// expectError: true, +// expectedErrMsg: "failed to get reference", +// }, +// { +// name: "fail to create branch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockSourceRef, +// ), +// mock.WithRequestMatchHandler( +// mock.PostReposGitRefsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "existing-branch", +// "from_branch": "main", +// }, +// expectError: true, +// expectedErrMsg: "failed to create branch", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CreateBranch(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedRef github.Reference +// err = json.Unmarshal([]byte(textContent.Text), &returnedRef) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) +// assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) +// }) +// } +// } + +// func Test_GetCommit(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_commit", 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, "sha") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"}) + +// mockCommit := &github.RepositoryCommit{ +// SHA: github.Ptr("abc123def456"), +// Commit: &github.Commit{ +// Message: github.Ptr("First commit"), +// Author: &github.CommitAuthor{ +// Name: github.Ptr("Test User"), +// Email: github.Ptr("test@example.com"), +// Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, +// }, +// }, +// Author: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), +// Stats: &github.CommitStats{ +// Additions: github.Ptr(10), +// Deletions: github.Ptr(2), +// Total: github.Ptr(12), +// }, +// Files: []*github.CommitFile{ +// { +// Filename: github.Ptr("file1.go"), +// Status: github.Ptr("modified"), +// Additions: github.Ptr(10), +// Deletions: github.Ptr(2), +// Changes: github.Ptr(12), +// Patch: github.Ptr("@@ -1,2 +1,10 @@"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedCommit *github.RepositoryCommit +// expectedErrMsg string +// }{ +// { +// name: "successful commit fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsByOwnerByRepoByRef, +// mockResponse(t, http.StatusOK, mockCommit), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "sha": "abc123def456", +// }, +// expectError: false, +// expectedCommit: mockCommit, +// }, +// { +// name: "commit fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "sha": "nonexistent-sha", +// }, +// expectError: true, +// expectedErrMsg: "failed to get commit", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedCommit github.RepositoryCommit +// err = json.Unmarshal([]byte(textContent.Text), &returnedCommit) +// require.NoError(t, err) + +// assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA) +// assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message) +// assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login) +// assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL) +// }) +// } +// } + +// func Test_ListCommits(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_commits", 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, "sha") +// assert.Contains(t, tool.InputSchema.Properties, "author") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock commits for success case +// mockCommits := []*github.RepositoryCommit{ +// { +// SHA: github.Ptr("abc123def456"), +// Commit: &github.Commit{ +// Message: github.Ptr("First commit"), +// Author: &github.CommitAuthor{ +// Name: github.Ptr("Test User"), +// Email: github.Ptr("test@example.com"), +// Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, +// }, +// }, +// Author: &github.User{ +// Login: github.Ptr("testuser"), +// ID: github.Ptr(int64(12345)), +// HTMLURL: github.Ptr("https://github.com/testuser"), +// AvatarURL: github.Ptr("https://github.com/testuser.png"), +// }, +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), +// Stats: &github.CommitStats{ +// Additions: github.Ptr(10), +// Deletions: github.Ptr(5), +// Total: github.Ptr(15), +// }, +// Files: []*github.CommitFile{ +// { +// Filename: github.Ptr("src/main.go"), +// Status: github.Ptr("modified"), +// Additions: github.Ptr(8), +// Deletions: github.Ptr(3), +// Changes: github.Ptr(11), +// }, +// { +// Filename: github.Ptr("README.md"), +// Status: github.Ptr("added"), +// Additions: github.Ptr(2), +// Deletions: github.Ptr(2), +// Changes: github.Ptr(4), +// }, +// }, +// }, +// { +// SHA: github.Ptr("def456abc789"), +// Commit: &github.Commit{ +// Message: github.Ptr("Second commit"), +// Author: &github.CommitAuthor{ +// Name: github.Ptr("Another User"), +// Email: github.Ptr("another@example.com"), +// Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, +// }, +// }, +// Author: &github.User{ +// Login: github.Ptr("anotheruser"), +// ID: github.Ptr(int64(67890)), +// HTMLURL: github.Ptr("https://github.com/anotheruser"), +// AvatarURL: github.Ptr("https://github.com/anotheruser.png"), +// }, +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), +// Stats: &github.CommitStats{ +// Additions: github.Ptr(20), +// Deletions: github.Ptr(10), +// Total: github.Ptr(30), +// }, +// Files: []*github.CommitFile{ +// { +// Filename: github.Ptr("src/utils.go"), +// Status: github.Ptr("added"), +// Additions: github.Ptr(20), +// Deletions: github.Ptr(10), +// Changes: github.Ptr(30), +// }, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedCommits []*github.RepositoryCommit +// expectedErrMsg string +// }{ +// { +// name: "successful commits fetch with default params", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposCommitsByOwnerByRepo, +// mockCommits, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedCommits: mockCommits, +// }, +// { +// name: "successful commits fetch with branch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "author": "username", +// "sha": "main", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockCommits), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "sha": "main", +// "author": "username", +// }, +// expectError: false, +// expectedCommits: mockCommits, +// }, +// { +// name: "successful commits fetch with pagination", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "page": "2", +// "per_page": "10", +// }).andThen( +// mockResponse(t, http.StatusOK, mockCommits), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedCommits: mockCommits, +// }, +// { +// name: "commits fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposCommitsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent-repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list commits", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListCommits(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedCommits []MinimalCommit +// err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) +// require.NoError(t, err) +// assert.Len(t, returnedCommits, len(tc.expectedCommits)) +// for i, commit := range returnedCommits { +// assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) +// assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) +// if tc.expectedCommits[i].Commit != nil { +// assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) +// } +// if tc.expectedCommits[i].Author != nil { +// assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) +// } + +// // Files and stats are never included in list_commits +// assert.Nil(t, commit.Files) +// assert.Nil(t, commit.Stats) +// } +// }) +// } +// } + +// func Test_CreateOrUpdateFile(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "create_or_update_file", 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, "path") +// assert.Contains(t, tool.InputSchema.Properties, "content") +// assert.Contains(t, tool.InputSchema.Properties, "message") +// assert.Contains(t, tool.InputSchema.Properties, "branch") +// assert.Contains(t, tool.InputSchema.Properties, "sha") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) + +// // Setup mock file content response +// mockFileResponse := &github.RepositoryContentResponse{ +// Content: &github.RepositoryContent{ +// Name: github.Ptr("example.md"), +// Path: github.Ptr("docs/example.md"), +// SHA: github.Ptr("abc123def456"), +// Size: github.Ptr(42), +// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), +// DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), +// }, +// Commit: github.Commit{ +// SHA: github.Ptr("def456abc789"), +// Message: github.Ptr("Add example file"), +// Author: &github.CommitAuthor{ +// Name: github.Ptr("Test User"), +// Email: github.Ptr("test@example.com"), +// Date: &github.Timestamp{Time: time.Now()}, +// }, +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedContent *github.RepositoryContentResponse +// expectedErrMsg string +// }{ +// { +// name: "successful file creation", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposContentsByOwnerByRepoByPath, +// expectRequestBody(t, map[string]interface{}{ +// "message": "Add example file", +// "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content +// "branch": "main", +// }).andThen( +// mockResponse(t, http.StatusOK, mockFileResponse), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "docs/example.md", +// "content": "# Example\n\nThis is an example file.", +// "message": "Add example file", +// "branch": "main", +// }, +// expectError: false, +// expectedContent: mockFileResponse, +// }, +// { +// name: "successful file update with SHA", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposContentsByOwnerByRepoByPath, +// expectRequestBody(t, map[string]interface{}{ +// "message": "Update example file", +// "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content +// "branch": "main", +// "sha": "abc123def456", +// }).andThen( +// mockResponse(t, http.StatusOK, mockFileResponse), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "docs/example.md", +// "content": "# Updated Example\n\nThis file has been updated.", +// "message": "Update example file", +// "branch": "main", +// "sha": "abc123def456", +// }, +// expectError: false, +// expectedContent: mockFileResponse, +// }, +// { +// name: "file creation fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutReposContentsByOwnerByRepoByPath, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "docs/example.md", +// "content": "#Invalid Content", +// "message": "Invalid request", +// "branch": "nonexistent-branch", +// }, +// expectError: true, +// expectedErrMsg: "failed to create/update file", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CreateOrUpdateFile(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedContent github.RepositoryContentResponse +// err = json.Unmarshal([]byte(textContent.Text), &returnedContent) +// require.NoError(t, err) + +// // Verify content +// assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) +// assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) +// assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) + +// // Verify commit +// assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) +// assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) +// }) +// } +// } + +// func Test_CreateRepository(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := CreateRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "create_repository", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "name") +// assert.Contains(t, tool.InputSchema.Properties, "description") +// assert.Contains(t, tool.InputSchema.Properties, "organization") +// assert.Contains(t, tool.InputSchema.Properties, "private") +// assert.Contains(t, tool.InputSchema.Properties, "autoInit") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) + +// // Setup mock repository response +// mockRepo := &github.Repository{ +// Name: github.Ptr("test-repo"), +// Description: github.Ptr("Test repository"), +// Private: github.Ptr(true), +// HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), +// CreatedAt: &github.Timestamp{Time: time.Now()}, +// Owner: &github.User{ +// Login: github.Ptr("testuser"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedRepo *github.Repository +// expectedErrMsg string +// }{ +// { +// name: "successful repository creation with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/user/repos", +// Method: "POST", +// }, +// expectRequestBody(t, map[string]interface{}{ +// "name": "test-repo", +// "description": "Test repository", +// "private": true, +// "auto_init": true, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockRepo), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "name": "test-repo", +// "description": "Test repository", +// "private": true, +// "autoInit": true, +// }, +// expectError: false, +// expectedRepo: mockRepo, +// }, +// { +// name: "successful repository creation in organization", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/orgs/testorg/repos", +// Method: "POST", +// }, +// expectRequestBody(t, map[string]interface{}{ +// "name": "test-repo", +// "description": "Test repository", +// "private": false, +// "auto_init": true, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockRepo), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "name": "test-repo", +// "description": "Test repository", +// "organization": "testorg", +// "private": false, +// "autoInit": true, +// }, +// expectError: false, +// expectedRepo: mockRepo, +// }, +// { +// name: "successful repository creation with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/user/repos", +// Method: "POST", +// }, +// expectRequestBody(t, map[string]interface{}{ +// "name": "test-repo", +// "auto_init": false, +// "description": "", +// "private": false, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockRepo), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "name": "test-repo", +// }, +// expectError: false, +// expectedRepo: mockRepo, +// }, +// { +// name: "repository creation fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.EndpointPattern{ +// Pattern: "/user/repos", +// Method: "POST", +// }, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnprocessableEntity) +// _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "name": "invalid-repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to create repository", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := CreateRepository(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the minimal result +// var returnedRepo MinimalResponse +// err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) +// assert.NoError(t, err) + +// // Verify repository details +// assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) +// }) +// } +// } + +// func Test_PushFiles(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "push_files", 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, "branch") +// assert.Contains(t, tool.InputSchema.Properties, "files") +// assert.Contains(t, tool.InputSchema.Properties, "message") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch", "files", "message"}) + +// // Setup mock objects +// mockRef := &github.Reference{ +// Ref: github.Ptr("refs/heads/main"), +// Object: &github.GitObject{ +// SHA: github.Ptr("abc123"), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), +// }, +// } + +// mockCommit := &github.Commit{ +// SHA: github.Ptr("abc123"), +// Tree: &github.Tree{ +// SHA: github.Ptr("def456"), +// }, +// } + +// mockTree := &github.Tree{ +// SHA: github.Ptr("ghi789"), +// } + +// mockNewCommit := &github.Commit{ +// SHA: github.Ptr("jkl012"), +// Message: github.Ptr("Update multiple files"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), +// } + +// mockUpdatedRef := &github.Reference{ +// Ref: github.Ptr("refs/heads/main"), +// Object: &github.GitObject{ +// SHA: github.Ptr("jkl012"), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), +// }, +// } + +// // Define test cases +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedRef *github.Reference +// expectedErrMsg string +// }{ +// { +// name: "successful push of multiple files", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Get commit +// mock.WithRequestMatch( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockCommit, +// ), +// // Create tree +// mock.WithRequestMatchHandler( +// mock.PostReposGitTreesByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "base_tree": "def456", +// "tree": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// "mode": "100644", +// "type": "blob", +// "content": "# Updated README\n\nThis is an updated README file.", +// }, +// map[string]interface{}{ +// "path": "docs/example.md", +// "mode": "100644", +// "type": "blob", +// "content": "# Example\n\nThis is an example file.", +// }, +// }, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockTree), +// ), +// ), +// // Create commit +// mock.WithRequestMatchHandler( +// mock.PostReposGitCommitsByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "message": "Update multiple files", +// "tree": "ghi789", +// "parents": []interface{}{"abc123"}, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockNewCommit), +// ), +// ), +// // Update reference +// mock.WithRequestMatchHandler( +// mock.PatchReposGitRefsByOwnerByRepoByRef, +// expectRequestBody(t, map[string]interface{}{ +// "sha": "jkl012", +// "force": false, +// }).andThen( +// mockResponse(t, http.StatusOK, mockUpdatedRef), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// "content": "# Updated README\n\nThis is an updated README file.", +// }, +// map[string]interface{}{ +// "path": "docs/example.md", +// "content": "# Example\n\nThis is an example file.", +// }, +// }, +// "message": "Update multiple files", +// }, +// expectError: false, +// expectedRef: mockUpdatedRef, +// }, +// { +// name: "fails when files parameter is invalid", +// mockedClient: mock.NewMockedHTTPClient( +// // No requests expected +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": "invalid-files-parameter", // Not an array +// "message": "Update multiple files", +// }, +// expectError: false, // This returns a tool error, not a Go error +// expectedErrMsg: "files parameter must be an array", +// }, +// { +// name: "fails when files contains object without path", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Get commit +// mock.WithRequestMatch( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockCommit, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": []interface{}{ +// map[string]interface{}{ +// "content": "# Missing path", +// }, +// }, +// "message": "Update file", +// }, +// expectError: false, // This returns a tool error, not a Go error +// expectedErrMsg: "each file must have a path", +// }, +// { +// name: "fails when files contains object without content", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Get commit +// mock.WithRequestMatch( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockCommit, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// // Missing content +// }, +// }, +// "message": "Update file", +// }, +// expectError: false, // This returns a tool error, not a Go error +// expectedErrMsg: "each file must have content", +// }, +// { +// name: "fails to get branch reference", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockResponse(t, http.StatusNotFound, nil), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "non-existent-branch", +// "files": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// "content": "# README", +// }, +// }, +// "message": "Update file", +// }, +// expectError: true, +// expectedErrMsg: "failed to get branch reference", +// }, +// { +// name: "fails to get base commit", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Fail to get commit +// mock.WithRequestMatchHandler( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockResponse(t, http.StatusNotFound, nil), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// "content": "# README", +// }, +// }, +// "message": "Update file", +// }, +// expectError: true, +// expectedErrMsg: "failed to get base commit", +// }, +// { +// name: "fails to create tree", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Get commit +// mock.WithRequestMatch( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockCommit, +// ), +// // Fail to create tree +// mock.WithRequestMatchHandler( +// mock.PostReposGitTreesByOwnerByRepo, +// mockResponse(t, http.StatusInternalServerError, nil), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "branch": "main", +// "files": []interface{}{ +// map[string]interface{}{ +// "path": "README.md", +// "content": "# README", +// }, +// }, +// "message": "Update file", +// }, +// expectError: true, +// expectedErrMsg: "failed to create tree", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := PushFiles(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// if tc.expectedErrMsg != "" { +// require.NotNil(t, result) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedRef github.Reference +// err = json.Unmarshal([]byte(textContent.Text), &returnedRef) +// require.NoError(t, err) + +// assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) +// assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) +// }) +// } +// } + +// func Test_ListBranches(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_branches", 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, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock branches for success case +// mockBranches := []*github.Branch{ +// { +// Name: github.Ptr("main"), +// Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, +// }, +// { +// Name: github.Ptr("develop"), +// Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, +// }, +// } + +// // Test cases +// tests := []struct { +// name string +// args map[string]interface{} +// mockResponses []mock.MockBackendOption +// wantErr bool +// errContains string +// }{ +// { +// name: "success", +// args: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "page": float64(2), +// }, +// mockResponses: []mock.MockBackendOption{ +// mock.WithRequestMatch( +// mock.GetReposBranchesByOwnerByRepo, +// mockBranches, +// ), +// }, +// wantErr: false, +// }, +// { +// name: "missing owner", +// args: map[string]interface{}{ +// "repo": "repo", +// }, +// mockResponses: []mock.MockBackendOption{}, +// wantErr: false, +// errContains: "missing required parameter: owner", +// }, +// { +// name: "missing repo", +// args: map[string]interface{}{ +// "owner": "owner", +// }, +// mockResponses: []mock.MockBackendOption{}, +// wantErr: false, +// errContains: "missing required parameter: repo", +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// // Create mock client +// mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) +// _, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// // Create request +// request := createMCPRequest(tt.args) + +// // Call handler +// result, err := handler(context.Background(), request) +// if tt.wantErr { +// require.Error(t, err) +// if tt.errContains != "" { +// assert.Contains(t, err.Error(), tt.errContains) +// } +// return +// } + +// require.NoError(t, err) +// require.NotNil(t, result) + +// if tt.errContains != "" { +// textContent := getTextResult(t, result) +// assert.Contains(t, textContent.Text, tt.errContains) +// return +// } + +// textContent := getTextResult(t, result) +// require.NotEmpty(t, textContent.Text) + +// // Verify response +// var branches []*github.Branch +// err = json.Unmarshal([]byte(textContent.Text), &branches) +// require.NoError(t, err) +// assert.Len(t, branches, 2) +// assert.Equal(t, "main", *branches[0].Name) +// assert.Equal(t, "develop", *branches[1].Name) +// }) +// } +// } + +// func Test_DeleteFile(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "delete_file", 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, "path") +// assert.Contains(t, tool.InputSchema.Properties, "message") +// assert.Contains(t, tool.InputSchema.Properties, "branch") +// // SHA is no longer required since we're using Git Data API +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"}) + +// // Setup mock objects for Git Data API +// mockRef := &github.Reference{ +// Ref: github.Ptr("refs/heads/main"), +// Object: &github.GitObject{ +// SHA: github.Ptr("abc123"), +// }, +// } + +// mockCommit := &github.Commit{ +// SHA: github.Ptr("abc123"), +// Tree: &github.Tree{ +// SHA: github.Ptr("def456"), +// }, +// } + +// mockTree := &github.Tree{ +// SHA: github.Ptr("ghi789"), +// } + +// mockNewCommit := &github.Commit{ +// SHA: github.Ptr("jkl012"), +// Message: github.Ptr("Delete example file"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedCommitSHA string +// expectedErrMsg string +// }{ +// { +// name: "successful file deletion using Git Data API", +// mockedClient: mock.NewMockedHTTPClient( +// // Get branch reference +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockRef, +// ), +// // Get commit +// mock.WithRequestMatch( +// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, +// mockCommit, +// ), +// // Create tree +// mock.WithRequestMatchHandler( +// mock.PostReposGitTreesByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "base_tree": "def456", +// "tree": []interface{}{ +// map[string]interface{}{ +// "path": "docs/example.md", +// "mode": "100644", +// "type": "blob", +// "sha": nil, +// }, +// }, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockTree), +// ), +// ), +// // Create commit +// mock.WithRequestMatchHandler( +// mock.PostReposGitCommitsByOwnerByRepo, +// expectRequestBody(t, map[string]interface{}{ +// "message": "Delete example file", +// "tree": "ghi789", +// "parents": []interface{}{"abc123"}, +// }).andThen( +// mockResponse(t, http.StatusCreated, mockNewCommit), +// ), +// ), +// // Update reference +// mock.WithRequestMatchHandler( +// mock.PatchReposGitRefsByOwnerByRepoByRef, +// expectRequestBody(t, map[string]interface{}{ +// "sha": "jkl012", +// "force": false, +// }).andThen( +// mockResponse(t, http.StatusOK, &github.Reference{ +// Ref: github.Ptr("refs/heads/main"), +// Object: &github.GitObject{ +// SHA: github.Ptr("jkl012"), +// }, +// }), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "docs/example.md", +// "message": "Delete example file", +// "branch": "main", +// }, +// expectError: false, +// expectedCommitSHA: "jkl012", +// }, +// { +// name: "file deletion fails - branch not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path": "docs/nonexistent.md", +// "message": "Delete nonexistent file", +// "branch": "nonexistent-branch", +// }, +// expectError: true, +// expectedErrMsg: "failed to get branch reference", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), 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 response map[string]interface{} +// err = json.Unmarshal([]byte(textContent.Text), &response) +// require.NoError(t, err) + +// // Verify the response contains the expected commit +// commit, ok := response["commit"].(map[string]interface{}) +// require.True(t, ok) +// commitSHA, ok := commit["sha"].(string) +// require.True(t, ok) +// assert.Equal(t, tc.expectedCommitSHA, commitSHA) +// }) +// } +// } + +// func Test_ListTags(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_tags", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock tags for success case +// mockTags := []*github.RepositoryTag{ +// { +// Name: github.Ptr("v1.0.0"), +// Commit: &github.Commit{ +// SHA: github.Ptr("v1.0.0-tag-sha"), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), +// }, +// ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), +// TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), +// }, +// { +// Name: github.Ptr("v0.9.0"), +// Commit: &github.Commit{ +// SHA: github.Ptr("v0.9.0-tag-sha"), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), +// }, +// ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), +// TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedTags []*github.RepositoryTag +// expectedErrMsg string +// }{ +// { +// name: "successful tags list", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposTagsByOwnerByRepo, +// expectPath( +// t, +// "/repos/owner/repo/tags", +// ).andThen( +// mockResponse(t, http.StatusOK, mockTags), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedTags: mockTags, +// }, +// { +// name: "list tags fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposTagsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusInternalServerError) +// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list tags", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Parse and verify the result +// var returnedTags []*github.RepositoryTag +// err = json.Unmarshal([]byte(textContent.Text), &returnedTags) +// require.NoError(t, err) + +// // Verify each tag +// require.Equal(t, len(tc.expectedTags), len(returnedTags)) +// for i, expectedTag := range tc.expectedTags { +// assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) +// assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) +// } +// }) +// } +// } + +// func Test_GetTag(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_tag", 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, "tag") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + +// mockTagRef := &github.Reference{ +// Ref: github.Ptr("refs/tags/v1.0.0"), +// Object: &github.GitObject{ +// SHA: github.Ptr("v1.0.0-tag-sha"), +// }, +// } + +// mockTagObj := &github.Tag{ +// SHA: github.Ptr("v1.0.0-tag-sha"), +// Tag: github.Ptr("v1.0.0"), +// Message: github.Ptr("Release v1.0.0"), +// Object: &github.GitObject{ +// Type: github.Ptr("commit"), +// SHA: github.Ptr("abc123"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedTag *github.Tag +// expectedErrMsg string +// }{ +// { +// name: "successful tag retrieval", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// expectPath( +// t, +// "/repos/owner/repo/git/ref/tags/v1.0.0", +// ).andThen( +// mockResponse(t, http.StatusOK, mockTagRef), +// ), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitTagsByOwnerByRepoByTagSha, +// expectPath( +// t, +// "/repos/owner/repo/git/tags/v1.0.0-tag-sha", +// ).andThen( +// mockResponse(t, http.StatusOK, mockTagObj), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: false, +// expectedTag: mockTagObj, +// }, +// { +// name: "tag reference not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: true, +// expectedErrMsg: "failed to get tag reference", +// }, +// { +// name: "tag object not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposGitRefByOwnerByRepoByRef, +// mockTagRef, +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitTagsByOwnerByRepoByTagSha, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: true, +// expectedErrMsg: "failed to get tag object", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Parse and verify the result +// var returnedTag github.Tag +// err = json.Unmarshal([]byte(textContent.Text), &returnedTag) +// require.NoError(t, err) + +// assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) +// assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) +// assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) +// assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) +// assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) +// }) +// } +// } + +// func Test_ListReleases(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_releases", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// mockReleases := []*github.RepositoryRelease{ +// { +// ID: github.Ptr(int64(1)), +// TagName: github.Ptr("v1.0.0"), +// Name: github.Ptr("First Release"), +// }, +// { +// ID: github.Ptr(int64(2)), +// TagName: github.Ptr("v0.9.0"), +// Name: github.Ptr("Beta Release"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult []*github.RepositoryRelease +// expectedErrMsg string +// }{ +// { +// name: "successful releases list", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposReleasesByOwnerByRepo, +// mockReleases, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedResult: mockReleases, +// }, +// { +// name: "releases list fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposReleasesByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list releases", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) +// var returnedReleases []*github.RepositoryRelease +// err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) +// require.NoError(t, err) +// assert.Len(t, returnedReleases, len(tc.expectedResult)) +// for i, rel := range returnedReleases { +// assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) +// } +// }) +// } +// } +// func Test_GetLatestRelease(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "get_latest_release", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// mockRelease := &github.RepositoryRelease{ +// ID: github.Ptr(int64(1)), +// TagName: github.Ptr("v1.0.0"), +// Name: github.Ptr("First Release"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.RepositoryRelease +// expectedErrMsg string +// }{ +// { +// name: "successful latest release fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposReleasesLatestByOwnerByRepo, +// mockRelease, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedResult: mockRelease, +// }, +// { +// name: "latest release fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposReleasesLatestByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to get latest release", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) +// request := createMCPRequest(tc.requestArgs) +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// textContent := getTextResult(t, result) +// var returnedRelease github.RepositoryRelease +// err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) +// }) +// } +// } + +// func Test_GetReleaseByTag(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_release_by_tag", 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, "tag") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + +// mockRelease := &github.RepositoryRelease{ +// ID: github.Ptr(int64(1)), +// TagName: github.Ptr("v1.0.0"), +// Name: github.Ptr("Release v1.0.0"), +// Body: github.Ptr("This is the first stable release."), +// Assets: []*github.ReleaseAsset{ +// { +// ID: github.Ptr(int64(1)), +// Name: github.Ptr("release-v1.0.0.tar.gz"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.RepositoryRelease +// expectedErrMsg string +// }{ +// { +// name: "successful release by tag fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposReleasesTagsByOwnerByRepoByTag, +// mockRelease, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: false, +// expectedResult: mockRelease, +// }, +// { +// name: "missing owner parameter", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: false, // Returns tool error, not Go error +// expectedErrMsg: "missing required parameter: owner", +// }, +// { +// name: "missing repo parameter", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "tag": "v1.0.0", +// }, +// expectError: false, // Returns tool error, not Go error +// expectedErrMsg: "missing required parameter: repo", +// }, +// { +// name: "missing tag parameter", +// mockedClient: mock.NewMockedHTTPClient(), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, // Returns tool error, not Go error +// expectedErrMsg: "missing required parameter: tag", +// }, +// { +// name: "release by tag not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposReleasesTagsByOwnerByRepoByTag, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v999.0.0", +// }, +// expectError: false, // API errors return tool errors, not Go errors +// expectedErrMsg: "failed to get release by tag: v999.0.0", +// }, +// { +// name: "server error", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposReleasesTagsByOwnerByRepoByTag, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusInternalServerError) +// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "tag": "v1.0.0", +// }, +// expectError: false, // API errors return tool errors, not Go errors +// expectedErrMsg: "failed to get release by tag: v1.0.0", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) + +// if tc.expectedErrMsg != "" { +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) + +// var returnedRelease github.RepositoryRelease +// err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) +// require.NoError(t, err) + +// assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) +// assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) +// assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) +// if tc.expectedResult.Body != nil { +// assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) +// } +// if len(tc.expectedResult.Assets) > 0 { +// require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) +// assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) +// } +// }) +// } +// } + +// func Test_filterPaths(t *testing.T) { +// tests := []struct { +// name string +// tree []*github.TreeEntry +// path string +// maxResults int +// expected []string +// }{ +// { +// name: "file name", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, +// {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, +// {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, +// {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, +// }, +// path: "foo.txt", +// maxResults: -1, +// expected: []string{"folder/foo.txt", "nested/folder/foo.txt"}, +// }, +// { +// name: "dir name", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, +// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, +// }, +// path: "folder/", +// maxResults: -1, +// expected: []string{"folder/", "nested/folder/"}, +// }, +// { +// name: "dir and file match", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("name"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("name"), Type: github.Ptr("blob")}, +// }, +// path: "name", // No trailing slash can match both files and directories +// maxResults: -1, +// expected: []string{"name/", "name"}, +// }, +// { +// name: "dir only match", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("name"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("name"), Type: github.Ptr("blob")}, +// }, +// path: "name/", // Trialing slash ensures only directories are matched +// maxResults: -1, +// expected: []string{"name/"}, +// }, +// { +// name: "max results limit 2", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, +// }, +// path: "folder/", +// maxResults: 2, +// expected: []string{"folder/", "nested/folder/"}, +// }, +// { +// name: "max results limit 1", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, +// }, +// path: "folder/", +// maxResults: 1, +// expected: []string{"folder/"}, +// }, +// { +// name: "max results limit 0", +// tree: []*github.TreeEntry{ +// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, +// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, +// }, +// path: "folder/", +// maxResults: 0, +// expected: []string{}, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// result := filterPaths(tc.tree, tc.path, tc.maxResults) +// assert.Equal(t, tc.expected, result) +// }) +// } +// } + +// func Test_resolveGitReference(t *testing.T) { +// ctx := context.Background() +// owner := "owner" +// repo := "repo" + +// tests := []struct { +// name string +// ref string +// sha string +// mockSetup func() *http.Client +// expectedOutput *raw.ContentOpts +// expectError bool +// errorContains string +// }{ +// { +// name: "sha takes precedence over ref", +// ref: "refs/heads/main", +// sha: "123sha456", +// mockSetup: func() *http.Client { +// // No API calls should be made when SHA is provided +// return mock.NewMockedHTTPClient() +// }, +// expectedOutput: &raw.ContentOpts{ +// SHA: "123sha456", +// }, +// expectError: false, +// }, +// { +// name: "use default branch if ref and sha both empty", +// ref: "", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) +// }), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// assert.Contains(t, r.URL.Path, "/git/ref/heads/main") +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/heads/main", +// SHA: "main-sha", +// }, +// expectError: false, +// }, +// { +// name: "fully qualified ref passed through unchanged", +// ref: "refs/heads/feature-branch", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/heads/feature-branch", +// SHA: "feature-sha", +// }, +// expectError: false, +// }, +// { +// name: "short branch name resolves to refs/heads/", +// ref: "main", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// if strings.Contains(r.URL.Path, "/git/ref/heads/main") { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) +// } else { +// t.Errorf("Unexpected path: %s", r.URL.Path) +// w.WriteHeader(http.StatusNotFound) +// } +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/heads/main", +// SHA: "main-sha", +// }, +// expectError: false, +// }, +// { +// name: "short tag name falls back to refs/tags/ when branch not found", +// ref: "v1.0.0", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// switch { +// case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"): +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) +// default: +// t.Errorf("Unexpected path: %s", r.URL.Path) +// w.WriteHeader(http.StatusNotFound) +// } +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/tags/v1.0.0", +// SHA: "tag-sha", +// }, +// expectError: false, +// }, +// { +// name: "heads/ prefix gets refs/ prepended", +// ref: "heads/feature-branch", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/heads/feature-branch", +// SHA: "feature-sha", +// }, +// expectError: false, +// }, +// { +// name: "tags/ prefix gets refs/ prepended", +// ref: "tags/v1.0.0", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/tags/v1.0.0", +// SHA: "tag-sha", +// }, +// expectError: false, +// }, +// { +// name: "invalid short name that doesn't exist as branch or tag", +// ref: "nonexistent", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// // Both branch and tag attempts should return 404 +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ) +// }, +// expectError: true, +// errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag", +// }, +// { +// name: "fully qualified pull request ref", +// ref: "refs/pull/123/head", +// sha: "", +// mockSetup: func() *http.Client { +// return mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposGitRefByOwnerByRepoByRef, +// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`)) +// }), +// ), +// ) +// }, +// expectedOutput: &raw.ContentOpts{ +// Ref: "refs/pull/123/head", +// SHA: "pr-sha", +// }, +// expectError: false, +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockSetup()) +// opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) + +// if tc.expectError { +// require.Error(t, err) +// if tc.errorContains != "" { +// assert.Contains(t, err.Error(), tc.errorContains) +// } +// return +// } + +// require.NoError(t, err) +// require.NotNil(t, opts) + +// if tc.expectedOutput.SHA != "" { +// assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) +// } +// if tc.expectedOutput.Ref != "" { +// assert.Equal(t, tc.expectedOutput.Ref, opts.Ref) +// } +// }) +// } +// } + +// func Test_ListStarredRepositories(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "list_starred_repositories", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "username") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "direction") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Empty(t, tool.InputSchema.Required) // All parameters are optional + +// // Setup mock starred repositories +// starredAt := time.Now().Add(-24 * time.Hour) +// updatedAt := time.Now().Add(-2 * time.Hour) +// mockStarredRepos := []*github.StarredRepository{ +// { +// StarredAt: &github.Timestamp{Time: starredAt}, +// Repository: &github.Repository{ +// ID: github.Ptr(int64(12345)), +// Name: github.Ptr("awesome-repo"), +// FullName: github.Ptr("owner/awesome-repo"), +// Description: github.Ptr("An awesome repository"), +// HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), +// Language: github.Ptr("Go"), +// StargazersCount: github.Ptr(100), +// ForksCount: github.Ptr(25), +// OpenIssuesCount: github.Ptr(5), +// UpdatedAt: &github.Timestamp{Time: updatedAt}, +// Private: github.Ptr(false), +// Fork: github.Ptr(false), +// Archived: github.Ptr(false), +// DefaultBranch: github.Ptr("main"), +// }, +// }, +// { +// StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, +// Repository: &github.Repository{ +// ID: github.Ptr(int64(67890)), +// Name: github.Ptr("cool-project"), +// FullName: github.Ptr("user/cool-project"), +// Description: github.Ptr("A very cool project"), +// HTMLURL: github.Ptr("https://github.com/user/cool-project"), +// Language: github.Ptr("Python"), +// StargazersCount: github.Ptr(500), +// ForksCount: github.Ptr(75), +// OpenIssuesCount: github.Ptr(10), +// UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, +// Private: github.Ptr(false), +// Fork: github.Ptr(true), +// Archived: github.Ptr(false), +// DefaultBranch: github.Ptr("master"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// expectedCount int +// }{ +// { +// name: "successful list for authenticated user", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetUserStarred, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: false, +// expectedCount: 2, +// }, +// { +// name: "successful list for specific user", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetUsersStarredByUsername, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusOK) +// _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "username": "testuser", +// }, +// expectError: false, +// expectedCount: 2, +// }, +// { +// name: "list fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetUserStarred, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "failed to list starred repositories", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListStarredRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NotNil(t, result) +// textResult, ok := result.Content[0].(mcp.TextContent) +// require.True(t, ok, "Expected text content") +// assert.Contains(t, textResult.Text, tc.expectedErrMsg) +// } else { +// require.NoError(t, err) +// require.NotNil(t, result) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedRepos []MinimalRepository +// err = json.Unmarshal([]byte(textContent.Text), &returnedRepos) +// require.NoError(t, err) + +// assert.Len(t, returnedRepos, tc.expectedCount) +// if tc.expectedCount > 0 { +// assert.Equal(t, "awesome-repo", returnedRepos[0].Name) +// assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName) +// } +// } +// }) +// } +// } + +// func Test_StarRepository(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "star_repository", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful star", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutUserStarredByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "testowner", +// "repo": "testrepo", +// }, +// expectError: false, +// }, +// { +// name: "star fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.PutUserStarredByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "testowner", +// "repo": "nonexistent", +// }, +// expectError: true, +// expectedErrMsg: "failed to star repository", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := StarRepository(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NotNil(t, result) +// textResult, ok := result.Content[0].(mcp.TextContent) +// require.True(t, ok, "Expected text content") +// assert.Contains(t, textResult.Text, tc.expectedErrMsg) +// } else { +// require.NoError(t, err) +// require.NotNil(t, result) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) +// assert.Contains(t, textContent.Text, "Successfully starred repository") +// } +// }) +// } +// } + +// func Test_UnstarRepository(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "unstar_repository", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "owner") +// assert.Contains(t, tool.InputSchema.Properties, "repo") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successful unstar", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteUserStarredByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNoContent) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "testowner", +// "repo": "testrepo", +// }, +// expectError: false, +// }, +// { +// name: "unstar fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.DeleteUserStarredByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "testowner", +// "repo": "nonexistent", +// }, +// expectError: true, +// expectedErrMsg: "failed to unstar repository", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := UnstarRepository(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NotNil(t, result) +// textResult, ok := result.Content[0].(mcp.TextContent) +// require.True(t, ok, "Expected text content") +// assert.Contains(t, textResult.Text, tc.expectedErrMsg) +// } else { +// require.NoError(t, err) +// require.NotNil(t, result) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) +// assert.Contains(t, textContent.Text, "Successfully unstarred repository") +// } +// }) +// } +// } + +// func Test_GetRepositoryTree(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "get_repository_tree", 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, "tree_sha") +// assert.Contains(t, tool.InputSchema.Properties, "recursive") +// assert.Contains(t, tool.InputSchema.Properties, "path_filter") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock data +// mockRepo := &github.Repository{ +// DefaultBranch: github.Ptr("main"), +// } +// mockTree := &github.Tree{ +// SHA: github.Ptr("abc123"), +// Truncated: github.Ptr(false), +// Entries: []*github.TreeEntry{ +// { +// Path: github.Ptr("README.md"), +// Mode: github.Ptr("100644"), +// Type: github.Ptr("blob"), +// SHA: github.Ptr("file1sha"), +// Size: github.Ptr(123), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"), +// }, +// { +// Path: github.Ptr("src/main.go"), +// Mode: github.Ptr("100644"), +// Type: github.Ptr("blob"), +// SHA: github.Ptr("file2sha"), +// Size: github.Ptr(456), +// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedErrMsg string +// }{ +// { +// name: "successfully get repository tree", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// mockResponse(t, http.StatusOK, mockRepo), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitTreesByOwnerByRepoByTreeSha, +// mockResponse(t, http.StatusOK, mockTree), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// }, +// { +// name: "successfully get repository tree with path filter", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// mockResponse(t, http.StatusOK, mockRepo), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitTreesByOwnerByRepoByTreeSha, +// mockResponse(t, http.StatusOK, mockTree), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "path_filter": "src/", +// }, +// }, +// { +// name: "repository not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "nonexistent", +// }, +// expectError: true, +// expectedErrMsg: "failed to get repository info", +// }, +// { +// name: "tree not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposByOwnerByRepo, +// mockResponse(t, http.StatusOK, mockRepo), +// ), +// mock.WithRequestMatchHandler( +// mock.GetReposGitTreesByOwnerByRepoByTreeSha, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to get repository tree", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// _, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper) + +// // Create the tool request +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// } else { +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content +// textContent := getTextResult(t, result) + +// // Parse the JSON response +// var treeResponse map[string]interface{} +// err := json.Unmarshal([]byte(textContent.Text), &treeResponse) +// require.NoError(t, err) + +// // Verify response structure +// assert.Equal(t, "owner", treeResponse["owner"]) +// assert.Equal(t, "repo", treeResponse["repo"]) +// assert.Contains(t, treeResponse, "tree") +// assert.Contains(t, treeResponse, "count") +// assert.Contains(t, treeResponse, "sha") +// assert.Contains(t, treeResponse, "truncated") + +// // Check filtering if path_filter was provided +// if pathFilter, exists := tc.requestArgs["path_filter"]; exists { +// tree := treeResponse["tree"].([]interface{}) +// for _, entry := range tree { +// entryMap := entry.(map[string]interface{}) +// path := entryMap["path"].(string) +// assert.True(t, strings.HasPrefix(path, pathFilter.(string)), +// "Path %s should start with filter %s", path, pathFilter) +// } +// } +// } +// }) +// } +// } diff --git a/pkg/github/search.go b/pkg/github/search.go index 5084773b2..ccae0f752 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -1,365 +1,365 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) -// SearchRepositories creates a tool to search for GitHub repositories. -func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_repositories", - mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")), +// // SearchRepositories creates a tool to search for GitHub repositories. +// func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("search_repositories", +// mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), - ), - mcp.WithString("sort", - mcp.Description("Sort repositories by field, defaults to best match"), - mcp.Enum("stars", "forks", "help-wanted-issues", "updated"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - mcp.WithBoolean("minimal_output", - mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), - mcp.DefaultBool(true), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - order, err := OptionalParam[string](request, "order") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - opts := &github.SearchOptions{ - Sort: sort, - Order: order, - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("query", +// mcp.Required(), +// mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort repositories by field, defaults to best match"), +// mcp.Enum("stars", "forks", "help-wanted-issues", "updated"), +// ), +// mcp.WithString("order", +// mcp.Description("Sort order"), +// mcp.Enum("asc", "desc"), +// ), +// mcp.WithBoolean("minimal_output", +// mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), +// mcp.DefaultBool(true), +// ), +// WithPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// query, err := RequiredParam[string](request, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// order, err := OptionalParam[string](request, "order") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// opts := &github.SearchOptions{ +// Sort: sort, +// Order: order, +// 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) - } - result, resp, err := client.Search.Repositories(ctx, query, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search repositories with query '%s'", query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// result, resp, err := client.Search.Repositories(ctx, query, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to search repositories with query '%s'", query), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != 200 { - 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 search repositories: %s", string(body))), nil - } +// if resp.StatusCode != 200 { +// 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 search repositories: %s", string(body))), nil +// } - // Return either minimal or full response based on parameter - var r []byte - if minimalOutput { - minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) - for _, repo := range result.Repositories { - minimalRepo := MinimalRepository{ - ID: repo.GetID(), - Name: repo.GetName(), - FullName: repo.GetFullName(), - Description: repo.GetDescription(), - HTMLURL: repo.GetHTMLURL(), - Language: repo.GetLanguage(), - Stars: repo.GetStargazersCount(), - Forks: repo.GetForksCount(), - OpenIssues: repo.GetOpenIssuesCount(), - Private: repo.GetPrivate(), - Fork: repo.GetFork(), - Archived: repo.GetArchived(), - DefaultBranch: repo.GetDefaultBranch(), - } +// // Return either minimal or full response based on parameter +// var r []byte +// if minimalOutput { +// minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) +// for _, repo := range result.Repositories { +// minimalRepo := MinimalRepository{ +// ID: repo.GetID(), +// Name: repo.GetName(), +// FullName: repo.GetFullName(), +// Description: repo.GetDescription(), +// HTMLURL: repo.GetHTMLURL(), +// Language: repo.GetLanguage(), +// Stars: repo.GetStargazersCount(), +// Forks: repo.GetForksCount(), +// OpenIssues: repo.GetOpenIssuesCount(), +// Private: repo.GetPrivate(), +// Fork: repo.GetFork(), +// Archived: repo.GetArchived(), +// DefaultBranch: repo.GetDefaultBranch(), +// } - if repo.UpdatedAt != nil { - minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.CreatedAt != nil { - minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.Topics != nil { - minimalRepo.Topics = repo.Topics - } +// if repo.UpdatedAt != nil { +// minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") +// } +// if repo.CreatedAt != nil { +// minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") +// } +// if repo.Topics != nil { +// minimalRepo.Topics = repo.Topics +// } - minimalRepos = append(minimalRepos, minimalRepo) - } +// minimalRepos = append(minimalRepos, minimalRepo) +// } - minimalResult := &MinimalSearchRepositoriesResult{ - TotalCount: result.GetTotal(), - IncompleteResults: result.GetIncompleteResults(), - Items: minimalRepos, - } +// minimalResult := &MinimalSearchRepositoriesResult{ +// TotalCount: result.GetTotal(), +// IncompleteResults: result.GetIncompleteResults(), +// Items: minimalRepos, +// } - r, err = json.Marshal(minimalResult) - if err != nil { - return nil, fmt.Errorf("failed to marshal minimal response: %w", err) - } - } else { - r, err = json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal full response: %w", err) - } - } +// r, err = json.Marshal(minimalResult) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal minimal response: %w", err) +// } +// } else { +// r, err = json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal full response: %w", err) +// } +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } -// SearchCode creates a tool to search for code across GitHub repositories. -func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_code", - mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."), - ), - mcp.WithString("sort", - mcp.Description("Sort field ('indexed' only)"), - ), - mcp.WithString("order", - mcp.Description("Sort order for results"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - order, err := OptionalParam[string](request, "order") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// // SearchCode creates a tool to search for code across GitHub repositories. +// func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("search_code", +// mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("query", +// mcp.Required(), +// mcp.Description("Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort field ('indexed' only)"), +// ), +// mcp.WithString("order", +// mcp.Description("Sort order for results"), +// mcp.Enum("asc", "desc"), +// ), +// WithPagination(), +// ), +// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// query, err := RequiredParam[string](request, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// order, err := OptionalParam[string](request, "order") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } - opts := &github.SearchOptions{ - Sort: sort, - Order: order, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } +// opts := &github.SearchOptions{ +// Sort: sort, +// Order: order, +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - result, resp, err := client.Search.Code(ctx, query, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search code with query '%s'", query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// result, resp, err := client.Search.Code(ctx, query, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to search code with query '%s'", query), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != 200 { - 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 search code: %s", string(body))), nil - } +// if resp.StatusCode != 200 { +// 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 search code: %s", string(body))), nil +// } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } -func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - order, err := OptionalParam[string](request, "order") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { +// return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// query, err := RequiredParam[string](request, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// order, err := OptionalParam[string](request, "order") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } - opts := &github.SearchOptions{ - Sort: sort, - Order: order, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } +// opts := &github.SearchOptions{ +// Sort: sort, +// Order: order, +// ListOptions: github.ListOptions{ +// PerPage: pagination.PerPage, +// Page: pagination.Page, +// }, +// } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - searchQuery := query - if !hasTypeFilter(query) { - searchQuery = "type:" + accountType + " " + query - } - result, resp, err := client.Search.Users(ctx, searchQuery, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to search %ss with query '%s'", accountType, query), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// searchQuery := query +// if !hasTypeFilter(query) { +// searchQuery = "type:" + accountType + " " + query +// } +// result, resp, err := client.Search.Users(ctx, searchQuery, opts) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to search %ss with query '%s'", accountType, query), +// resp, +// err, +// ), nil +// } +// defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != 200 { - 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 search %ss: %s", accountType, string(body))), nil - } +// if resp.StatusCode != 200 { +// 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 search %ss: %s", accountType, string(body))), nil +// } - minimalUsers := make([]MinimalUser, 0, len(result.Users)) +// minimalUsers := make([]MinimalUser, 0, len(result.Users)) - for _, user := range result.Users { - if user.Login != nil { - mu := MinimalUser{ - Login: user.GetLogin(), - ID: user.GetID(), - ProfileURL: user.GetHTMLURL(), - AvatarURL: user.GetAvatarURL(), - } - minimalUsers = append(minimalUsers, mu) - } - } - minimalResp := &MinimalSearchUsersResult{ - TotalCount: result.GetTotal(), - IncompleteResults: result.GetIncompleteResults(), - Items: minimalUsers, - } - if result.Total != nil { - minimalResp.TotalCount = *result.Total - } - if result.IncompleteResults != nil { - minimalResp.IncompleteResults = *result.IncompleteResults - } +// for _, user := range result.Users { +// if user.Login != nil { +// mu := MinimalUser{ +// Login: user.GetLogin(), +// ID: user.GetID(), +// ProfileURL: user.GetHTMLURL(), +// AvatarURL: user.GetAvatarURL(), +// } +// minimalUsers = append(minimalUsers, mu) +// } +// } +// minimalResp := &MinimalSearchUsersResult{ +// TotalCount: result.GetTotal(), +// IncompleteResults: result.GetIncompleteResults(), +// Items: minimalUsers, +// } +// if result.Total != nil { +// minimalResp.TotalCount = *result.Total +// } +// if result.IncompleteResults != nil { +// minimalResp.IncompleteResults = *result.IncompleteResults +// } - r, err := json.Marshal(minimalResp) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - return mcp.NewToolResultText(string(r)), nil - } -} +// r, err := json.Marshal(minimalResp) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal response: %w", err) +// } +// return mcp.NewToolResultText(string(r)), nil +// } +// } -// SearchUsers creates a tool to search for GitHub users. -func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_users", - mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."), - ), - mcp.WithString("sort", - mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."), - mcp.Enum("followers", "repositories", "joined"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), userOrOrgHandler("user", getClient) -} +// // SearchUsers creates a tool to search for GitHub users. +// func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("search_users", +// mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("query", +// mcp.Required(), +// mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."), +// mcp.Enum("followers", "repositories", "joined"), +// ), +// mcp.WithString("order", +// mcp.Description("Sort order"), +// mcp.Enum("asc", "desc"), +// ), +// WithPagination(), +// ), userOrOrgHandler("user", getClient) +// } -// SearchOrgs creates a tool to search for GitHub organizations. -func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_orgs", - mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")), +// // SearchOrgs creates a tool to search for GitHub organizations. +// func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("search_orgs", +// mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."), - ), - mcp.WithString("sort", - mcp.Description("Sort field by category"), - mcp.Enum("followers", "repositories", "joined"), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), userOrOrgHandler("org", getClient) -} +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("query", +// mcp.Required(), +// mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort field by category"), +// mcp.Enum("followers", "repositories", "joined"), +// ), +// mcp.WithString("order", +// mcp.Description("Sort order"), +// mcp.Enum("asc", "desc"), +// ), +// WithPagination(), +// ), userOrOrgHandler("org", getClient) +// } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index e14ba023f..a8e749939 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -1,743 +1,743 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_SearchRepositories(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SearchRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "search_repositories", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - // Setup mock search results - mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - Repositories: []*github.Repository{ - { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("repo-1"), - FullName: github.Ptr("owner/repo-1"), - HTMLURL: github.Ptr("https://github.com/owner/repo-1"), - Description: github.Ptr("Test repository 1"), - StargazersCount: github.Ptr(100), - }, - { - ID: github.Ptr(int64(67890)), - Name: github.Ptr("repo-2"), - FullName: github.Ptr("owner/repo-2"), - HTMLURL: github.Ptr("https://github.com/owner/repo-2"), - Description: github.Ptr("Test repository 2"), - StargazersCount: github.Ptr(50), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.RepositoriesSearchResult - expectedErrMsg string - }{ - { - name: "successful repository search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "sort": "stars", - "order": "desc", - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "golang test", - "sort": "stars", - "order": "desc", - "page": float64(2), - "perPage": float64(10), - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "repository search with default pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "golang test", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search repositories", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedResult MinimalSearchRepositoriesResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) - for i, repo := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) - assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) - assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) - assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) - } - - }) - } -} - -func Test_SearchRepositories_FullOutput(t *testing.T) { - mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), - Repositories: []*github.Repository{ - { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("test-repo"), - FullName: github.Ptr("owner/test-repo"), - HTMLURL: github.Ptr("https://github.com/owner/test-repo"), - Description: github.Ptr("Test repository"), - StargazersCount: github.Ptr(100), - }, - }, - } - - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ) - - client := github.NewClient(mockedClient) - _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(map[string]interface{}{ - "query": "golang test", - "minimal_output": false, - }) - - result, err := handlerTest(context.Background(), request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - - // Unmarshal as full GitHub API response - var returnedResult github.RepositoriesSearchResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - - // Verify it's the full API response, not minimal - assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) - assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Repositories, 1) - assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) - assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) -} - -func Test_SearchCode(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SearchCode(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "search_code", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - // Setup mock search results - mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - CodeResults: []*github.CodeResult{ - { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, - }, - { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.CodeSearchResult - expectedErrMsg string - }{ - { - name: "successful code search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "sort": "indexed", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "fmt.Println language:go", - "sort": "indexed", - "order": "desc", - "page": float64(1), - "perPage": float64(30), - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "code search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "fmt.Println language:go", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "search code fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search code", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchCode(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedResult github.CodeSearchResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) - for i, code := range returnedResult.CodeResults { - assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) - assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) - assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) - } - }) - } -} - -func Test_SearchUsers(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SearchUsers(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "search_users", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - // Setup mock search results - mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), - Users: []*github.User{ - { - Login: github.Ptr("user1"), - ID: github.Ptr(int64(1001)), - HTMLURL: github.Ptr("https://github.com/user1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), - }, - { - Login: github.Ptr("user2"), - ID: github.Ptr(int64(1002)), - HTMLURL: github.Ptr("https://github.com/user2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), - Type: github.Ptr("User"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.UsersSearchResult - expectedErrMsg string - }{ - { - name: "successful users search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "sort": "followers", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "location:finland language:go", - "sort": "followers", - "order": "desc", - "page": float64(1), - "perPage": float64(30), - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "users search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "location:finland language:go", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing type:user filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:seattle followers:>100", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "type:user location:seattle followers:>100", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "complex query with existing type:user filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user (location:seattle OR location:california) followers:>50", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "type:user (location:seattle OR location:california) followers:>50", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "search users fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search users", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchUsers(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - require.NotNil(t, result) - - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedResult MinimalSearchUsersResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) - for i, user := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login) - assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID) - assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL) - assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL) - } - }) - } -} - -func Test_SearchOrgs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "search_orgs", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - - // Setup mock search results - mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(int(2)), - IncompleteResults: github.Ptr(false), - Users: []*github.User{ - { - Login: github.Ptr("org-1"), - ID: github.Ptr(int64(111)), - HTMLURL: github.Ptr("https://github.com/org-1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), - }, - { - Login: github.Ptr("org-2"), - ID: github.Ptr(int64(222)), - HTMLURL: github.Ptr("https://github.com/org-2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), - }, - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedResult *github.UsersSearchResult - expectedErrMsg string - }{ - { - name: "successful org search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org github", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "github", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "query with existing type:org filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org location:california followers:>1000", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "type:org location:california followers:>1000", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "complex query with existing type:org filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), - ), - ), - requestArgs: map[string]interface{}{ - "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", - }, - expectError: false, - expectedResult: mockSearchResult, - }, - { - name: "org search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "query": "invalid:query", - }, - expectError: true, - expectedErrMsg: "failed to search orgs", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedResult MinimalSearchUsersResult - err = json.Unmarshal([]byte(textContent.Text), &returnedResult) - require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) - for i, org := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login) - assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID) - assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL) - assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL) - } - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/internal/toolsnaps" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_SearchRepositories(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SearchRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "search_repositories", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// // Setup mock search results +// mockSearchResult := &github.RepositoriesSearchResult{ +// Total: github.Ptr(2), +// IncompleteResults: github.Ptr(false), +// Repositories: []*github.Repository{ +// { +// ID: github.Ptr(int64(12345)), +// Name: github.Ptr("repo-1"), +// FullName: github.Ptr("owner/repo-1"), +// HTMLURL: github.Ptr("https://github.com/owner/repo-1"), +// Description: github.Ptr("Test repository 1"), +// StargazersCount: github.Ptr(100), +// }, +// { +// ID: github.Ptr(int64(67890)), +// Name: github.Ptr("repo-2"), +// FullName: github.Ptr("owner/repo-2"), +// HTMLURL: github.Ptr("https://github.com/owner/repo-2"), +// Description: github.Ptr("Test repository 2"), +// StargazersCount: github.Ptr(50), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.RepositoriesSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful repository search", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchRepositories, +// expectQueryParams(t, map[string]string{ +// "q": "golang test", +// "sort": "stars", +// "order": "desc", +// "page": "2", +// "per_page": "10", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "golang test", +// "sort": "stars", +// "order": "desc", +// "page": float64(2), +// "perPage": float64(10), +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "repository search with default pagination", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchRepositories, +// expectQueryParams(t, map[string]string{ +// "q": "golang test", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "golang test", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "search fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchRepositories, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search repositories", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedResult MinimalSearchRepositoriesResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) +// for i, repo := range returnedResult.Items { +// assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) +// assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) +// assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) +// assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) +// } + +// }) +// } +// } + +// func Test_SearchRepositories_FullOutput(t *testing.T) { +// mockSearchResult := &github.RepositoriesSearchResult{ +// Total: github.Ptr(1), +// IncompleteResults: github.Ptr(false), +// Repositories: []*github.Repository{ +// { +// ID: github.Ptr(int64(12345)), +// Name: github.Ptr("test-repo"), +// FullName: github.Ptr("owner/test-repo"), +// HTMLURL: github.Ptr("https://github.com/owner/test-repo"), +// Description: github.Ptr("Test repository"), +// StargazersCount: github.Ptr(100), +// }, +// }, +// } + +// mockedClient := mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchRepositories, +// expectQueryParams(t, map[string]string{ +// "q": "golang test", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ) + +// client := github.NewClient(mockedClient) +// _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(map[string]interface{}{ +// "query": "golang test", +// "minimal_output": false, +// }) + +// result, err := handlerTest(context.Background(), request) + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) + +// // Unmarshal as full GitHub API response +// var returnedResult github.RepositoriesSearchResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) + +// // Verify it's the full API response, not minimal +// assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) +// assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Repositories, 1) +// assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) +// assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) +// } + +// func Test_SearchCode(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SearchCode(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "search_code", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// // Setup mock search results +// mockSearchResult := &github.CodeSearchResult{ +// Total: github.Ptr(2), +// IncompleteResults: github.Ptr(false), +// CodeResults: []*github.CodeResult{ +// { +// Name: github.Ptr("file1.go"), +// Path: github.Ptr("path/to/file1.go"), +// SHA: github.Ptr("abc123def456"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), +// Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, +// }, +// { +// Name: github.Ptr("file2.go"), +// Path: github.Ptr("path/to/file2.go"), +// SHA: github.Ptr("def456abc123"), +// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), +// Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.CodeSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful code search with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchCode, +// expectQueryParams(t, map[string]string{ +// "q": "fmt.Println language:go", +// "sort": "indexed", +// "order": "desc", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "fmt.Println language:go", +// "sort": "indexed", +// "order": "desc", +// "page": float64(1), +// "perPage": float64(30), +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "code search with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchCode, +// expectQueryParams(t, map[string]string{ +// "q": "fmt.Println language:go", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "fmt.Println language:go", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "search code fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchCode, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search code", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchCode(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedResult github.CodeSearchResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) +// for i, code := range returnedResult.CodeResults { +// assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) +// assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) +// assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) +// assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) +// assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) +// } +// }) +// } +// } + +// func Test_SearchUsers(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SearchUsers(stubGetClientFn(mockClient), translations.NullTranslationHelper) +// require.NoError(t, toolsnaps.Test(tool.Name, tool)) + +// assert.Equal(t, "search_users", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// // Setup mock search results +// mockSearchResult := &github.UsersSearchResult{ +// Total: github.Ptr(2), +// IncompleteResults: github.Ptr(false), +// Users: []*github.User{ +// { +// Login: github.Ptr("user1"), +// ID: github.Ptr(int64(1001)), +// HTMLURL: github.Ptr("https://github.com/user1"), +// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), +// }, +// { +// Login: github.Ptr("user2"), +// ID: github.Ptr(int64(1002)), +// HTMLURL: github.Ptr("https://github.com/user2"), +// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), +// Type: github.Ptr("User"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.UsersSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful users search with all parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:user location:finland language:go", +// "sort": "followers", +// "order": "desc", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "location:finland language:go", +// "sort": "followers", +// "order": "desc", +// "page": float64(1), +// "perPage": float64(30), +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "users search with minimal parameters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:user location:finland language:go", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "location:finland language:go", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing type:user filter - no duplication", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:user location:seattle followers:>100", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "type:user location:seattle followers:>100", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "complex query with existing type:user filter and OR operators", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:user (location:seattle OR location:california) followers:>50", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "type:user (location:seattle OR location:california) followers:>50", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "search users fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search users", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchUsers(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// require.NotNil(t, result) + +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedResult MinimalSearchUsersResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) +// for i, user := range returnedResult.Items { +// assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login) +// assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID) +// assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL) +// assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL) +// } +// }) +// } +// } + +// func Test_SearchOrgs(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "search_orgs", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "query") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "order") +// assert.Contains(t, tool.InputSchema.Properties, "perPage") +// assert.Contains(t, tool.InputSchema.Properties, "page") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + +// // Setup mock search results +// mockSearchResult := &github.UsersSearchResult{ +// Total: github.Ptr(int(2)), +// IncompleteResults: github.Ptr(false), +// Users: []*github.User{ +// { +// Login: github.Ptr("org-1"), +// ID: github.Ptr(int64(111)), +// HTMLURL: github.Ptr("https://github.com/org-1"), +// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), +// }, +// { +// Login: github.Ptr("org-2"), +// ID: github.Ptr(int64(222)), +// HTMLURL: github.Ptr("https://github.com/org-2"), +// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), +// }, +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedResult *github.UsersSearchResult +// expectedErrMsg string +// }{ +// { +// name: "successful org search", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:org github", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "github", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "query with existing type:org filter - no duplication", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:org location:california followers:>1000", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "type:org location:california followers:>1000", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "complex query with existing type:org filter and OR operators", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// expectQueryParams(t, map[string]string{ +// "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", +// "page": "1", +// "per_page": "30", +// }).andThen( +// mockResponse(t, http.StatusOK, mockSearchResult), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", +// }, +// expectError: false, +// expectedResult: mockSearchResult, +// }, +// { +// name: "org search fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetSearchUsers, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "query": "invalid:query", +// }, +// expectError: true, +// expectedErrMsg: "failed to search orgs", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.NotNil(t, result) + +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedResult MinimalSearchUsersResult +// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) +// require.NoError(t, err) +// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) +// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) +// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) +// for i, org := range returnedResult.Items { +// assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login) +// assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID) +// assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL) +// assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL) +// } +// }) +// } +// } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 04cb2224f..0e3915389 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -1,115 +1,115 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "regexp" - - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" -) - -func hasFilter(query, filterType string) bool { - // Match filter at start of string, after whitespace, or after non-word characters like '(' - pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType)) - matched, _ := regexp.MatchString(pattern, query) - return matched -} - -func hasSpecificFilter(query, filterType, filterValue string) bool { - // Match specific filter:value at start, after whitespace, or after non-word characters - // End with word boundary, whitespace, or non-word characters like ')' - pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue)) - matched, _ := regexp.MatchString(pattern, query) - return matched -} - -func hasRepoFilter(query string) bool { - return hasFilter(query, "repo") -} - -func hasTypeFilter(query string) bool { - return hasFilter(query, "type") -} - -func searchHandler( - ctx context.Context, - getClient GetClientFn, - request mcp.CallToolRequest, - searchType string, - errorPrefix string, -) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - if !hasSpecificFilter(query, "is", searchType) { - query = fmt.Sprintf("is:%s %s", searchType, query) - } - - owner, err := OptionalParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - repo, err := OptionalParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - if owner != "" && repo != "" && !hasRepoFilter(query) { - query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) - } - - sort, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - order, err := OptionalParam[string](request, "order") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.SearchOptions{ - // Default to "created" if no sort is provided, as it's a common use case. - Sort: sort, - Order: order, - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err) - } - result, resp, err := client.Search.Issues(ctx, query, opts) - if err != nil { - return nil, fmt.Errorf("%s: %w", errorPrefix, err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err) - } - return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) - } - - return mcp.NewToolResultText(string(r)), nil -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" +// "regexp" + +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// ) + +// func hasFilter(query, filterType string) bool { +// // Match filter at start of string, after whitespace, or after non-word characters like '(' +// pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType)) +// matched, _ := regexp.MatchString(pattern, query) +// return matched +// } + +// func hasSpecificFilter(query, filterType, filterValue string) bool { +// // Match specific filter:value at start, after whitespace, or after non-word characters +// // End with word boundary, whitespace, or non-word characters like ')' +// pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue)) +// matched, _ := regexp.MatchString(pattern, query) +// return matched +// } + +// func hasRepoFilter(query string) bool { +// return hasFilter(query, "repo") +// } + +// func hasTypeFilter(query string) bool { +// return hasFilter(query, "type") +// } + +// func searchHandler( +// ctx context.Context, +// getClient GetClientFn, +// request mcp.CallToolRequest, +// searchType string, +// errorPrefix string, +// ) (*mcp.CallToolResult, error) { +// query, err := RequiredParam[string](request, "query") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// if !hasSpecificFilter(query, "is", searchType) { +// query = fmt.Sprintf("is:%s %s", searchType, query) +// } + +// owner, err := OptionalParam[string](request, "owner") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// repo, err := OptionalParam[string](request, "repo") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// if owner != "" && repo != "" && !hasRepoFilter(query) { +// query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) +// } + +// sort, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// order, err := OptionalParam[string](request, "order") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// pagination, err := OptionalPaginationParams(request) +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } + +// opts := &github.SearchOptions{ +// // Default to "created" if no sort is provided, as it's a common use case. +// Sort: sort, +// Order: order, +// ListOptions: github.ListOptions{ +// Page: pagination.Page, +// PerPage: pagination.PerPage, +// }, +// } + +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err) +// } +// result, resp, err := client.Search.Issues(ctx, query, opts) +// if err != nil { +// return nil, fmt.Errorf("%s: %w", errorPrefix, err) +// } +// defer func() { _ = resp.Body.Close() }() + +// if resp.StatusCode != http.StatusOK { +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err) +// } +// return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil +// } + +// r, err := json.Marshal(result) +// if err != nil { +// return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go index 85f953eed..052dfe19f 100644 --- a/pkg/github/search_utils_test.go +++ b/pkg/github/search_utils_test.go @@ -1,352 +1,352 @@ package github -import ( - "testing" +// import ( +// "testing" - "github.com/stretchr/testify/assert" -) +// "github.com/stretchr/testify/assert" +// ) -func Test_hasFilter(t *testing.T) { - tests := []struct { - name string - query string - filterType string - expected bool - }{ - { - name: "query has is:issue filter", - query: "is:issue bug report", - filterType: "is", - expected: true, - }, - { - name: "query has repo: filter", - query: "repo:github/github-mcp-server critical bug", - filterType: "repo", - expected: true, - }, - { - name: "query has multiple is: filters", - query: "is:issue is:open bug", - filterType: "is", - expected: true, - }, - { - name: "query has filter at the beginning", - query: "is:issue some text", - filterType: "is", - expected: true, - }, - { - name: "query has filter in the middle", - query: "some text is:issue more text", - filterType: "is", - expected: true, - }, - { - name: "query has filter at the end", - query: "some text is:issue", - filterType: "is", - expected: true, - }, - { - name: "query does not have the filter", - query: "bug report critical", - filterType: "is", - expected: false, - }, - { - name: "query has similar text but not the filter", - query: "this issue is important", - filterType: "is", - expected: false, - }, - { - name: "empty query", - query: "", - filterType: "is", - expected: false, - }, - { - name: "query has label: filter but looking for is:", - query: "label:bug critical", - filterType: "is", - expected: false, - }, - { - name: "query has author: filter", - query: "author:octocat bug", - filterType: "author", - expected: true, - }, - { - name: "query with complex OR expression", - query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", - filterType: "is", - expected: true, - }, - { - name: "query with complex OR expression checking repo", - query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", - filterType: "repo", - expected: true, - }, - { - name: "filter in parentheses at start", - query: "(label:bug OR owner:bob) is:issue", - filterType: "label", - expected: true, - }, - { - name: "filter after opening parenthesis", - query: "is:issue (label:critical OR repo:test/test)", - filterType: "label", - expected: true, - }, - } +// func Test_hasFilter(t *testing.T) { +// tests := []struct { +// name string +// query string +// filterType string +// expected bool +// }{ +// { +// name: "query has is:issue filter", +// query: "is:issue bug report", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query has repo: filter", +// query: "repo:github/github-mcp-server critical bug", +// filterType: "repo", +// expected: true, +// }, +// { +// name: "query has multiple is: filters", +// query: "is:issue is:open bug", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query has filter at the beginning", +// query: "is:issue some text", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query has filter in the middle", +// query: "some text is:issue more text", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query has filter at the end", +// query: "some text is:issue", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query does not have the filter", +// query: "bug report critical", +// filterType: "is", +// expected: false, +// }, +// { +// name: "query has similar text but not the filter", +// query: "this issue is important", +// filterType: "is", +// expected: false, +// }, +// { +// name: "empty query", +// query: "", +// filterType: "is", +// expected: false, +// }, +// { +// name: "query has label: filter but looking for is:", +// query: "label:bug critical", +// filterType: "is", +// expected: false, +// }, +// { +// name: "query has author: filter", +// query: "author:octocat bug", +// filterType: "author", +// expected: true, +// }, +// { +// name: "query with complex OR expression", +// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", +// filterType: "is", +// expected: true, +// }, +// { +// name: "query with complex OR expression checking repo", +// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", +// filterType: "repo", +// expected: true, +// }, +// { +// name: "filter in parentheses at start", +// query: "(label:bug OR owner:bob) is:issue", +// filterType: "label", +// expected: true, +// }, +// { +// name: "filter after opening parenthesis", +// query: "is:issue (label:critical OR repo:test/test)", +// filterType: "label", +// expected: true, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasFilter(tt.query, tt.filterType) - assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) - }) - } -} +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// result := hasFilter(tt.query, tt.filterType) +// assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) +// }) +// } +// } -func Test_hasRepoFilter(t *testing.T) { - tests := []struct { - name string - query string - expected bool - }{ - { - name: "query with repo: filter at beginning", - query: "repo:github/github-mcp-server is:issue", - expected: true, - }, - { - name: "query with repo: filter in middle", - query: "is:issue repo:octocat/Hello-World bug", - expected: true, - }, - { - name: "query with repo: filter at end", - query: "is:issue critical repo:owner/repo-name", - expected: true, - }, - { - name: "query with complex repo name", - query: "repo:microsoft/vscode-extension-samples bug", - expected: true, - }, - { - name: "query without repo: filter", - query: "is:issue bug critical", - expected: false, - }, - { - name: "query with malformed repo: filter (no slash)", - query: "repo:github bug", - expected: true, // hasRepoFilter only checks for repo: prefix, not format - }, - { - name: "empty query", - query: "", - expected: false, - }, - { - name: "query with multiple repo: filters", - query: "repo:github/first repo:octocat/second", - expected: true, - }, - { - name: "query with repo: in text but not as filter", - query: "this repo: is important", - expected: false, - }, - { - name: "query with complex OR expression", - query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", - expected: true, - }, - } +// func Test_hasRepoFilter(t *testing.T) { +// tests := []struct { +// name string +// query string +// expected bool +// }{ +// { +// name: "query with repo: filter at beginning", +// query: "repo:github/github-mcp-server is:issue", +// expected: true, +// }, +// { +// name: "query with repo: filter in middle", +// query: "is:issue repo:octocat/Hello-World bug", +// expected: true, +// }, +// { +// name: "query with repo: filter at end", +// query: "is:issue critical repo:owner/repo-name", +// expected: true, +// }, +// { +// name: "query with complex repo name", +// query: "repo:microsoft/vscode-extension-samples bug", +// expected: true, +// }, +// { +// name: "query without repo: filter", +// query: "is:issue bug critical", +// expected: false, +// }, +// { +// name: "query with malformed repo: filter (no slash)", +// query: "repo:github bug", +// expected: true, // hasRepoFilter only checks for repo: prefix, not format +// }, +// { +// name: "empty query", +// query: "", +// expected: false, +// }, +// { +// name: "query with multiple repo: filters", +// query: "repo:github/first repo:octocat/second", +// expected: true, +// }, +// { +// name: "query with repo: in text but not as filter", +// query: "this repo: is important", +// expected: false, +// }, +// { +// name: "query with complex OR expression", +// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", +// expected: true, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasRepoFilter(tt.query) - assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) - }) - } -} +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// result := hasRepoFilter(tt.query) +// assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) +// }) +// } +// } -func Test_hasSpecificFilter(t *testing.T) { - tests := []struct { - name string - query string - filterType string - filterValue string - expected bool - }{ - { - name: "query has exact is:issue filter", - query: "is:issue bug report", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "query has is:open but looking for is:issue", - query: "is:open bug report", - filterType: "is", - filterValue: "issue", - expected: false, - }, - { - name: "query has both is:issue and is:open, looking for is:issue", - query: "is:issue is:open bug", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "query has both is:issue and is:open, looking for is:open", - query: "is:issue is:open bug", - filterType: "is", - filterValue: "open", - expected: true, - }, - { - name: "query has is:issue at the beginning", - query: "is:issue some text", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "query has is:issue in the middle", - query: "some text is:issue more text", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "query has is:issue at the end", - query: "some text is:issue", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "query does not have is:issue", - query: "bug report critical", - filterType: "is", - filterValue: "issue", - expected: false, - }, - { - name: "query has similar text but not the exact filter", - query: "this issue is important", - filterType: "is", - filterValue: "issue", - expected: false, - }, - { - name: "empty query", - query: "", - filterType: "is", - filterValue: "issue", - expected: false, - }, - { - name: "partial match should not count", - query: "is:issues bug", // "issues" vs "issue" - filterType: "is", - filterValue: "issue", - expected: false, - }, - { - name: "complex query with parentheses", - query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "filter:value in parentheses at start", - query: "(is:issue OR is:pr) label:bug", - filterType: "is", - filterValue: "issue", - expected: true, - }, - { - name: "filter:value after opening parenthesis", - query: "repo:test/repo (is:issue AND label:bug)", - filterType: "is", - filterValue: "issue", - expected: true, - }, - } +// func Test_hasSpecificFilter(t *testing.T) { +// tests := []struct { +// name string +// query string +// filterType string +// filterValue string +// expected bool +// }{ +// { +// name: "query has exact is:issue filter", +// query: "is:issue bug report", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "query has is:open but looking for is:issue", +// query: "is:open bug report", +// filterType: "is", +// filterValue: "issue", +// expected: false, +// }, +// { +// name: "query has both is:issue and is:open, looking for is:issue", +// query: "is:issue is:open bug", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "query has both is:issue and is:open, looking for is:open", +// query: "is:issue is:open bug", +// filterType: "is", +// filterValue: "open", +// expected: true, +// }, +// { +// name: "query has is:issue at the beginning", +// query: "is:issue some text", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "query has is:issue in the middle", +// query: "some text is:issue more text", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "query has is:issue at the end", +// query: "some text is:issue", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "query does not have is:issue", +// query: "bug report critical", +// filterType: "is", +// filterValue: "issue", +// expected: false, +// }, +// { +// name: "query has similar text but not the exact filter", +// query: "this issue is important", +// filterType: "is", +// filterValue: "issue", +// expected: false, +// }, +// { +// name: "empty query", +// query: "", +// filterType: "is", +// filterValue: "issue", +// expected: false, +// }, +// { +// name: "partial match should not count", +// query: "is:issues bug", // "issues" vs "issue" +// filterType: "is", +// filterValue: "issue", +// expected: false, +// }, +// { +// name: "complex query with parentheses", +// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "filter:value in parentheses at start", +// query: "(is:issue OR is:pr) label:bug", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// { +// name: "filter:value after opening parenthesis", +// query: "repo:test/repo (is:issue AND label:bug)", +// filterType: "is", +// filterValue: "issue", +// expected: true, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) - assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) - }) - } -} +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) +// assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) +// }) +// } +// } -func Test_hasTypeFilter(t *testing.T) { - tests := []struct { - name string - query string - expected bool - }{ - { - name: "query with type:user filter at beginning", - query: "type:user location:seattle", - expected: true, - }, - { - name: "query with type:org filter in middle", - query: "location:california type:org followers:>100", - expected: true, - }, - { - name: "query with type:user filter at end", - query: "location:seattle followers:>50 type:user", - expected: true, - }, - { - name: "query without type: filter", - query: "location:seattle followers:>50", - expected: false, - }, - { - name: "empty query", - query: "", - expected: false, - }, - { - name: "query with type: in text but not as filter", - query: "this type: is important", - expected: false, - }, - { - name: "query with multiple type: filters", - query: "type:user type:org", - expected: true, - }, - { - name: "complex query with OR expression", - query: "type:user (location:seattle OR location:california)", - expected: true, - }, - } +// func Test_hasTypeFilter(t *testing.T) { +// tests := []struct { +// name string +// query string +// expected bool +// }{ +// { +// name: "query with type:user filter at beginning", +// query: "type:user location:seattle", +// expected: true, +// }, +// { +// name: "query with type:org filter in middle", +// query: "location:california type:org followers:>100", +// expected: true, +// }, +// { +// name: "query with type:user filter at end", +// query: "location:seattle followers:>50 type:user", +// expected: true, +// }, +// { +// name: "query without type: filter", +// query: "location:seattle followers:>50", +// expected: false, +// }, +// { +// name: "empty query", +// query: "", +// expected: false, +// }, +// { +// name: "query with type: in text but not as filter", +// query: "this type: is important", +// expected: false, +// }, +// { +// name: "query with multiple type: filters", +// query: "type:user type:org", +// expected: true, +// }, +// { +// name: "complex query with OR expression", +// query: "type:user (location:seattle OR location:california)", +// expected: true, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasTypeFilter(tt.query) - assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) - }) - } -} +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// result := hasTypeFilter(tt.query) +// assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) +// }) +// } +// } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 866c54617..70ba6ce27 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -1,163 +1,163 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) +// ghErrors "github.com/github/github-mcp-server/pkg/errors" +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) -func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool( - "get_secret_scanning_alert", - mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithNumber("alertNumber", - mcp.Required(), - mcp.Description("The number of the alert."), - ), - ), - 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 - } - alertNumber, err := RequiredInt(request, "alertNumber") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool( +// "get_secret_scanning_alert", +// mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithNumber("alertNumber", +// mcp.Required(), +// mcp.Description("The number of the alert."), +// ), +// ), +// 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 +// } +// alertNumber, err := RequiredInt(request, "alertNumber") +// 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) - } +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } - alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to get alert with number '%d'", alertNumber), +// 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 get alert: %s", string(body))), 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 alert: %s", string(body))), nil +// } - r, err := json.Marshal(alert) - if err != nil { - return nil, fmt.Errorf("failed to marshal alert: %w", err) - } +// r, err := json.Marshal(alert) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alert: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } -func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool( - "list_secret_scanning_alerts", - mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithString("state", - mcp.Description("Filter by state"), - mcp.Enum("open", "resolved"), - ), - mcp.WithString("secret_type", - mcp.Description("A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter."), - ), - mcp.WithString("resolution", - mcp.Description("Filter by resolution"), - mcp.Enum("false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"), - ), - ), - 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 - } - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - secretType, err := OptionalParam[string](request, "secret_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - resolution, err := OptionalParam[string](request, "resolution") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool( +// "list_secret_scanning_alerts", +// mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithString("state", +// mcp.Description("Filter by state"), +// mcp.Enum("open", "resolved"), +// ), +// mcp.WithString("secret_type", +// mcp.Description("A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter."), +// ), +// mcp.WithString("resolution", +// mcp.Description("Filter by resolution"), +// mcp.Enum("false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"), +// ), +// ), +// 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 +// } +// state, err := OptionalParam[string](request, "state") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// secretType, err := OptionalParam[string](request, "secret_type") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// resolution, err := OptionalParam[string](request, "resolution") +// 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) - } - alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } +// alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) +// if err != nil { +// return ghErrors.NewGitHubAPIErrorResponse(ctx, +// fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), +// 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 alerts: %s", string(body))), 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 list alerts: %s", string(body))), nil +// } - r, err := json.Marshal(alerts) - if err != nil { - return nil, fmt.Errorf("failed to marshal alerts: %w", err) - } +// r, err := json.Marshal(alerts) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal alerts: %w", err) +// } - return mcp.NewToolResultText(string(r)), nil - } -} +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 4a9d50ab9..f70111fec 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -1,249 +1,249 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetSecretScanningAlert(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetSecretScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_secret_scanning_alert", 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, "alertNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - - // Setup mock alert for success case - mockAlert := &github.SecretScanningAlert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlert *github.SecretScanningAlert - expectedErrMsg string - }{ - { - name: "successful alert fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, - mockAlert, - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(42), - }, - expectError: false, - expectedAlert: mockAlert, - }, - { - name: "alert fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "alertNumber": float64(9999), - }, - expectError: true, - expectedErrMsg: "failed to get alert", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetSecretScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedAlert github.Alert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) - assert.NoError(t, err) - assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) - assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) - assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) - - }) - } -} - -func Test_ListSecretScanningAlerts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListSecretScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_secret_scanning_alerts", 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, "state") - assert.Contains(t, tool.InputSchema.Properties, "secret_type") - assert.Contains(t, tool.InputSchema.Properties, "resolution") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Setup mock alerts for success case - resolvedAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), - State: github.Ptr("resolved"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), - } - openAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), - State: github.Ptr("open"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlerts []*github.SecretScanningAlert - expectedErrMsg string - }{ - { - name: "successful resolved alerts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposSecretScanningAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "resolved", - }).andThen( - mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "resolved", - }, - expectError: false, - expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert}, - }, - { - name: "successful alerts listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposSecretScanningAlertsByOwnerByRepo, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, - }, - { - name: "alerts listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposSecretScanningAlertsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list alerts", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedAlerts []*github.SecretScanningAlert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) - assert.NoError(t, err) - assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) - for i, alert := range returnedAlerts { - assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) - assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) - assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) - assert.Equal(t, *tc.expectedAlerts[i].Resolution, *alert.Resolution) - assert.Equal(t, *tc.expectedAlerts[i].SecretType, *alert.SecretType) - } - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_GetSecretScanningAlert(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := GetSecretScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "get_secret_scanning_alert", 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, "alertNumber") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + +// // Setup mock alert for success case +// mockAlert := &github.SecretScanningAlert{ +// Number: github.Ptr(42), +// State: github.Ptr("open"), +// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlert *github.SecretScanningAlert +// expectedErrMsg string +// }{ +// { +// name: "successful alert fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, +// mockAlert, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(42), +// }, +// expectError: false, +// expectedAlert: mockAlert, +// }, +// { +// name: "alert fetch fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "alertNumber": float64(9999), +// }, +// expectError: true, +// expectedErrMsg: "failed to get alert", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetSecretScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedAlert github.Alert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) +// assert.NoError(t, err) +// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) +// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) +// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + +// }) +// } +// } + +// func Test_ListSecretScanningAlerts(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListSecretScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_secret_scanning_alerts", 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, "state") +// assert.Contains(t, tool.InputSchema.Properties, "secret_type") +// assert.Contains(t, tool.InputSchema.Properties, "resolution") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Setup mock alerts for success case +// resolvedAlert := github.SecretScanningAlert{ +// Number: github.Ptr(2), +// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), +// State: github.Ptr("resolved"), +// Resolution: github.Ptr("false_positive"), +// SecretType: github.Ptr("adafruit_io_key"), +// } +// openAlert := github.SecretScanningAlert{ +// Number: github.Ptr(2), +// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), +// State: github.Ptr("open"), +// Resolution: github.Ptr("false_positive"), +// SecretType: github.Ptr("adafruit_io_key"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAlerts []*github.SecretScanningAlert +// expectedErrMsg string +// }{ +// { +// name: "successful resolved alerts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposSecretScanningAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{ +// "state": "resolved", +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// "state": "resolved", +// }, +// expectError: false, +// expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert}, +// }, +// { +// name: "successful alerts listing", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposSecretScanningAlertsByOwnerByRepo, +// expectQueryParams(t, map[string]string{}).andThen( +// mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, +// }, +// { +// name: "alerts listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetReposSecretScanningAlertsByOwnerByRepo, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusUnauthorized) +// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list alerts", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.NoError(t, err) +// require.True(t, result.IsError) +// errorContent := getErrorResult(t, result) +// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) +// require.False(t, result.IsError) + +// textContent := getTextResult(t, result) + +// // Unmarshal and verify the result +// var returnedAlerts []*github.SecretScanningAlert +// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) +// assert.NoError(t, err) +// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) +// for i, alert := range returnedAlerts { +// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) +// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) +// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) +// assert.Equal(t, *tc.expectedAlerts[i].Resolution, *alert.Resolution) +// assert.Equal(t, *tc.expectedAlerts[i].SecretType, *alert.SecretType) +// } +// }) +// } +// } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 316b5d58c..5f6c211d5 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -1,397 +1,397 @@ package github -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_global_security_advisories", - mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("ghsaId", - mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), - ), - mcp.WithString("type", - mcp.Description("Advisory type."), - mcp.Enum("reviewed", "malware", "unreviewed"), - mcp.DefaultString("reviewed"), - ), - mcp.WithString("cveId", - mcp.Description("Filter by CVE ID."), - ), - mcp.WithString("ecosystem", - mcp.Description("Filter by package ecosystem."), - mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"), - ), - mcp.WithString("severity", - mcp.Description("Filter by severity."), - mcp.Enum("unknown", "low", "medium", "high", "critical"), - ), - mcp.WithArray("cwes", - mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."), - mcp.Items(map[string]any{ - "type": "string", - }), - ), - mcp.WithBoolean("isWithdrawn", - mcp.Description("Whether to only return withdrawn advisories."), - ), - mcp.WithString("affects", - mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."), - ), - mcp.WithString("published", - mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."), - ), - mcp.WithString("updated", - mcp.Description("Filter by update date or date range (ISO 8601 date or range)."), - ), - mcp.WithString("modified", - mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."), - ), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - ghsaID, err := OptionalParam[string](request, "ghsaId") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil - } - - typ, err := OptionalParam[string](request, "type") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil - } - - cveID, err := OptionalParam[string](request, "cveId") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil - } - - eco, err := OptionalParam[string](request, "ecosystem") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil - } - - sev, err := OptionalParam[string](request, "severity") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil - } - - cwes, err := OptionalParam[[]string](request, "cwes") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil - } - - isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil - } - - affects, err := OptionalParam[string](request, "affects") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil - } - - published, err := OptionalParam[string](request, "published") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil - } - - updated, err := OptionalParam[string](request, "updated") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil - } - - modified, err := OptionalParam[string](request, "modified") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil - } - - opts := &github.ListGlobalSecurityAdvisoriesOptions{} - - if ghsaID != "" { - opts.GHSAID = &ghsaID - } - if typ != "" { - opts.Type = &typ - } - if cveID != "" { - opts.CVEID = &cveID - } - if eco != "" { - opts.Ecosystem = &eco - } - if sev != "" { - opts.Severity = &sev - } - if len(cwes) > 0 { - opts.CWEs = cwes - } - - if isWithdrawn { - opts.IsWithdrawn = &isWithdrawn - } - - if affects != "" { - opts.Affects = &affects - } - if published != "" { - opts.Published = &published - } - if updated != "" { - opts.Updated = &updated - } - if modified != "" { - opts.Modified = &modified - } - - advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) - if err != nil { - return nil, fmt.Errorf("failed to list global security advisories: %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 list advisories: %s", string(body))), nil - } - - r, err := json.Marshal(advisories) - if err != nil { - return nil, fmt.Errorf("failed to marshal advisories: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_repository_security_advisories", - mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository."), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository."), - ), - mcp.WithString("direction", - mcp.Description("Sort direction."), - mcp.Enum("asc", "desc"), - ), - mcp.WithString("sort", - mcp.Description("Sort field."), - mcp.Enum("created", "updated", "published"), - ), - mcp.WithString("state", - mcp.Description("Filter by advisory state."), - mcp.Enum("triage", "draft", "published", "closed"), - ), - ), 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 - } - - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sortField, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - state, err := OptionalParam[string](request, "state") - 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) - } - - opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction - } - if sortField != "" { - opts.Sort = sortField - } - if state != "" { - opts.State = state - } - - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list repository security advisories: %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 list repository advisories: %s", string(body))), nil - } - - r, err := json.Marshal(advisories) - if err != nil { - return nil, fmt.Errorf("failed to marshal advisories: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_global_security_advisory", - mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("ghsaId", - mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), - mcp.Required(), - ), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - ghsaID, err := RequiredParam[string](request, "ghsaId") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil - } - - advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) - if err != nil { - return nil, fmt.Errorf("failed to get advisory: %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 advisory: %s", string(body))), nil - } - - r, err := json.Marshal(advisory) - if err != nil { - return nil, fmt.Errorf("failed to marshal advisory: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_org_repository_security_advisories", - mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("The organization login."), - ), - mcp.WithString("direction", - mcp.Description("Sort direction."), - mcp.Enum("asc", "desc"), - ), - mcp.WithString("sort", - mcp.Description("Sort field."), - mcp.Enum("created", "updated", "published"), - ), - mcp.WithString("state", - mcp.Description("Filter by advisory state."), - mcp.Enum("triage", "draft", "published", "closed"), - ), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - sortField, err := OptionalParam[string](request, "sort") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - state, err := OptionalParam[string](request, "state") - 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) - } - - opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction - } - if sortField != "" { - opts.Sort = sortField - } - if state != "" { - opts.State = state - } - - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) - if err != nil { - return nil, fmt.Errorf("failed to list organization repository security advisories: %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 list organization repository advisories: %s", string(body))), nil - } - - r, err := json.Marshal(advisories) - if err != nil { - return nil, fmt.Errorf("failed to marshal advisories: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} +// import ( +// "context" +// "encoding/json" +// "fmt" +// "io" +// "net/http" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/mark3labs/mcp-go/mcp" +// "github.com/mark3labs/mcp-go/server" +// ) + +// func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_global_security_advisories", +// mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("ghsaId", +// mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), +// ), +// mcp.WithString("type", +// mcp.Description("Advisory type."), +// mcp.Enum("reviewed", "malware", "unreviewed"), +// mcp.DefaultString("reviewed"), +// ), +// mcp.WithString("cveId", +// mcp.Description("Filter by CVE ID."), +// ), +// mcp.WithString("ecosystem", +// mcp.Description("Filter by package ecosystem."), +// mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"), +// ), +// mcp.WithString("severity", +// mcp.Description("Filter by severity."), +// mcp.Enum("unknown", "low", "medium", "high", "critical"), +// ), +// mcp.WithArray("cwes", +// mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."), +// mcp.Items(map[string]any{ +// "type": "string", +// }), +// ), +// mcp.WithBoolean("isWithdrawn", +// mcp.Description("Whether to only return withdrawn advisories."), +// ), +// mcp.WithString("affects", +// mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."), +// ), +// mcp.WithString("published", +// mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."), +// ), +// mcp.WithString("updated", +// mcp.Description("Filter by update date or date range (ISO 8601 date or range)."), +// ), +// mcp.WithString("modified", +// mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."), +// ), +// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// ghsaID, err := OptionalParam[string](request, "ghsaId") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil +// } + +// typ, err := OptionalParam[string](request, "type") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil +// } + +// cveID, err := OptionalParam[string](request, "cveId") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil +// } + +// eco, err := OptionalParam[string](request, "ecosystem") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil +// } + +// sev, err := OptionalParam[string](request, "severity") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil +// } + +// cwes, err := OptionalParam[[]string](request, "cwes") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil +// } + +// isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil +// } + +// affects, err := OptionalParam[string](request, "affects") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil +// } + +// published, err := OptionalParam[string](request, "published") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil +// } + +// updated, err := OptionalParam[string](request, "updated") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil +// } + +// modified, err := OptionalParam[string](request, "modified") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil +// } + +// opts := &github.ListGlobalSecurityAdvisoriesOptions{} + +// if ghsaID != "" { +// opts.GHSAID = &ghsaID +// } +// if typ != "" { +// opts.Type = &typ +// } +// if cveID != "" { +// opts.CVEID = &cveID +// } +// if eco != "" { +// opts.Ecosystem = &eco +// } +// if sev != "" { +// opts.Severity = &sev +// } +// if len(cwes) > 0 { +// opts.CWEs = cwes +// } + +// if isWithdrawn { +// opts.IsWithdrawn = &isWithdrawn +// } + +// if affects != "" { +// opts.Affects = &affects +// } +// if published != "" { +// opts.Published = &published +// } +// if updated != "" { +// opts.Updated = &updated +// } +// if modified != "" { +// opts.Modified = &modified +// } + +// advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list global security advisories: %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 list advisories: %s", string(body))), nil +// } + +// r, err := json.Marshal(advisories) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal advisories: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_repository_security_advisories", +// mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("owner", +// mcp.Required(), +// mcp.Description("The owner of the repository."), +// ), +// mcp.WithString("repo", +// mcp.Required(), +// mcp.Description("The name of the repository."), +// ), +// mcp.WithString("direction", +// mcp.Description("Sort direction."), +// mcp.Enum("asc", "desc"), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort field."), +// mcp.Enum("created", "updated", "published"), +// ), +// mcp.WithString("state", +// mcp.Description("Filter by advisory state."), +// mcp.Enum("triage", "draft", "published", "closed"), +// ), +// ), 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 +// } + +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sortField, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// state, err := OptionalParam[string](request, "state") +// 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) +// } + +// opts := &github.ListRepositorySecurityAdvisoriesOptions{} +// if direction != "" { +// opts.Direction = direction +// } +// if sortField != "" { +// opts.Sort = sortField +// } +// if state != "" { +// opts.State = state +// } + +// advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list repository security advisories: %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 list repository advisories: %s", string(body))), nil +// } + +// r, err := json.Marshal(advisories) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal advisories: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("get_global_security_advisory", +// mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("ghsaId", +// mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), +// mcp.Required(), +// ), +// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// client, err := getClient(ctx) +// if err != nil { +// return nil, fmt.Errorf("failed to get GitHub client: %w", err) +// } + +// ghsaID, err := RequiredParam[string](request, "ghsaId") +// if err != nil { +// return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil +// } + +// advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) +// if err != nil { +// return nil, fmt.Errorf("failed to get advisory: %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 advisory: %s", string(body))), nil +// } + +// r, err := json.Marshal(advisory) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal advisory: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } + +// func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// return mcp.NewTool("list_org_repository_security_advisories", +// mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")), +// mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"), +// ReadOnlyHint: ToBoolPtr(true), +// }), +// mcp.WithString("org", +// mcp.Required(), +// mcp.Description("The organization login."), +// ), +// mcp.WithString("direction", +// mcp.Description("Sort direction."), +// mcp.Enum("asc", "desc"), +// ), +// mcp.WithString("sort", +// mcp.Description("Sort field."), +// mcp.Enum("created", "updated", "published"), +// ), +// mcp.WithString("state", +// mcp.Description("Filter by advisory state."), +// mcp.Enum("triage", "draft", "published", "closed"), +// ), +// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// org, err := RequiredParam[string](request, "org") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// direction, err := OptionalParam[string](request, "direction") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// sortField, err := OptionalParam[string](request, "sort") +// if err != nil { +// return mcp.NewToolResultError(err.Error()), nil +// } +// state, err := OptionalParam[string](request, "state") +// 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) +// } + +// opts := &github.ListRepositorySecurityAdvisoriesOptions{} +// if direction != "" { +// opts.Direction = direction +// } +// if sortField != "" { +// opts.Sort = sortField +// } +// if state != "" { +// opts.State = state +// } + +// advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) +// if err != nil { +// return nil, fmt.Errorf("failed to list organization repository security advisories: %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 list organization repository advisories: %s", string(body))), nil +// } + +// r, err := json.Marshal(advisories) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal advisories: %w", err) +// } + +// return mcp.NewToolResultText(string(r)), nil +// } +// } diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index e083cb166..d7efca0ce 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -1,526 +1,526 @@ package github -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v77/github" - "github.com/migueleliasweb/go-github-mock/src/mock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_ListGlobalSecurityAdvisories(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_global_security_advisories", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "ecosystem") - assert.Contains(t, tool.InputSchema.Properties, "severity") - assert.Contains(t, tool.InputSchema.Properties, "ghsaId") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{}) - - // Setup mock advisory for success case - mockAdvisory := &github.GlobalSecurityAdvisory{ - SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAdvisories []*github.GlobalSecurityAdvisory - expectedErrMsg string - }{ - { - name: "successful advisory fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetAdvisories, - []*github.GlobalSecurityAdvisory{mockAdvisory}, - ), - ), - requestArgs: map[string]interface{}{ - "type": "reviewed", - "ecosystem": "npm", - "severity": "high", - }, - expectError: false, - expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory}, - }, - { - name: "invalid severity value", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetAdvisories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "type": "reviewed", - "severity": "extreme", - }, - expectError: true, - expectedErrMsg: "failed to list global security advisories", - }, - { - name: "API error handling", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetAdvisories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{}, - expectError: true, - expectedErrMsg: "failed to list global security advisories", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListGlobalSecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), 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 returnedAdvisories []*github.GlobalSecurityAdvisory - err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) - assert.NoError(t, err) - assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) - for i, advisory := range returnedAdvisories { - assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) - assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) - assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) - assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) - } - }) - } -} - -func Test_GetGlobalSecurityAdvisory(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_global_security_advisory", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "ghsaId") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"}) - - // Setup mock advisory for success case - mockAdvisory := &github.GlobalSecurityAdvisory{ - SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAdvisory *github.GlobalSecurityAdvisory - expectedErrMsg string - }{ - { - name: "successful advisory fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetAdvisoriesByGhsaId, - mockAdvisory, - ), - ), - requestArgs: map[string]interface{}{ - "ghsaId": "GHSA-xxxx-xxxx-xxxx", - }, - expectError: false, - expectedAdvisory: mockAdvisory, - }, - { - name: "invalid ghsaId format", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetAdvisoriesByGhsaId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "ghsaId": "invalid-ghsa-id", - }, - expectError: true, - expectedErrMsg: "failed to get advisory", - }, - { - name: "advisory not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetAdvisoriesByGhsaId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "ghsaId": "GHSA-xxxx-xxxx-xxxx", - }, - expectError: true, - expectedErrMsg: "failed to get advisory", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetGlobalSecurityAdvisory(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - // Verify the result - assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID) - assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary) - assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description) - assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity) - }) - } -} - -func Test_ListRepositorySecurityAdvisories(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_repository_security_advisories", 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, "direction") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - - // Local endpoint pattern for repository security advisories - var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{ - Pattern: "/repos/{owner}/{repo}/security-advisories", - Method: "GET", - } - - // Setup mock advisories for success cases - adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-1111-1111-1111"), - Summary: github.Ptr("Repo advisory one"), - Description: github.Ptr("First repo advisory."), - Severity: github.Ptr("high"), - } - adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-2222-2222-2222"), - Summary: github.Ptr("Repo advisory two"), - Description: github.Ptr("Second repo advisory."), - Severity: github.Ptr("medium"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAdvisories []*github.SecurityAdvisory - expectedErrMsg string - }{ - { - name: "successful advisories listing (no filters)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetReposSecurityAdvisoriesByOwnerByRepo, - expect(t, expectations{ - path: "/repos/owner/repo/security-advisories", - queryParams: map[string]string{}, - }).andThen( - mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, - }, - { - name: "successful advisories listing with filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetReposSecurityAdvisoriesByOwnerByRepo, - expect(t, expectations{ - path: "/repos/octo/hello-world/security-advisories", - queryParams: map[string]string{ - "direction": "desc", - "sort": "updated", - "state": "published", - }, - }).andThen( - mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "octo", - "repo": "hello-world", - "direction": "desc", - "sort": "updated", - "state": "published", - }, - expectError: false, - expectedAdvisories: []*github.SecurityAdvisory{adv1}, - }, - { - name: "advisories listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetReposSecurityAdvisoriesByOwnerByRepo, - expect(t, expectations{ - path: "/repos/owner/repo/security-advisories", - queryParams: map[string]string{}, - }).andThen( - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "failed to list repository security advisories", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - - textContent := getTextResult(t, result) - - var returnedAdvisories []*github.SecurityAdvisory - err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) - assert.NoError(t, err) - assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) - for i, advisory := range returnedAdvisories { - assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) - assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) - assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) - assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) - } - }) - } -} - -func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_org_repository_security_advisories", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) - - // Endpoint pattern for org repository security advisories - var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{ - Pattern: "/orgs/{org}/security-advisories", - Method: "GET", - } - - adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), - Summary: github.Ptr("Org repo advisory 1"), - Description: github.Ptr("First advisory"), - Severity: github.Ptr("low"), - } - adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), - Summary: github.Ptr("Org repo advisory 2"), - Description: github.Ptr("Second advisory"), - Severity: github.Ptr("critical"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAdvisories []*github.SecurityAdvisory - expectedErrMsg string - }{ - { - name: "successful listing (no filters)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetOrgsSecurityAdvisoriesByOrg, - expect(t, expectations{ - path: "/orgs/octo/security-advisories", - queryParams: map[string]string{}, - }).andThen( - mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "org": "octo", - }, - expectError: false, - expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, - }, - { - name: "successful listing with filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetOrgsSecurityAdvisoriesByOrg, - expect(t, expectations{ - path: "/orgs/octo/security-advisories", - queryParams: map[string]string{ - "direction": "asc", - "sort": "created", - "state": "triage", - }, - }).andThen( - mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "org": "octo", - "direction": "asc", - "sort": "created", - "state": "triage", - }, - expectError: false, - expectedAdvisories: []*github.SecurityAdvisory{adv1}, - }, - { - name: "listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - GetOrgsSecurityAdvisoriesByOrg, - expect(t, expectations{ - path: "/orgs/octo/security-advisories", - queryParams: map[string]string{}, - }).andThen( - mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), - ), - ), - ), - requestArgs: map[string]interface{}{ - "org": "octo", - }, - expectError: true, - expectedErrMsg: "failed to list organization repository security advisories", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := ListOrgRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(context.Background(), request) - - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - - textContent := getTextResult(t, result) - - var returnedAdvisories []*github.SecurityAdvisory - err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) - assert.NoError(t, err) - assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) - for i, advisory := range returnedAdvisories { - assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) - assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) - assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) - assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) - } - }) - } -} +// import ( +// "context" +// "encoding/json" +// "net/http" +// "testing" + +// "github.com/github/github-mcp-server/pkg/translations" +// "github.com/google/go-github/v77/github" +// "github.com/migueleliasweb/go-github-mock/src/mock" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) + +// func Test_ListGlobalSecurityAdvisories(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_global_security_advisories", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "ecosystem") +// assert.Contains(t, tool.InputSchema.Properties, "severity") +// assert.Contains(t, tool.InputSchema.Properties, "ghsaId") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{}) + +// // Setup mock advisory for success case +// mockAdvisory := &github.GlobalSecurityAdvisory{ +// SecurityAdvisory: github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), +// Summary: github.Ptr("Test advisory"), +// Description: github.Ptr("This is a test advisory."), +// Severity: github.Ptr("high"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAdvisories []*github.GlobalSecurityAdvisory +// expectedErrMsg string +// }{ +// { +// name: "successful advisory fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetAdvisories, +// []*github.GlobalSecurityAdvisory{mockAdvisory}, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "type": "reviewed", +// "ecosystem": "npm", +// "severity": "high", +// }, +// expectError: false, +// expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory}, +// }, +// { +// name: "invalid severity value", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetAdvisories, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "type": "reviewed", +// "severity": "extreme", +// }, +// expectError: true, +// expectedErrMsg: "failed to list global security advisories", +// }, +// { +// name: "API error handling", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetAdvisories, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusInternalServerError) +// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{}, +// expectError: true, +// expectedErrMsg: "failed to list global security advisories", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := ListGlobalSecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), 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 returnedAdvisories []*github.GlobalSecurityAdvisory +// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) +// assert.NoError(t, err) +// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) +// for i, advisory := range returnedAdvisories { +// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) +// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) +// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) +// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) +// } +// }) +// } +// } + +// func Test_GetGlobalSecurityAdvisory(t *testing.T) { +// mockClient := github.NewClient(nil) +// tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "get_global_security_advisory", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "ghsaId") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"}) + +// // Setup mock advisory for success case +// mockAdvisory := &github.GlobalSecurityAdvisory{ +// SecurityAdvisory: github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), +// Summary: github.Ptr("Test advisory"), +// Description: github.Ptr("This is a test advisory."), +// Severity: github.Ptr("high"), +// }, +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAdvisory *github.GlobalSecurityAdvisory +// expectedErrMsg string +// }{ +// { +// name: "successful advisory fetch", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatch( +// mock.GetAdvisoriesByGhsaId, +// mockAdvisory, +// ), +// ), +// requestArgs: map[string]interface{}{ +// "ghsaId": "GHSA-xxxx-xxxx-xxxx", +// }, +// expectError: false, +// expectedAdvisory: mockAdvisory, +// }, +// { +// name: "invalid ghsaId format", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetAdvisoriesByGhsaId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusBadRequest) +// _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "ghsaId": "invalid-ghsa-id", +// }, +// expectError: true, +// expectedErrMsg: "failed to get advisory", +// }, +// { +// name: "advisory not found", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// mock.GetAdvisoriesByGhsaId, +// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +// w.WriteHeader(http.StatusNotFound) +// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) +// }), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "ghsaId": "GHSA-xxxx-xxxx-xxxx", +// }, +// expectError: true, +// expectedErrMsg: "failed to get advisory", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // Setup client with mock +// client := github.NewClient(tc.mockedClient) +// _, handler := GetGlobalSecurityAdvisory(stubGetClientFn(client), translations.NullTranslationHelper) + +// // Create call request +// request := createMCPRequest(tc.requestArgs) + +// // Call handler +// result, err := handler(context.Background(), request) + +// // Verify results +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) + +// // Parse the result and get the text content if no error +// textContent := getTextResult(t, result) + +// // Verify the result +// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID) +// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary) +// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description) +// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity) +// }) +// } +// } + +// func Test_ListRepositorySecurityAdvisories(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_repository_security_advisories", 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, "direction") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "state") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + +// // Local endpoint pattern for repository security advisories +// var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{ +// Pattern: "/repos/{owner}/{repo}/security-advisories", +// Method: "GET", +// } + +// // Setup mock advisories for success cases +// adv1 := &github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-1111-1111-1111"), +// Summary: github.Ptr("Repo advisory one"), +// Description: github.Ptr("First repo advisory."), +// Severity: github.Ptr("high"), +// } +// adv2 := &github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-2222-2222-2222"), +// Summary: github.Ptr("Repo advisory two"), +// Description: github.Ptr("Second repo advisory."), +// Severity: github.Ptr("medium"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAdvisories []*github.SecurityAdvisory +// expectedErrMsg string +// }{ +// { +// name: "successful advisories listing (no filters)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetReposSecurityAdvisoriesByOwnerByRepo, +// expect(t, expectations{ +// path: "/repos/owner/repo/security-advisories", +// queryParams: map[string]string{}, +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: false, +// expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, +// }, +// { +// name: "successful advisories listing with filters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetReposSecurityAdvisoriesByOwnerByRepo, +// expect(t, expectations{ +// path: "/repos/octo/hello-world/security-advisories", +// queryParams: map[string]string{ +// "direction": "desc", +// "sort": "updated", +// "state": "published", +// }, +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "octo", +// "repo": "hello-world", +// "direction": "desc", +// "sort": "updated", +// "state": "published", +// }, +// expectError: false, +// expectedAdvisories: []*github.SecurityAdvisory{adv1}, +// }, +// { +// name: "advisories listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetReposSecurityAdvisoriesByOwnerByRepo, +// expect(t, expectations{ +// path: "/repos/owner/repo/security-advisories", +// queryParams: map[string]string{}, +// }).andThen( +// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "owner": "owner", +// "repo": "repo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list repository security advisories", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// var returnedAdvisories []*github.SecurityAdvisory +// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) +// assert.NoError(t, err) +// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) +// for i, advisory := range returnedAdvisories { +// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) +// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) +// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) +// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) +// } +// }) +// } +// } + +// func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { +// // Verify tool definition once +// mockClient := github.NewClient(nil) +// tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +// assert.Equal(t, "list_org_repository_security_advisories", tool.Name) +// assert.NotEmpty(t, tool.Description) +// assert.Contains(t, tool.InputSchema.Properties, "org") +// assert.Contains(t, tool.InputSchema.Properties, "direction") +// assert.Contains(t, tool.InputSchema.Properties, "sort") +// assert.Contains(t, tool.InputSchema.Properties, "state") +// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + +// // Endpoint pattern for org repository security advisories +// var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{ +// Pattern: "/orgs/{org}/security-advisories", +// Method: "GET", +// } + +// adv1 := &github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), +// Summary: github.Ptr("Org repo advisory 1"), +// Description: github.Ptr("First advisory"), +// Severity: github.Ptr("low"), +// } +// adv2 := &github.SecurityAdvisory{ +// GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), +// Summary: github.Ptr("Org repo advisory 2"), +// Description: github.Ptr("Second advisory"), +// Severity: github.Ptr("critical"), +// } + +// tests := []struct { +// name string +// mockedClient *http.Client +// requestArgs map[string]interface{} +// expectError bool +// expectedAdvisories []*github.SecurityAdvisory +// expectedErrMsg string +// }{ +// { +// name: "successful listing (no filters)", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetOrgsSecurityAdvisoriesByOrg, +// expect(t, expectations{ +// path: "/orgs/octo/security-advisories", +// queryParams: map[string]string{}, +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "org": "octo", +// }, +// expectError: false, +// expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, +// }, +// { +// name: "successful listing with filters", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetOrgsSecurityAdvisoriesByOrg, +// expect(t, expectations{ +// path: "/orgs/octo/security-advisories", +// queryParams: map[string]string{ +// "direction": "asc", +// "sort": "created", +// "state": "triage", +// }, +// }).andThen( +// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "org": "octo", +// "direction": "asc", +// "sort": "created", +// "state": "triage", +// }, +// expectError: false, +// expectedAdvisories: []*github.SecurityAdvisory{adv1}, +// }, +// { +// name: "listing fails", +// mockedClient: mock.NewMockedHTTPClient( +// mock.WithRequestMatchHandler( +// GetOrgsSecurityAdvisoriesByOrg, +// expect(t, expectations{ +// path: "/orgs/octo/security-advisories", +// queryParams: map[string]string{}, +// }).andThen( +// mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), +// ), +// ), +// ), +// requestArgs: map[string]interface{}{ +// "org": "octo", +// }, +// expectError: true, +// expectedErrMsg: "failed to list organization repository security advisories", +// }, +// } + +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// client := github.NewClient(tc.mockedClient) +// _, handler := ListOrgRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + +// request := createMCPRequest(tc.requestArgs) + +// result, err := handler(context.Background(), request) + +// if tc.expectError { +// require.Error(t, err) +// assert.Contains(t, err.Error(), tc.expectedErrMsg) +// return +// } + +// require.NoError(t, err) + +// textContent := getTextResult(t, result) + +// var returnedAdvisories []*github.SecurityAdvisory +// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) +// assert.NoError(t, err) +// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) +// for i, advisory := range returnedAdvisories { +// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) +// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) +// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) +// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) +// } +// }) +// } +// } diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 77752d090..e34e353cf 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -141,8 +141,7 @@ func Test_RequiredStringParam(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := RequiredParam[string](request, tc.paramName) + result, err := RequiredParam[string](tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -194,8 +193,7 @@ func Test_OptionalStringParam(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalParam[string](request, tc.paramName) + result, err := OptionalParam[string](tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -240,8 +238,7 @@ func Test_RequiredInt(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := RequiredInt(request, tc.paramName) + result, err := RequiredInt(tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -292,8 +289,7 @@ func Test_OptionalIntParam(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalIntParam(request, tc.paramName) + result, err := OptionalIntParam(tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -350,8 +346,7 @@ func Test_OptionalNumberParamWithDefault(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalIntParamWithDefault(request, tc.paramName, tc.defaultVal) + result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal) if tc.expectError { assert.Error(t, err) @@ -403,8 +398,7 @@ func Test_OptionalBooleanParam(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalParam[bool](request, tc.paramName) + result, err := OptionalParam[bool](tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -471,8 +465,7 @@ func TestOptionalStringArrayParam(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalStringArrayParam(request, tc.paramName) + result, err := OptionalStringArrayParam(tc.params, tc.paramName) if tc.expectError { assert.Error(t, err) @@ -554,8 +547,7 @@ func TestOptionalPaginationParams(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - request := createMCPRequest(tc.params) - result, err := OptionalPaginationParams(request) + result, err := OptionalPaginationParams(tc.params) if tc.expectError { assert.Error(t, err) From afc455ddda8ebb6b917ff042d58692600f4be976 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 13 Nov 2025 17:20:54 +0100 Subject: [PATCH 07/22] fix schema issues --- pkg/github/context_tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 433b30216..822f2ba0d 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -112,7 +112,6 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{}, }, func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") @@ -199,6 +198,7 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ + Type: "object", Properties: map[string]*jsonschema.Schema{ "org": { Type: "string", From b2f07e526bbbbb1d0af804799781cf946cd710da Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 10:57:25 +0100 Subject: [PATCH 08/22] Get tests fully working --- cmd/github-mcp-server/generate_docs.go | 37 +++++++++---------- pkg/github/__toolsnaps__/get_me.snap | 9 ++--- .../__toolsnaps__/get_team_members.snap | 24 ++++++------ pkg/github/__toolsnaps__/get_teams.snap | 12 +++--- pkg/github/context_tools.go | 9 +++++ pkg/github/context_tools_test.go | 2 +- pkg/github/helper_test.go | 7 ++-- 7 files changed, 53 insertions(+), 47 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 478b35bd4..eab24aa30 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -14,7 +14,8 @@ import ( "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v77/github" - "github.com/mark3labs/mcp-go/mcp" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/spf13/cobra" ) @@ -224,7 +225,12 @@ func generateToolDoc(tool mcp.Tool) string { lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title)) // Parameters - schema := tool.InputSchema + schema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok { + lines = append(lines, " - No parameters required") + return strings.Join(lines, "\n") + } + if len(schema.Properties) > 0 { // Get parameter names and sort them for deterministic order var paramNames []string @@ -245,26 +251,19 @@ func generateToolDoc(tool mcp.Tool) string { typeStr := "unknown" description := "" - if propMap, ok := prop.(map[string]interface{}); ok { - if typeVal, ok := propMap["type"].(string); ok { - if typeVal == "array" { - if items, ok := propMap["items"].(map[string]interface{}); ok { - if itemType, ok := items["type"].(string); ok { - typeStr = itemType + "[]" - } - } else { - typeStr = "array" - } - } else { - typeStr = typeVal - } - } - - if desc, ok := propMap["description"].(string); ok { - description = desc + switch prop.Type { + case "array": + if prop.Items != nil { + typeStr = prop.Items.Type + "[]" + } else { + typeStr = "array" } + default: + typeStr = prop.Type } + description = prop.Description + paramLine := fmt.Sprintf(" - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) lines = append(lines, paramLine) } diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index 13b061741..2ccdeda5b 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -1,12 +1,9 @@ { "annotations": { - "title": "Get my user profile", - "readOnlyHint": true + "readOnlyHint": true, + "title": "Get my user profile" }, "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", - "inputSchema": { - "properties": {}, - "type": "object" - }, + "inputSchema": null, "name": "get_me" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index 2d91bb5ea..5b7f090fe 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -1,25 +1,25 @@ { "annotations": { - "title": "Get team members", - "readOnlyHint": true + "readOnlyHint": true, + "title": "Get team members" }, "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", "inputSchema": { + "type": "object", + "required": [ + "org", + "team_slug" + ], "properties": { "org": { - "description": "Organization login (owner) that contains the team.", - "type": "string" + "type": "string", + "description": "Organization login (owner) that contains the team." }, "team_slug": { - "description": "Team slug", - "type": "string" + "type": "string", + "description": "Team slug" } - }, - "required": [ - "org", - "team_slug" - ], - "type": "object" + } }, "name": "get_team_members" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 39ed4db35..595dd262d 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -1,17 +1,17 @@ { "annotations": { - "title": "Get teams", - "readOnlyHint": true + "readOnlyHint": true, + "title": "Get teams" }, "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", "inputSchema": { + "type": "object", "properties": { "user": { - "description": "Username to get teams for. If not provided, uses the authenticated user.", - "type": "string" + "type": "string", + "description": "Username to get teams for. If not provided, uses the authenticated user." } - }, - "type": "object" + } }, "name": "get_teams" } \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 822f2ba0d..c9bece02a 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -112,6 +112,15 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), ReadOnlyHint: true, }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "user": { + Type: "string", + Description: t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."), + }, + }, + }, }, func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 7573da5fd..50c788b0d 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -112,10 +112,10 @@ func Test_GetMe(t *testing.T) { request := createMCPRequest(tc.requestArgs) result, _, err := handler(context.Background(), &request, tc.requestArgs) - require.NoError(t, err) textContent := getTextResult(t, result) if tc.expectToolError { + assert.Error(t, err) assert.True(t, result.IsError, "expected tool call result to be an error") assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) return diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index a869dd0bb..1e4627544 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -133,8 +133,8 @@ func getTextResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent { t.Helper() assert.NotNil(t, result) require.Len(t, result.Content, 1) - require.IsType(t, mcp.TextContent{}, result.Content[0]) - textContent := result.Content[0].(*mcp.TextContent) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") return textContent } @@ -151,7 +151,8 @@ func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.Resour require.Len(t, result.Content, 2) content := result.Content[1] require.IsType(t, mcp.EmbeddedResource{}, content) - resource := content.(*mcp.EmbeddedResource) + resource, ok := content.(*mcp.EmbeddedResource) + require.True(t, ok, "expected content to be of type EmbeddedResource") require.IsType(t, mcp.ResourceContents{}, resource.Resource) require.NotEmpty(t, resource.Resource.Text) From 11a154cef38d158a3c98ea5c60d89dfaa787dde4 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 13:31:55 +0100 Subject: [PATCH 09/22] Add sdk migration agent --- .github/agents/go-sdk-tool-migrator.md | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/agents/go-sdk-tool-migrator.md diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md new file mode 100644 index 000000000..648107a63 --- /dev/null +++ b/.github/agents/go-sdk-tool-migrator.md @@ -0,0 +1,95 @@ +--- +name: go-sdk-tool-migrator +description: Agent specializing in migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk +--- + +You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using mark3labs/mcp-go and provide a step-by-step migration guide to convert it to use the modelcontextprotocol/go-sdk. Do not modify the original tool code; instead, focus on generating clear and concise migration instructions. + +You should focus on ONLY the tool provided to you and it's corresponding test file. + +When generating the migration guide, consider the following aspects: + +* The import for `github.com/mark3labs/mcp-go/mcp` should be changed to `github.com/modelcontextprotocol/go-sdk/mcp` +* The return type for the tool constructor function should be updated from `mcp.Tool, server.ToolHandlerFunc` to `(mcp.Tool, mcp.ToolHandlerFor[map[string]any, any])`. +* The tool handler function signature should be updated to use generics, changing from `func(ctx context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` to `func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error)`. +* The `RequiredParam`, `RequiredInt`, `RequiredBigInt`, `OptionalParamOK`, `OptionalParam`, `OptionalIntParam`, `OptionalIntParamWithDefault`, `OptionalBoolParamWithDefault`, `OptionalStringArrayParam`, `OptionalBigIntArrayParam` and `OptionalCursorPaginationParams` functions should be changed to use the tool arguments that are now passed as a map in the tool handler function, rather than extracting them from the `mcp.CallToolRequest`. + +# Schema Changes + +The biggest change when migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk is the way input and output schemas are defined and handled. In mark3labs/mcp-go, input and output schemas were often defined using a DSL provided by the library. In modelcontextprotocol/go-sdk, schemas are defined using jsonschema.Schema structures, which are more verbose. + +When migrating a tool, you will need to convert the existing schema definitions to JSON Schema format. This involves defining the properties, types, and any validation rules using the JSON Schema specification. + +# Example Schema Guide + +If we take an example of a tool that has the following input schema in mark3labs/mcp-go: + +```go +... +return mcp.NewTool( + "list_dependabot_alerts", + mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter dependabot alerts by state. Defaults to open"), + mcp.DefaultString("open"), + mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), + ), + mcp.WithString("severity", + mcp.Description("Filter dependabot alerts by severity"), + mcp.Enum("low", "medium", "high", "critical"), + ), + ), +... +``` + +The corresponding input schema in modelcontextprotocol/go-sdk would look like this: + +```go +... +return mcp.Tool{ + Name: "list_dependabot_alerts", + Description: t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter dependabot alerts by state. Defaults to open", + Enum: []string{"open", "fixed", "dismissed", "auto_dismissed"}, + Default: "open", + }, + "severity": { + Type: "string", + Description: "Filter dependabot alerts by severity", + Enum: []string{"low", "medium", "high", "critical"}, + }, + }, + Required: []string{"owner", "repo"}, + }, +} +``` + From 5483a5b215f8aab3e923d0abb1f97bbfd212a5ae Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 13:37:22 +0100 Subject: [PATCH 10/22] Lets try actually making the changes in the subagent --- .github/agents/go-sdk-tool-migrator.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index 648107a63..18186414f 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -3,12 +3,13 @@ name: go-sdk-tool-migrator description: Agent specializing in migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk --- -You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using mark3labs/mcp-go and provide a step-by-step migration guide to convert it to use the modelcontextprotocol/go-sdk. Do not modify the original tool code; instead, focus on generating clear and concise migration instructions. +You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using mark3labs/mcp-go and convert it to use the modelcontextprotocol/go-sdk. You should focus on ONLY the tool provided to you and it's corresponding test file. When generating the migration guide, consider the following aspects: +* The initial tool file and it's corresponding test file will be fully commented out, as the tests will fail if the code is uncommented. The code should be uncommented before work begins. * The import for `github.com/mark3labs/mcp-go/mcp` should be changed to `github.com/modelcontextprotocol/go-sdk/mcp` * The return type for the tool constructor function should be updated from `mcp.Tool, server.ToolHandlerFunc` to `(mcp.Tool, mcp.ToolHandlerFor[map[string]any, any])`. * The tool handler function signature should be updated to use generics, changing from `func(ctx context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` to `func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error)`. From cabc7b6ac944ddb65b3c9f74988ec3e0ca2aabb1 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 13:45:04 +0100 Subject: [PATCH 11/22] Be explicit about the files to use --- .github/agents/go-sdk-tool-migrator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index 18186414f..0f4c4e3b8 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -5,7 +5,7 @@ description: Agent specializing in migrating MCP tools from mark3labs/mcp-go to You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using mark3labs/mcp-go and convert it to use the modelcontextprotocol/go-sdk. -You should focus on ONLY the tool provided to you and it's corresponding test file. +You should focus on ONLY the toolset provided to you and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`. If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. When generating the migration guide, consider the following aspects: From 99f2d8d6c73115eaebe39347150671c7e1e496c3 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 15:03:49 +0100 Subject: [PATCH 12/22] Update about toolsnaps --- .github/agents/go-sdk-tool-migrator.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index 0f4c4e3b8..fb3bf6e13 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -14,10 +14,11 @@ When generating the migration guide, consider the following aspects: * The return type for the tool constructor function should be updated from `mcp.Tool, server.ToolHandlerFunc` to `(mcp.Tool, mcp.ToolHandlerFor[map[string]any, any])`. * The tool handler function signature should be updated to use generics, changing from `func(ctx context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` to `func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error)`. * The `RequiredParam`, `RequiredInt`, `RequiredBigInt`, `OptionalParamOK`, `OptionalParam`, `OptionalIntParam`, `OptionalIntParamWithDefault`, `OptionalBoolParamWithDefault`, `OptionalStringArrayParam`, `OptionalBigIntArrayParam` and `OptionalCursorPaginationParams` functions should be changed to use the tool arguments that are now passed as a map in the tool handler function, rather than extracting them from the `mcp.CallToolRequest`. +* `mcp.NewToolResultText`, `mcp.NewToolResultError`, `mcp.NewToolResultErrorFromErr` and `mcp.NewToolResultResource` no longer available in `modelcontextprotocol/go-sdk`. There are a few helper functions available in `pkg/utils/result.go` that can be used to replace these, in the `utils` package. # Schema Changes -The biggest change when migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk is the way input and output schemas are defined and handled. In mark3labs/mcp-go, input and output schemas were often defined using a DSL provided by the library. In modelcontextprotocol/go-sdk, schemas are defined using jsonschema.Schema structures, which are more verbose. +The biggest change when migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk is the way input and output schemas are defined and handled. In `mark3labs/mcp-go`, input and output schemas were often defined using a DSL provided by the library. In `modelcontextprotocol/go-sdk`, schemas are defined using `jsonschema.Schema` structures using `github.com/google/jsonschema-go`, which are more verbose. When migrating a tool, you will need to convert the existing schema definitions to JSON Schema format. This involves defining the properties, types, and any validation rules using the JSON Schema specification. @@ -94,3 +95,14 @@ return mcp.Tool{ } ``` +# Running tests + +After migrating the tool code and test file, ensure that all tests pass successfully. If any tests fail, review the error messages and adjust the migrated code as necessary to resolve any issues. If you encounter any challenges or need further assistance during the migration process, please let me know. + +At the end of your changes, you will continue to have an issue with the `toolsnaps` tests, these validate that the schema has not changed unexpectedly. You can update the snapshots by setting `UPDATE_TOOLSNAPS=true` before running the tests, e.g.: + +```bash +UPDATE_TOOLSNAPS=true go test ./... +``` + +You should however, only update the toolsnaps after confirming that the schema changes are intentional and correct. Some schema changes are unavoidable, such as argument ordering, however the schemas themselves should remain logically equivalent. From 6fa137b6155b7eda6fba5a062fc6eab4b4e7d1c9 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 15:11:20 +0100 Subject: [PATCH 13/22] Add info to allow this to be parallelized --- .github/agents/go-sdk-tool-migrator.md | 28 ++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index fb3bf6e13..3bfff3534 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -3,7 +3,27 @@ name: go-sdk-tool-migrator description: Agent specializing in migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk --- -You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using mark3labs/mcp-go and convert it to use the modelcontextprotocol/go-sdk. +# Go SDK Tool Migrator Agent + +You are a specialized agent designed to assist developers in migrating MCP tools from the mark3labs/mcp-go library to the modelcontextprotocol/go-sdk. Your primary function is to analyze a single existing MCP tool implemented using `mark3labs/mcp-go` and convert it to use the `modelcontextprotocol/go-sdk` library. + +## Preparation + +A cooridinator will assign you a specific MCP tool to migrate. + +So that you can work independently of other ongoing migrations, you should immediately begin by creating a git worktree branch named `migrate-go-sdk-`, where `` is the name of the toolset you are migrating. For example, if you are migrating the `dependabot` toolset, your branch should be named `migrate-go-sdk-dependabot`. You can create the worktree using the following command: + +```bash +git worktree add -b migrate-go-sdk- origin/omgitsads/go-sdk +``` + +You should then change into that branch to begin your work: + +```bash +cd migrate-go-sdk- +``` + +## Migration Process You should focus on ONLY the toolset provided to you and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`. If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. @@ -16,13 +36,13 @@ When generating the migration guide, consider the following aspects: * The `RequiredParam`, `RequiredInt`, `RequiredBigInt`, `OptionalParamOK`, `OptionalParam`, `OptionalIntParam`, `OptionalIntParamWithDefault`, `OptionalBoolParamWithDefault`, `OptionalStringArrayParam`, `OptionalBigIntArrayParam` and `OptionalCursorPaginationParams` functions should be changed to use the tool arguments that are now passed as a map in the tool handler function, rather than extracting them from the `mcp.CallToolRequest`. * `mcp.NewToolResultText`, `mcp.NewToolResultError`, `mcp.NewToolResultErrorFromErr` and `mcp.NewToolResultResource` no longer available in `modelcontextprotocol/go-sdk`. There are a few helper functions available in `pkg/utils/result.go` that can be used to replace these, in the `utils` package. -# Schema Changes +### Schema Changes The biggest change when migrating MCP tools from mark3labs/mcp-go to modelcontextprotocol/go-sdk is the way input and output schemas are defined and handled. In `mark3labs/mcp-go`, input and output schemas were often defined using a DSL provided by the library. In `modelcontextprotocol/go-sdk`, schemas are defined using `jsonschema.Schema` structures using `github.com/google/jsonschema-go`, which are more verbose. When migrating a tool, you will need to convert the existing schema definitions to JSON Schema format. This involves defining the properties, types, and any validation rules using the JSON Schema specification. -# Example Schema Guide +#### Example Schema Guide If we take an example of a tool that has the following input schema in mark3labs/mcp-go: @@ -95,7 +115,7 @@ return mcp.Tool{ } ``` -# Running tests +### Running tests After migrating the tool code and test file, ensure that all tests pass successfully. If any tests fail, review the error messages and adjust the migrated code as necessary to resolve any issues. If you encounter any challenges or need further assistance during the migration process, please let me know. From 2b323c218b2a6c1aa7857ac53e60826ca82fc6d8 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 17:33:36 +0100 Subject: [PATCH 14/22] Dont use the request var --- pkg/github/context_tools.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index c9bece02a..5f248934b 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -122,7 +122,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations }, }, }, - func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -221,7 +221,7 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe Required: []string{"org", "team_slug"}, }, }, - func(ctx context.Context, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil From 6ce7550b618e85b786c71bf8d7e174c1320714d4 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 18:04:31 +0100 Subject: [PATCH 15/22] Take any ToolHandlerFor --- pkg/toolsets/toolsets.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 4fc25e1f4..7ff7a8d41 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -29,12 +29,14 @@ func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError { } type ServerTool struct { - Tool mcp.Tool - Handler mcp.ToolHandlerFor[map[string]any, any] + Tool mcp.Tool + RegisterFunc func(s *mcp.Server) } -func NewServerTool(tool mcp.Tool, handler mcp.ToolHandlerFor[map[string]any, any]) ServerTool { - return ServerTool{Tool: tool, Handler: handler} +func NewServerTool[In, Out any](tool mcp.Tool, handler mcp.ToolHandlerFor[In, Out]) ServerTool { + return ServerTool{Tool: tool, RegisterFunc: func(s *mcp.Server) { + mcp.AddTool(s, &tool, handler) + }} } type ServerResourceTemplate struct { @@ -98,11 +100,11 @@ func (t *Toolset) RegisterTools(s *mcp.Server) { return } for _, tool := range t.readTools { - mcp.AddTool(s, &tool.Tool, tool.Handler) + tool.RegisterFunc(s) } if !t.readOnly { for _, tool := range t.writeTools { - mcp.AddTool(s, &tool.Tool, tool.Handler) + tool.RegisterFunc(s) } } } From 5b4c6dfe2eb04bec58dd9368ec3644accebbb46f Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 18:42:13 +0100 Subject: [PATCH 16/22] More info about moved files --- .github/agents/go-sdk-tool-migrator.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index 3bfff3534..23b110256 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -25,7 +25,7 @@ cd migrate-go-sdk- ## Migration Process -You should focus on ONLY the toolset provided to you and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`. If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. +You should focus on ONLY the toolset provided to you and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `.tools-to-be-migrated/dependabot.go` and `.tools-to-be-migrated/dependabot_test.go`. The migrated version should be placed in the `github` package directory, `pkg/github` (e.g. `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`). If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. When generating the migration guide, consider the following aspects: @@ -115,7 +115,7 @@ return mcp.Tool{ } ``` -### Running tests +### Tests After migrating the tool code and test file, ensure that all tests pass successfully. If any tests fail, review the error messages and adjust the migrated code as necessary to resolve any issues. If you encounter any challenges or need further assistance during the migration process, please let me know. From 655bcca719be1e65fda47e037cdb0c55825e6c07 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 18:42:29 +0100 Subject: [PATCH 17/22] move files rather than commenting out --- .tools-to-be-migrated/actions.go | 1224 ++++++ .tools-to-be-migrated/actions_test.go | 1321 +++++++ .tools-to-be-migrated/code_scanning.go | 169 + .tools-to-be-migrated/code_scanning_test.go | 249 ++ .tools-to-be-migrated/dependabot.go | 161 + .tools-to-be-migrated/dependabot_test.go | 276 ++ .tools-to-be-migrated/discussions.go | 531 +++ .tools-to-be-migrated/discussions_test.go | 778 ++++ .tools-to-be-migrated/dynamic_tools.go | 138 + .tools-to-be-migrated/gists.go | 316 ++ .tools-to-be-migrated/gists_test.go | 595 +++ .tools-to-be-migrated/git.go | 160 + .tools-to-be-migrated/issues.go | 1661 ++++++++ .tools-to-be-migrated/issues_test.go | 3521 +++++++++++++++++ .tools-to-be-migrated/labels.go | 399 ++ .tools-to-be-migrated/labels_test.go | 491 +++ .tools-to-be-migrated/notifications.go | 525 +++ .tools-to-be-migrated/notifications_test.go | 765 ++++ .tools-to-be-migrated/projects.go | 1142 ++++++ .tools-to-be-migrated/projects_test.go | 1649 ++++++++ .tools-to-be-migrated/pullrequests.go | 1630 ++++++++ .tools-to-be-migrated/pullrequests_test.go | 2943 ++++++++++++++ .tools-to-be-migrated/repositories.go | 1928 +++++++++ .tools-to-be-migrated/repositories_test.go | 3414 ++++++++++++++++ .tools-to-be-migrated/search.go | 365 ++ .tools-to-be-migrated/search_test.go | 743 ++++ .tools-to-be-migrated/search_utils.go | 115 + .tools-to-be-migrated/search_utils_test.go | 352 ++ .tools-to-be-migrated/secret_scanning.go | 163 + .tools-to-be-migrated/secret_scanning_test.go | 249 ++ .tools-to-be-migrated/security_advisories.go | 397 ++ .../security_advisories_test.go | 526 +++ pkg/github/actions.go | 1224 ------ pkg/github/actions_test.go | 1321 ------- pkg/github/code_scanning.go | 169 - pkg/github/code_scanning_test.go | 249 -- pkg/github/dependabot.go | 161 - pkg/github/dependabot_test.go | 276 -- pkg/github/discussions.go | 531 --- pkg/github/discussions_test.go | 778 ---- pkg/github/dynamic_tools.go | 138 - pkg/github/gists.go | 316 -- pkg/github/gists_test.go | 595 --- pkg/github/git.go | 160 - pkg/github/issues.go | 1661 -------- pkg/github/issues_test.go | 3521 ----------------- pkg/github/labels.go | 399 -- pkg/github/labels_test.go | 491 --- pkg/github/notifications.go | 525 --- pkg/github/notifications_test.go | 765 ---- pkg/github/projects.go | 1142 ------ pkg/github/projects_test.go | 1649 -------- pkg/github/pullrequests.go | 1630 -------- pkg/github/pullrequests_test.go | 2943 -------------- pkg/github/repositories.go | 1928 --------- pkg/github/repositories_test.go | 3414 ---------------- pkg/github/search.go | 365 -- pkg/github/search_test.go | 743 ---- pkg/github/search_utils.go | 115 - pkg/github/search_utils_test.go | 352 -- pkg/github/secret_scanning.go | 163 - pkg/github/secret_scanning_test.go | 249 -- pkg/github/security_advisories.go | 397 -- pkg/github/security_advisories_test.go | 526 --- 64 files changed, 28896 insertions(+), 28896 deletions(-) create mode 100644 .tools-to-be-migrated/actions.go create mode 100644 .tools-to-be-migrated/actions_test.go create mode 100644 .tools-to-be-migrated/code_scanning.go create mode 100644 .tools-to-be-migrated/code_scanning_test.go create mode 100644 .tools-to-be-migrated/dependabot.go create mode 100644 .tools-to-be-migrated/dependabot_test.go create mode 100644 .tools-to-be-migrated/discussions.go create mode 100644 .tools-to-be-migrated/discussions_test.go create mode 100644 .tools-to-be-migrated/dynamic_tools.go create mode 100644 .tools-to-be-migrated/gists.go create mode 100644 .tools-to-be-migrated/gists_test.go create mode 100644 .tools-to-be-migrated/git.go create mode 100644 .tools-to-be-migrated/issues.go create mode 100644 .tools-to-be-migrated/issues_test.go create mode 100644 .tools-to-be-migrated/labels.go create mode 100644 .tools-to-be-migrated/labels_test.go create mode 100644 .tools-to-be-migrated/notifications.go create mode 100644 .tools-to-be-migrated/notifications_test.go create mode 100644 .tools-to-be-migrated/projects.go create mode 100644 .tools-to-be-migrated/projects_test.go create mode 100644 .tools-to-be-migrated/pullrequests.go create mode 100644 .tools-to-be-migrated/pullrequests_test.go create mode 100644 .tools-to-be-migrated/repositories.go create mode 100644 .tools-to-be-migrated/repositories_test.go create mode 100644 .tools-to-be-migrated/search.go create mode 100644 .tools-to-be-migrated/search_test.go create mode 100644 .tools-to-be-migrated/search_utils.go create mode 100644 .tools-to-be-migrated/search_utils_test.go create mode 100644 .tools-to-be-migrated/secret_scanning.go create mode 100644 .tools-to-be-migrated/secret_scanning_test.go create mode 100644 .tools-to-be-migrated/security_advisories.go create mode 100644 .tools-to-be-migrated/security_advisories_test.go delete mode 100644 pkg/github/actions.go delete mode 100644 pkg/github/actions_test.go delete mode 100644 pkg/github/code_scanning.go delete mode 100644 pkg/github/code_scanning_test.go delete mode 100644 pkg/github/dependabot.go delete mode 100644 pkg/github/dependabot_test.go delete mode 100644 pkg/github/discussions.go delete mode 100644 pkg/github/discussions_test.go delete mode 100644 pkg/github/dynamic_tools.go delete mode 100644 pkg/github/gists.go delete mode 100644 pkg/github/gists_test.go delete mode 100644 pkg/github/git.go delete mode 100644 pkg/github/issues.go delete mode 100644 pkg/github/issues_test.go delete mode 100644 pkg/github/labels.go delete mode 100644 pkg/github/labels_test.go delete mode 100644 pkg/github/notifications.go delete mode 100644 pkg/github/notifications_test.go delete mode 100644 pkg/github/projects.go delete mode 100644 pkg/github/projects_test.go delete mode 100644 pkg/github/pullrequests.go delete mode 100644 pkg/github/pullrequests_test.go delete mode 100644 pkg/github/repositories.go delete mode 100644 pkg/github/repositories_test.go delete mode 100644 pkg/github/search.go delete mode 100644 pkg/github/search_test.go delete mode 100644 pkg/github/search_utils.go delete mode 100644 pkg/github/search_utils_test.go delete mode 100644 pkg/github/secret_scanning.go delete mode 100644 pkg/github/secret_scanning_test.go delete mode 100644 pkg/github/security_advisories.go delete mode 100644 pkg/github/security_advisories_test.go diff --git a/.tools-to-be-migrated/actions.go b/.tools-to-be-migrated/actions.go new file mode 100644 index 000000000..ecf538323 --- /dev/null +++ b/.tools-to-be-migrated/actions.go @@ -0,0 +1,1224 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/github/github-mcp-server/internal/profiler" + buffer "github.com/github/github-mcp-server/pkg/buffer" + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + DescriptionRepositoryOwner = "Repository owner" + DescriptionRepositoryName = "Repository name" +) + +// ListWorkflows creates a tool to list workflows in a repository +func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflows", + mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + 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 + } + + // Get optional pagination parameters + 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) + } + + // Set up list options + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow +func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_runs", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithString("workflow_id", + mcp.Required(), + mcp.Description("The workflow ID or workflow file name"), + ), + mcp.WithString("actor", + mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), + ), + mcp.WithString("branch", + mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), + ), + mcp.WithString("event", + mcp.Description("Returns workflow runs for a specific event type"), + mcp.Enum( + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", + ), + ), + mcp.WithString("status", + mcp.Description("Returns workflow runs with the check run status"), + mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), + ), + 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 + } + workflowID, err := RequiredParam[string](request, "workflow_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional filtering parameters + actor, err := OptionalParam[string](request, "actor") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + branch, err := OptionalParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + event, err := OptionalParam[string](request, "event") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + status, err := OptionalParam[string](request, "status") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional pagination parameters + 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) + } + + // Set up list options + opts := &github.ListWorkflowRunsOptions{ + Actor: actor, + Branch: branch, + Event: event, + Status: status, + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow runs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// RunWorkflow creates a tool to run an Actions workflow +func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("run_workflow", + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithString("workflow_id", + mcp.Required(), + mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"), + ), + mcp.WithString("ref", + mcp.Required(), + mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), + ), + mcp.WithObject("inputs", + mcp.Description("Inputs the workflow accepts"), + ), + ), + 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 + } + workflowID, err := RequiredParam[string](request, "workflow_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := RequiredParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := request.GetArguments()["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + var resp *github.Response + var workflowType string + + if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { + resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + workflowType = "workflow_id" + } else { + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + workflowType = "workflow_file" + } + + if err != nil { + return nil, fmt.Errorf("failed to run workflow: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow_type": workflowType, + "workflow_id": workflowID, + "ref": ref, + "inputs": inputs, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRun creates a tool to get details of a specific workflow run +func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to get workflow run: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run +func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run_logs", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get workflow run logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowJobs creates a tool to list jobs for a specific workflow run +func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_jobs", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + mcp.WithString("filter", + mcp.Description("Filters jobs by their completed_at timestamp"), + mcp.Enum("latest", "all"), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + // Get optional filtering parameters + filter, err := OptionalParam[string](request, "filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional pagination parameters + 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) + } + + // Set up list options + opts := &github.ListWorkflowJobsOptions{ + Filter: filter, + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow jobs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Add optimization tip for failed job debugging + response := map[string]any{ + "jobs": jobs, + "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run +func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_job_logs", + mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("job_id", + mcp.Description("The unique identifier of the workflow job (required for single job logs)"), + ), + mcp.WithNumber("run_id", + mcp.Description("Workflow run ID (required when using failed_only)"), + ), + mcp.WithBoolean("failed_only", + mcp.Description("When true, gets logs for all failed jobs in run_id"), + ), + mcp.WithBoolean("return_content", + mcp.Description("Returns actual log content instead of URLs"), + ), + mcp.WithNumber("tail_lines", + mcp.Description("Number of lines to return from the end of the log"), + mcp.DefaultNumber(500), + ), + ), + 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 + } + + // Get optional parameters + jobID, err := OptionalIntParam(request, "job_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID, err := OptionalIntParam(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + failedOnly, err := OptionalParam[bool](request, "failed_only") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + returnContent, err := OptionalParam[bool](request, "return_content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + tailLines, err := OptionalIntParam(request, "tail_lines") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Validate parameters + if failedOnly && runID == 0 { + return mcp.NewToolResultError("run_id is required when failed_only is true"), nil + } + if !failedOnly && jobID == 0 { + return mcp.NewToolResultError("job_id is required when failed_only is false"), nil + } + + if failedOnly && runID > 0 { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize) + } + + return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil + } +} + +// handleFailedJobLogs gets logs for all failed jobs in a workflow run +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { + // First, get all jobs for the workflow run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + // Filter for failed jobs + var failedJobs []*github.WorkflowJob + for _, job := range jobs.Jobs { + if job.GetConclusion() == "failure" { + failedJobs = append(failedJobs, job) + } + } + + if len(failedJobs) == 0 { + result := map[string]any{ + "message": "No failed jobs found in this workflow run", + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": 0, + } + r, _ := json.Marshal(result) + return mcp.NewToolResultText(string(r)), nil + } + + // Collect logs for all failed jobs + var logResults []map[string]any + for _, job := range failedJobs { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + if err != nil { + // Continue with other jobs even if one fails + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "error": err.Error(), + } + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } + + logResults = append(logResults, jobResult) + } + + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": len(failedJobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// handleSingleJobLogs gets logs for a single job +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil + } + + r, err := json.Marshal(jobResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// getJobLogData retrieves log data for a single job, either as URL or content +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { + // Get the download URL for the job logs + url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) + if err != nil { + return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "job_id": jobID, + } + if jobName != "" { + result["job_name"] = jobName + } + + if returnContent { + // Download and return the actual log content + content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + if err != nil { + // To keep the return value consistent wrap the response as a GitHub Response + ghRes := &github.Response{ + Response: httpResp, + } + return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) + } + result["logs_content"] = content + result["message"] = "Job logs content retrieved successfully" + result["original_length"] = originalLength + } else { + // Return just the URL + result["logs_url"] = url.String() + result["message"] = "Job logs are available for download" + result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + } + + return result, resp, nil +} + +func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { + prof := profiler.New(nil, profiler.IsProfilingEnabled()) + finish := prof.Start(ctx, "log_buffer_processing") + + httpResp, err := http.Get(logURL) //nolint:gosec + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode != http.StatusOK { + return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + } + + bufferSize := tailLines + if bufferSize > maxLines { + bufferSize = maxLines + } + + processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) + } + + lines := strings.Split(processedInput, "\n") + if len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + finalResult := strings.Join(lines, "\n") + + _ = finish(len(lines), int64(len(finalResult))) + + return finalResult, totalLines, httpResp, nil +} + +// RerunWorkflowRun creates a tool to re-run an entire workflow run +func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("rerun_workflow_run", + mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run +func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("rerun_failed_jobs", + mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Failed jobs have been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CancelWorkflowRun creates a tool to cancel a workflow run +func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("cancel_workflow_run", + mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + if _, ok := err.(*github.AcceptedError); !ok { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil + } + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run +func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_workflow_run_artifacts", + mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + // Get optional pagination parameters + 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) + } + + // Set up list options + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact +func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("download_workflow_run_artifact", + mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("artifact_id", + mcp.Required(), + mcp.Description("The unique identifier of the artifact"), + ), + ), + 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 + } + artifactIDInt, err := RequiredInt(request, "artifact_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + artifactID := int64(artifactIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the download URL and information + result := map[string]any{ + "download_url": url.String(), + "message": "Artifact is available for download", + "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + "artifact_id": artifactID, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run +func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_workflow_run_logs", + mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), + ReadOnlyHint: ToBoolPtr(false), + DestructiveHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run logs have been deleted", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run +func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_workflow_run_usage", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + ), + 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 + } + runIDInt, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID := int64(runIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/actions_test.go b/.tools-to-be-migrated/actions_test.go new file mode 100644 index 000000000..1738bc8e5 --- /dev/null +++ b/.tools-to-be-migrated/actions_test.go @@ -0,0 +1,1321 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "runtime" + "runtime/debug" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/profiler" + buffer "github.com/github/github-mcp-server/pkg/buffer" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListWorkflows(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_workflows", 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, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Ptr(2), + Workflows: []*github.Workflow{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), + BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), + NodeID: github.Ptr("W_123"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), + BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), + NodeID: github.Ptr("W_456"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response github.Workflows + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, 0) + assert.NotEmpty(t, response.Workflows) + }) + } +} + +func Test_RunWorkflow(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "run_workflow", 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, "workflow_id") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued", response["message"]) + assert.Contains(t, response, "workflow_type") + }) + } +} + +func Test_RunWorkflow_WithFilename(t *testing.T) { + // Test the unified RunWorkflow function with filenames + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run by filename", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": "ci.yml", + "ref": "main", + }, + expectError: false, + }, + { + name: "successful workflow run by numeric ID as string", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued", response["message"]) + assert.Contains(t, response, "workflow_type") + }) + } +} + +func Test_CancelWorkflowRun(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "cancel_workflow_run", 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, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run cancellation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "conflict when cancelling a workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: true, + expectedErrMsg: "failed to cancel workflow run", + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been cancelled", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +func Test_ListWorkflowRunArtifacts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifacts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(2)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(1)), + NodeID: github.Ptr("A_1"), + Name: github.Ptr("build-artifacts"), + SizeInBytes: github.Ptr(int64(1024)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), + ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), + Expired: github.Ptr(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Ptr(int64(12345)), + RepositoryID: github.Ptr(int64(1)), + HeadRepositoryID: github.Ptr(int64(1)), + HeadBranch: github.Ptr("main"), + HeadSHA: github.Ptr("abc123"), + }, + }, + { + ID: github.Ptr(int64(2)), + NodeID: github.Ptr("A_2"), + Name: github.Ptr("test-results"), + SizeInBytes: github.Ptr(int64(512)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), + ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), + Expired: github.Ptr(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Ptr(int64(12345)), + RepositoryID: github.Ptr(int64(1)), + HeadRepositoryID: github.Ptr(int64(1)), + HeadBranch: github.Ptr("main"), + HeadSHA: github.Ptr("abc123"), + }, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response github.ArtifactList + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, int64(0)) + assert.NotEmpty(t, response.Artifacts) + }) + } +} + +func Test_DownloadWorkflowRunArtifact(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifact download URL", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/artifacts/123/zip", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // GitHub returns a 302 redirect to the download URL + w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "artifact_id": float64(123), + }, + expectError: false, + }, + { + name: "missing required parameter artifact_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: artifact_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Contains(t, response, "download_url") + assert.Contains(t, response, "message") + assert.Equal(t, "Artifact is available for download", response["message"]) + assert.Equal(t, float64(123), response["artifact_id"]) + }) + } +} + +func Test_DeleteWorkflowRunLogs(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "delete_workflow_run_logs", 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, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful logs deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run logs have been deleted", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +func Test_GetWorkflowRunUsage(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_workflow_run_usage", 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, "run_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run usage", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + usage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Ptr(int64(120000)), + Jobs: github.Ptr(2), + JobRuns: []*github.WorkflowRunJobRun{ + { + JobID: github.Ptr(1), + DurationMS: github.Ptr(int64(60000)), + }, + { + JobID: github.Ptr(2), + DurationMS: github.Ptr(int64(60000)), + }, + }, + }, + }, + RunDurationMS: github.Ptr(int64(120000)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(usage) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response github.WorkflowRunUsage + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.RunDurationMS) + assert.NotNil(t, response.Billable) + }) + } +} + +func Test_GetJobLogs(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) + + assert.Equal(t, "get_job_logs", 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, "job_id") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.Contains(t, tool.InputSchema.Properties, "failed_only") + assert.Contains(t, tool.InputSchema.Properties, "return_content") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + checkResponse func(t *testing.T, response map[string]any) + }{ + { + name: "successful single job logs with URL", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, float64(123), response["job_id"]) + assert.Contains(t, response, "logs_url") + assert.Equal(t, "Job logs are available for download", response["message"]) + assert.Contains(t, response, "note") + }, + }, + { + name: "successful failed jobs logs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, float64(456), response["run_id"]) + assert.Equal(t, float64(3), response["total_jobs"]) + assert.Equal(t, float64(2), response["failed_jobs"]) + assert.Contains(t, response, "logs") + assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) + + logs, ok := response["logs"].([]interface{}) + assert.True(t, ok) + assert.Len(t, logs, 2) + }, + }, + { + name: "no failed jobs found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) + assert.Equal(t, float64(456), response["run_id"]) + assert.Equal(t, float64(2), response["total_jobs"]) + assert.Equal(t, float64(0), response["failed_jobs"]) + }, + }, + { + name: "missing job_id when not using failed_only", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "job_id is required when failed_only is false", + }, + { + name: "missing run_id when using failed_only", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "failed_only": true, + }, + expectError: true, + expectedErrMsg: "run_id is required when failed_only is true", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + "job_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "job_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "API error when getting single job logs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Not Found", + }) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(999), + }, + expectError: true, + }, + { + name: "API error when listing workflow jobs for failed_only", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Not Found", + }) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(999), + "failed_only": true, + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectError { + // For API errors, just verify we got an error + assert.True(t, result.IsError) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tc.checkResponse != nil { + tc.checkResponse(t, response) + } + }) + } +} + +func Test_GetJobLogs_WithContentReturn(t *testing.T) { + // Test the return_content functionality with a mock HTTP server + logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, logContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +} + +func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { + // Test the return_content functionality with a mock HTTP server + logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + "tail_lines": float64(1), // Requesting last 1 line + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, float64(3), response["original_length"]) + assert.Equal(t, expectedLogContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +} + +func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { + logContent := "Line 1\nLine 2\nLine 3" + expectedLogContent := "Line 1\nLine 2\nLine 3" + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + "tail_lines": float64(100), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, float64(3), response["original_length"]) + assert.Equal(t, expectedLogContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") +} + +func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping memory profiling test in short mode") + } + + const logLines = 100000 + const bufferSize = 5000 + largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(largeLogContent)) + })) + defer testServer.Close() + + os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") + defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") + + profiler.InitFromEnv(nil) + ctx := context.Background() + + debug.SetGCPercent(-1) + defer debug.SetGCPercent(100) + + for i := 0; i < 3; i++ { + runtime.GC() + } + + var baselineStats runtime.MemStats + runtime.ReadMemStats(&baselineStats) + + profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { + resp1, err := http.Get(testServer.URL) + if err != nil { + return 0, 0, err + } + defer resp1.Body.Close() //nolint:bodyclose + content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose + return totalLines, int64(len(content)), err + }) + require.NoError(t, err1) + + for i := 0; i < 3; i++ { + runtime.GC() + } + + profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { + resp2, err := http.Get(testServer.URL) + if err != nil { + return 0, 0, err + } + defer resp2.Body.Close() //nolint:bodyclose + + allContent, err := io.ReadAll(resp2.Body) + if err != nil { + return 0, 0, err + } + + allLines := strings.Split(string(allContent), "\n") + var nonEmptyLines []string + for _, line := range allLines { + if line != "" { + nonEmptyLines = append(nonEmptyLines, line) + } + } + totalLines := len(nonEmptyLines) + + var resultLines []string + if totalLines > bufferSize { + resultLines = nonEmptyLines[totalLines-bufferSize:] + } else { + resultLines = nonEmptyLines + } + + result := strings.Join(resultLines, "\n") + return totalLines, int64(len(result)), nil + }) + require.NoError(t, err2) + + assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, + "Sliding window should use less memory than reading all into memory") + + assert.Equal(t, profile1.LinesCount, profile2.LinesCount, + "Both approaches should count the same number of input lines") + assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, + "Both approaches should produce similar output sizes (within 100 bytes)") + + memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 + t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", + memoryReduction, + float64(profile2.MemoryDelta)/1024/1024, + float64(profile1.MemoryDelta)/1024/1024) + + t.Logf("Baseline: %d bytes", baselineStats.Alloc) + t.Logf("Sliding window: %s", profile1.String()) + t.Logf("No window: %s", profile2.String()) +} diff --git a/.tools-to-be-migrated/code_scanning.go b/.tools-to-be-migrated/code_scanning.go new file mode 100644 index 000000000..aa39cfc35 --- /dev/null +++ b/.tools-to-be-migrated/code_scanning.go @@ -0,0 +1,169 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_code_scanning_alert", + mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithNumber("alertNumber", + mcp.Required(), + mcp.Description("The number of the alert."), + ), + ), + 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 + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get alert", + 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 get alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_code_scanning_alerts", + mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter code scanning alerts by state. Defaults to open"), + mcp.DefaultString("open"), + mcp.Enum("open", "closed", "dismissed", "fixed"), + ), + mcp.WithString("ref", + mcp.Description("The Git reference for the results you want to list."), + ), + mcp.WithString("severity", + mcp.Description("Filter code scanning alerts by severity"), + mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"), + ), + mcp.WithString("tool_name", + mcp.Description("The name of the tool used for code scanning."), + ), + ), + 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 + } + ref, err := OptionalParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + severity, err := OptionalParam[string](request, "severity") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + toolName, err := OptionalParam[string](request, "tool_name") + 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) + } + alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list alerts", + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/code_scanning_test.go b/.tools-to-be-migrated/code_scanning_test.go new file mode 100644 index 000000000..874d1eeda --- /dev/null +++ b/.tools-to-be-migrated/code_scanning_test.go @@ -0,0 +1,249 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetCodeScanningAlert(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetCodeScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_code_scanning_alert", 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, "alertNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + + // Setup mock alert for success case + mockAlert := &github.Alert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + Rule: &github.Rule{ID: github.Ptr("test-rule"), Description: github.Ptr("Test Rule Description")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlert *github.Alert + expectedErrMsg string + }{ + { + name: "successful alert fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, + mockAlert, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: false, + expectedAlert: mockAlert, + }, + { + name: "alert fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetCodeScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlert github.Alert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.Rule.ID, *returnedAlert.Rule.ID) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + + }) + } +} + +func Test_ListCodeScanningAlerts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_code_scanning_alerts", 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, "ref") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.Contains(t, tool.InputSchema.Properties, "tool_name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock alerts for success case + mockAlerts := []*github.Alert{ + { + Number: github.Ptr(42), + State: github.Ptr("open"), + Rule: &github.Rule{ID: github.Ptr("test-rule-1"), Description: github.Ptr("Test Rule 1")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), + }, + { + Number: github.Ptr(43), + State: github.Ptr("fixed"), + Rule: &github.Rule{ID: github.Ptr("test-rule-2"), Description: github.Ptr("Test Rule 2")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/43"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlerts []*github.Alert + expectedErrMsg string + }{ + { + name: "successful alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCodeScanningAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "ref": "main", + "state": "open", + "severity": "high", + "tool_name": "codeql", + }).andThen( + mockResponse(t, http.StatusOK, mockAlerts), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "ref": "main", + "state": "open", + "severity": "high", + "tool_name": "codeql", + }, + expectError: false, + expectedAlerts: mockAlerts, + }, + { + name: "alerts listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCodeScanningAlertsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlerts []*github.Alert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + assert.Equal(t, *tc.expectedAlerts[i].Rule.ID, *alert.Rule.ID) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + } + }) + } +} diff --git a/.tools-to-be-migrated/dependabot.go b/.tools-to-be-migrated/dependabot.go new file mode 100644 index 000000000..e21562c02 --- /dev/null +++ b/.tools-to-be-migrated/dependabot.go @@ -0,0 +1,161 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "get_dependabot_alert", + mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithNumber("alertNumber", + mcp.Required(), + mcp.Description("The number of the alert."), + ), + ), + 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 + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + 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 get alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "list_dependabot_alerts", + mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter dependabot alerts by state. Defaults to open"), + mcp.DefaultString("open"), + mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), + ), + mcp.WithString("severity", + mcp.Description("Filter dependabot alerts by severity"), + mcp.Enum("low", "medium", "high", "critical"), + ), + ), + 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 + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + severity, err := OptionalParam[string](request, "severity") + 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) + } + + alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ + State: ToStringPtr(state), + Severity: ToStringPtr(severity), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/dependabot_test.go b/.tools-to-be-migrated/dependabot_test.go new file mode 100644 index 000000000..302692a3a --- /dev/null +++ b/.tools-to-be-migrated/dependabot_test.go @@ -0,0 +1,276 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetDependabotAlert(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // Validate tool schema + assert.Equal(t, "get_dependabot_alert", 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, "alertNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + + // Setup mock alert for success case + mockAlert := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlert *github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful alert fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + mockAlert, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: false, + expectedAlert: mockAlert, + }, + { + name: "alert fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlert github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + }) + } +} + +func Test_ListDependabotAlerts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_dependabot_alerts", 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, "state") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock alerts for success case + criticalAlert := github.DependabotAlert{ + Number: github.Ptr(1), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), + State: github.Ptr("open"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("critical"), + }, + } + highSeverityAlert := github.DependabotAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), + State: github.Ptr("fixed"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlerts []*github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful open alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "state": "open", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "open", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, + { + name: "successful severity filtered listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "severity": "high", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "severity": "high", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, + }, + { + name: "successful all alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, + }, + { + name: "alerts listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlerts []*github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && + alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { + assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) + } + } + }) + } +} diff --git a/.tools-to-be-migrated/discussions.go b/.tools-to-be-migrated/discussions.go new file mode 100644 index 000000000..3aa92f05c --- /dev/null +++ b/.tools-to-be-migrated/discussions.go @@ -0,0 +1,531 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +const DefaultGraphQLPageSize = 30 + +// Common interface for all discussion query types +type DiscussionQueryResult interface { + GetDiscussionFragment() DiscussionFragment +} + +// Implement the interface for all query types +func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { + return q.Repository.Discussions +} + +type DiscussionFragment struct { + Nodes []NodeFragment + PageInfo PageInfoFragment + TotalCount githubv4.Int +} + +type NodeFragment struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Author struct { + Login githubv4.String + } + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` +} + +type PageInfoFragment struct { + HasNextPage bool + HasPreviousPage bool + StartCursor githubv4.String + EndCursor githubv4.String +} + +type BasicNoOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type BasicWithOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type WithCategoryAndOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type WithCategoryNoOrder struct { + Repository struct { + Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { + return &github.Discussion{ + Number: github.Ptr(int(fragment.Number)), + Title: github.Ptr(string(fragment.Title)), + HTMLURL: github.Ptr(string(fragment.URL)), + CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, + UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, + User: &github.User{ + Login: github.Ptr(string(fragment.Author.Login)), + }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(fragment.Category.Name)), + }, + } +} + +func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { + if categoryID != nil && useOrdering { + return &WithCategoryAndOrder{} + } + if categoryID != nil && !useOrdering { + return &WithCategoryNoOrder{} + } + if categoryID == nil && useOrdering { + return &BasicWithOrder{} + } + return &BasicNoOrder{} +} + +func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_discussions", + mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), + ), + mcp.WithString("category", + mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), + ), + mcp.WithString("orderBy", + mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), + mcp.Enum("CREATED_AT", "UPDATED_AT"), + ), + mcp.WithString("direction", + mcp.Description("Order direction."), + mcp.Enum("ASC", "DESC"), + ), + WithCursorPagination(), + ), + 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 := OptionalParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // when not provided, default to the .github repository + // this will query discussions at the organisation level + if repo == "" { + repo = ".github" + } + + category, err := OptionalParam[string](request, "category") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var categoryID *githubv4.ID + if category != "" { + id := githubv4.ID(category) + categoryID = &id + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } + + // this is an extra check in case the tool description is misinterpreted, because + // we shouldn't use ordering unless both a 'field' and 'direction' are provided + useOrdering := orderBy != "" && direction != "" + if useOrdering { + vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) + vars["orderByDirection"] = githubv4.OrderDirection(direction) + } + + if categoryID != nil { + vars["categoryId"] = *categoryID + } + + discussionQuery := getQueryType(useOrdering, categoryID) + if err := client.Query(ctx, discussionQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Extract and convert all discussion nodes using the common interface + var discussions []*github.Discussion + var pageInfo PageInfoFragment + var totalCount githubv4.Int + if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { + fragment := queryResult.GetDiscussionFragment() + for _, node := range fragment.Nodes { + discussions = append(discussions, fragmentToDiscussion(node)) + } + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount + } + + // Create response with pagination info + response := map[string]interface{}{ + "discussions": discussions, + "pageInfo": map[string]interface{}{ + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), + }, + "totalCount": totalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions: %w", err) + } + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("discussionNumber", + mcp.Required(), + mcp.Description("Discussion Number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var q struct { + Repository struct { + Discussion struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + CreatedAt githubv4.DateTime + URL githubv4.String `graphql:"url"` + Category struct { + Name githubv4.String + } `graphql:"category"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "discussionNumber": githubv4.Int(params.DiscussionNumber), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + d := q.Repository.Discussion + discussion := &github.Discussion{ + Number: github.Ptr(int(d.Number)), + Title: github.Ptr(string(d.Title)), + Body: github.Ptr(string(d.Body)), + HTMLURL: github.Ptr(string(d.URL)), + CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(d.Category.Name)), + }, + } + out, err := json.Marshal(discussion) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion_comments", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), + WithCursorPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided + + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + + // Use default of 30 if pagination was not explicitly provided + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "discussionNumber": githubv4.Int(params.DiscussionNumber), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var comments []*github.IssueComment + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + } + + // Create response with pagination info + response := map[string]interface{}{ + "comments": comments, + "pageInfo": map[string]interface{}{ + "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, + "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, + "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), + "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), + }, + "totalCount": q.Repository.Discussion.Comments.TotalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal comments: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_discussion_categories", + mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), + ), + ), + 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 := OptionalParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // when not provided, default to the .github repository + // this will query discussion categories at the organisation level + if repo == "" { + repo = ".github" + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"discussionCategories(first: $first)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(25), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var categories []map[string]string + for _, c := range q.Repository.DiscussionCategories.Nodes { + categories = append(categories, map[string]string{ + "id": fmt.Sprint(c.ID), + "name": string(c.Name), + }) + } + + // Create response with pagination info + response := map[string]interface{}{ + "categories": categories, + "pageInfo": map[string]interface{}{ + "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, + "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, + "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), + "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), + }, + "totalCount": q.Repository.DiscussionCategories.TotalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + } + return mcp.NewToolResultText(string(out)), nil + } +} diff --git a/.tools-to-be-migrated/discussions_test.go b/.tools-to-be-migrated/discussions_test.go new file mode 100644 index 000000000..0930b1421 --- /dev/null +++ b/.tools-to-be-migrated/discussions_test.go @@ -0,0 +1,778 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + discussionsGeneral = []map[string]any{ + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + } + discussionsAll = []map[string]any{ + { + "number": 1, + "title": "Discussion 1 title", + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "url": "https://github.com/owner/repo/discussions/1", + "category": map[string]any{"name": "General"}, + }, + { + "number": 2, + "title": "Discussion 2 title", + "createdAt": "2023-02-01T00:00:00Z", + "updatedAt": "2023-02-01T00:00:00Z", + "author": map[string]any{"login": "user2"}, + "url": "https://github.com/owner/repo/discussions/2", + "category": map[string]any{"name": "Questions"}, + }, + { + "number": 3, + "title": "Discussion 3 title", + "createdAt": "2023-03-01T00:00:00Z", + "updatedAt": "2023-03-01T00:00:00Z", + "author": map[string]any{"login": "user3"}, + "url": "https://github.com/owner/repo/discussions/3", + "category": map[string]any{"name": "General"}, + }, + } + + discussionsOrgLevel = []map[string]any{ + { + "number": 1, + "title": "Org Discussion 1 - Community Guidelines", + "createdAt": "2023-01-15T00:00:00Z", + "updatedAt": "2023-01-15T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/1", + "category": map[string]any{"name": "Announcements"}, + }, + { + "number": 2, + "title": "Org Discussion 2 - Roadmap 2023", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/2", + "category": map[string]any{"name": "General"}, + }, + { + "number": 3, + "title": "Org Discussion 3 - Roadmap 2024", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/3", + "category": map[string]any{"name": "General"}, + }, + { + "number": 4, + "title": "Org Discussion 4 - Roadmap 2025", + "createdAt": "2023-02-20T00:00:00Z", + "updatedAt": "2023-02-20T00:00:00Z", + "author": map[string]any{"login": "org-admin"}, + "url": "https://github.com/owner/.github/discussions/4", + "category": map[string]any{"name": "General"}, + }, + } + + // Ordered mock responses + discussionsOrderedCreatedAsc = []map[string]any{ + discussionsAll[0], // Discussion 1 (created 2023-01-01) + discussionsAll[1], // Discussion 2 (created 2023-02-01) + discussionsAll[2], // Discussion 3 (created 2023-03-01) + } + + discussionsOrderedUpdatedDesc = []map[string]any{ + discussionsAll[2], // Discussion 3 (updated 2023-03-01) + discussionsAll[1], // Discussion 2 (updated 2023-02-01) + discussionsAll[0], // Discussion 1 (updated 2023-01-01) + } + + // only 'General' category discussions ordered by created date descending + discussionsGeneralOrderedDesc = []map[string]any{ + discussionsGeneral[1], // Discussion 3 (created 2023-03-01) + discussionsGeneral[0], // Discussion 1 (created 2023-01-01) + } + + mockResponseListAll = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsAll, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsGeneral, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrderedCreatedAsc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrderedUpdatedDesc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsGeneralOrderedDesc, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{ + "nodes": discussionsOrgLevel, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 4, + }, + }, + }) + + mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") +) + +func Test_ListDiscussions(t *testing.T) { + mockClient := githubv4.NewClient(nil) + toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussions", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") + assert.Contains(t, toolDef.InputSchema.Properties, "direction") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + varsListAll := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "first": float64(30), + "after": (*string)(nil), + } + + varsRepoNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "first": float64(30), + "after": (*string)(nil), + } + + varsDiscussionsFiltered := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrderByCreatedAsc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "CREATED_AT", + "orderByDirection": "ASC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrderByUpdatedDesc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "UPDATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsCategoryWithOrder := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "orderByField": "CREATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrgLevel := map[string]interface{}{ + "owner": "owner", + "repo": ".github", // This is what gets set when repo is not provided + "first": float64(30), + "after": (*string)(nil), + } + + tests := []struct { + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int + verifyOrder func(t *testing.T, discussions []*github.Discussion) + }{ + { + name: "list all discussions without category filter", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedCount: 3, // All discussions + }, + { + name: "filter by category ID", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "category": "DIC_kwDOABC123", + }, + expectError: false, + expectedCount: 2, // Only General discussions (matching the category ID) + }, + { + name: "order by created at ascending", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "CREATED_AT", + "direction": "ASC", + }, + expectError: false, + expectedCount: 3, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify discussions are ordered by created date ascending + require.Len(t, discussions, 3) + assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") + assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") + assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") + }, + }, + { + name: "order by updated at descending", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "UPDATED_AT", + "direction": "DESC", + }, + expectError: false, + expectedCount: 3, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify discussions are ordered by updated date descending + require.Len(t, discussions, 3) + assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") + assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") + assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") + }, + }, + { + name: "filter by category with order", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "category": "DIC_kwDOABC123", + "orderBy": "CREATED_AT", + "direction": "DESC", + }, + expectError: false, + expectedCount: 2, + verifyOrder: func(t *testing.T, discussions []*github.Discussion) { + // Verify only General discussions, ordered by created date descending + require.Len(t, discussions, 2) + assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") + assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") + }, + }, + { + name: "order by without direction (should not use ordering)", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderBy": "CREATED_AT", + }, + expectError: false, + expectedCount: 3, + }, + { + name: "direction without order by (should not use ordering)", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "direction": "DESC", + }, + expectError: false, + expectedCount: 3, + }, + { + name: "repository not found error", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", + }, + { + name: "list org-level discussions (no repo provided)", + reqParams: map[string]interface{}{ + "owner": "owner", + // repo is not provided, it will default to ".github" + }, + expectError: false, + expectedCount: 4, + }, + } + + // Define the actual query strings that match the implementation + qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var httpClient *http.Client + + switch tc.name { + case "list all discussions without category filter": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category ID": + matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by created at ascending": + matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by updated at descending": + matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category with order": + matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "order by without direction (should not use ordering)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "direction without order by (should not use ordering)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "list org-level discussions (no repo provided)": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + // Parse the structured response with pagination info + var response struct { + Discussions []*github.Discussion `json:"discussions"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + err = json.Unmarshal([]byte(text), &response) + require.NoError(t, err) + + assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) + + // Verify order if verifyOrder function is provided + if tc.verifyOrder != nil { + tc.verifyOrder(t, response.Discussions) + } + + // Verify that all returned discussions have a category if filtered + if _, hasCategory := tc.reqParams["category"]; hasCategory { + for _, discussion := range response.Discussions { + require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") + assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") + } + } + }) + } +} + +func Test_GetDiscussion(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + // Use exact string query that matches implementation output + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" + + vars := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + } + tests := []struct { + name string + response githubv4mock.GQLResponse + expectError bool + expected *github.Discussion + errContains string + }{ + { + name: "successful retrieval", + response: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "title": "Test Discussion Title", + "body": "This is a test discussion", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + "category": map[string]any{"name": "General"}, + }}, + }), + expectError: false, + expected: &github.Discussion{ + HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), + Number: github.Ptr(1), + Title: github.Ptr("Test Discussion Title"), + Body: github.Ptr("This is a test discussion"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr("General"), + }, + }, + }, + { + name: "discussion not found", + response: githubv4mock.ErrorResponse("discussion not found"), + expectError: true, + errContains: "discussion not found", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + require.NoError(t, err) + var out github.Discussion + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) + assert.Equal(t, *tc.expected.Number, *out.Number) + assert.Equal(t, *tc.expected.Title, *out.Title) + assert.Equal(t, *tc.expected.Body, *out.Body) + // Check category label + assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) + }) + } +} + +func Test_GetDiscussionComments(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion_comments", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + // Use exact string query that matches implementation output + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + vars := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), + } + + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + {"body": "This is the first comment"}, + {"body": "This is the second comment"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + // (Lines removed) + + var response struct { + Comments []*github.IssueComment `json:"comments"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Len(t, response.Comments, 2) + expectedBodies := []string{"This is the first comment", "This is the second comment"} + for i, comment := range response.Comments { + assert.Equal(t, expectedBodies[i], *comment.Body) + } +} + +func Test_ListDiscussionCategories(t *testing.T) { + mockClient := githubv4.NewClient(nil) + toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussion_categories", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.Description, "or organisation") + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + + // Use exact string query that matches implementation output + qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + + // Variables for repository-level categories + varsRepo := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "first": float64(25), + } + + // Variables for organization-level categories (using .github repo) + varsOrg := map[string]interface{}{ + "owner": "owner", + "repo": ".github", + "first": float64(25), + } + + mockRespRepo := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockRespOrg := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "789", "name": "Announcements"}, + {"id": "101", "name": "General"}, + {"id": "112", "name": "Ideas"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 3, + }, + }, + }) + + tests := []struct { + name string + reqParams map[string]interface{} + vars map[string]interface{} + mockResponse githubv4mock.GQLResponse + expectError bool + expectedCount int + expectedCategories []map[string]string + }{ + { + name: "list repository-level discussion categories", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + vars: varsRepo, + mockResponse: mockRespRepo, + expectError: false, + expectedCount: 2, + expectedCategories: []map[string]string{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + }, + { + name: "list org-level discussion categories (no repo provided)", + reqParams: map[string]interface{}{ + "owner": "owner", + // repo is not provided, it will default to ".github" + }, + vars: varsOrg, + mockResponse: mockRespOrg, + expectError: false, + expectedCount: 3, + expectedCategories: []map[string]string{ + {"id": "789", "name": "Announcements"}, + {"id": "101", "name": "General"}, + {"id": "112", "name": "Ideas"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + + _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + return + } + require.NoError(t, err) + + var response struct { + Categories []map[string]string `json:"categories"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedCategories, response.Categories) + }) + } +} diff --git a/.tools-to-be-migrated/dynamic_tools.go b/.tools-to-be-migrated/dynamic_tools.go new file mode 100644 index 000000000..e703a885e --- /dev/null +++ b/.tools-to-be-migrated/dynamic_tools.go @@ -0,0 +1,138 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption { + toolsetNames := make([]string, 0, len(toolsetGroup.Toolsets)) + for name := range toolsetGroup.Toolsets { + toolsetNames = append(toolsetNames, name) + } + return mcp.Enum(toolsetNames...) +} + +func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("enable_toolset", + mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), + // Not modifying GitHub data so no need to show a warning + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("toolset", + mcp.Required(), + mcp.Description("The name of the toolset to enable"), + ToolsetEnum(toolsetGroup), + ), + ), + func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // We need to convert the toolsets back to a map for JSON serialization + toolsetName, err := RequiredParam[string](request, "toolset") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + toolset := toolsetGroup.Toolsets[toolsetName] + if toolset == nil { + return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil + } + if toolset.Enabled { + return mcp.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil + } + + toolset.Enabled = true + + // caution: this currently affects the global tools and notifies all clients: + // + // Send notification to all initialized sessions + // s.sendNotificationToAllClients("notifications/tools/list_changed", nil) + s.AddTools(toolset.GetActiveTools()...) + + return mcp.NewToolResultText(fmt.Sprintf("Toolset %s enabled", toolsetName)), nil + } +} + +func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_available_toolsets", + mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // We need to convert the toolsetGroup back to a map for JSON serialization + + payload := []map[string]string{} + + for name, ts := range toolsetGroup.Toolsets { + { + t := map[string]string{ + "name": name, + "description": ts.Description, + "can_enable": "true", + "currently_enabled": fmt.Sprintf("%t", ts.Enabled), + } + payload = append(payload, t) + } + } + + r, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal features: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_toolset_tools", + mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("toolset", + mcp.Required(), + mcp.Description("The name of the toolset you want to get the tools for"), + ToolsetEnum(toolsetGroup), + ), + ), + func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // We need to convert the toolsetGroup back to a map for JSON serialization + toolsetName, err := RequiredParam[string](request, "toolset") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + toolset := toolsetGroup.Toolsets[toolsetName] + if toolset == nil { + return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil + } + payload := []map[string]string{} + + for _, st := range toolset.GetAvailableTools() { + tool := map[string]string{ + "name": st.Tool.Name, + "description": st.Tool.Description, + "can_enable": "true", + "toolset": toolsetName, + } + payload = append(payload, tool) + } + + r, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal features: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/gists.go b/.tools-to-be-migrated/gists.go new file mode 100644 index 000000000..7168f8c0e --- /dev/null +++ b/.tools-to-be-migrated/gists.go @@ -0,0 +1,316 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListGists creates a tool to list gists for a user +func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_gists", + mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_GISTS", "List Gists"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("username", + mcp.Description("GitHub username (omit for authenticated user's gists)"), + ), + mcp.WithString("since", + mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := OptionalParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.GistListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Parse since timestamp if provided + if since != "" { + sinceTime, err := parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil + } + opts.Since = sinceTime + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gists, resp, err := client.Gists.List(ctx, username, opts) + if err != nil { + return nil, fmt.Errorf("failed to list gists: %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 list gists: %s", string(body))), nil + } + + r, err := json.Marshal(gists) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetGist creates a tool to get the content of a gist +func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_gist", + mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_GIST", "Get Gist Content"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("gist_id", + mcp.Required(), + mcp.Description("The ID of the gist"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gistID, err := RequiredParam[string](request, "gist_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) + } + + gist, resp, err := client.Gists.Get(ctx, gistID) + if err != nil { + return nil, fmt.Errorf("failed to get gist: %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 gist: %s", string(body))), nil + } + + r, err := json.Marshal(gist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CreateGist creates a tool to create a new gist +func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_gist", + mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_GIST", "Create Gist"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("description", + mcp.Description("Description of the gist"), + ), + mcp.WithString("filename", + mcp.Required(), + mcp.Description("Filename for simple single-file gist creation"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Content for simple single-file gist creation"), + ), + mcp.WithBoolean("public", + mcp.Description("Whether the gist is public"), + mcp.DefaultBool(false), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + public, err := OptionalParam[bool](request, "public") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Public: github.Ptr(public), + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + createdGist, resp, err := client.Gists.Create(ctx, gist) + if err != nil { + return nil, fmt.Errorf("failed to create gist: %w", err) + } + 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 gist: %s", string(body))), nil + } + + minimalResponse := MinimalResponse{ + ID: createdGist.GetID(), + URL: createdGist.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 + } +} + +// UpdateGist creates a tool to edit an existing gist +func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_gist", + mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_GIST", "Update Gist"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("gist_id", + mcp.Required(), + mcp.Description("ID of the gist to update"), + ), + mcp.WithString("description", + mcp.Description("Updated description of the gist"), + ), + mcp.WithString("filename", + mcp.Required(), + mcp.Description("Filename to update or create"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Content for the file"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gistID, err := RequiredParam[string](request, "gist_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) + if err != nil { + return nil, fmt.Errorf("failed to update gist: %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 update gist: %s", string(body))), nil + } + + minimalResponse := MinimalResponse{ + ID: updatedGist.GetID(), + URL: updatedGist.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 + } +} diff --git a/.tools-to-be-migrated/gists_test.go b/.tools-to-be-migrated/gists_test.go new file mode 100644 index 000000000..e8eb6d7f4 --- /dev/null +++ b/.tools-to-be-migrated/gists_test.go @@ -0,0 +1,595 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListGists(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_gists", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "username") + assert.Contains(t, tool.InputSchema.Properties, "since") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Empty(t, tool.InputSchema.Required) + + // Setup mock gists for success case + mockGists := []*github.Gist{ + { + ID: github.Ptr("gist1"), + Description: github.Ptr("First Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), + Public: github.Ptr(true), + CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "file1.txt": { + Filename: github.Ptr("file1.txt"), + Content: github.Ptr("content of file 1"), + }, + }, + }, + { + ID: github.Ptr("gist2"), + Description: github.Ptr("Second Gist"), + HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"), + Public: github.Ptr(false), + CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, + Owner: &github.User{Login: github.Ptr("testuser")}, + Files: map[github.GistFilename]github.GistFile{ + "file2.js": { + Filename: github.Ptr("file2.js"), + Content: github.Ptr("console.log('hello');"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedGists []*github.Gist + expectedErrMsg string + }{ + { + name: "list authenticated user's gists", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetGists, + mockGists, + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedGists: mockGists, + }, + { + name: "list specific user's gists", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersGistsByUsername, + mockResponse(t, http.StatusOK, mockGists), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + }, + expectError: false, + expectedGists: mockGists, + }, + { + name: "list gists with pagination and since parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGists, + expectQueryParams(t, map[string]string{ + "since": "2023-01-01T00:00:00Z", + "page": "2", + "per_page": "5", + }).andThen( + mockResponse(t, http.StatusOK, mockGists), + ), + ), + ), + requestArgs: map[string]interface{}{ + "since": "2023-01-01T00:00:00Z", + "page": float64(2), + "perPage": float64(5), + }, + expectError: false, + expectedGists: mockGists, + }, + { + name: "invalid since parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetGists, + mockGists, + ), + ), + requestArgs: map[string]interface{}{ + "since": "invalid-date", + }, + expectError: true, + expectedErrMsg: "invalid since timestamp", + }, + { + name: "list gists fails with error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGists, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list gists", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.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 returnedGists []*github.Gist + err = json.Unmarshal([]byte(textContent.Text), &returnedGists) + require.NoError(t, err) + + assert.Len(t, returnedGists, len(tc.expectedGists)) + for i, gist := range returnedGists { + assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID) + assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description) + assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL) + assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public) + } + }) + } +} + +func Test_GetGist(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_gist", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "gist_id") + + assert.Contains(t, tool.InputSchema.Required, "gist_id") + + // Setup mock gist for success case + mockGist := github.Gist{ + ID: github.Ptr("gist1"), + Description: github.Ptr("First Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), + Public: github.Ptr(true), + CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + github.GistFilename("file1.txt"): { + Filename: github.Ptr("file1.txt"), + Content: github.Ptr("content of file 1"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedGists github.Gist + expectedErrMsg string + }{ + { + name: "Successful fetching different gist", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGistsByGistId, + mockResponse(t, http.StatusOK, mockGist), + ), + ), + requestArgs: map[string]interface{}{ + "gist_id": "gist1", + }, + expectError: false, + expectedGists: mockGist, + }, + { + name: "gist_id parameter missing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetGistsByGistId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid Request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "missing required parameter: gist_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetGist(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.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 returnedGists github.Gist + err = json.Unmarshal([]byte(textContent.Text), &returnedGists) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedGists.ID, *returnedGists.ID) + assert.Equal(t, *tc.expectedGists.Description, *returnedGists.Description) + assert.Equal(t, *tc.expectedGists.HTMLURL, *returnedGists.HTMLURL) + assert.Equal(t, *tc.expectedGists.Public, *returnedGists.Public) + }) + } +} + +func Test_CreateGist(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "create_gist", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "filename") + assert.Contains(t, tool.InputSchema.Properties, "content") + assert.Contains(t, tool.InputSchema.Properties, "public") + + // Verify required parameters + assert.Contains(t, tool.InputSchema.Required, "filename") + assert.Contains(t, tool.InputSchema.Required, "content") + + // Setup mock data for test cases + createdGist := &github.Gist{ + ID: github.Ptr("new-gist-id"), + Description: github.Ptr("Test Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"), + Public: github.Ptr(false), + CreatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "test.go": { + Filename: github.Ptr("test.go"), + Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedGist *github.Gist + }{ + { + name: "create gist successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostGists, + mockResponse(t, http.StatusCreated, createdGist), + ), + ), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", + "description": "Test Gist", + "public": false, + }, + expectError: false, + expectedGist: createdGist, + }, + { + name: "missing required filename", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "content": "test content", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: filename", + }, + { + name: "missing required content", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: content", + }, + { + name: "api returns error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostGists, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "filename": "test.go", + "content": "package main", + "description": "Test Gist", + }, + expectError: true, + expectedErrMsg: "failed to create gist", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var gist MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &gist) + require.NoError(t, err) + + assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) + }) + } +} + +func Test_UpdateGist(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "update_gist", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "gist_id") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "filename") + assert.Contains(t, tool.InputSchema.Properties, "content") + + // Verify required parameters + assert.Contains(t, tool.InputSchema.Required, "gist_id") + assert.Contains(t, tool.InputSchema.Required, "filename") + assert.Contains(t, tool.InputSchema.Required, "content") + + // Setup mock data for test cases + updatedGist := &github.Gist{ + ID: github.Ptr("existing-gist-id"), + Description: github.Ptr("Updated Test Gist"), + HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), + Public: github.Ptr(true), + UpdatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{Login: github.Ptr("user")}, + Files: map[github.GistFilename]github.GistFile{ + "updated.go": { + Filename: github.Ptr("updated.go"), + Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedGist *github.Gist + }{ + { + name: "update gist successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchGistsByGistId, + mockResponse(t, http.StatusOK, updatedGist), + ), + ), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", + "description": "Updated Test Gist", + }, + expectError: false, + expectedGist: updatedGist, + }, + { + name: "missing required gist_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "filename": "updated.go", + "content": "updated content", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: gist_id", + }, + { + name: "missing required filename", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "content": "updated content", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: filename", + }, + { + name: "missing required content", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "missing required parameter: content", + }, + { + name: "api returns error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchGistsByGistId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "gist_id": "nonexistent-gist-id", + "filename": "updated.go", + "content": "package main", + "description": "Updated Test Gist", + }, + expectError: true, + expectedErrMsg: "failed to update gist", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) + require.NoError(t, err) + + assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) + }) + } +} diff --git a/.tools-to-be-migrated/git.go b/.tools-to-be-migrated/git.go new file mode 100644 index 000000000..5dfc8e0e8 --- /dev/null +++ b/.tools-to-be-migrated/git.go @@ -0,0 +1,160 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// TreeEntryResponse represents a single entry in a Git tree. +type TreeEntryResponse struct { + Path string `json:"path"` + Type string `json:"type"` + Size *int `json:"size,omitempty"` + Mode string `json:"mode"` + SHA string `json:"sha"` + URL string `json:"url"` +} + +// TreeResponse represents the response structure for a Git tree. +type TreeResponse struct { + SHA string `json:"sha"` + Truncated bool `json:"truncated"` + Tree []TreeEntryResponse `json:"tree"` + TreeSHA string `json:"tree_sha"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Recursive bool `json:"recursive"` + Count int `json:"count"` +} + +// GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. +func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_repository_tree", + mcp.WithDescription(t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("tree_sha", + mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"), + ), + mcp.WithBoolean("recursive", + mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"), + mcp.DefaultBool(false), + ), + mcp.WithString("path_filter", + mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"), + ), + ), + 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 + } + treeSHA, err := OptionalParam[string](request, "tree_sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + recursive, err := OptionalBoolParamWithDefault(request, "recursive", false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pathFilter, err := OptionalParam[string](request, "path_filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError("failed to get GitHub client"), nil + } + + // If no tree_sha is provided, use the repository's default branch + if treeSHA == "" { + repoInfo, repoResp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get repository info", + repoResp, + err, + ), nil + } + treeSHA = *repoInfo.DefaultBranch + } + + // Get the tree using the GitHub Git Tree API + tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get repository tree", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Filter tree entries if path_filter is provided + var filteredEntries []*github.TreeEntry + if pathFilter != "" { + for _, entry := range tree.Entries { + if strings.HasPrefix(entry.GetPath(), pathFilter) { + filteredEntries = append(filteredEntries, entry) + } + } + } else { + filteredEntries = tree.Entries + } + + treeEntries := make([]TreeEntryResponse, len(filteredEntries)) + for i, entry := range filteredEntries { + treeEntries[i] = TreeEntryResponse{ + Path: entry.GetPath(), + Type: entry.GetType(), + Mode: entry.GetMode(), + SHA: entry.GetSHA(), + URL: entry.GetURL(), + } + if entry.Size != nil { + treeEntries[i].Size = entry.Size + } + } + + response := TreeResponse{ + SHA: *tree.SHA, + Truncated: *tree.Truncated, + Tree: treeEntries, + TreeSHA: treeSHA, + Owner: owner, + Repo: repo, + Recursive: recursive, + Count: len(filteredEntries), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/issues.go b/.tools-to-be-migrated/issues.go new file mode 100644 index 000000000..bd437bde1 --- /dev/null +++ b/.tools-to-be-migrated/issues.go @@ -0,0 +1,1661 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// CloseIssueInput represents the input for closing an issue via the GraphQL API. +// Used to extend the functionality of the githubv4 library to support closing issues as duplicates. +type CloseIssueInput struct { + IssueID githubv4.ID `json:"issueId"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` + StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` + DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"` +} + +// IssueClosedStateReason represents the reason an issue was closed. +// Used to extend the functionality of the githubv4 library to support closing issues as duplicates. +type IssueClosedStateReason string + +const ( + IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" + IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" + IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED" +) + +// fetchIssueIDs retrieves issue IDs via the GraphQL API. +// When duplicateOf is 0, it fetches only the main issue ID. +// When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. +func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { + // Build query variables common to both cases + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers + } + + if duplicateOf == 0 { + // Only fetch the main issue ID + var query struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return "", "", fmt.Errorf("failed to get issue ID") + } + + return query.Repository.Issue.ID, "", nil + } + + // Fetch both issue IDs in a single query + var query struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + DuplicateIssue struct { + ID githubv4.ID + } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + // Add duplicate issue number to variables + vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers + + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return "", "", fmt.Errorf("failed to get issue ID") + } + + return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil +} + +// getCloseStateReason converts a string state reason to the appropriate enum value +func getCloseStateReason(stateReason string) IssueClosedStateReason { + switch stateReason { + case "not_planned": + return IssueClosedStateReasonNotPlanned + case "duplicate": + return IssueClosedStateReasonDuplicate + default: // Default to "completed" for empty or "completed" values + return IssueClosedStateReasonCompleted + } +} + +// IssueFragment represents a fragment of an issue node in the GraphQL API. +type IssueFragment struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + State githubv4.String + DatabaseID int64 + + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + ID githubv4.String + Description githubv4.String + } + } `graphql:"labels(first: 100)"` + Comments struct { + TotalCount githubv4.Int + } `graphql:"comments"` +} + +// Common interface for all issue query types +type IssueQueryResult interface { + GetIssueFragment() IssueQueryFragment +} + +type IssueQueryFragment struct { + Nodes []IssueFragment `graphql:"nodes"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int +} + +// ListIssuesQuery is the root query structure for fetching issues with optional label filtering. +type ListIssuesQuery struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. +type ListIssuesQueryTypeWithLabels struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. +type ListIssuesQueryWithSince struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. +type ListIssuesQueryTypeWithLabelsWithSince struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// Implement the interface for all query types +func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func getIssueQueryType(hasLabels bool, hasSince bool) any { + switch { + case hasLabels && hasSince: + return &ListIssuesQueryTypeWithLabelsWithSince{} + case hasLabels: + return &ListIssuesQueryTypeWithLabels{} + case hasSince: + return &ListIssuesQueryWithSince{} + default: + return &ListIssuesQuery{} + } +} + +func fragmentToIssue(fragment IssueFragment) *github.Issue { + // Convert GraphQL labels to GitHub API labels format + var foundLabels []*github.Label + for _, labelNode := range fragment.Labels.Nodes { + foundLabels = append(foundLabels, &github.Label{ + Name: github.Ptr(string(labelNode.Name)), + NodeID: github.Ptr(string(labelNode.ID)), + Description: github.Ptr(string(labelNode.Description)), + }) + } + + return &github.Issue{ + Number: github.Ptr(int(fragment.Number)), + Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), + CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, + UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, + User: &github.User{ + Login: github.Ptr(string(fragment.Author.Login)), + }, + State: github.Ptr(string(fragment.State)), + ID: github.Ptr(fragment.DatabaseID), + Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), + Labels: foundLabels, + Comments: github.Ptr(int(fragment.Comments.TotalCount)), + } +} + +// GetIssue creates a tool to get details of a specific issue in a GitHub repository. +func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (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_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"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository"), + ), + mcp.WithNumber("issue_number", + 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 + } + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) + } + + switch method { + case "get": + return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) + case "get_comments": + return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) + case "get_sub_issues": + return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) + case "get_labels": + return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + } + } +} + +func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, flags FeatureFlags) (*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() }() + + 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 + } + + if flags.LockdownMode { + if issue.User != nil { + shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + } + if shouldRemoveContent { + return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil + } + } + } + + // Sanitize title/body on response + if issue != nil { + if issue.Title != nil { + issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) + } + if issue.Body != nil { + issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) + } + } + + 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, _ FeatureFlags) (*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, _ FeatureFlags) (*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, _ FeatureFlags) (*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. +func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + + return mcp.NewTool("list_issue_types", + mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The organization owner of the repository"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return nil, fmt.Errorf("failed to list issue types: %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 list issue types: %s", string(body))), nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue types: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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. 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), + }), + 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 comment on"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("Comment content"), + ), + ), + 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 + } + body, err := RequiredParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + comment := &github.IssueComment{ + Body: github.Ptr(body), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) + if err != nil { + return nil, fmt.Errorf("failed to create comment: %w", err) + } + 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 comment: %s", string(body))), nil + } + + r, err := json.Marshal(createdComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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_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"), + ), + 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 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. 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 + } + 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 + } + replaceParent, err := OptionalParam[bool](request, "replace_parent") + 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) + } + + 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 + } + } +} + +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), + } + + 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 + } + + 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 add 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 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 +} + +// 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") + } +} + +// 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"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("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. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter."), + ), + 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) { + 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 + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := OptionalParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Optional parameters + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get assignees + assignees, err := OptionalStringArrayParam(request, "assignees") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional milestone + milestone, err := OptionalIntParam(request, "milestone") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var milestoneNum int + if milestone != 0 { + milestoneNum = milestone + } + + // Get optional type + issueType, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL client: %w", err) + } + + 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 mcp.NewToolResultError(err.Error()), nil + } + 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 + } + } +} + +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 + } + + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + } + + if milestoneNum != 0 { + issueRequest.Milestone = &milestoneNum + } + + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } + + 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 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 + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.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 +} + +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{} + + // Set optional parameters if provided + if title != "" { + issueRequest.Title = github.Ptr(title) + } + + if body != "" { + issueRequest.Body = github.Ptr(body) + } + + if len(labels) > 0 { + issueRequest.Labels = &labels + } + + if len(assignees) > 0 { + issueRequest.Assignees = &assignees + } + + if milestoneNum != 0 { + issueRequest.Milestone = &milestoneNum + } + + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } + + 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 != "" { + // 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 +} + +// ListIssues creates a tool to list and filter repository issues +func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_issues", + mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("state", + mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), + mcp.Enum("OPEN", "CLOSED"), + ), + mcp.WithArray("labels", + mcp.Description("Filter by labels"), + mcp.Items( + map[string]interface{}{ + "type": "string", + }, + ), + ), + mcp.WithString("orderBy", + mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), + mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), + ), + mcp.WithString("direction", + mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), + mcp.Enum("ASC", "DESC"), + ), + mcp.WithString("since", + mcp.Description("Filter by date (ISO 8601 timestamp)"), + ), + WithCursorPagination(), + ), + 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 + } + + // Set optional parameters if provided + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // If the state has a value, cast into an array of strings + var states []githubv4.IssueState + if state != "" { + states = append(states, githubv4.IssueState(state)) + } else { + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} + } + + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // These variables are required for the GraphQL query to be set by default + // If orderBy is empty, default to CREATED_AT + if orderBy == "" { + orderBy = "CREATED_AT" + } + // If direction is empty, default to DESC + if direction == "" { + direction = "DESC" + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // There are two optional parameters: since and labels. + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + } + hasSince = true + } + hasLabels := len(labels) > 0 + + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + + // Check if someone tried to use page-based pagination instead of cursor-based + if _, pageProvided := request.GetArguments()["page"]; pageProvided { + return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil + } + + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided + + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + + // Use default of 30 if pagination was not explicitly provided + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + // Used within query, therefore must be set to nil and provided as $after + vars["after"] = (*githubv4.String)(nil) + } + + // Ensure optional parameters are set + if hasLabels { + // Use query with labels filtering - convert string labels to githubv4.String slice + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } + + issueQuery := getIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Extract and convert all issue nodes using the common interface + var issues []*github.Issue + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + var totalCount int + + if queryResult, ok := issueQuery.(IssueQueryResult); ok { + fragment := queryResult.GetIssueFragment() + for _, issue := range fragment.Nodes { + issues = append(issues, fragmentToIssue(issue)) + } + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount + } + + // Create response with issues + response := map[string]interface{}{ + "issues": issues, + "pageInfo": map[string]interface{}{ + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), + }, + "totalCount": totalCount, + } + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal issues: %w", err) + } + return mcp.NewToolResultText(string(out)), 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 { + summary string + outcomes []string + referenceLinks []string +} + +func (d *mvpDescription) String() string { + var sb strings.Builder + sb.WriteString(d.summary) + if len(d.outcomes) > 0 { + sb.WriteString("\n\n") + sb.WriteString("This tool can help with the following outcomes:\n") + for _, outcome := range d.outcomes { + sb.WriteString(fmt.Sprintf("- %s\n", outcome)) + } + } + + if len(d.referenceLinks) > 0 { + sb.WriteString("\n\n") + sb.WriteString("More information can be found at:\n") + for _, link := range d.referenceLinks { + sb.WriteString(fmt.Sprintf("- %s\n", link)) + } + } + + return sb.String() +} + +func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + description := mvpDescription{ + summary: "Assign Copilot to a specific issue in a GitHub repository.", + outcomes: []string{ + "a Pull Request created with source code changes to resolve the issue", + }, + referenceLinks: []string{ + "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", + }, + } + + return mcp.NewTool("assign_copilot_to_issue", + mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), + ReadOnlyHint: ToBoolPtr(false), + IdempotentHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issueNumber", + mcp.Required(), + mcp.Description("Issue number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var params struct { + Owner string + Repo string + IssueNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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) + } + + // Firstly, we try to find the copilot bot in the suggested actors for the repository. + // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe + // it will not be on the first page of responses, thus we will keep paginating until we find it. + type botAssignee struct { + ID githubv4.ID + Login string + TypeName string `graphql:"__typename"` + } + + type suggestedActorsQuery struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot botAssignee `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "endCursor": (*githubv4.String)(nil), + } + + var copilotAssignee *botAssignee + for { + var query suggestedActorsQuery + err := client.Query(ctx, &query, variables) + if err != nil { + return nil, err + } + + // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the + // same name on each host. We need this in order to get the ID for later assignment. + for _, node := range query.Repository.SuggestedActors.Nodes { + if node.Bot.Login == "copilot-swe-agent" { + copilotAssignee = &node.Bot + break + } + } + + if !query.Repository.SuggestedActors.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + } + + // If we didn't find the copilot bot, we can't proceed any further. + if copilotAssignee == nil { + // The e2e tests depend upon this specific message to skip the test. + return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil + } + + // Next let's get the GQL Node ID and current assignees for this issue because the only way to + // assign copilot is to use replaceActorsForAssignable which requires the full list. + var getIssueQuery struct { + Repository struct { + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables = map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "number": githubv4.Int(params.IssueNumber), + } + + if err := client.Query(ctx, &getIssueQuery, variables); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil + } + + // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already + // assigned to seems to have no impact (which is a good thing). + var assignCopilotMutation struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors + } `graphql:"replaceActorsForAssignable(input: $input)"` + } + + actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) + for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { + actorIDs[i] = node.ID + } + actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + + if err := client.Mutate( + ctx, + &assignCopilotMutation, + ReplaceActorsForAssignableInput{ + AssignableID: getIssueQuery.Repository.Issue.ID, + ActorIDs: actorIDs, + }, + nil, + ); err != nil { + return nil, fmt.Errorf("failed to replace actors for assignable: %w", err) + } + + return mcp.NewToolResultText("successfully assigned copilot to issue"), nil + } +} + +type ReplaceActorsForAssignableInput struct { + AssignableID githubv4.ID `json:"assignableId"` + ActorIDs []githubv4.ID `json:"actorIds"` +} + +// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. +// Returns the parsed time or an error if parsing fails. +// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" +func parseISOTimestamp(timestamp string) (time.Time, error) { + if timestamp == "" { + return time.Time{}, fmt.Errorf("empty timestamp") + } + + // Try RFC3339 format (standard ISO 8601 with time) + t, err := time.Parse(time.RFC3339, timestamp) + if err == nil { + return t, nil + } + + // Try simple date format (YYYY-MM-DD) + t, err = time.Parse("2006-01-02", timestamp) + if err == nil { + return t, nil + } + + // Return error with supported formats + return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) +} + +func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { + return mcp.NewPrompt("AssignCodingAgent", + mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), + mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + repo := request.Params.Arguments["repo"] + + messages := []mcp.PromptMessage{ + { + Role: "user", + Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), + }, + { + Role: "user", + Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)), + }, + { + Role: "user", + Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."), + }, + { + Role: "user", + Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."), + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + } +} diff --git a/.tools-to-be-migrated/issues_test.go b/.tools-to-be-migrated/issues_test.go new file mode 100644 index 000000000..d13b93e4b --- /dev/null +++ b/.tools-to-be-migrated/issues_test.go @@ -0,0 +1,3521 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + defaultGQLClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(defaultGQLClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + 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"}) + + // Setup mock issue for success case + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Repository: &github.Repository{ + Name: github.Ptr("repo"), + Owner: &github.User{ + Login: github.Ptr("owner"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + gqlHTTPClient *http.Client + requestArgs map[string]interface{} + expectHandlerError bool + expectResultError bool + expectedIssue *github.Issue + expectedErrMsg string + lockdownEnabled bool + }{ + { + name: "successful issue retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockIssue, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectedIssue: mockIssue, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectHandlerError: true, + expectedErrMsg: "failed to get issue", + }, + { + name: "lockdown enabled - private repository", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockIssue, + ), + ), + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + IsPrivate githubv4.Boolean + Collaborators struct { + Edges []struct { + Permission githubv4.String + Node struct { + Login githubv4.String + } + } + } `graphql:"collaborators(query: $username, first: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "username": githubv4.String("testuser"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "isPrivate": true, + "collaborators": map[string]any{ + "edges": []any{}, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectedIssue: mockIssue, + lockdownEnabled: true, + }, + { + name: "lockdown enabled - user lacks push access", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockIssue, + ), + ), + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + IsPrivate githubv4.Boolean + Collaborators struct { + Edges []struct { + Permission githubv4.String + Node struct { + Login githubv4.String + } + } + } `graphql:"collaborators(query: $username, first: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "username": githubv4.String("testuser"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "isPrivate": false, + "collaborators": map[string]any{ + "edges": []any{ + map[string]any{ + "permission": "READ", + "node": map[string]any{ + "login": "testuser", + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectResultError: true, + expectedErrMsg: "access to issue details is restricted by lockdown mode", + lockdownEnabled: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + + var gqlClient *githubv4.Client + if tc.gqlHTTPClient != nil { + gqlClient = githubv4.NewClient(tc.gqlHTTPClient) + } else { + gqlClient = defaultGQLClient + } + + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, flags) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectHandlerError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + + if tc.expectResultError { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + textContent := getTextResult(t, result) + + var returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_AddIssueComment(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"}) + + // Setup mock comment for success case + mockComment := &github.IssueComment{ + ID: github.Ptr(int64(123)), + Body: github.Ptr("This is a test comment"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComment *github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comment creation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockComment), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "body": "This is a test comment", + }, + expectError: false, + expectedComment: mockComment, + }, + { + name: "comment creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "body": "", + }, + expectError: false, + expectedErrMsg: "missing required parameter: body", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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 returnedComment github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComment) + require.NoError(t, err) + assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) + assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) + assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) + + }) + } +} + +func Test_SearchIssues(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_issues", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Setup mock search results + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + Body: github.Ptr("This is a bug report"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Comments: github.Ptr(5), + User: &github.User{ + Login: github.Ptr("user1"), + }, + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Feature: Add new functionality"), + Body: github.Ptr("This is a feature request"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + Comments: github.Ptr(3), + User: &github.User{ + Login: github.Ptr("user2"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.IssuesSearchResult + expectedErrMsg string + }{ + { + name: "successful issues search with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": float64(1), + "perPage": float64(30), + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "issues search with owner and repo parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:issue is:open", + "sort": "created", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:open", + "owner": "test-owner", + "repo": "test-repo", + "sort": "created", + "order": "asc", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "issues search with only owner parameter (should ignore it)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "bug", + "owner": "test-owner", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "issues search with only repo parameter (should ignore it)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "feature", + "repo": "test-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "issues search with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetSearchIssues, + mockSearchResult, + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:issue repo:owner/repo is:open", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing is:issue filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server critical", + "owner": "different-owner", + "repo": "different-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with both is: and repo: filters already present", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:issue repo:octocat/Hello-World bug", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with multiple OR operators and existing filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search issues fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search issues", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) + assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) + for i, issue := range returnedResult.Issues { + assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) + assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) + assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) + assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) + assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) + } + }) + } +} + +func Test_CreateIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + mockGQLClient := githubv4.NewClient(nil) + tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "assignees") + 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{"method", "owner", "repo"}) + + // Setup mock issue for success case + mockIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful issue creation with all fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "This is a test issue", + "labels": []any{"bug", "help wanted"}, + "assignees": []any{"user1", "user2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + ), + requestArgs: map[string]interface{}{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test Issue", + "body": "This is a test issue", + "assignees": []any{"user1", "user2"}, + "labels": []any{"bug", "help wanted"}, + "milestone": float64(5), + "type": "Bug", + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful issue creation with minimal fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Minimal Issue", + "assignees": nil, // Expect no failure with nil optional value. + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }, + }, + { + name: "issue creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "", + }, + expectError: false, + expectedErrMsg: "missing required parameter: title", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueWrite(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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) + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var returnedIssue MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) + }) + } +} + +func Test_ListIssues(t *testing.T) { + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issues", 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, "state") + assert.Contains(t, tool.InputSchema.Properties, "labels") + assert.Contains(t, tool.InputSchema.Properties, "orderBy") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "since") + assert.Contains(t, tool.InputSchema.Properties, "after") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Mock issues data + mockIssuesAll := []map[string]any{ + { + "number": 123, + "title": "First Issue", + "body": "This is the first test issue", + "state": "OPEN", + "databaseId": 1001, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "bug", "id": "label1", "description": "Bug label"}, + }, + }, + "comments": map[string]any{ + "totalCount": 5, + }, + }, + { + "number": 456, + "title": "Second Issue", + "body": "This is the second test issue", + "state": "OPEN", + "databaseId": 1002, + "createdAt": "2023-02-01T00:00:00Z", + "updatedAt": "2023-02-01T00:00:00Z", + "author": map[string]any{"login": "user2"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, + }, + }, + "comments": map[string]any{ + "totalCount": 3, + }, + }, + } + + mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} + mockIssuesClosed := []map[string]any{ + { + "number": 789, + "title": "Closed Issue", + "body": "This is a closed issue", + "state": "CLOSED", + "databaseId": 1003, + "createdAt": "2023-03-01T00:00:00Z", + "updatedAt": "2023-03-01T00:00:00Z", + "author": map[string]any{"login": "user3"}, + "labels": map[string]any{ + "nodes": []map[string]any{}, + }, + "comments": map[string]any{ + "totalCount": 1, + }, + }, + } + + // Mock responses + mockResponseListAll := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesAll, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesOpen, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesClosed, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 1, + }, + }, + }) + + mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + varsListAll := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOpenOnly := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsClosedOnly := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsWithLabels := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "labels": []interface{}{"bug", "enhancement"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsRepoNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + tests := []struct { + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int + verifyOrder func(t *testing.T, issues []*github.Issue) + }{ + { + name: "list all issues", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedCount: 2, + }, + { + name: "filter by open state", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "OPEN", + }, + expectError: false, + expectedCount: 2, + }, + { + name: "filter by closed state", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "CLOSED", + }, + expectError: false, + expectedCount: 1, + }, + { + name: "filter by labels", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug", "enhancement"}, + }, + expectError: false, + expectedCount: 2, + }, + { + name: "repository not found error", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", + }, + } + + // Define the actual query strings that match the implementation + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var httpClient *http.Client + + switch tc.name { + case "list all issues": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by closed state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by labels": + matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + // Parse the structured response with pagination info + var response struct { + Issues []*github.Issue `json:"issues"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + err = json.Unmarshal([]byte(text), &response) + require.NoError(t, err) + + assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + + // Verify order if verifyOrder function is provided + if tc.verifyOrder != nil { + tc.verifyOrder(t, response.Issues) + } + + // Verify that returned issues have expected structure + for _, issue := range response.Issues { + assert.NotNil(t, issue.Number, "Issue should have number") + assert.NotNil(t, issue.Title, "Issue should have title") + assert.NotNil(t, issue.State, "Issue should have state") + } + }) + } +} + +func Test_UpdateIssue(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + mockGQLClient := githubv4.NewClient(nil) + tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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") + assert.Contains(t, tool.InputSchema.Properties, "title") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "labels") + assert.Contains(t, tool.InputSchema.Properties, "assignees") + assert.Contains(t, tool.InputSchema.Properties, "milestone") + assert.Contains(t, tool.InputSchema.Properties, "type") + 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{"method", "owner", "repo"}) + + // Mock issues for reuse across test cases + mockBaseIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Title"), + Body: github.Ptr("Description"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + mockUpdatedIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + State: github.Ptr("closed"), + StateReason: github.Ptr("duplicate"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + mockReopenedIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Title"), + State: github.Ptr("open"), + StateReason: github.Ptr("reopened"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + } + + // Mock GraphQL responses for reuse across test cases + issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + }, + }) + + duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + "duplicateIssue": map[string]any{ + "id": "I_kwDOA0xdyM50BPbP", + }, + }, + }) + + closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ + "closeIssue": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + "number": 123, + "url": "https://github.com/owner/repo/issues/123", + "state": "CLOSED", + }, + }, + }) + + reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{ + "reopenIssue": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + "number": 123, + "url": "https://github.com/owner/repo/issues/123", + "state": "OPEN", + }, + }, + }) + + duplicateStateReason := IssueClosedStateReasonDuplicate + + tests := []struct { + name string + mockedRESTClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "partial update of non-state fields only", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "body": "Updated Description", + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, + { + name: "issue not found when updating non-state fields only", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "title": "Updated Title", + }, + expectError: true, + expectedErrMsg: "failed to update issue", + }, + { + name: "close issue as duplicate", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + mockBaseIssue, + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + DuplicateIssue struct { + ID githubv4.ID + } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + "duplicateOf": githubv4.Int(456), + }, + duplicateIssueIDQueryResponse, + ), + githubv4mock.NewMutationMatcher( + struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"closeIssue(input: $input)"` + }{}, + CloseIssueInput{ + IssueID: "I_kwDOA0xdyM50BPaO", + StateReason: &duplicateStateReason, + DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), + }, + nil, + closeSuccessResponse, + ), + ), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "state": "closed", + "state_reason": "duplicate", + "duplicate_of": float64(456), + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, + { + name: "reopen issue", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + mockBaseIssue, + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `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), + }, + issueIDQueryResponse, + ), + githubv4mock.NewMutationMatcher( + struct { + ReopenIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"reopenIssue(input: $input)"` + }{}, + githubv4.ReopenIssueInput{ + IssueID: "I_kwDOA0xdyM50BPaO", + }, + nil, + reopenSuccessResponse, + ), + ), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "state": "open", + }, + expectError: false, + expectedIssue: mockReopenedIssue, + }, + { + name: "main issue not found when trying to close it", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + mockBaseIssue, + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), + ), + ), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "state": "closed", + "state_reason": "not_planned", + }, + expectError: true, + expectedErrMsg: "Failed to find issues", + }, + { + name: "duplicate issue not found when closing as duplicate", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + mockBaseIssue, + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + DuplicateIssue struct { + ID githubv4.ID + } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + "duplicateOf": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), + ), + ), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "state": "closed", + "state_reason": "duplicate", + "duplicate_of": float64(999), + }, + expectError: true, + expectedErrMsg: "Failed to find issues", + }, + { + name: "close as duplicate with combined non-state updates", + mockedRESTClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesByOwnerByRepoByIssueNumber, + expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + "labels": []any{"bug", "priority"}, + "assignees": []any{"assignee1", "assignee2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + State: github.Ptr("open"), // Still open after REST update + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), + ), + ), + ), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + DuplicateIssue struct { + ID githubv4.ID + } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + "duplicateOf": githubv4.Int(456), + }, + duplicateIssueIDQueryResponse, + ), + githubv4mock.NewMutationMatcher( + struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"closeIssue(input: $input)"` + }{}, + CloseIssueInput{ + IssueID: "I_kwDOA0xdyM50BPaO", + StateReason: &duplicateStateReason, + DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), + }, + nil, + closeSuccessResponse, + ), + ), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "body": "Updated Description", + "labels": []any{"bug", "priority"}, + "assignees": []any{"assignee1", "assignee2"}, + "milestone": float64(5), + "type": "Bug", + "state": "closed", + "state_reason": "duplicate", + "duplicate_of": float64(456), + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, + { + name: "duplicate_of without duplicate state_reason should fail", + mockedRESTClient: mock.NewMockedHTTPClient(), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "state": "closed", + "state_reason": "completed", + "duplicate_of": float64(456), + }, + expectError: true, + expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup clients with mocks + restClient := github.NewClient(tc.mockedRESTClient) + gqlClient := githubv4.NewClient(tc.mockedGQLClient) + _, handler := IssueWrite(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError || tc.expectedErrMsg != "" { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + 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 + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) + require.NoError(t, err) + + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) + }) + } +} + +func Test_ParseISOTimestamp(t *testing.T) { + tests := []struct { + name string + input string + expectedErr bool + expectedTime time.Time + }{ + { + name: "valid RFC3339 format", + input: "2023-01-15T14:30:00Z", + expectedErr: false, + expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC), + }, + { + name: "valid date only format", + input: "2023-01-15", + expectedErr: false, + expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), + }, + { + name: "empty timestamp", + input: "", + expectedErr: true, + }, + { + name: "invalid format", + input: "15/01/2023", + expectedErr: true, + }, + { + name: "invalid date", + input: "2023-13-45", + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + parsedTime, err := parseISOTimestamp(tc.input) + + if tc.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedTime, parsedTime) + } + }) + } +} + +func Test_GetIssueComments(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + gqlClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + 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.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + + // Setup mock comments for success case + mockComments := []*github.IssueComment{ + { + ID: github.Ptr(int64(123)), + Body: github.Ptr("This is the first comment"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, + }, + { + ID: github.Ptr(int64(456)), + Body: github.Ptr("This is the second comment"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComments []*github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comments retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockComments, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_comments", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "successful comments retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockComments), + ), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_comments", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_comments", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue comments", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedComments []*github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedComments), len(returnedComments)) + if len(returnedComments) > 0 { + assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) + assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) + } + }) + } +} + +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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + 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() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "assign_copilot_to_issue", 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, "issueNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) + + var pageOfFakeBots = func(n int) []struct{} { + // We don't _really_ need real bots here, just objects that count as entries for the page + bots := make([]struct{}, n) + for i := range n { + bots[i] = struct{}{} + } + return bots + } + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful assignment when there are no existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "successful assignment when there are existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("existing-assignee-id"), + }, + map[string]any{ + "id": githubv4.ID("existing-assignee-id-2"), + }, + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{ + githubv4.ID("existing-assignee-id"), + githubv4.ID("existing-assignee-id-2"), + githubv4.ID("copilot-swe-agent-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "copilot bot not on first page of suggested actors", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + // First page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": pageOfFakeBots(100), + "pageInfo": map[string]any{ + "hasNextPage": true, + "endCursor": githubv4.String("next-page-cursor"), + }, + }, + }, + }), + ), + // Second page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": githubv4.String("next-page-cursor"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "copilot not a suggested actor", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + + t.Parallel() + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) + require.Equal(t, textContent.Text, "successfully assigned copilot to issue") + }) + } +} + +func Test_AddSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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{"method", "owner", "repo", "issue_number", "sub_issue_id"}) + + // Setup mock issue for success case (matches GitHub API response format) + mockIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue with a sub-issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issue addition with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "replace_parent": true, + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful sub-issue addition with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(456), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful sub-issue addition with replace_parent false", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(789), + "replace_parent": false, + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "validation failed - sub-issue cannot be parent of itself", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + 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), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // 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), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "add", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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 returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_GetSubIssues(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + gqlClient := githubv4.NewClient(nil) + tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + 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.Contains(t, tool.InputSchema.Properties, "page") + 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{ + { + Number: github.Ptr(123), + Title: github.Ptr("Sub-issue 1"), + Body: github.Ptr("This is the first sub-issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("bug"), + Color: github.Ptr("d73a4a"), + Description: github.Ptr("Something isn't working"), + }, + }, + }, + { + Number: github.Ptr(124), + Title: github.Ptr("Sub-issue 2"), + Body: github.Ptr("This is the second sub-issue"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + Assignees: []*github.User{ + {Login: github.Ptr("assignee1")}, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedSubIssues []*github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issues listing with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockSubIssues, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedSubIssues: mockSubIssues, + }, + { + name: "successful sub-issues listing with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSubIssues), + ), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedSubIssues: mockSubIssues, + }, + { + name: "successful sub-issues listing with empty result", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + []*github.Issue{}, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedSubIssues: []*github.Issue{}, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "nonexistent", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "sub-issues feature gone/deprecated", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "failed to list sub-issues", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter issue_number", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "get_sub_issues", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedErrMsg: "missing required parameter: issue_number", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + gqlClient := githubv4.NewClient(nil) + _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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 returnedSubIssues []*github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) + require.NoError(t, err) + + assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) + for i, subIssue := range returnedSubIssues { + if i < len(tc.expectedSubIssues) { + assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) + assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) + assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) + assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) + assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) + + if tc.expectedSubIssues[i].Body != nil { + assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) + } + } + } + }) + } +} + +func Test_RemoveSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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{"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{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue after sub-issue removal"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issue removal", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "bad request - invalid sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(-1), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "nonexistent", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, + 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), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "failed to remove sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // 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), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "remove", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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 returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_ReprioritizeSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + 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{"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{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + Labels: []*github.Label{ + { + Name: github.Ptr("enhancement"), + Color: github.Ptr("84b6eb"), + Description: github.Ptr("New feature or request"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful reprioritization with after_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful reprioritization with before_id", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "before_id": float64(789), + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "validation error - neither after_id nor before_id specified", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + }, + expectError: false, + expectedErrMsg: "either after_id or before_id must be specified", + }, + { + name: "validation error - both after_id and before_id specified", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + "before_id": float64(789), + }, + expectError: false, + expectedErrMsg: "only one of after_id or before_id should be specified, not both", + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(999), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "validation failed - positioning sub-issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "service unavailable", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), + ), + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "sub_issue_id": float64(123), + "before_id": float64(456), + }, + expectError: false, + expectedErrMsg: "failed to reprioritize sub-issue", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient( + // 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), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter sub_issue_id", + mockedClient: mock.NewMockedHTTPClient( + // No mocked requests needed since validation fails before HTTP call + ), + requestArgs: map[string]interface{}{ + "method": "reprioritize", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "after_id": float64(456), + }, + expectError: false, + expectedErrMsg: "missing required parameter: sub_issue_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), 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 returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + }) + } +} + +func Test_ListIssueTypes(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_types", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) + + // Setup mock issue types for success case + mockIssueTypes := []*github.IssueType{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("bug"), + Description: github.Ptr("Something isn't working"), + Color: github.Ptr("d73a4a"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("feature"), + Description: github.Ptr("New feature or enhancement"), + Color: github.Ptr("a2eeef"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssueTypes []*github.IssueType + expectedErrMsg string + }{ + { + name: "successful issue types retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testorg", + }, + expectError: false, + expectedIssueTypes: mockIssueTypes, + }, + { + name: "organization not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/nonexistent/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, // This should be handled by parameter validation, error returned in result + expectedErrMsg: "missing required parameter: owner", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + // Check if error is returned as tool result error + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + // Check if it's a parameter validation error (returned as tool result error) + if result != nil && result.IsError { + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { + return // This is expected for parameter validation errors + } + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssueTypes []*github.IssueType + err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) + require.NoError(t, err) + + if tc.expectedIssueTypes != nil { + require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) + for i, expected := range tc.expectedIssueTypes { + assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) + assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) + assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) + assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) + } + } + }) + } +} diff --git a/.tools-to-be-migrated/labels.go b/.tools-to-be-migrated/labels.go new file mode 100644 index 000000000..c9be7be75 --- /dev/null +++ b/.tools-to-be-migrated/labels.go @@ -0,0 +1,399 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// GetLabel retrieves a specific label by name from a GitHub repository +func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool( + "get_label", + mcp.WithDescription(t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Label name."), + ), + ), + 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 + } + + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var query struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "name": githubv4.String(name), + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil + } + + if query.Repository.Label.Name == "" { + return mcp.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil + } + + label := map[string]any{ + "id": fmt.Sprintf("%v", query.Repository.Label.ID), + "name": string(query.Repository.Label.Name), + "color": string(query.Repository.Label.Color), + "description": string(query.Repository.Label.Description), + } + + out, err := json.Marshal(label) + if err != nil { + return nil, fmt.Errorf("failed to marshal label: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +// 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")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name) - required for all operations"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name - required for all operations"), + ), + ), + 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 + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var query struct { + Repository 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:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil + } + + labels := make([]map[string]any, len(query.Repository.Labels.Nodes)) + for i, labelNode := range query.Repository.Labels.Nodes { + labels[i] = map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + } + } + + response := map[string]any{ + "labels": labels, + "totalCount": int(query.Repository.Labels.TotalCount), + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +// LabelWrite handles create, update, and delete operations for GitHub labels +func LabelWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool( + "label_write", + mcp.WithDescription(t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("method", + mcp.Required(), + mcp.Description("Operation to perform: 'create', 'update', or 'delete'"), + mcp.Enum("create", "update", "delete"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Label name - required for all operations"), + ), + mcp.WithString("new_name", + mcp.Description("New name for the label (used only with 'update' method to rename)"), + ), + mcp.WithString("color", + mcp.Description("Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."), + ), + mcp.WithString("description", + mcp.Description("Label description text. Optional for 'create' and 'update'."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Get and validate required parameters + method, err := RequiredParam[string](request, "method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + method = strings.ToLower(method) + + 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 + } + + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional parameters + newName, _ := OptionalParam[string](request, "new_name") + color, _ := OptionalParam[string](request, "color") + description, _ := OptionalParam[string](request, "description") + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case "create": + // Validate required params for create + if color == "" { + return mcp.NewToolResultError("color is required for create"), nil + } + + // Get repository ID + repoID, err := getRepositoryID(ctx, client, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil + } + + input := githubv4.CreateLabelInput{ + RepositoryID: repoID, + Name: githubv4.String(name), + Color: githubv4.String(color), + } + if description != "" { + d := githubv4.String(description) + input.Description = &d + } + + var mutation struct { + CreateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"createLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil + + case "update": + // Validate required params for update + if newName == "" && color == "" && description == "" { + return mcp.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil + } + + // Get the label ID + labelID, err := getLabelID(ctx, client, owner, repo, name) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + input := githubv4.UpdateLabelInput{ + ID: labelID, + } + if newName != "" { + n := githubv4.String(newName) + input.Name = &n + } + if color != "" { + c := githubv4.String(color) + input.Color = &c + } + if description != "" { + d := githubv4.String(description) + input.Description = &d + } + + var mutation struct { + UpdateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"updateLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil + + case "delete": + // Get the label ID + labelID, err := getLabelID(ctx, client, owner, repo, name) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + input := githubv4.DeleteLabelInput{ + ID: labelID, + } + + var mutation struct { + DeleteLabel struct { + ClientMutationID githubv4.String + } `graphql:"deleteLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil + + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil + } + } +} + +// Helper function to get repository ID +func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) { + var repoQuery struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := client.Query(ctx, &repoQuery, vars); err != nil { + return "", err + } + return repoQuery.Repository.ID, nil +} + +// Helper function to get label by name +func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) { + var query struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "name": githubv4.String(labelName), + } + if err := client.Query(ctx, &query, vars); err != nil { + return "", err + } + if query.Repository.Label.Name == "" { + return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo) + } + return query.Repository.Label.ID, nil +} diff --git a/.tools-to-be-migrated/labels_test.go b/.tools-to-be-migrated/labels_test.go new file mode 100644 index 000000000..6bb91da26 --- /dev/null +++ b/.tools-to-be-migrated/labels_test.go @@ -0,0 +1,491 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetLabel(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := GetLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_label", 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, "name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "name"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful label retrieval", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("test-label-id"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "label not found", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "name": "nonexistent", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("nonexistent"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID(""), + "name": githubv4.String(""), + "color": githubv4.String(""), + "description": githubv4.String(""), + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "label 'nonexistent' not found in owner/repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := GetLabel(stubGetGQLClientFn(client), 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 TestListLabels(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := ListLabels(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_label", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful repository labels listing", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository 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:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": 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"), + }, + map[string]any{ + "id": githubv4.ID("label-2"), + "name": githubv4.String("enhancement"), + "color": githubv4.String("a2eeef"), + "description": githubv4.String("New feature or request"), + }, + }, + "totalCount": githubv4.Int(2), + }, + }, + }), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := ListLabels(stubGetGQLClientFn(client), 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 TestWriteLabel(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := LabelWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "label_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, "name") + assert.Contains(t, tool.InputSchema.Properties, "new_name") + assert.Contains(t, tool.InputSchema.Properties, "color") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "name"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful label creation", + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "name": "new-label", + "color": "f29513", + "description": "A new test label", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"createLabel(input: $input)"` + }{}, + githubv4.CreateLabelInput{ + RepositoryID: githubv4.ID("test-repo-id"), + Name: githubv4.String("new-label"), + Color: githubv4.String("f29513"), + Description: func() *githubv4.String { s := githubv4.String("A new test label"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createLabel": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("new-label-id"), + "name": githubv4.String("new-label"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "create label without color", + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "name": "new-label", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "color is required for create", + }, + { + name: "successful label update", + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "name": "bug", + "new_name": "defect", + "color": "ff0000", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("bug"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"updateLabel(input: $input)"` + }{}, + githubv4.UpdateLabelInput{ + ID: githubv4.ID("bug-label-id"), + Name: func() *githubv4.String { s := githubv4.String("defect"); return &s }(), + Color: func() *githubv4.String { s := githubv4.String("ff0000"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateLabel": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("defect"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "update label without any changes", + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "at least one of new_name, color, or description must be provided for update", + }, + { + name: "successful label deletion", + requestArgs: map[string]any{ + "method": "delete", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("bug"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + DeleteLabel struct { + ClientMutationID githubv4.String + } `graphql:"deleteLabel(input: $input)"` + }{}, + githubv4.DeleteLabelInput{ + ID: githubv4.ID("bug-label-id"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteLabel": map[string]any{ + "clientMutationId": githubv4.String("test-mutation-id"), + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "invalid method", + requestArgs: map[string]any{ + "method": "invalid", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := LabelWrite(stubGetGQLClientFn(client), 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) + } + }) + } +} diff --git a/.tools-to-be-migrated/notifications.go b/.tools-to-be-migrated/notifications.go new file mode 100644 index 000000000..6dca53cca --- /dev/null +++ b/.tools-to-be-migrated/notifications.go @@ -0,0 +1,525 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + FilterDefault = "default" + FilterIncludeRead = "include_read_notifications" + FilterOnlyParticipating = "only_participating" +) + +// ListNotifications creates a tool to list notifications for the current user. +func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_notifications", + mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("filter", + mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."), + mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating), + ), + mcp.WithString("since", + mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"), + ), + mcp.WithString("before", + mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"), + ), + mcp.WithString("owner", + mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."), + ), + mcp.WithString("repo", + mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + filter, err := OptionalParam[string](request, "filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + before, err := OptionalParam[string](request, "before") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + owner, err := OptionalParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := OptionalParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + paginationParams, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Build options + opts := &github.NotificationListOptions{ + All: filter == FilterIncludeRead, + Participating: filter == FilterOnlyParticipating, + ListOptions: github.ListOptions{ + Page: paginationParams.Page, + PerPage: paginationParams.PerPage, + }, + } + + // Parse time parameters if provided + if since != "" { + sinceTime, err := time.Parse(time.RFC3339, since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil + } + opts.Since = sinceTime + } + + if before != "" { + beforeTime, err := time.Parse(time.RFC3339, before) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil + } + opts.Before = beforeTime + } + + var notifications []*github.Notification + var resp *github.Response + + if owner != "" && repo != "" { + notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts) + } else { + notifications, resp, err = client.Activity.ListNotifications(ctx, opts) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list notifications", + 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 get notifications: %s", string(body))), nil + } + + // Marshal response to JSON + r, err := json.Marshal(notifications) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DismissNotification creates a tool to mark a notification as read/done. +func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("dismiss_notification", + mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("threadID", + mcp.Required(), + mcp.Description("The ID of the notification thread"), + ), + mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getclient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + threadID, err := RequiredParam[string](request, "threadID") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := RequiredParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + switch state { + case "done": + // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint + var threadIDInt int64 + threadIDInt, err = strconv.ParseInt(threadID, 10, 64) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil + } + resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + case "read": + resp, err = client.Activity.MarkThreadRead(ctx, threadID) + default: + return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to mark notification as %s", state), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusResetContent && 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 mark notification as %s: %s", state, string(body))), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil + } +} + +// MarkAllNotificationsRead creates a tool to mark all notifications as read. +func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("mark_all_notifications_read", + mcp.WithDescription(t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("lastReadAt", + mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"), + ), + mcp.WithString("owner", + mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."), + ), + mcp.WithString("repo", + mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + lastReadAt, err := OptionalParam[string](request, "lastReadAt") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + owner, err := OptionalParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := OptionalParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var lastReadTime time.Time + if lastReadAt != "" { + lastReadTime, err = time.Parse(time.RFC3339, lastReadAt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil + } + } else { + lastReadTime = time.Now() + } + + markReadOptions := github.Timestamp{ + Time: lastReadTime, + } + + var resp *github.Response + if owner != "" && repo != "" { + resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) + } else { + resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to mark all notifications as read", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusResetContent && 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 mark all notifications as read: %s", string(body))), nil + } + + return mcp.NewToolResultText("All notifications marked as read"), nil + } +} + +// GetNotificationDetails creates a tool to get details for a specific notification. +func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_notification_details", + mcp.WithDescription(t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("notificationID", + mcp.Required(), + mcp.Description("The ID of the notification"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + notificationID, err := RequiredParam[string](request, "notificationID") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + thread, resp, err := client.Activity.GetThread(ctx, notificationID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), + 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 get notification details: %s", string(body))), nil + } + + r, err := json.Marshal(thread) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// Enum values for ManageNotificationSubscription action +const ( + NotificationActionIgnore = "ignore" + NotificationActionWatch = "watch" + NotificationActionDelete = "delete" +) + +// ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) +func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_notification_subscription", + mcp.WithDescription(t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("notificationID", + mcp.Required(), + mcp.Description("The ID of the notification thread."), + ), + mcp.WithString("action", + mcp.Required(), + mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."), + mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + notificationID, err := RequiredParam[string](request, "notificationID") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + action, err := RequiredParam[string](request, "action") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var ( + resp *github.Response + result any + apiErr error + ) + + switch action { + case NotificationActionIgnore: + sub := &github.Subscription{Ignored: ToBoolPtr(true)} + result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) + case NotificationActionWatch: + sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} + result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) + case NotificationActionDelete: + resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID) + default: + return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil + } + + if apiErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to %s notification subscription", action), + resp, + apiErr, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil + } + + if action == NotificationActionDelete { + // Special case for delete as there is no response body + return mcp.NewToolResultText("Notification subscription deleted"), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + return mcp.NewToolResultText(string(r)), nil + } +} + +const ( + RepositorySubscriptionActionWatch = "watch" + RepositorySubscriptionActionIgnore = "ignore" + RepositorySubscriptionActionDelete = "delete" +) + +// ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) +func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_repository_notification_subscription", + mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("action", + mcp.Required(), + mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."), + mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + 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 + } + action, err := RequiredParam[string](request, "action") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var ( + resp *github.Response + result any + apiErr error + ) + + switch action { + case RepositorySubscriptionActionIgnore: + sub := &github.Subscription{Ignored: ToBoolPtr(true)} + result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) + case RepositorySubscriptionActionWatch: + sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} + result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) + case RepositorySubscriptionActionDelete: + resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) + default: + return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil + } + + if apiErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to %s repository subscription", action), + resp, + apiErr, + ), nil + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Handle non-2xx status codes + if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + body, _ := io.ReadAll(resp.Body) + return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil + } + + if action == RepositorySubscriptionActionDelete { + // Special case for delete as there is no response body + return mcp.NewToolResultText("Repository subscription deleted"), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/notifications_test.go b/.tools-to-be-migrated/notifications_test.go new file mode 100644 index 000000000..034d8d4e2 --- /dev/null +++ b/.tools-to-be-migrated/notifications_test.go @@ -0,0 +1,765 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListNotifications(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_notifications", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "filter") + assert.Contains(t, tool.InputSchema.Properties, "since") + assert.Contains(t, tool.InputSchema.Properties, "before") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + // All fields are optional, so Required should be empty + assert.Empty(t, tool.InputSchema.Required) + + mockNotification := &github.Notification{ + ID: github.Ptr("123"), + Reason: github.Ptr("mention"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult []*github.Notification + expectedErrMsg string + }{ + { + name: "success default filter (no params)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotifications, + []*github.Notification{mockNotification}, + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedResult: []*github.Notification{mockNotification}, + }, + { + name: "success with filter=include_read_notifications", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotifications, + []*github.Notification{mockNotification}, + ), + ), + requestArgs: map[string]interface{}{ + "filter": "include_read_notifications", + }, + expectError: false, + expectedResult: []*github.Notification{mockNotification}, + }, + { + name: "success with filter=only_participating", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotifications, + []*github.Notification{mockNotification}, + ), + ), + requestArgs: map[string]interface{}{ + "filter": "only_participating", + }, + expectError: false, + expectedResult: []*github.Notification{mockNotification}, + }, + { + name: "success for repo notifications", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposNotificationsByOwnerByRepo, + []*github.Notification{mockNotification}, + ), + ), + requestArgs: map[string]interface{}{ + "filter": "default", + "since": "2024-01-01T00:00:00Z", + "before": "2024-01-02T00:00:00Z", + "owner": "octocat", + "repo": "hello-world", + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedResult: []*github.Notification{mockNotification}, + }, + { + name: "error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetNotifications, + mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + t.Logf("textContent: %s", textContent.Text) + var returned []*github.Notification + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + require.NotEmpty(t, returned) + assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) + }) + } +} + +func Test_ManageNotificationSubscription(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "manage_notification_subscription", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "notificationID") + assert.Contains(t, tool.InputSchema.Properties, "action") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"}) + + mockSub := &github.Subscription{Ignored: github.Ptr(true)} + mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectIgnored *bool + expectDeleted bool + expectInvalid bool + expectedErrMsg string + }{ + { + name: "ignore subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutNotificationsThreadsSubscriptionByThreadId, + mockSub, + ), + ), + requestArgs: map[string]interface{}{ + "notificationID": "123", + "action": "ignore", + }, + expectError: false, + expectIgnored: github.Ptr(true), + }, + { + name: "watch subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutNotificationsThreadsSubscriptionByThreadId, + mockSubWatch, + ), + ), + requestArgs: map[string]interface{}{ + "notificationID": "123", + "action": "watch", + }, + expectError: false, + expectIgnored: github.Ptr(false), + }, + { + name: "delete subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.DeleteNotificationsThreadsSubscriptionByThreadId, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "notificationID": "123", + "action": "delete", + }, + expectError: false, + expectDeleted: true, + }, + { + name: "invalid action", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "notificationID": "123", + "action": "invalid", + }, + expectError: false, + expectInvalid: true, + }, + { + name: "missing required notificationID", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "action": "ignore", + }, + expectError: true, + }, + { + name: "missing required action", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "notificationID": "123", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.NotNil(t, result) + text := getTextResult(t, result).Text + switch { + case tc.requestArgs["notificationID"] == nil: + assert.Contains(t, text, "missing required parameter: notificationID") + case tc.requestArgs["action"] == nil: + assert.Contains(t, text, "missing required parameter: action") + default: + assert.Contains(t, text, "error") + } + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectIgnored != nil { + var returned github.Subscription + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Equal(t, *tc.expectIgnored, *returned.Ignored) + } + if tc.expectDeleted { + assert.Contains(t, textContent.Text, "deleted") + } + if tc.expectInvalid { + assert.Contains(t, textContent.Text, "Invalid action") + } + }) + } +} + +func Test_ManageRepositoryNotificationSubscription(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "manage_repository_notification_subscription", 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, "action") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"}) + + mockSub := &github.Subscription{Ignored: github.Ptr(true)} + mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectIgnored *bool + expectSubscribed *bool + expectDeleted bool + expectInvalid bool + expectedErrMsg string + }{ + { + name: "ignore subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutReposSubscriptionByOwnerByRepo, + mockSub, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "action": "ignore", + }, + expectError: false, + expectIgnored: github.Ptr(true), + }, + { + name: "watch subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutReposSubscriptionByOwnerByRepo, + mockWatchSub, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "action": "watch", + }, + expectError: false, + expectIgnored: github.Ptr(false), + expectSubscribed: github.Ptr(true), + }, + { + name: "delete subscription", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.DeleteReposSubscriptionByOwnerByRepo, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "action": "delete", + }, + expectError: false, + expectDeleted: true, + }, + { + name: "invalid action", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "action": "invalid", + }, + expectError: false, + expectInvalid: true, + }, + { + name: "missing required owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + "action": "ignore", + }, + expectError: true, + }, + { + name: "missing required repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "action": "ignore", + }, + expectError: true, + }, + { + name: "missing required action", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.NotNil(t, result) + text := getTextResult(t, result).Text + switch { + case tc.requestArgs["owner"] == nil: + assert.Contains(t, text, "missing required parameter: owner") + case tc.requestArgs["repo"] == nil: + assert.Contains(t, text, "missing required parameter: repo") + case tc.requestArgs["action"] == nil: + assert.Contains(t, text, "missing required parameter: action") + default: + assert.Contains(t, text, "error") + } + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectIgnored != nil || tc.expectSubscribed != nil { + var returned github.Subscription + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + if tc.expectIgnored != nil { + assert.Equal(t, *tc.expectIgnored, *returned.Ignored) + } + if tc.expectSubscribed != nil { + assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed) + } + } + if tc.expectDeleted { + assert.Contains(t, textContent.Text, "deleted") + } + if tc.expectInvalid { + assert.Contains(t, textContent.Text, "Invalid action") + } + }) + } +} + +func Test_DismissNotification(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "dismiss_notification", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "threadID") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectRead bool + expectDone bool + expectInvalid bool + expectedErrMsg string + }{ + { + name: "mark as read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PatchNotificationsThreadsByThreadId, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "threadID": "123", + "state": "read", + }, + expectError: false, + expectRead: true, + }, + { + name: "mark as done", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.DeleteNotificationsThreadsByThreadId, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "threadID": "123", + "state": "done", + }, + expectError: false, + expectDone: true, + }, + { + name: "invalid threadID format", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "threadID": "notanumber", + "state": "done", + }, + expectError: false, + expectInvalid: true, + }, + { + name: "missing required threadID", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "state": "read", + }, + expectError: true, + }, + { + name: "missing required state", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "threadID": "123", + }, + expectError: true, + }, + { + name: "invalid state value", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "threadID": "123", + "state": "invalid", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + // The tool returns a ToolResultError with a specific message + require.NoError(t, err) + require.NotNil(t, result) + text := getTextResult(t, result).Text + switch { + case tc.requestArgs["threadID"] == nil: + assert.Contains(t, text, "missing required parameter: threadID") + case tc.requestArgs["state"] == nil: + assert.Contains(t, text, "missing required parameter: state") + case tc.name == "invalid threadID format": + assert.Contains(t, text, "invalid threadID format") + case tc.name == "invalid state value": + assert.Contains(t, text, "Invalid state. Must be one of: read, done.") + default: + // fallback for other errors + assert.Contains(t, text, "error") + } + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectRead { + assert.Contains(t, textContent.Text, "Notification marked as read") + } + if tc.expectDone { + assert.Contains(t, textContent.Text, "Notification marked as done") + } + if tc.expectInvalid { + assert.Contains(t, textContent.Text, "invalid threadID format") + } + }) + } +} + +func Test_MarkAllNotificationsRead(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "mark_all_notifications_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "lastReadAt") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Empty(t, tool.InputSchema.Required) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectMarked bool + expectedErrMsg string + }{ + { + name: "success (no params)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutNotifications, + nil, + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectMarked: true, + }, + { + name: "success with lastReadAt param", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutNotifications, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "lastReadAt": "2024-01-01T00:00:00Z", + }, + expectError: false, + expectMarked: true, + }, + { + name: "success with owner and repo", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.PutReposNotificationsByOwnerByRepo, + nil, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "repo": "hello-world", + }, + expectError: false, + expectMarked: true, + }, + { + name: "API error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutNotifications, + mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + if tc.expectMarked { + assert.Contains(t, textContent.Text, "All notifications marked as read") + } + }) + } +} + +func Test_GetNotificationDetails(t *testing.T) { + // Verify tool definition and schema + mockClient := github.NewClient(nil) + tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_notification_details", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "notificationID") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"}) + + mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectResult *github.Notification + expectedErrMsg string + }{ + { + name: "success", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotificationsThreadsByThreadId, + mockThread, + ), + ), + requestArgs: map[string]interface{}{ + "notificationID": "123", + }, + expectError: false, + expectResult: mockThread, + }, + { + name: "not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetNotificationsThreadsByThreadId, + mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "notificationID": "123", + }, + expectError: true, + expectedErrMsg: "not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var returned github.Notification + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Equal(t, *tc.expectResult.ID, *returned.ID) + }) + } +} diff --git a/.tools-to-be-migrated/projects.go b/.tools-to-be-migrated/projects.go new file mode 100644 index 000000000..21d4c1103 --- /dev/null +++ b/.tools-to-be-migrated/projects.go @@ -0,0 +1,1142 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/google/go-querystring/query" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ProjectUpdateFailedError = "failed to update a project item" + ProjectAddFailedError = "failed to add a project item" + ProjectDeleteFailedError = "failed to delete a project item" + ProjectListFailedError = "failed to list project items" +) + +func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_projects", + mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner_type", + mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithString("query", + mcp.Description("Filter projects by a search query (matches title and description)"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of results per page (max 100, default: 30)"), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + queryStr, err := OptionalParam[string](req, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, + Query: queryPtr, + } + + if ownerType == "org" { + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + 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 projects: %s", string(body))), nil + } + r, err := json.Marshal(minimalProjects) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project", + mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number"), + ), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), + mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + var project *github.ProjectV2 + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + 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 get project: %s", string(body))), nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_project_fields", + mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), + mcp.Enum("user", "org")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of results per page (max 100, default: 30)"), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + 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 project fields: %s", string(body))), nil + } + r, err := json.Marshal(projectFields) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project_field", + mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), mcp.Enum("user", "org")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number.")), + mcp.WithNumber("field_id", + mcp.Required(), + mcp.Description("The field's id."), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldID, err := RequiredBigInt(req, "field_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + var projectField *github.ProjectV2Field + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + 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 get project field: %s", string(body))), nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_project_items", + mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), + mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithString("query", + mcp.Description("Search query to filter items"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of results per page (max 100, default: 30)"), + ), + mcp.WithArray("fields", + mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), + mcp.WithStringItems(), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + queryStr, err := OptionalParam[string](req, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fields, err := OptionalBigIntArrayParam(req, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + var projectItems []*github.ProjectV2Item + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + opts := &github.ListProjectItemsOptions{ + Fields: fields, + ListProjectsOptions: github.ListProjectsOptions{ + ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, + Query: queryPtr, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + 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("%s: %s", ProjectListFailedError, string(body))), nil + } + + r, err := json.Marshal(projectItems) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project_item", + mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), + mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithNumber("item_id", + mcp.Required(), + mcp.Description("The item's ID."), + ), + mcp.WithArray("fields", + mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), + mcp.WithStringItems(), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredBigInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fields, err := OptionalBigIntArrayParam(req, "fields") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + + opts := fieldSelectionOptions{} + + if len(fields) > 0 { + opts.Fields = fields + } + + url, err = addOptions(url, opts) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + projectItem := projectV2Item{} + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projectItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + 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 get project item: %s", string(body))), nil + } + r, err := json.Marshal(projectItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("add_project_item", + mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithString("item_type", + mcp.Required(), + mcp.Description("The item's type, either issue or pull_request."), + mcp.Enum("issue", "pull_request"), + ), + mcp.WithNumber("item_id", + mcp.Required(), + mcp.Description("The numeric ID of the issue or pull request to add to the project."), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredBigInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + itemType, err := RequiredParam[string](req, "item_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if itemType != "issue" && itemType != "pull_request" { + return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + newItem := &github.AddProjectItemOptions{ + ID: itemID, + Type: toNewProjectType(itemType), + } + + var resp *github.Response + var addedItem *github.ProjectV2Item + + if ownerType == "org" { + addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) + } else { + addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectAddFailedError, + resp, + err, + ), nil + } + 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("%s: %s", ProjectAddFailedError, string(body))), nil + } + r, err := json.Marshal(addedItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_project_item", + mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner_type", + mcp.Required(), mcp.Description("Owner type"), + mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithNumber("item_id", + mcp.Required(), + mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), + ), + mcp.WithObject("updated_field", + mcp.Required(), + mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + rawUpdatedField, exists := req.GetArguments()["updated_field"] + if !exists { + return mcp.NewToolResultError("missing required parameter: updated_field"), nil + } + + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return mcp.NewToolResultError("field_value must be an object"), nil + } + + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ + Fields: []updateProjectItem{*updatePayload}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + updatedItem := projectV2Item{} + + resp, err := client.Do(ctx, httpRequest, &updatedItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + 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("%s: %s", ProjectUpdateFailedError, string(body))), nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_project_item", + mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner_type", + mcp.Required(), + mcp.Description("Owner type"), + mcp.Enum("user", "org"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), + ), + mcp.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithNumber("item_id", + mcp.Required(), + mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), + ), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredBigInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var resp *github.Response + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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("%s: %s", ProjectDeleteFailedError, string(body))), nil + } + return mcp.NewToolResultText("project item successfully deleted"), nil + } +} + +type fieldSelectionOptions struct { + // Specific list of field IDs to include in the response. If not provided, only the title field is included. + // The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875 + Fields []int64 `url:"fields,omitempty,comma"` +} + +type updateProjectItemPayload struct { + Fields []updateProjectItem `json:"fields"` +} + +type updateProjectItem struct { + ID int `json:"id"` + Value any `json:"value"` +} + +type projectV2ItemFieldValue struct { + ID *int64 `json:"id,omitempty"` // The unique identifier for this field. + Name string `json:"name,omitempty"` // The display name of the field. + DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). + Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. +} + +type projectV2Item struct { + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + Content *projectV2ItemContent `json:"content,omitempty"` + ContentType *string `json:"content_type,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + Creator *github.User `json:"creator,omitempty"` + Description *string `json:"description,omitempty"` + Fields []*projectV2ItemFieldValue `json:"fields,omitempty"` + ID *int64 `json:"id,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +} + +type projectV2ItemContent struct { + Body *string `json:"body,omitempty"` + ClosedAt *github.Timestamp `json:"closed_at,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + ID *int64 `json:"id,omitempty"` + Number *int `json:"number,omitempty"` + Repository MinimalRepository `json:"repository,omitempty"` + State *string `json:"state,omitempty"` + StateReason *string `json:"stateReason,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + URL *string `json:"url,omitempty"` +} + +func toNewProjectType(projType string) string { + switch strings.ToLower(projType) { + case "issue": + return "Issue" + case "pull_request": + return "PullRequest" + default: + return "" + } +} + +func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { + if input == nil { + return nil, fmt.Errorf("updated_field must be an object") + } + + idField, ok := input["id"] + if !ok { + return nil, fmt.Errorf("updated_field.id is required") + } + + idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 + if !ok { + return nil, fmt.Errorf("updated_field.id must be a number") + } + + valueField, ok := input["value"] + if !ok { + return nil, fmt.Errorf("updated_field.value is required") + } + payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} + + return payload, nil +} + +// addOptions adds the parameters in opts as URL query parameters to s. opts +// must be a struct whose fields may contain "url" tags. +func addOptions(s string, opts any) (string, error) { + v := reflect.ValueOf(opts) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + origURL, err := url.Parse(s) + if err != nil { + return s, err + } + + origValues := origURL.Query() + + // Use the github.com/google/go-querystring library to parse the struct + newValues, err := query.Values(opts) + if err != nil { + return s, err + } + + // Merge the values + for key, values := range newValues { + for _, value := range values { + origValues.Add(key, value) + } + } + + origURL.RawQuery = origValues.Encode() + return origURL.String(), nil +} + +func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { + return mcp.NewPrompt("ManageProjectItems", + mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), + mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), + mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), + mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + owner := request.Params.Arguments["owner"] + ownerType := request.Params.Arguments["owner_type"] + + task := "" + if t, exists := request.Params.Arguments["task"]; exists { + task = fmt.Sprintf("%v", t) + } + + messages := []mcp.PromptMessage{ + { + Role: "system", + Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + + "**Core Capabilities:**\n" + + "- Project discovery and field analysis\n" + + "- Item querying with advanced filters\n" + + "- Field value updates and management\n" + + "- Progress reporting and insights\n\n" + + "**Key Rules:**\n" + + "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + + "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + + "- Use proper field IDs (not names) when updating items\n" + + "- Provide step-by-step workflows with concrete examples\n\n" + + "**Understanding Project Items:**\n" + + "- Project items reference underlying content (issues or pull requests)\n" + + "- Project tools provide: project fields, item metadata, and basic content info\n" + + "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + + "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + + "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + + "**Available Tools:**\n" + + "- **list_projects**: Discover available projects\n" + + "- **get_project**: Get detailed project information\n" + + "- **list_project_fields**: Get field definitions and IDs\n" + + "- **list_project_items**: Query items with filters and field selection\n" + + "- **get_project_item**: Get specific item details\n" + + "- **add_project_item**: Add issues/PRs to projects\n" + + "- **update_project_item**: Update field values\n" + + "- **delete_project_item**: Remove items from projects"), + }, + { + Role: "user", + Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ + "Help me get started with project management tasks.", + owner, + ownerType, + func() string { + if task != "" { + return fmt.Sprintf(" I'm specifically interested in: %s.", task) + } + return "" + }())), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ + "**🔍 Step 1: Project Discovery**\n"+ + "First, let's see what projects are available using **list_projects**.", owner)), + }, + { + Role: "user", + Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + + "Once you select a project, I'll help you:\n\n" + + "1. **Get field information** using **list_project_fields**\n" + + " - Find field IDs, names, and data types\n" + + " - Understand available options for select fields\n" + + " - Identify required vs. optional fields\n\n" + + "2. **Query project items** using **list_project_items**\n" + + " - Filter by assignees: query=\"assignee:@me\"\n" + + " - Filter by status: query=\"status:In Progress\"\n" + + " - Filter by labels: query=\"label:bug\"\n" + + " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + + "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), + }, + { + Role: "user", + Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + + "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + + "**Text fields:**\n" + + "```json\n" + + "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + + "```\n\n" + + "**Single-select fields:**\n" + + "```json\n" + + "{\"id\": 198354254, \"value\": 18498754}\n" + + "```\n" + + "*(Use option ID, not option name)*\n\n" + + "**Date fields:**\n" + + "```json\n" + + "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + + "```\n\n" + + "**Number fields:**\n" + + "```json\n" + + "{\"id\": 345678, \"value\": 5}\n" + + "```\n\n" + + "**Clear a field:**\n" + + "```json\n" + + "{\"id\": 123456, \"value\": null}\n" + + "```\n\n" + + "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), + }, + { + Role: "user", + Content: mcp.NewTextContent("Can you show me a complete workflow example?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ + "Here's how to find and update your assigned items:\n\n"+ + "**Step 1:** Discover projects\n\n"+ + "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ + "**Step 2:** Get project fields (using project #123)\n\n"+ + "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ + "*(Note the Status field ID, e.g., 198354254)*\n\n"+ + "**Step 3:** Query your assigned items\n\n"+ + "**list_project_items**\n"+ + " owner=\"%s\"\n"+ + " owner_type=\"%s\"\n"+ + " project_number=123\n"+ + " query=\"assignee:@me\"\n"+ + " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ + "**Step 4:** Update item status\n\n"+ + "**update_project_item**\n"+ + " owner=\"%s\"\n"+ + " owner_type=\"%s\"\n"+ + " project_number=123\n"+ + " item_id=789123\n"+ + " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ + "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), + }, + { + Role: "user", + Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), + }, + { + Role: "assistant", + Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + + "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + + "**From project items, extract:**\n" + + "- content.repository.name and content.repository.owner.login\n" + + "- content.number (the issue/PR number)\n" + + "- content_type (\"Issue\" or \"PullRequest\")\n\n" + + "**Then use these tools for details:**\n\n" + + "1. **Get full issue/PR details:**\n" + + " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Returns: full body, labels, assignees, milestone, etc.\n\n" + + "2. **Get recent comments:**\n" + + " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Add since parameter to filter recent comments\n\n" + + "3. **Get issue events:**\n" + + " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + + " - Shows timeline: assignments, label changes, status updates\n\n" + + "4. **For pull requests specifically:**\n" + + " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + + " - **list_pull_request_reviews** for review status\n\n" + + "**💡 Example:** To check for blockers in comments:\n" + + "1. Get project items with query=\"assignee:@me is:open\"\n" + + "2. For each item, extract repository and issue number from content\n" + + "3. Use **list_comments** to get recent comments\n" + + "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + } +} diff --git a/.tools-to-be-migrated/projects_test.go b/.tools-to-be-migrated/projects_test.go new file mode 100644 index 000000000..ed198a97a --- /dev/null +++ b/.tools-to-be-migrated/projects_test.go @@ -0,0 +1,1649 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + gh "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListProjects(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_projects", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) + + orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgProjects), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userProjects), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success organization with pagination & query", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgProjects)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "per_page": float64(50), + "query": "roadmap", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "failed to list projects", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var arr []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &arr) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(arr)) + }) + } +} + +func Test_GetProject(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) + + project := map[string]any{"id": 123, "title": "Project Title"} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "success organization project fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, project), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + }, + { + name: "success user project fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, project), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(456), + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(999), + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "failed to get project", + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner_type": "org", + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner": "octo-org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var arr map[string]any + err = json.Unmarshal([]byte(textContent.Text), &arr) + require.NoError(t, err) + }) + } +} + +func Test_ListProjectFields(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + + orgFields := []map[string]any{ + {"id": 101, "name": "Status", "dataType": "single_select"}, + } + userFields := []map[string]any{ + {"id": 201, "name": "Priority", "dataType": "single_select"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgFields), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + }, + expectedLength: 1, + }, + { + name: "success user fields with per_page override", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userFields)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "per_page": float64(50), + }, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + }, + expectError: true, + expectedErrMsg: "failed to list project fields", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + "project_number": 10, + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "project_number": 10, + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var fields []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &fields) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(fields)) + }) + } +} + +func Test_GetProjectField(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_field", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "field_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) + + orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} + userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization field", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgField), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "field_id": float64(101), + }, + expectedID: 101, + }, + { + name: "success user field", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userField), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "field_id": float64(202), + }, + expectedID: 202, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + "field_id": float64(303), + }, + expectError: true, + expectedErrMsg: "failed to get project field", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(10), + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(10), + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing field_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing field_id" { + assert.Contains(t, text, "missing required parameter: field_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var field map[string]any + err = json.Unmarshal([]byte(textContent.Text), &field) + require.NoError(t, err) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), field["id"]) + } + }) + } +} + +func Test_ListProjectItems(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_items", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "fields") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + + orgItems := []map[string]any{ + {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ + {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, + {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, + }}, + } + userItems := []map[string]any{ + {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, + {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + }, + expectedLength: 1, + }, + { + name: "success organization items with fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + fieldParams := q.Get("fields") + if fieldParams == "123,456,789" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItems)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "fields": []interface{}{"123", "456", "789"}, + }, + expectedLength: 1, + }, + { + name: "success user items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + }, + expectedLength: 2, + }, + { + name: "success with pagination and query", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" && q.Get("q") == "bug" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItems)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "per_page": float64(50), + "query": "bug", + }, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + }, + expectError: true, + expectedErrMsg: ProjectListFailedError, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var items []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &items) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(items)) + }) + } +} + +func Test_GetProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_item", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.Contains(t, tool.InputSchema.Properties, "fields") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + + orgItem := map[string]any{ + "id": 301, + "content_type": "Issue", + "project_node_id": "PR_1", + "creator": map[string]any{"login": "octocat"}, + } + userItem := map[string]any{ + "id": 501, + "content_type": "PullRequest", + "project_node_id": "PR_2", + "creator": map[string]any{"login": "jane"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(301), + }, + expectedID: 301, + }, + { + name: "success organization item with fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + fieldParams := q.Get("fields") + if fieldParams == "123,456" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItem)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(301), + "fields": []interface{}{"123", "456"}, + }, + expectedID: 301, + }, + { + name: "success user item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "item_id": float64(501), + }, + expectedID: 501, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + "item_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing item_id" { + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + err = json.Unmarshal([]byte(textContent.Text), &item) + require.NoError(t, err) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + }) + } +} + +func Test_AddProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_project_item", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_type") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) + + orgItem := map[string]any{ + "id": 601, + "content_type": "Issue", + "creator": map[string]any{ + "login": "octocat", + "id": 1, + "html_url": "https://github.com/octocat", + "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", + }, + } + + userItem := map[string]any{ + "id": 701, + "content_type": "PullRequest", + "creator": map[string]any{ + "login": "hubot", + "id": 2, + "html_url": "https://github.com/hubot", + "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + expectedContentType string + expectedCreatorLogin string + }{ + { + name: "success organization issue", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Type string `json:"type"` + ID int `json:"id"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "Issue", payload.Type) + assert.Equal(t, 9876, payload.ID) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(orgItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(321), + "item_type": "issue", + "item_id": float64(9876), + }, + expectedID: 601, + expectedContentType: "Issue", + expectedCreatorLogin: "octocat", + }, + { + name: "success user pull request", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Type string `json:"type"` + ID int `json:"id"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "PullRequest", payload.Type) + assert.Equal(t, 7654, payload.ID) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(userItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(222), + "item_type": "pull_request", + "item_id": float64(7654), + }, + expectedID: 701, + expectedContentType: "PullRequest", + expectedCreatorLogin: "hubot", + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(999), + "item_type": "issue", + "item_id": float64(8888), + }, + expectError: true, + expectedErrMsg: ProjectAddFailedError, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_type": "Issue", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_type": + assert.Contains(t, text, "missing required parameter: item_type") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + // case "api error": + // assert.Contains(t, text, ProjectAddFailedError) + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + if tc.expectedContentType != "" { + assert.Equal(t, tc.expectedContentType, item["content_type"]) + } + if tc.expectedCreatorLogin != "" { + creator, ok := item["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) + } + }) + } +} + +func Test_UpdateProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_project_item", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.Contains(t, tool.InputSchema.Properties, "updated_field") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) + + orgUpdatedItem := map[string]any{ + "id": 801, + "content_type": "Issue", + } + userUpdatedItem := map[string]any{ + "id": 802, + "content_type": "PullRequest", + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Fields []struct { + ID int `json:"id"` + Value interface{} `json:"value"` + } `json:"fields"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + require.Len(t, payload.Fields, 1) + assert.Equal(t, 101, payload.Fields[0].ID) + assert.Equal(t, "Done", payload.Fields[0].Value) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1001), + "item_id": float64(5555), + "updated_field": map[string]any{ + "id": float64(101), + "value": "Done", + }, + }, + expectedID: 801, + }, + { + name: "success user update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Fields []struct { + ID int `json:"id"` + Value interface{} `json:"value"` + } `json:"fields"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + require.Len(t, payload.Fields, 1) + assert.Equal(t, 202, payload.Fields[0].ID) + assert.Equal(t, 42.0, payload.Fields[0].Value) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2002), + "item_id": float64(6666), + "updated_field": map[string]any{ + "id": float64(202), + "value": float64(42), + }, + }, + expectedID: 802, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(3003), + "item_id": float64(7777), + "updated_field": map[string]any{ + "id": float64(303), + "value": "In Progress", + }, + }, + expectError: true, + expectedErrMsg: "failed to update a project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "field_id": float64(1), + "new_field": map[string]any{ + "value": "X", + }, + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_id": float64(2), + "new_field": map[string]any{ + "id": float64(1), + "value": "X", + }, + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(2), + "new_field": map[string]any{ + "id": float64(1), + "value": "X", + }, + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "new_field": map[string]any{ + "id": float64(1), + "value": "X", + }, + }, + expectError: true, + }, + { + name: "missing field_value", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "field_id": float64(2), + }, + expectError: true, + }, + { + name: "new_field not object", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "updated_field": "not-an-object", + }, + expectError: true, + }, + { + name: "new_field missing id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "updated_field": map[string]any{}, + }, + expectError: true, + }, + { + name: "new_field missing value", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "updated_field": map[string]any{ + "id": float64(9), + }, + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + case "missing field_value": + assert.Contains(t, text, "missing required parameter: updated_field") + case "field_value not object": + assert.Contains(t, text, "field_value must be an object") + case "field_value missing id": + assert.Contains(t, text, "missing required parameter: field_id") + case "field_value missing value": + assert.Contains(t, text, "field_value.value is required") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + }) + } +} + +func Test_DeleteProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_project_item", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedText string + }{ + { + name: "success organization delete", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(555), + }, + expectedText: "project item successfully deleted", + }, + { + name: "success user delete", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "item_id": float64(777), + }, + expectedText: "project item successfully deleted", + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(321), + "item_id": float64(999), + }, + expectError: true, + expectedErrMsg: ProjectDeleteFailedError, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, tc.expectedText) + }) + } +} diff --git a/.tools-to-be-migrated/pullrequests.go b/.tools-to-be-migrated/pullrequests.go new file mode 100644 index 000000000..24454a0c8 --- /dev/null +++ b/.tools-to-be-migrated/pullrequests.go @@ -0,0 +1,1630 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/translations" +) + +// GetPullRequest creates a tool to get details of a specific pull request. +func PullRequestRead(getClient GetClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("pull_request_read", + mcp.WithDescription(t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("method", + mcp.Required(), + mcp.Description(`Action to specify what pull request data needs to be retrieved from GitHub. +Possible options: + 1. get - Get details of a specific pull request. + 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. 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", "get_comments"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + 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 + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + 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) + } + + switch method { + + case "get": + return GetPullRequest(ctx, client, owner, repo, pullNumber) + case "get_diff": + return GetPullRequestDiff(ctx, client, owner, repo, pullNumber) + case "get_status": + return GetPullRequestStatus(ctx, client, owner, repo, pullNumber) + case "get_files": + return GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) + case "get_review_comments": + 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, flags) + default: + return nil, fmt.Errorf("unknown method: %s", method) + } + } +} + +func GetPullRequest(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request", + 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 get pull request: %s", string(body))), nil + } + + // sanitize title/body on response + if pr != nil { + if pr.Title != nil { + pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) + } + if pr.Body != nil { + pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) + } + } + + r, err := json.Marshal(pr) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + raw, resp, err := client.PullRequests.GetRaw( + ctx, + owner, + repo, + pullNumber, + github.RawOptions{Type: github.Diff}, + ) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request diff", + resp, + err, + ), 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 pull request diff: %s", string(body))), nil + } + + defer func() { _ = resp.Body.Close() }() + + // Return the raw response + return mcp.NewToolResultText(string(raw)), nil +} + +func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request", + 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 get pull request: %s", string(body))), nil + } + + // Get combined status for the head SHA + status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get combined status", + 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 get combined status: %s", string(body))), nil + } + + r, err := json.Marshal(status) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request files", + 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 get pull request files: %s", string(body))), nil + } + + r, err := json.Marshal(files) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func GetPullRequestReviewComments(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.PullRequestListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request review comments", + 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 get pull request review 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 GetPullRequestReviews(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request reviews", + 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 get pull request reviews: %s", string(body))), nil + } + + r, err := json.Marshal(reviews) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// CreatePullRequest creates a tool to create a new pull request. +func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("create_pull_request", + mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), + 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("PR title"), + ), + mcp.WithString("body", + mcp.Description("PR description"), + ), + mcp.WithString("head", + mcp.Required(), + mcp.Description("Branch containing changes"), + ), + mcp.WithString("base", + mcp.Required(), + mcp.Description("Branch to merge into"), + ), + mcp.WithBoolean("draft", + mcp.Description("Create as draft PR"), + ), + mcp.WithBoolean("maintainer_can_modify", + mcp.Description("Allow maintainer edits"), + ), + ), + 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 + } + head, err := RequiredParam[string](request, "head") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + base, err := RequiredParam[string](request, "base") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + draft, err := OptionalParam[bool](request, "draft") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + newPR := &github.NewPullRequest{ + Title: github.Ptr(title), + Head: github.Ptr(head), + Base: github.Ptr(base), + } + + if body != "" { + newPR.Body = github.Ptr(body) + } + + newPR.Draft = github.Ptr(draft) + newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create pull request", + resp, + err, + ), nil + } + 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 pull request: %s", string(body))), nil + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", pr.GetID()), + URL: pr.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 + } +} + +// UpdatePullRequest creates a tool to update an existing pull request. +func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("update_pull_request", + mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number to update"), + ), + mcp.WithString("title", + mcp.Description("New title"), + ), + mcp.WithString("body", + mcp.Description("New description"), + ), + mcp.WithString("state", + mcp.Description("New state"), + mcp.Enum("open", "closed"), + ), + mcp.WithBoolean("draft", + mcp.Description("Mark pull request as draft (true) or ready for review (false)"), + ), + mcp.WithString("base", + mcp.Description("New base branch name"), + ), + mcp.WithBoolean("maintainer_can_modify", + mcp.Description("Allow maintainer edits"), + ), + mcp.WithArray("reviewers", + mcp.Description("GitHub usernames to request reviews from"), + mcp.Items(map[string]interface{}{ + "type": "string", + }), + ), + ), + 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 + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Check if draft parameter is provided + draftProvided := request.GetArguments()["draft"] != nil + var draftValue bool + if draftProvided { + draftValue, err = OptionalParam[bool](request, "draft") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + } + + // Build the update struct only with provided fields + update := &github.PullRequest{} + restUpdateNeeded := false + + if title, ok, err := OptionalParamOK[string](request, "title"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if ok { + update.Title = github.Ptr(title) + restUpdateNeeded = true + } + + if body, ok, err := OptionalParamOK[string](request, "body"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if ok { + update.Body = github.Ptr(body) + restUpdateNeeded = true + } + + if state, ok, err := OptionalParamOK[string](request, "state"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if ok { + update.State = github.Ptr(state) + restUpdateNeeded = true + } + + if base, ok, err := OptionalParamOK[string](request, "base"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if ok { + update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} + restUpdateNeeded = true + } + + if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } else if ok { + update.MaintainerCanModify = github.Ptr(maintainerCanModify) + restUpdateNeeded = true + } + + // Handle reviewers separately + reviewers, err := OptionalStringArrayParam(request, "reviewers") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // If no updates, no draft change, and no reviewers, return error early + if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { + return mcp.NewToolResultError("No update parameters provided."), nil + } + + // Handle REST API updates (title, body, state, base, maintainer_can_modify) + if restUpdateNeeded { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update pull request", + 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 pull request: %s", string(body))), nil + } + } + + // Handle draft status changes using GraphQL + if draftProvided { + gqlClient, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + var prQuery struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers + }) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil + } + + currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) + + if currentIsDraft != draftValue { + if draftValue { + // Convert to draft + var mutation struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil + } + } else { + // Mark as ready for review + var mutation struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil + } + } + } + } + + // Handle reviewer requests + if len(reviewers) > 0 { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + reviewersRequest := github.ReviewersRequest{ + Reviewers: reviewers, + } + + _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + resp, + err, + ), nil + } + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + if resp.StatusCode != http.StatusCreated && 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 request reviewers: %s", string(body))), nil + } + } + + // Get the final state of the PR to return + client, err := getClient(ctx) + if err != nil { + return nil, err + } + + finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil + } + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", finalPR.GetID()), + URL: finalPR.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListPullRequests creates a tool to list and filter repository pull requests. +func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("list_pull_requests", + mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("state", + mcp.Description("Filter by state"), + mcp.Enum("open", "closed", "all"), + ), + mcp.WithString("head", + mcp.Description("Filter by head user/org and branch"), + ), + mcp.WithString("base", + mcp.Description("Filter by base branch"), + ), + mcp.WithString("sort", + mcp.Description("Sort by"), + mcp.Enum("created", "updated", "popularity", "long-running"), + ), + mcp.WithString("direction", + mcp.Description("Sort direction"), + mcp.Enum("asc", "desc"), + ), + 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 + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + head, err := OptionalParam[string](request, "head") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + base, err := OptionalParam[string](request, "base") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + opts := &github.PullRequestListOptions{ + State: state, + Head: head, + Base: base, + Sort: sort, + Direction: direction, + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list pull requests", + 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 pull requests: %s", string(body))), nil + } + + // sanitize title/body on each PR + for _, pr := range prs { + if pr == nil { + continue + } + if pr.Title != nil { + pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) + } + if pr.Body != nil { + pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) + } + } + + r, err := json.Marshal(prs) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// MergePullRequest creates a tool to merge a pull request. +func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("merge_pull_request", + mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + mcp.WithString("commit_title", + mcp.Description("Title for merge commit"), + ), + mcp.WithString("commit_message", + mcp.Description("Extra detail for merge commit"), + ), + mcp.WithString("merge_method", + mcp.Description("Merge method"), + mcp.Enum("merge", "squash", "rebase"), + ), + ), + 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 + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + commitTitle, err := OptionalParam[string](request, "commit_title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + commitMessage, err := OptionalParam[string](request, "commit_message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + mergeMethod, err := OptionalParam[string](request, "merge_method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + options := &github.PullRequestOptions{ + CommitTitle: commitTitle, + MergeMethod: mergeMethod, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to merge pull request", + 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 merge pull request: %s", string(body))), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// SearchPullRequests creates a tool to search for pull requests. +func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_pull_requests", + mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query using GitHub pull request search syntax"), + ), + mcp.WithString("owner", + mcp.Description("Optional repository owner. If provided with repo, only pull requests for this repository are listed."), + ), + mcp.WithString("repo", + mcp.Description("Optional repository name. If provided with owner, only pull requests 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, "pr", "failed to search pull requests") + } +} + +// UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. +func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("update_pull_request_branch", + mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + mcp.WithString("expectedHeadSha", + mcp.Description("The expected SHA of the pull request's HEAD ref"), + ), + ), + 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 + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + expectedHeadSHA, err := OptionalParam[string](request, "expectedHeadSha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + opts := &github.PullRequestBranchUpdateOptions{} + if expectedHeadSHA != "" { + opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts) + if err != nil { + // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, + // and it's not a real error. + if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { + return mcp.NewToolResultText("Pull request branch update is in progress"), nil + } + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update pull request branch", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + 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 pull request branch: %s", string(body))), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +type PullRequestReviewWriteParams struct { + Method string + Owner string + Repo string + PullNumber int32 + Body string + Event string + CommitID *string +} + +func PullRequestReviewWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("pull_request_review_write", + mcp.WithDescription(t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. + +Available methods: +- create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. +- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. +- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. +`)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), + ReadOnlyHint: ToBoolPtr(false), + }), + // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up. + // Since our other Pull Request tools are working with the REST Client, will handle the lookup + // internally for now. + mcp.WithString("method", + mcp.Required(), + mcp.Description("The write operation to perform on pull request review."), + mcp.Enum("create", "submit_pending", "delete_pending"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + mcp.WithString("body", + mcp.Description("Review comment text"), + ), + mcp.WithString("event", + mcp.Description("Review action to perform."), + mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"), + ), + mcp.WithString("commitID", + mcp.Description("SHA of commit to review"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var params PullRequestReviewWriteParams + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Given our owner, repo and PR number, lookup the GQL ID of the PR. + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + switch params.Method { + case "create": + return CreatePullRequestReview(ctx, client, params) + case "submit_pending": + return SubmitPendingPullRequestReview(ctx, client, params) + case "delete_pending": + return DeletePendingPullRequestReview(ctx, client, params) + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil + } + } +} + +func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { + var getPullRequestQuery struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + if err := client.Query(ctx, &getPullRequestQuery, map[string]any{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "prNum": githubv4.Int(params.PullNumber), + }); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get pull request", + err, + ), nil + } + + // Now we have the GQL ID, we can create a review + var addPullRequestReviewMutation struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID // We don't need this, but a selector is required or GQL complains. + } + } `graphql:"addPullRequestReview(input: $input)"` + } + + addPullRequestReviewInput := githubv4.AddPullRequestReviewInput{ + PullRequestID: getPullRequestQuery.Repository.PullRequest.ID, + CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID), + } + + // Event and Body are provided if we submit a review + if params.Event != "" { + addPullRequestReviewInput.Event = newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event) + addPullRequestReviewInput.Body = githubv4.NewString(githubv4.String(params.Body)) + } + + if err := client.Mutate( + ctx, + &addPullRequestReviewMutation, + addPullRequestReviewInput, + nil, + ); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Return nothing interesting, just indicate success for the time being. + // In future, we may want to return the review ID, but for the moment, we're not leaking + // API implementation details to the LLM. + if params.Event == "" { + return mcp.NewToolResultText("pending pull request created"), nil + } + return mcp.NewToolResultText("pull request review submitted successfully"), nil +} + +func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { + // First we'll get the current user + var getViewerQuery struct { + Viewer struct { + Login githubv4.String + } + } + + if err := client.Query(ctx, &getViewerQuery, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get current user", + err, + ), nil + } + + var getLatestReviewForViewerQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + vars := map[string]any{ + "author": githubv4.String(getViewerQuery.Viewer.Login), + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "prNum": githubv4.Int(params.PullNumber), + } + + if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get latest review for current user", + err, + ), nil + } + + // Validate there is one review and the state is pending + if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { + return mcp.NewToolResultError("No pending review found for the viewer"), nil + } + + review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] + if review.State != githubv4.PullRequestReviewStatePending { + errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) + return mcp.NewToolResultError(errText), nil + } + + // Prepare the mutation + var submitPullRequestReviewMutation struct { + SubmitPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID // We don't need this, but a selector is required or GQL complains. + } + } `graphql:"submitPullRequestReview(input: $input)"` + } + + if err := client.Mutate( + ctx, + &submitPullRequestReviewMutation, + githubv4.SubmitPullRequestReviewInput{ + PullRequestReviewID: &review.ID, + Event: githubv4.PullRequestReviewEvent(params.Event), + Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), + }, + nil, + ); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to submit pull request review", + err, + ), nil + } + + // Return nothing interesting, just indicate success for the time being. + // In future, we may want to return the review ID, but for the moment, we're not leaking + // API implementation details to the LLM. + return mcp.NewToolResultText("pending pull request review successfully submitted"), nil +} + +func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { + // First we'll get the current user + var getViewerQuery struct { + Viewer struct { + Login githubv4.String + } + } + + if err := client.Query(ctx, &getViewerQuery, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get current user", + err, + ), nil + } + + var getLatestReviewForViewerQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + vars := map[string]any{ + "author": githubv4.String(getViewerQuery.Viewer.Login), + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "prNum": githubv4.Int(params.PullNumber), + } + + if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get latest review for current user", + err, + ), nil + } + + // Validate there is one review and the state is pending + if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { + return mcp.NewToolResultError("No pending review found for the viewer"), nil + } + + review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] + if review.State != githubv4.PullRequestReviewStatePending { + errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) + return mcp.NewToolResultError(errText), nil + } + + // Prepare the mutation + var deletePullRequestReviewMutation struct { + DeletePullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID // We don't need this, but a selector is required or GQL complains. + } + } `graphql:"deletePullRequestReview(input: $input)"` + } + + if err := client.Mutate( + ctx, + &deletePullRequestReviewMutation, + githubv4.DeletePullRequestReviewInput{ + PullRequestReviewID: &review.ID, + }, + nil, + ); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Return nothing interesting, just indicate success for the time being. + // In future, we may want to return the review ID, but for the moment, we're not leaking + // API implementation details to the LLM. + return mcp.NewToolResultText("pending pull request review successfully deleted"), nil +} + +// AddCommentToPendingReview creates a tool to add a comment to a pull request review. +func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("add_comment_to_pending_review", + mcp.WithDescription(t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), + ReadOnlyHint: ToBoolPtr(false), + }), + // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to + // add a new tool to get that ID for clients that aren't in the same context as the original pending review + // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment + // the latest review from a user, since only one can be active at a time. It can later be extended with + // a pullRequestReviewID parameter if targeting other reviews is desired: + // mcp.WithString("pullRequestReviewID", + // mcp.Required(), + // mcp.Description("The ID of the pull request review to add a comment to"), + // ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("The relative path to the file that necessitates a comment"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("The text of the review comment"), + ), + mcp.WithString("subjectType", + mcp.Required(), + mcp.Description("The level at which the comment is targeted"), + mcp.Enum("FILE", "LINE"), + ), + mcp.WithNumber("line", + mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"), + ), + mcp.WithString("side", + mcp.Description("The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state"), + mcp.Enum("LEFT", "RIGHT"), + ), + mcp.WithNumber("startLine", + mcp.Description("For multi-line comments, the first line of the range that the comment applies to"), + ), + mcp.WithString("startSide", + mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state"), + mcp.Enum("LEFT", "RIGHT"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var params struct { + Owner string + Repo string + PullNumber int32 + Path string + Body string + SubjectType string + Line *int32 + Side *string + StartLine *int32 + StartSide *string + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err) + } + + // First we'll get the current user + var getViewerQuery struct { + Viewer struct { + Login githubv4.String + } + } + + if err := client.Query(ctx, &getViewerQuery, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get current user", + err, + ), nil + } + + var getLatestReviewForViewerQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + vars := map[string]any{ + "author": githubv4.String(getViewerQuery.Viewer.Login), + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "prNum": githubv4.Int(params.PullNumber), + } + + if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get latest review for current user", + err, + ), nil + } + + // Validate there is one review and the state is pending + if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { + return mcp.NewToolResultError("No pending review found for the viewer"), nil + } + + review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] + if review.State != githubv4.PullRequestReviewStatePending { + errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) + return mcp.NewToolResultError(errText), nil + } + + // Then we can create a new review thread comment on the review. + var addPullRequestReviewThreadMutation struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.ID // We don't need this, but a selector is required or GQL complains. + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + } + + if err := client.Mutate( + ctx, + &addPullRequestReviewThreadMutation, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String(params.Path), + Body: githubv4.String(params.Body), + SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), + Line: newGQLIntPtr(params.Line), + Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), + StartLine: newGQLIntPtr(params.StartLine), + StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), + PullRequestReviewID: &review.ID, + }, + nil, + ); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Return nothing interesting, just indicate success for the time being. + // In future, we may want to return the review ID, but for the moment, we're not leaking + // API implementation details to the LLM. + return mcp.NewToolResultText("pull request review comment successfully added to pending review"), nil + } +} + +// RequestCopilotReview creates a tool to request a Copilot review for a pull request. +// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this +// tool if the configured host does not support it. +func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("request_copilot_review", + mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + ), + 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 + } + + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + _, resp, err := client.PullRequests.RequestReviewers( + ctx, + owner, + repo, + pullNumber, + github.ReviewersRequest{ + // The login name of the copilot reviewer bot + Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, + }, + ) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request copilot review", + resp, + err, + ), nil + } + 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 request copilot review: %s", string(body))), nil + } + + // Return nothing on success, as there's not much value in returning the Pull Request itself + return mcp.NewToolResultText(""), nil + } +} + +// newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) +// and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse +// params from the MCP request, we need to convert them to types that are pointers of type def strings and it's +// not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo"). +func newGQLStringlike[T ~string](s string) *T { + if s == "" { + return nil + } + stringlike := T(s) + return &stringlike +} + +func newGQLStringlikePtr[T ~string](s *string) *T { + if s == nil { + return nil + } + stringlike := T(*s) + return &stringlike +} + +func newGQLIntPtr(i *int32) *githubv4.Int { + if i == nil { + return nil + } + gi := githubv4.Int(*i) + return &gi +} diff --git a/.tools-to-be-migrated/pullrequests_test.go b/.tools-to-be-migrated/pullrequests_test.go new file mode 100644 index 000000000..4cc4480e9 --- /dev/null +++ b/.tools-to-be-migrated/pullrequests_test.go @@ -0,0 +1,2943 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/shurcooL/githubv4" + + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetPullRequest(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + }{ + { + name: "successful PR fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockPR, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedPR: mockPR, + }, + { + name: "PR fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedPR github.PullRequest + err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + require.NoError(t, err) + assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) + assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) + assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) + assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) + }) + } +} + +func Test_UpdatePullRequest(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_pull_request", 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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "draft") + assert.Contains(t, tool.InputSchema.Properties, "title") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.Contains(t, tool.InputSchema.Properties, "base") + assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") + assert.Contains(t, tool.InputSchema.Properties, "reviewers") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockUpdatedPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Updated Test PR Title"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Body: github.Ptr("Updated test PR body."), + MaintainerCanModify: github.Ptr(false), + Draft: github.Ptr(false), + Base: &github.PullRequestBranch{ + Ref: github.Ptr("develop"), + }, + } + + mockClosedPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("closed"), // State updated + } + + // Mock PR for when there are no updates but we still need a response + mockPRWithReviewers := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + RequestedReviewers: []*github.User{ + {Login: github.Ptr("reviewer1")}, + {Login: github.Ptr("reviewer2")}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + }{ + { + name: "successful PR update (title, body, base, maintainer_can_modify)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsByOwnerByRepoByPullNumber, + // Expect the flat string based on previous test failure output and API docs + expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + "body": "Updated test PR body.", + "base": "develop", + "maintainer_can_modify": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated Test PR Title", + "body": "Updated test PR body.", + "base": "develop", + "maintainer_can_modify": false, + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + { + name: "successful PR update (state)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{ + "state": "closed", + }).andThen( + mockResponse(t, http.StatusOK, mockClosedPR), + ), + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockClosedPR, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "state": "closed", + }, + expectError: false, + expectedPR: mockClosedPR, + }, + { + name: "successful PR update with reviewers", + mockedClient: mock.NewMockedHTTPClient( + // Mock for RequestReviewers call, returning the PR with reviewers + mock.WithRequestMatch( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + mockPRWithReviewers, + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockPRWithReviewers, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []interface{}{"reviewer1", "reviewer2"}, + }, + expectError: false, + expectedPR: mockPRWithReviewers, + }, + { + name: "successful PR update (title only)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + ), + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated Test PR Title", + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + { + name: "no update parameters provided", + mockedClient: mock.NewMockedHTTPClient(), // No API call expected + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + // No update fields + }, + expectError: false, // Error is returned in the result, not as Go error + expectedErrMsg: "No update parameters provided", + }, + { + name: "PR update fails (API error)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PatchReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Invalid Title Causing Error", + }, + expectError: true, + expectedErrMsg: "failed to update pull request", + }, + { + name: "request reviewers fails", + mockedClient: mock.NewMockedHTTPClient( + // Then reviewer request fails + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []interface{}{"invalid-user"}, + }, + expectError: true, + expectedErrMsg: "failed to request reviewers", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError || tc.expectedErrMsg != "" { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) + require.NoError(t, err) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) + }) + } +} + +func Test_UpdatePullRequest_Draft(t *testing.T) { + // Setup mock PR for success case + mockUpdatedPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR Title"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Body: github.Ptr("Test PR body."), + MaintainerCanModify: github.Ptr(false), + Draft: github.Ptr(false), // Updated to ready for review + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + }{ + { + name: "successful draft update to ready for review", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": true, // Current state is draft + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + }{}, + githubv4.MarkPullRequestReadyForReviewInput{ + PullRequestID: "PR_kwDOA0xdyM50BPaO", + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "markPullRequestReadyForReview": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": false, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "draft": false, + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + { + name: "successful convert pull request to draft", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": false, // Current state is draft + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + }{}, + githubv4.ConvertPullRequestToDraftInput{ + PullRequestID: "PR_kwDOA0xdyM50BPaO", + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "convertPullRequestToDraft": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDOA0xdyM50BPaO", + "isDraft": true, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "draft": true, + }, + expectError: false, + expectedPR: mockUpdatedPR, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // For draft-only tests, we need to mock both GraphQL and the final REST GET call + restClient := github.NewClient(mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockUpdatedPR, + ), + )) + gqlClient := githubv4.NewClient(tc.mockedClient) + + _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError || tc.expectedErrMsg != "" { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" { + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) + require.NoError(t, err) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) + }) + } +} + +func Test_ListPullRequests(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_pull_requests", 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, "state") + assert.Contains(t, tool.InputSchema.Properties, "head") + assert.Contains(t, tool.InputSchema.Properties, "base") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock PRs for success case + mockPRs := []*github.PullRequest{ + { + Number: github.Ptr(42), + Title: github.Ptr("First PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Second PR"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPRs []*github.PullRequest + expectedErrMsg string + }{ + { + name: "successful PRs listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "state": "all", + "sort": "created", + "direction": "desc", + "per_page": "30", + "page": "1", + }).andThen( + mockResponse(t, http.StatusOK, mockPRs), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "all", + "sort": "created", + "direction": "desc", + "perPage": float64(30), + "page": float64(1), + }, + expectError: false, + expectedPRs: mockPRs, + }, + { + name: "PRs listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "invalid", + }, + expectError: true, + expectedErrMsg: "failed to list pull requests", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedPRs []*github.PullRequest + err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) + require.NoError(t, err) + assert.Len(t, returnedPRs, 2) + assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) + assert.Equal(t, *tc.expectedPRs[0].Title, *returnedPRs[0].Title) + assert.Equal(t, *tc.expectedPRs[0].State, *returnedPRs[0].State) + assert.Equal(t, *tc.expectedPRs[1].Number, *returnedPRs[1].Number) + assert.Equal(t, *tc.expectedPRs[1].Title, *returnedPRs[1].Title) + assert.Equal(t, *tc.expectedPRs[1].State, *returnedPRs[1].State) + }) + } +} + +func Test_MergePullRequest(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := MergePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "merge_pull_request", 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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "commit_title") + assert.Contains(t, tool.InputSchema.Properties, "commit_message") + assert.Contains(t, tool.InputSchema.Properties, "merge_method") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock merge result for success case + mockMergeResult := &github.PullRequestMergeResult{ + Merged: github.Ptr(true), + Message: github.Ptr("Pull Request successfully merged"), + SHA: github.Ptr("abcd1234efgh5678"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedMergeResult *github.PullRequestMergeResult + expectedErrMsg string + }{ + { + name: "successful merge", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposPullsMergeByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{ + "commit_title": "Merge PR #42", + "commit_message": "Merging awesome feature", + "merge_method": "squash", + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commit_title": "Merge PR #42", + "commit_message": "Merging awesome feature", + "merge_method": "squash", + }, + expectError: false, + expectedMergeResult: mockMergeResult, + }, + { + name: "merge fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposPullsMergeByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to merge pull request", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := MergePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedResult github.PullRequestMergeResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedMergeResult.Merged, *returnedResult.Merged) + assert.Equal(t, *tc.expectedMergeResult.Message, *returnedResult.Message) + assert.Equal(t, *tc.expectedMergeResult.SHA, *returnedResult.SHA) + }) + } +} + +func Test_SearchPullRequests(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_pull_requests", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Test PR 1"), + Body: github.Ptr("Updated tests."), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), + Comments: github.Ptr(5), + User: &github.User{ + Login: github.Ptr("user1"), + }, + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Test PR 2"), + Body: github.Ptr("Updated build scripts."), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), + Comments: github.Ptr(3), + User: &github.User{ + Login: github.Ptr("user2"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.IssuesSearchResult + expectedErrMsg string + }{ + { + name: "successful pull request search with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": float64(1), + "perPage": float64(30), + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "pull request search with owner and repo parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:pr draft:false", + "sort": "updated", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "draft:false", + "owner": "test-owner", + "repo": "test-repo", + "sort": "updated", + "order": "asc", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "pull request search with only owner parameter (should ignore it)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "feature", + "owner": "test-owner", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "pull request search with only repo parameter (should ignore it)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr review-required", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "review-required", + "repo": "test-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "pull request search with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetSearchIssues, + mockSearchResult, + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:pr repo:owner/repo is:open", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing is:pr filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server is:open draft:false", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:pr repo:github/github-mcp-server is:open draft:false", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server author:octocat", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server author:octocat", + "owner": "different-owner", + "repo": "different-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing is:pr filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search pull requests fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search pull requests", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) + assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) + for i, issue := range returnedResult.Issues { + assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) + assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) + assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) + assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) + assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) + } + }) + } + +} + +func Test_GetPullRequestFiles(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR files for success case + mockFiles := []*github.CommitFile{ + { + Filename: github.Ptr("file1.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(10), + Deletions: github.Ptr(5), + Changes: github.Ptr(15), + Patch: github.Ptr("@@ -1,5 +1,10 @@"), + }, + { + Filename: github.Ptr("file2.go"), + Status: github.Ptr("added"), + Additions: github.Ptr(20), + Deletions: github.Ptr(0), + Changes: github.Ptr(20), + Patch: github.Ptr("@@ -0,0 +1,20 @@"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedFiles []*github.CommitFile + expectedErrMsg string + }{ + { + name: "successful files fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + mockFiles, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedFiles: mockFiles, + }, + { + name: "successful files fetch with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + mockFiles, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedFiles: mockFiles, + }, + { + name: "files fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_files", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request files", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedFiles []*github.CommitFile + err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) + require.NoError(t, err) + assert.Len(t, returnedFiles, len(tc.expectedFiles)) + for i, file := range returnedFiles { + assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) + assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) + assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) + assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) + } + }) + } +} + +func Test_GetPullRequestStatus(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR for successful PR fetch + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + } + + // Setup mock status for success case + mockStatus := &github.CombinedStatus{ + State: github.Ptr("success"), + TotalCount: github.Ptr(3), + Statuses: []*github.RepoStatus{ + { + State: github.Ptr("success"), + Context: github.Ptr("continuous-integration/travis-ci"), + Description: github.Ptr("Build succeeded"), + TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), + }, + { + State: github.Ptr("success"), + Context: github.Ptr("codecov/patch"), + Description: github.Ptr("Coverage increased"), + TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), + }, + { + State: github.Ptr("success"), + Context: github.Ptr("lint/golangci-lint"), + Description: github.Ptr("No issues found"), + TargetURL: github.Ptr("https://golangci.com/r/owner/repo/pull/42"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedStatus *github.CombinedStatus + expectedErrMsg string + }{ + { + name: "successful status fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockPR, + ), + mock.WithRequestMatch( + mock.GetReposCommitsStatusByOwnerByRepoByRef, + mockStatus, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_status", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedStatus: mockStatus, + }, + { + name: "PR fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_status", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request", + }, + { + name: "status fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsByOwnerByRepoByPullNumber, + mockPR, + ), + mock.WithRequestMatchHandler( + mock.GetReposCommitsStatusesByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_status", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to get combined status", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedStatus github.CombinedStatus + err = json.Unmarshal([]byte(textContent.Text), &returnedStatus) + require.NoError(t, err) + assert.Equal(t, *tc.expectedStatus.State, *returnedStatus.State) + assert.Equal(t, *tc.expectedStatus.TotalCount, *returnedStatus.TotalCount) + assert.Len(t, returnedStatus.Statuses, len(tc.expectedStatus.Statuses)) + for i, status := range returnedStatus.Statuses { + assert.Equal(t, *tc.expectedStatus.Statuses[i].State, *status.State) + assert.Equal(t, *tc.expectedStatus.Statuses[i].Context, *status.Context) + assert.Equal(t, *tc.expectedStatus.Statuses[i].Description, *status.Description) + } + }) + } +} + +func Test_UpdatePullRequestBranch(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := UpdatePullRequestBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_pull_request_branch", 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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "expectedHeadSha") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock update result for success case + mockUpdateResult := &github.PullRequestBranchUpdateResponse{ + Message: github.Ptr("Branch was updated successfully"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedUpdateResult *github.PullRequestBranchUpdateResponse + expectedErrMsg string + }{ + { + name: "successful branch update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{ + "expected_head_sha": "abcd1234", + }).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "expectedHeadSha": "abcd1234", + }, + expectError: false, + expectedUpdateResult: mockUpdateResult, + }, + { + name: "branch update without expected SHA", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, + expectRequestBody(t, map[string]interface{}{}).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedUpdateResult: mockUpdateResult, + }, + { + name: "branch update fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to update pull request branch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := UpdatePullRequestBranch(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + assert.Contains(t, textContent.Text, "is in progress") + }) + } +} + +func Test_GetPullRequestComments(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR comments for success case + mockComments := []*github.PullRequestComment{ + { + ID: github.Ptr(int64(101)), + Body: github.Ptr("This looks good"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r101"), + User: &github.User{ + Login: github.Ptr("reviewer1"), + }, + Path: github.Ptr("file1.go"), + Position: github.Ptr(5), + CommitID: github.Ptr("abcdef123456"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + UpdatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + }, + { + ID: github.Ptr(int64(102)), + Body: github.Ptr("Please fix this"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r102"), + User: &github.User{ + Login: github.Ptr("reviewer2"), + }, + Path: github.Ptr("file2.go"), + Position: github.Ptr(10), + CommitID: github.Ptr("abcdef123456"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, + UpdatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComments []*github.PullRequestComment + expectedErrMsg string + }{ + { + name: "successful comments fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, + mockComments, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_review_comments", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "comments fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_review_comments", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request review comments", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedComments []*github.PullRequestComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Len(t, returnedComments, len(tc.expectedComments)) + for i, comment := range returnedComments { + assert.Equal(t, *tc.expectedComments[i].ID, *comment.ID) + assert.Equal(t, *tc.expectedComments[i].Body, *comment.Body) + assert.Equal(t, *tc.expectedComments[i].User.Login, *comment.User.Login) + assert.Equal(t, *tc.expectedComments[i].Path, *comment.Path) + assert.Equal(t, *tc.expectedComments[i].HTMLURL, *comment.HTMLURL) + } + }) + } +} + +func Test_GetPullRequestReviews(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR reviews for success case + mockReviews := []*github.PullRequestReview{ + { + ID: github.Ptr(int64(201)), + State: github.Ptr("APPROVED"), + Body: github.Ptr("LGTM"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), + User: &github.User{ + Login: github.Ptr("approver"), + }, + CommitID: github.Ptr("abcdef123456"), + SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + }, + { + ID: github.Ptr(int64(202)), + State: github.Ptr("CHANGES_REQUESTED"), + Body: github.Ptr("Please address the following issues"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), + User: &github.User{ + Login: github.Ptr("reviewer"), + }, + CommitID: github.Ptr("abcdef123456"), + SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedReviews []*github.PullRequestReview + expectedErrMsg string + }{ + { + name: "successful reviews fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, + mockReviews, + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedReviews: mockReviews, + }, + { + name: "reviews fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request reviews", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedReviews []*github.PullRequestReview + err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) + require.NoError(t, err) + assert.Len(t, returnedReviews, len(tc.expectedReviews)) + for i, review := range returnedReviews { + assert.Equal(t, *tc.expectedReviews[i].ID, *review.ID) + assert.Equal(t, *tc.expectedReviews[i].State, *review.State) + assert.Equal(t, *tc.expectedReviews[i].Body, *review.Body) + assert.Equal(t, *tc.expectedReviews[i].User.Login, *review.User.Login) + assert.Equal(t, *tc.expectedReviews[i].HTMLURL, *review.HTMLURL) + } + }) + } +} + +func Test_CreatePullRequest(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_pull_request", 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, "title") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "head") + assert.Contains(t, tool.InputSchema.Properties, "base") + assert.Contains(t, tool.InputSchema.Properties, "draft") + assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title", "head", "base"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + SHA: github.Ptr("efgh5678"), + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + Draft: github.Ptr(false), + MaintainerCanModify: github.Ptr(true), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + }{ + { + name: "successful PR creation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "title": "Test PR", + "body": "This is a test PR", + "head": "feature-branch", + "base": "main", + "draft": false, + "maintainer_can_modify": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "body": "This is a test PR", + "head": "feature-branch", + "base": "main", + "draft": false, + "maintainer_can_modify": true, + }, + expectError: false, + expectedPR: mockPR, + }, + { + name: "missing required parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + // missing title, head, base + }, + expectError: true, + expectedErrMsg: "missing required parameter: title", + }, + { + name: "PR creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature-branch", + "base": "main", + }, + expectError: true, + expectedErrMsg: "failed to create pull request", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + // If no error returned but in the 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 minimal result + var returnedPR MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + require.NoError(t, err) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) + }) + } +} + +func TestCreateAndSubmitPullRequestReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_review_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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "event") + assert.Contains(t, tool.InputSchema.Properties, "commitID") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful review creation", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + Body: githubv4.NewString("This is a test review"), + Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "body": "This is a test review", + "event": "COMMENT", + "commitID": "abcd1234", + }, + expectToolError: false, + }, + { + name: "failure to get pull request", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.ErrorResponse("expected test failure"), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "body": "This is a test review", + "event": "COMMENT", + "commitID": "abcd1234", + }, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + { + name: "failure to submit review", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + Body: githubv4.NewString("This is a test review"), + Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.ErrorResponse("expected test failure"), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "body": "This is a test review", + "event": "COMMENT", + "commitID": "abcd1234", + }, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, textContent.Text, "pull request review submitted successfully") + }) + } +} + +func Test_RequestCopilotReview(t *testing.T) { + t.Parallel() + + mockClient := github.NewClient(nil) + tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "request_copilot_review", 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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful request", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + }, + expectError: false, + }, + { + name: "request fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to request copilot review", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := github.NewClient(tc.mockedClient) + _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + assert.NotNil(t, result) + assert.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + require.Equal(t, "", textContent.Text) + }) + } +} + +func TestCreatePendingPullRequestReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_review_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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "commitID") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful review creation", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commitID": "abcd1234", + }, + expectToolError: false, + }, + { + name: "failure to get pull request", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.ErrorResponse("expected test failure"), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commitID": "abcd1234", + }, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + { + name: "failure to create pending review", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.ErrorResponse("expected test failure"), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commitID": "abcd1234", + }, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, "pending pull request created", textContent.Text) + }) + } +} + +func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := AddCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_comment_to_pending_review", 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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "path") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "subjectType") + assert.Contains(t, tool.InputSchema.Properties, "line") + assert.Contains(t, tool.InputSchema.Properties, "side") + assert.Contains(t, tool.InputSchema.Properties, "startLine") + assert.Contains(t, tool.InputSchema.Properties, "startSide") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful line comment addition", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "path": "file.go", + "body": "This is a test comment", + "subjectType": "LINE", + "line": float64(10), + "side": "RIGHT", + "startLine": float64(5), + "startSide": "RIGHT", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + viewerQuery("williammartin"), + getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ + author: "williammartin", + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []getLatestPendingReviewQueryReview{ + { + id: "PR_kwDODKw3uc6WYN1T", + state: "PENDING", + url: "https://github.com/owner/repo/pull/42", + }, + }, + }), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.String // We don't need this, but a selector is required or GQL complains. + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + }{}, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String("file.go"), + Body: githubv4.String("This is a test comment"), + SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), + Line: githubv4.NewInt(10), + Side: githubv4mock.Ptr(githubv4.DiffSideRight), + StartLine: githubv4.NewInt(5), + StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), + PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := AddCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review") + }) + } +} + +func TestSubmitPendingPullRequestReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_review_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, "pullNumber") + assert.Contains(t, tool.InputSchema.Properties, "event") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful review submission", + requestArgs: map[string]any{ + "method": "submit_pending", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "event": "COMMENT", + "body": "This is a test review", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + viewerQuery("williammartin"), + getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ + author: "williammartin", + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []getLatestPendingReviewQueryReview{ + { + id: "PR_kwDODKw3uc6WYN1T", + state: "PENDING", + url: "https://github.com/owner/repo/pull/42", + }, + }, + }), + githubv4mock.NewMutationMatcher( + struct { + SubmitPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"submitPullRequestReview(input: $input)"` + }{}, + githubv4.SubmitPullRequestReviewInput{ + PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), + Event: githubv4.PullRequestReviewEventComment, + Body: githubv4.NewString("This is a test review"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, "pending pull request review successfully submitted", textContent.Text) + }) + } +} + +func TestDeletePendingPullRequestReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_review_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful review deletion", + requestArgs: map[string]any{ + "method": "delete_pending", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + viewerQuery("williammartin"), + getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ + author: "williammartin", + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []getLatestPendingReviewQueryReview{ + { + id: "PR_kwDODKw3uc6WYN1T", + state: "PENDING", + url: "https://github.com/owner/repo/pull/42", + }, + }, + }), + githubv4mock.NewMutationMatcher( + struct { + DeletePullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"deletePullRequestReview(input: $input)"` + }{}, + githubv4.DeletePullRequestReviewInput{ + PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, "pending pull request review successfully deleted", textContent.Text) + }) + } +} + +func TestGetPullRequestDiff(t *testing.T) { + t.Parallel() + + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_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, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + stubbedDiff := `diff --git a/README.md b/README.md +index 5d6e7b2..8a4f5c3 100644 +--- a/README.md ++++ b/README.md +@@ -1,4 +1,6 @@ + # Hello-World + + Hello World project for GitHub + ++## New Section ++ ++This is a new section added in the pull request.` + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful diff retrieval", + requestArgs: map[string]any{ + "method": "get_diff", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposPullsByOwnerByRepoByPullNumber, + // Should also expect Accept header to be application/vnd.github.v3.diff + expectPath(t, "/repos/owner/repo/pulls/42").andThen( + mockResponse(t, http.StatusOK, stubbedDiff), + ), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and get the text content if no error + require.Equal(t, stubbedDiff, textContent.Text) + }) + } +} + +func viewerQuery(login string) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Viewer struct { + Login githubv4.String + } `graphql:"viewer"` + }{}, + map[string]any{}, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{ + "login": login, + }, + }), + ) +} + +type getLatestPendingReviewQueryReview struct { + id string + state string + url string +} + +type getLatestPendingReviewQueryParams struct { + author string + owner string + repo string + prNum int32 + + reviews []getLatestPendingReviewQueryReview +} + +func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "author": githubv4.String(p.author), + "owner": githubv4.String(p.owner), + "name": githubv4.String(p.repo), + "prNum": githubv4.Int(p.prNum), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviews": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": p.reviews[0].id, + "state": p.reviews[0].state, + "url": p.reviews[0].url, + }, + }, + }, + }, + }, + }, + ), + ) +} diff --git a/.tools-to-be-migrated/repositories.go b/.tools-to-be-migrated/repositories.go new file mode 100644 index 000000000..0d4d11bbf --- /dev/null +++ b/.tools-to-be-migrated/repositories.go @@ -0,0 +1,1928 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_commit", + mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("sha", + mcp.Required(), + mcp.Description("Commit SHA, branch name, or tag name"), + ), + mcp.WithBoolean("include_diff", + mcp.Description("Whether to include file diffs and stats in the response. Default is true."), + mcp.DefaultBool(true), + ), + 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 + } + sha, err := RequiredParam[string](request, "sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &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) + } + commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get commit: %s", sha), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 commit: %s", string(body))), nil + } + + // Convert to minimal commit + minimalCommit := convertToMinimalCommit(commit, includeDiff) + + r, err := json.Marshal(minimalCommit) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListCommits creates a tool to get commits of a branch in a repository. +func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_commits", + mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("sha", + mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), + ), + mcp.WithString("author", + mcp.Description("Author username or email address to filter commits by"), + ), + 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 + } + sha, err := OptionalParam[string](request, "sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + author, err := OptionalParam[string](request, "author") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Set default perPage to 30 if not provided + perPage := pagination.PerPage + if perPage == 0 { + perPage = 30 + } + opts := &github.CommitsListOptions{ + SHA: sha, + Author: author, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: perPage, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list commits: %s", sha), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 commits: %s", string(body))), nil + } + + // Convert to minimal commits + minimalCommits := make([]MinimalCommit, len(commits)) + for i, commit := range commits { + minimalCommits[i] = convertToMinimalCommit(commit, false) + } + + r, err := json.Marshal(minimalCommits) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListBranches creates a tool to list branches in a GitHub repository. +func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_branches", + mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + 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 + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.BranchListOptions{ + 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) + } + + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list branches", + 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 branches: %s", string(body))), nil + } + + // Convert to minimal branches + minimalBranches := make([]MinimalBranch, 0, len(branches)) + for _, branch := range branches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(minimalBranches) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. +func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_or_update_file", + mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("Path where to create/update the file"), + ), + mcp.WithString("content", + mcp.Required(), + mcp.Description("Content of the file"), + ), + mcp.WithString("message", + mcp.Required(), + mcp.Description("Commit message"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("Branch to create/update the file in"), + ), + mcp.WithString("sha", + mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), + ), + ), + 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 + } + path, err := RequiredParam[string](request, "path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + message, err := RequiredParam[string](request, "message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + branch, err := RequiredParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // json.Marshal encodes byte arrays with base64, which is required for the API. + contentBytes := []byte(content) + + // Create the file options + opts := &github.RepositoryContentFileOptions{ + Message: github.Ptr(message), + Content: contentBytes, + Branch: github.Ptr(branch), + } + + // If SHA is provided, set it (for updates) + sha, err := OptionalParam[string](request, "sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if sha != "" { + opts.SHA = github.Ptr(sha) + } + + // Create or update the file + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create/update file", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + 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/update file: %s", string(body))), nil + } + + r, err := json.Marshal(fileContent) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CreateRepository creates a tool to create a new GitHub repository. +func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_repository", + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("description", + mcp.Description("Repository description"), + ), + mcp.WithString("organization", + mcp.Description("Organization to create the repository in (omit to create in your personal account)"), + ), + mcp.WithBoolean("private", + mcp.Description("Whether repo should be private"), + ), + mcp.WithBoolean("autoInit", + mcp.Description("Initialize with README"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + organization, err := OptionalParam[string](request, "organization") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + private, err := OptionalParam[bool](request, "private") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + autoInit, err := OptionalParam[bool](request, "autoInit") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo := &github.Repository{ + Name: github.Ptr(name), + Description: github.Ptr(description), + Private: github.Ptr(private), + AutoInit: github.Ptr(autoInit), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create repository", + resp, + err, + ), nil + } + 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 repository: %s", string(body))), nil + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", createdRepo.GetID()), + URL: createdRepo.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 + } +} + +// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. +func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_file_contents", + mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("path", + mcp.Description("Path to file/directory (directories must end with a slash '/')"), + mcp.DefaultString("/"), + ), + mcp.WithString("ref", + mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), + ), + mcp.WithString("sha", + mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), + ), + ), + 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 + } + path, err := RequiredParam[string](request, "path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := OptionalParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sha, err := OptionalParam[string](request, "sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError("failed to get GitHub client"), nil + } + + rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil + } + + // If the path is (most likely) not to be a directory, we will + // first try to get the raw content from the GitHub raw content API. + + var rawAPIResponseCode int + if path != "" && !strings.HasSuffix(path, "/") { + // First, get file info from Contents API to retrieve SHA + var fileSHA string + opts := &github.RepositoryContentGetOptions{Ref: ref} + fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if respContents != nil { + defer func() { _ = respContents.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get file SHA", + respContents, + err, + ), nil + } + if fileContent == nil || fileContent.SHA == nil { + return mcp.NewToolResultError("file content SHA is nil"), nil + } + fileSHA = *fileContent.SHA + + rawClient, err := getRawClient(ctx) + if err != nil { + return mcp.NewToolResultError("failed to get GitHub raw content client"), nil + } + resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) + if err != nil { + return mcp.NewToolResultError("failed to get raw repository content"), nil + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusOK { + // If the raw content is found, return it directly + body, err := io.ReadAll(resp.Body) + if err != nil { + return mcp.NewToolResultError("failed to read response body"), nil + } + contentType := resp.Header.Get("Content-Type") + + var resourceURI string + switch { + case sha != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) + if err != nil { + return nil, fmt.Errorf("failed to create resource URI: %w", err) + } + case ref != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) + if err != nil { + return nil, fmt.Errorf("failed to create resource URI: %w", err) + } + default: + resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) + if err != nil { + return nil, fmt.Errorf("failed to create resource URI: %w", err) + } + } + + // Determine if content is text or binary + isTextContent := strings.HasPrefix(contentType, "text/") || + contentType == "application/json" || + contentType == "application/xml" || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "+xml") + + if isTextContent { + result := mcp.TextResourceContents{ + URI: resourceURI, + Text: string(body), + MIMEType: contentType, + } + // Include SHA in the result metadata + if fileSHA != "" { + return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil + } + return mcp.NewToolResultResource("successfully downloaded text file", result), nil + } + + result := mcp.BlobResourceContents{ + URI: resourceURI, + Blob: base64.StdEncoding.EncodeToString(body), + MIMEType: contentType, + } + // Include SHA in the result metadata + if fileSHA != "" { + return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil + } + return mcp.NewToolResultResource("successfully downloaded binary file", result), nil + } + rawAPIResponseCode = resp.StatusCode + } + + if rawOpts.SHA != "" { + ref = rawOpts.SHA + } + if strings.HasSuffix(path, "/") { + opts := &github.RepositoryContentGetOptions{Ref: ref} + _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if err == nil && resp.StatusCode == http.StatusOK { + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(dirContent) + if err != nil { + return mcp.NewToolResultError("failed to marshal response"), nil + } + return mcp.NewToolResultText(string(r)), nil + } + } + + // The path does not point to a file or directory. + // Instead let's try to find it in the Git Tree by matching the end of the path. + + // Step 1: Get Git Tree recursively + tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil + } + resolvedRefs, err := json.Marshal(rawOpts) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil + } + return mcp.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil + } + + return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil + } +} + +// ForkRepository creates a tool to fork a repository. +func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("fork_repository", + mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("organization", + mcp.Description("Organization to fork to"), + ), + ), + 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 + } + org, err := OptionalParam[string](request, "organization") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.RepositoryCreateForkOptions{} + if org != "" { + opts.Organization = org + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + forkedRepo, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) + if err != nil { + // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, + // and it's not a real error. + if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { + return mcp.NewToolResultText("Fork is in progress"), nil + } + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to fork repository", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusAccepted { + 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 fork repository: %s", string(body))), nil + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", forkedRepo.GetID()), + URL: forkedRepo.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 + } +} + +// DeleteFile creates a tool to delete a file in a GitHub repository. +// This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. +// This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, +// unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. +// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, +// both of which suit an LLM well. +func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_file", + mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), + ReadOnlyHint: ToBoolPtr(false), + DestructiveHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("Path to the file to delete"), + ), + mcp.WithString("message", + mcp.Required(), + mcp.Description("Commit message"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("Branch to delete the file from"), + ), + ), + 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 + } + path, err := RequiredParam[string](request, "path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + message, err := RequiredParam[string](request, "message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + branch, err := RequiredParam[string](request, "branch") + 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) + } + + // Get the reference for the branch + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return nil, fmt.Errorf("failed to get branch reference: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Get the commit object that the branch points to + baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + 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 get commit: %s", string(body))), nil + } + + // Create a tree entry for the file deletion by setting SHA to nil + treeEntries := []*github.TreeEntry{ + { + Path: github.Ptr(path), + Mode: github.Ptr("100644"), // Regular file mode + Type: github.Ptr("blob"), + SHA: nil, // Setting SHA to nil deletes the file + }, + } + + // Create a new tree with the deletion + newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create tree", + resp, + err, + ), nil + } + 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 tree: %s", string(body))), nil + } + + // Create a new commit with the new tree + commit := github.Commit{ + Message: github.Ptr(message), + Tree: newTree, + Parents: []*github.Commit{{SHA: baseCommit.SHA}}, + } + newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create commit", + resp, + err, + ), nil + } + 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 commit: %s", string(body))), nil + } + + // Update the branch reference to point to the new commit + ref.Object.SHA = newCommit.SHA + _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ + SHA: *newCommit.SHA, + Force: github.Ptr(false), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update reference", + 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 reference: %s", string(body))), nil + } + + // Create a response similar to what the DeleteFile API would return + response := map[string]interface{}{ + "commit": newCommit, + "content": nil, + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// CreateBranch creates a tool to create a new branch. +func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_branch", + mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("Name for new branch"), + ), + mcp.WithString("from_branch", + mcp.Description("Source branch (defaults to repo default)"), + ), + ), + 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 + } + branch, err := RequiredParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fromBranch, err := OptionalParam[string](request, "from_branch") + 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) + } + + // Get the source branch SHA + var ref *github.Reference + + if fromBranch == "" { + // Get default branch if from_branch not specified + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get repository", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + fromBranch = *repository.DefaultBranch + } + + // Get SHA of source branch + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get reference", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Create new branch + newRef := github.CreateRef{ + Ref: "refs/heads/" + branch, + SHA: *ref.Object.SHA, + } + + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create branch", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(createdRef) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. +func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("push_files", + mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("Branch to push to"), + ), + mcp.WithArray("files", + mcp.Required(), + mcp.Items( + map[string]interface{}{ + "type": "object", + "additionalProperties": false, + "required": []string{"path", "content"}, + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "path to the file", + }, + "content": map[string]interface{}{ + "type": "string", + "description": "file content", + }, + }, + }), + mcp.Description("Array of file objects to push, each object with path (string) and content (string)"), + ), + mcp.WithString("message", + mcp.Required(), + mcp.Description("Commit message"), + ), + ), + 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 + } + branch, err := RequiredParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + message, err := RequiredParam[string](request, "message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Parse files parameter - this should be an array of objects with path and content + filesObj, ok := request.GetArguments()["files"].([]interface{}) + if !ok { + return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the reference for the branch + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch reference", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Get the commit object that the branch points to + baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Create tree entries for all files + var entries []*github.TreeEntry + + for _, file := range filesObj { + fileMap, ok := file.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("each file must be an object with path and content"), nil + } + + path, ok := fileMap["path"].(string) + if !ok || path == "" { + return mcp.NewToolResultError("each file must have a path"), nil + } + + content, ok := fileMap["content"].(string) + if !ok { + return mcp.NewToolResultError("each file must have content"), nil + } + + // Create a tree entry for the file + entries = append(entries, &github.TreeEntry{ + Path: github.Ptr(path), + Mode: github.Ptr("100644"), // Regular file mode + Type: github.Ptr("blob"), + Content: github.Ptr(content), + }) + } + + // Create a new tree with the file entries + newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create tree", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Create a new commit + commit := github.Commit{ + Message: github.Ptr(message), + Tree: newTree, + Parents: []*github.Commit{{SHA: baseCommit.SHA}}, + } + newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create commit", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Update the reference to point to the new commit + ref.Object.SHA = newCommit.SHA + updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ + SHA: *newCommit.SHA, + Force: github.Ptr(false), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update reference", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(updatedRef) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListTags creates a tool to list tags in a GitHub repository. +func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_tags", + mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + 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 + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &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) + } + + tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list tags", + 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 tags: %s", string(body))), nil + } + + r, err := json.Marshal(tags) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetTag creates a tool to get details about a specific tag in a GitHub repository. +func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_tag", + mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("tag", + mcp.Required(), + mcp.Description("Tag name"), + ), + ), + 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 + } + tag, err := RequiredParam[string](request, "tag") + 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) + } + + // First get the tag reference + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get tag reference", + 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 get tag reference: %s", string(body))), nil + } + + // Then get the tag object + tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get tag object", + 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 get tag object: %s", string(body))), nil + } + + r, err := json.Marshal(tagObj) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListReleases creates a tool to list releases in a GitHub repository. +func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_releases", + mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + 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 + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &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) + } + + releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %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 list releases: %s", string(body))), nil + } + + r, err := json.Marshal(releases) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetLatestRelease creates a tool to get the latest release in a GitHub repository. +func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_latest_release", + mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %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 latest release: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_release_by_tag", + mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("tag", + mcp.Required(), + mcp.Description("Tag name (e.g., 'v1.0.0')"), + ), + ), + 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 + } + tag, err := RequiredParam[string](request, "tag") + 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) + } + + release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get release by tag: %s", tag), + 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 get release by tag: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// filterPaths filters the entries in a GitHub tree to find paths that +// match the given suffix. +// maxResults limits the number of results returned to first maxResults entries, +// a maxResults of -1 means no limit. +// It returns a slice of strings containing the matching paths. +// Directories are returned with a trailing slash. +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { + // Remove trailing slash for matching purposes, but flag whether we + // only want directories. + dirOnly := false + if strings.HasSuffix(path, "/") { + dirOnly = true + path = strings.TrimSuffix(path, "/") + } + + matchedPaths := []string{} + for _, entry := range entries { + if len(matchedPaths) == maxResults { + break // Limit the number of results to maxResults + } + if dirOnly && entry.GetType() != "tree" { + continue // Skip non-directory entries if dirOnly is true + } + entryPath := entry.GetPath() + if entryPath == "" { + continue // Skip empty paths + } + if strings.HasSuffix(entryPath, path) { + if entry.GetType() == "tree" { + entryPath += "/" // Return directories with a trailing slash + } + matchedPaths = append(matchedPaths, entryPath) + } + } + return matchedPaths +} + +// resolveGitReference takes a user-provided ref and sha and resolves them into a +// definitive commit SHA and its corresponding fully-qualified reference. +// +// The resolution logic follows a clear priority: +// +// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// and all reference resolution is skipped. +// +// 2. If no `sha` is provided, the function resolves the `ref` +// string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { + // 1) If SHA explicitly provided, it's the highest priority. + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, nil + } + + originalRef := ref // Keep original ref for clearer error messages down the line. + + // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. + var reference *github.Reference + var resp *github.Response + var err error + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) + case strings.HasPrefix(originalRef, "refs/"): + // 2b) Already fully qualified. The reference will be fetched at the end. + case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): + // 2c) Partially qualified. Make it fully qualified. + ref = "refs/" + originalRef + default: + // 2d) It's a short name, so we try to resolve it to either a branch or a tag. + branchRef := "refs/heads/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + + if err == nil { + ref = branchRef // It's a branch. + } else { + // The branch lookup failed. Check if it was a 404 Not Found error. + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { + tagRef := "refs/tags/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) + if err == nil { + ref = tagRef // It's a tag. + } else { + // The tag lookup also failed. Check if it was a 404 Not Found error. + ghErr2, isGhErr2 := err.(*github.ErrorResponse) + if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + } + // The tag lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) + return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + } + } else { + // The branch lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) + return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + } + } + } + + if reference == nil { + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } + } + + sha = reference.GetObject().GetSHA() + return &raw.ContentOpts{Ref: ref, SHA: sha}, nil +} + +// ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. +func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_starred_repositories", + mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("username", + mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."), + ), + mcp.WithString("sort", + mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."), + mcp.Enum("created", "updated"), + ), + mcp.WithString("direction", + mcp.Description("The direction to sort the results by."), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := OptionalParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ActivityListStarredOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + if sort != "" { + opts.Sort = sort + } + if direction != "" { + opts.Direction = direction + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var repos []*github.StarredRepository + var resp *github.Response + if username == "" { + // List starred repositories for the authenticated user + repos, resp, err = client.Activity.ListStarred(ctx, "", opts) + } else { + // List starred repositories for a specific user + repos, resp, err = client.Activity.ListStarred(ctx, username, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list starred repositories for user '%s'", username), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 starred repositories: %s", string(body))), nil + } + + // Convert to minimal format + minimalRepos := make([]MinimalRepository, 0, len(repos)) + for _, starredRepo := range repos { + repo := starredRepo.Repository + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + + minimalRepos = append(minimalRepos, minimalRepo) + } + + r, err := json.Marshal(minimalRepos) + if err != nil { + return nil, fmt.Errorf("failed to marshal starred repositories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// StarRepository creates a tool to star a repository. +func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("star_repository", + mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Activity.Star(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to star repository %s/%s", owner, repo), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 204 { + 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 star repository: %s", string(body))), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil + } +} + +// UnstarRepository creates a tool to unstar a repository. +func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("unstar_repository", + mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Activity.Unstar(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to unstar repository %s/%s", owner, repo), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 204 { + 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 unstar repository: %s", string(body))), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil + } +} diff --git a/.tools-to-be-migrated/repositories_test.go b/.tools-to-be-migrated/repositories_test.go new file mode 100644 index 000000000..665af6b0a --- /dev/null +++ b/.tools-to-be-migrated/repositories_test.go @@ -0,0 +1,3414 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetFileContents(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + mockRawClient := raw.NewClient(mockClient, &url.URL{Scheme: "https", Host: "raw.githubusercontent.com", Path: "/"}) + tool, _ := GetFileContents(stubGetClientFn(mockClient), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_file_contents", 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, "path") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "sha") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Mock response for raw content + mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") + + // Setup mock directory content for success case + mockDirContent := []*github.RepositoryContent{ + { + Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Size: github.Ptr(42), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), + }, + { + Type: github.Ptr("dir"), + Name: github.Ptr("src"), + Path: github.Ptr("src"), + SHA: github.Ptr("def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult interface{} + expectedErrMsg string + expectStatus int + }{ + { + name: "successful text content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoByBranchByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.TextResourceContents{ + URI: "repo://owner/repo/refs/heads/main/contents/README.md", + Text: "# Test Repository\n\nThis is a test repository.", + MIMEType: "text/markdown", + }, + }, + { + name: "successful file blob content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoByBranchByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(mockRawContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "test.png", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.BlobResourceContents{ + URI: "repo://owner/repo/refs/heads/main/contents/test.png", + Blob: base64.StdEncoding.EncodeToString(mockRawContent), + MIMEType: "image/png", + }, + }, + { + name: "successful PDF file content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoByBranchByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write(mockRawContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "document.pdf", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.BlobResourceContents{ + URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", + Blob: base64.StdEncoding.EncodeToString(mockRawContent), + MIMEType: "application/pdf", + }, + }, + { + name: "successful directory content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, mockDirContent), + ), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoByPath, + expectQueryParams(t, map[string]string{ + "branch": "main", + }).andThen( + mockResponse(t, http.StatusNotFound, nil), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "src/", + }, + expectError: false, + expectedResult: mockDirContent, + }, + { + name: "content fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + mock.WithRequestMatchHandler( + raw.GetRawReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "nonexistent.md", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + _, handler := GetFileContents(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + // Use the correct result helper based on the expected type + switch expected := tc.expectedResult.(type) { + case mcp.TextResourceContents: + textResource := getTextResourceResult(t, result) + assert.Equal(t, expected, textResource) + case mcp.BlobResourceContents: + blobResource := getBlobResourceResult(t, result) + assert.Equal(t, expected, blobResource) + case []*github.RepositoryContent: + // Directory content fetch returns a text result (JSON array) + textContent := getTextResult(t, result) + var returnedContents []*github.RepositoryContent + err = json.Unmarshal([]byte(textContent.Text), &returnedContents) + require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text) + assert.Len(t, returnedContents, len(expected)) + for i, content := range returnedContents { + assert.Equal(t, *expected[i].Name, *content.Name) + assert.Equal(t, *expected[i].Path, *content.Path) + assert.Equal(t, *expected[i].Type, *content.Type) + } + case mcp.TextContent: + textContent := getErrorResult(t, result) + require.Equal(t, textContent, expected) + } + }) + } +} + +func Test_ForkRepository(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ForkRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "fork_repository", 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, "organization") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock forked repo for success case + mockForkedRepo := &github.Repository{ + ID: github.Ptr(int64(123456)), + Name: github.Ptr("repo"), + FullName: github.Ptr("new-owner/repo"), + Owner: &github.User{ + Login: github.Ptr("new-owner"), + }, + HTMLURL: github.Ptr("https://github.com/new-owner/repo"), + DefaultBranch: github.Ptr("main"), + Fork: github.Ptr(true), + ForksCount: github.Ptr(0), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedRepo *github.Repository + expectedErrMsg string + }{ + { + name: "successful repository fork", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposForksByOwnerByRepo, + mockResponse(t, http.StatusAccepted, mockForkedRepo), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedRepo: mockForkedRepo, + }, + { + name: "repository fork fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposForksByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to fork repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ForkRepository(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + assert.Contains(t, textContent.Text, "Fork is in progress") + }) + } +} + +func Test_CreateBranch(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreateBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_branch", 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, "branch") + assert.Contains(t, tool.InputSchema.Properties, "from_branch") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch"}) + + // Setup mock repository for default branch test + mockRepo := &github.Repository{ + DefaultBranch: github.Ptr("main"), + } + + // Setup mock reference for from_branch tests + mockSourceRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123def456"), + }, + } + + // Setup mock created reference + mockCreatedRef := &github.Reference{ + Ref: github.Ptr("refs/heads/new-feature"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123def456"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedRef *github.Reference + expectedErrMsg string + }{ + { + name: "successful branch creation with from_branch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockSourceRef, + ), + mock.WithRequestMatch( + mock.PostReposGitRefsByOwnerByRepo, + mockCreatedRef, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "new-feature", + "from_branch": "main", + }, + expectError: false, + expectedRef: mockCreatedRef, + }, + { + name: "successful branch creation with default branch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + mockRepo, + ), + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockSourceRef, + ), + mock.WithRequestMatchHandler( + mock.PostReposGitRefsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "ref": "refs/heads/new-feature", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusCreated, mockCreatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "new-feature", + }, + expectError: false, + expectedRef: mockCreatedRef, + }, + { + name: "fail to get repository", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "branch": "new-feature", + }, + expectError: true, + expectedErrMsg: "failed to get repository", + }, + { + name: "fail to get reference", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "new-feature", + "from_branch": "nonexistent-branch", + }, + expectError: true, + expectedErrMsg: "failed to get reference", + }, + { + name: "fail to create branch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockSourceRef, + ), + mock.WithRequestMatchHandler( + mock.PostReposGitRefsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "existing-branch", + "from_branch": "main", + }, + expectError: true, + expectedErrMsg: "failed to create branch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateBranch(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedRef github.Reference + err = json.Unmarshal([]byte(textContent.Text), &returnedRef) + require.NoError(t, err) + assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) + assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) + }) + } +} + +func Test_GetCommit(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_commit", 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, "sha") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"}) + + mockCommit := &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("First commit"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, + }, + }, + Author: &github.User{ + Login: github.Ptr("testuser"), + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Total: github.Ptr(12), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("file1.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Changes: github.Ptr(12), + Patch: github.Ptr("@@ -1,2 +1,10 @@"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedCommit *github.RepositoryCommit + expectedErrMsg string + }{ + { + name: "successful commit fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCommitsByOwnerByRepoByRef, + mockResponse(t, http.StatusOK, mockCommit), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "sha": "abc123def456", + }, + expectError: false, + expectedCommit: mockCommit, + }, + { + name: "commit fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCommitsByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "sha": "nonexistent-sha", + }, + expectError: true, + expectedErrMsg: "failed to get commit", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedCommit github.RepositoryCommit + err = json.Unmarshal([]byte(textContent.Text), &returnedCommit) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA) + assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message) + assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login) + assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL) + }) + } +} + +func Test_ListCommits(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_commits", 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, "sha") + assert.Contains(t, tool.InputSchema.Properties, "author") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock commits for success case + mockCommits := []*github.RepositoryCommit{ + { + SHA: github.Ptr("abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("First commit"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, + }, + }, + Author: &github.User{ + Login: github.Ptr("testuser"), + ID: github.Ptr(int64(12345)), + HTMLURL: github.Ptr("https://github.com/testuser"), + AvatarURL: github.Ptr("https://github.com/testuser.png"), + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(5), + Total: github.Ptr(15), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/main.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(8), + Deletions: github.Ptr(3), + Changes: github.Ptr(11), + }, + { + Filename: github.Ptr("README.md"), + Status: github.Ptr("added"), + Additions: github.Ptr(2), + Deletions: github.Ptr(2), + Changes: github.Ptr(4), + }, + }, + }, + { + SHA: github.Ptr("def456abc789"), + Commit: &github.Commit{ + Message: github.Ptr("Second commit"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Another User"), + Email: github.Ptr("another@example.com"), + Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + }, + }, + Author: &github.User{ + Login: github.Ptr("anotheruser"), + ID: github.Ptr(int64(67890)), + HTMLURL: github.Ptr("https://github.com/anotheruser"), + AvatarURL: github.Ptr("https://github.com/anotheruser.png"), + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + Stats: &github.CommitStats{ + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Total: github.Ptr(30), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/utils.go"), + Status: github.Ptr("added"), + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Changes: github.Ptr(30), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedCommits []*github.RepositoryCommit + expectedErrMsg string + }{ + { + name: "successful commits fetch with default params", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposCommitsByOwnerByRepo, + mockCommits, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with branch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCommitsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "author": "username", + "sha": "main", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "sha": "main", + "author": "username", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCommitsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "commits fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposCommitsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + expectedErrMsg: "failed to list commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListCommits(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedCommits []MinimalCommit + err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) + require.NoError(t, err) + assert.Len(t, returnedCommits, len(tc.expectedCommits)) + for i, commit := range returnedCommits { + assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) + assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) + if tc.expectedCommits[i].Commit != nil { + assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) + } + if tc.expectedCommits[i].Author != nil { + assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) + } + + // Files and stats are never included in list_commits + assert.Nil(t, commit.Files) + assert.Nil(t, commit.Stats) + } + }) + } +} + +func Test_CreateOrUpdateFile(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_or_update_file", 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, "path") + assert.Contains(t, tool.InputSchema.Properties, "content") + assert.Contains(t, tool.InputSchema.Properties, "message") + assert.Contains(t, tool.InputSchema.Properties, "branch") + assert.Contains(t, tool.InputSchema.Properties, "sha") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) + + // Setup mock file content response + mockFileResponse := &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{ + Name: github.Ptr("example.md"), + Path: github.Ptr("docs/example.md"), + SHA: github.Ptr("abc123def456"), + Size: github.Ptr(42), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), + }, + Commit: github.Commit{ + SHA: github.Ptr("def456abc789"), + Message: github.Ptr("Add example file"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Date: &github.Timestamp{Time: time.Now()}, + }, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedContent *github.RepositoryContentResponse + expectedErrMsg string + }{ + { + name: "successful file creation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Example\n\nThis is an example file.", + "message": "Add example file", + "branch": "main", + }, + expectError: false, + expectedContent: mockFileResponse, + }, + { + name: "successful file update with SHA", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Updated Example\n\nThis file has been updated.", + "message": "Update example file", + "branch": "main", + "sha": "abc123def456", + }, + expectError: false, + expectedContent: mockFileResponse, + }, + { + name: "file creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "#Invalid Content", + "message": "Invalid request", + "branch": "nonexistent-branch", + }, + expectError: true, + expectedErrMsg: "failed to create/update file", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateOrUpdateFile(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedContent github.RepositoryContentResponse + err = json.Unmarshal([]byte(textContent.Text), &returnedContent) + require.NoError(t, err) + + // Verify content + assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) + assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) + assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) + + // Verify commit + assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) + assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) + }) + } +} + +func Test_CreateRepository(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreateRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_repository", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "name") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "organization") + assert.Contains(t, tool.InputSchema.Properties, "private") + assert.Contains(t, tool.InputSchema.Properties, "autoInit") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) + + // Setup mock repository response + mockRepo := &github.Repository{ + Name: github.Ptr("test-repo"), + Description: github.Ptr("Test repository"), + Private: github.Ptr(true), + HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), + CreatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedRepo *github.Repository + expectedErrMsg string + }{ + { + name: "successful repository creation with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/repos", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": true, + "auto_init": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": true, + "autoInit": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "successful repository creation in organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/repos", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": false, + "auto_init": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "organization": "testorg", + "private": false, + "autoInit": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "successful repository creation with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/repos", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "auto_init": false, + "description": "", + "private": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "repository creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/user/repos", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "name": "invalid-repo", + }, + expectError: true, + expectedErrMsg: "failed to create repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateRepository(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var returnedRepo MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) + assert.NoError(t, err) + + // Verify repository details + assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) + }) + } +} + +func Test_PushFiles(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "push_files", 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, "branch") + assert.Contains(t, tool.InputSchema.Properties, "files") + assert.Contains(t, tool.InputSchema.Properties, "message") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch", "files", "message"}) + + // Setup mock objects + mockRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), + }, + } + + mockCommit := &github.Commit{ + SHA: github.Ptr("abc123"), + Tree: &github.Tree{ + SHA: github.Ptr("def456"), + }, + } + + mockTree := &github.Tree{ + SHA: github.Ptr("ghi789"), + } + + mockNewCommit := &github.Commit{ + SHA: github.Ptr("jkl012"), + Message: github.Ptr("Update multiple files"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + } + + mockUpdatedRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("jkl012"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), + }, + } + + // Define test cases + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedRef *github.Reference + expectedErrMsg string + }{ + { + name: "successful push of multiple files", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Create tree + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "def456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "mode": "100644", + "type": "blob", + "content": "# Updated README\n\nThis is an updated README file.", + }, + map[string]interface{}{ + "path": "docs/example.md", + "mode": "100644", + "type": "blob", + "content": "# Example\n\nThis is an example file.", + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit + mock.WithRequestMatchHandler( + mock.PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Update multiple files", + "tree": "ghi789", + "parents": []interface{}{"abc123"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + mock.WithRequestMatchHandler( + mock.PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Updated README\n\nThis is an updated README file.", + }, + map[string]interface{}{ + "path": "docs/example.md", + "content": "# Example\n\nThis is an example file.", + }, + }, + "message": "Update multiple files", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "fails when files parameter is invalid", + mockedClient: mock.NewMockedHTTPClient( + // No requests expected + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": "invalid-files-parameter", // Not an array + "message": "Update multiple files", + }, + expectError: false, // This returns a tool error, not a Go error + expectedErrMsg: "files parameter must be an array", + }, + { + name: "fails when files contains object without path", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "content": "# Missing path", + }, + }, + "message": "Update file", + }, + expectError: false, // This returns a tool error, not a Go error + expectedErrMsg: "each file must have a path", + }, + { + name: "fails when files contains object without content", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + // Missing content + }, + }, + "message": "Update file", + }, + expectError: false, // This returns a tool error, not a Go error + expectedErrMsg: "each file must have content", + }, + { + name: "fails to get branch reference", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + mockResponse(t, http.StatusNotFound, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "non-existent-branch", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Update file", + }, + expectError: true, + expectedErrMsg: "failed to get branch reference", + }, + { + name: "fails to get base commit", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Fail to get commit + mock.WithRequestMatchHandler( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockResponse(t, http.StatusNotFound, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Update file", + }, + expectError: true, + expectedErrMsg: "failed to get base commit", + }, + { + name: "fails to create tree", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Fail to create tree + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Update file", + }, + expectError: true, + expectedErrMsg: "failed to create tree", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := PushFiles(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedRef github.Reference + err = json.Unmarshal([]byte(textContent.Text), &returnedRef) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) + assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) + }) + } +} + +func Test_ListBranches(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_branches", 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, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock branches for success case + mockBranches := []*github.Branch{ + { + Name: github.Ptr("main"), + Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, + }, + { + Name: github.Ptr("develop"), + Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, + }, + } + + // Test cases + tests := []struct { + name string + args map[string]interface{} + mockResponses []mock.MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + }, + mockResponses: []mock.MockBackendOption{ + mock.WithRequestMatch( + mock.GetReposBranchesByOwnerByRepo, + mockBranches, + ), + }, + wantErr: false, + }, + { + name: "missing owner", + args: map[string]interface{}{ + "repo": "repo", + }, + mockResponses: []mock.MockBackendOption{}, + wantErr: false, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]interface{}{ + "owner": "owner", + }, + mockResponses: []mock.MockBackendOption{}, + wantErr: false, + errContains: "missing required parameter: repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock client + mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) + _, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + // Create request + request := createMCPRequest(tt.args) + + // Call handler + result, err := handler(context.Background(), request) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, result) + + if tt.errContains != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.errContains) + return + } + + textContent := getTextResult(t, result) + require.NotEmpty(t, textContent.Text) + + // Verify response + var branches []*github.Branch + err = json.Unmarshal([]byte(textContent.Text), &branches) + require.NoError(t, err) + assert.Len(t, branches, 2) + assert.Equal(t, "main", *branches[0].Name) + assert.Equal(t, "develop", *branches[1].Name) + }) + } +} + +func Test_DeleteFile(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_file", 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, "path") + assert.Contains(t, tool.InputSchema.Properties, "message") + assert.Contains(t, tool.InputSchema.Properties, "branch") + // SHA is no longer required since we're using Git Data API + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"}) + + // Setup mock objects for Git Data API + mockRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123"), + }, + } + + mockCommit := &github.Commit{ + SHA: github.Ptr("abc123"), + Tree: &github.Tree{ + SHA: github.Ptr("def456"), + }, + } + + mockTree := &github.Tree{ + SHA: github.Ptr("ghi789"), + } + + mockNewCommit := &github.Commit{ + SHA: github.Ptr("jkl012"), + Message: github.Ptr("Delete example file"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedCommitSHA string + expectedErrMsg string + }{ + { + name: "successful file deletion using Git Data API", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Create tree + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "def456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "docs/example.md", + "mode": "100644", + "type": "blob", + "sha": nil, + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit + mock.WithRequestMatchHandler( + mock.PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Delete example file", + "tree": "ghi789", + "parents": []interface{}{"abc123"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + mock.WithRequestMatchHandler( + mock.PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("jkl012"), + }, + }), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "message": "Delete example file", + "branch": "main", + }, + expectError: false, + expectedCommitSHA: "jkl012", + }, + { + name: "file deletion fails - branch not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/nonexistent.md", + "message": "Delete nonexistent file", + "branch": "nonexistent-branch", + }, + expectError: true, + expectedErrMsg: "failed to get branch reference", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), 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 response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify the response contains the expected commit + commit, ok := response["commit"].(map[string]interface{}) + require.True(t, ok) + commitSHA, ok := commit["sha"].(string) + require.True(t, ok) + assert.Equal(t, tc.expectedCommitSHA, commitSHA) + }) + } +} + +func Test_ListTags(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_tags", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock tags for success case + mockTags := []*github.RepositoryTag{ + { + Name: github.Ptr("v1.0.0"), + Commit: &github.Commit{ + SHA: github.Ptr("v1.0.0-tag-sha"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), + }, + ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), + TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), + }, + { + Name: github.Ptr("v0.9.0"), + Commit: &github.Commit{ + SHA: github.Ptr("v0.9.0-tag-sha"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), + }, + ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), + TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTags []*github.RepositoryTag + expectedErrMsg string + }{ + { + name: "successful tags list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposTagsByOwnerByRepo, + expectPath( + t, + "/repos/owner/repo/tags", + ).andThen( + mockResponse(t, http.StatusOK, mockTags), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedTags: mockTags, + }, + { + name: "list tags fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposTagsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list tags", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse and verify the result + var returnedTags []*github.RepositoryTag + err = json.Unmarshal([]byte(textContent.Text), &returnedTags) + require.NoError(t, err) + + // Verify each tag + require.Equal(t, len(tc.expectedTags), len(returnedTags)) + for i, expectedTag := range tc.expectedTags { + assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) + assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) + } + }) + } +} + +func Test_GetTag(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_tag", 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, "tag") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + + mockTagRef := &github.Reference{ + Ref: github.Ptr("refs/tags/v1.0.0"), + Object: &github.GitObject{ + SHA: github.Ptr("v1.0.0-tag-sha"), + }, + } + + mockTagObj := &github.Tag{ + SHA: github.Ptr("v1.0.0-tag-sha"), + Tag: github.Ptr("v1.0.0"), + Message: github.Ptr("Release v1.0.0"), + Object: &github.GitObject{ + Type: github.Ptr("commit"), + SHA: github.Ptr("abc123"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTag *github.Tag + expectedErrMsg string + }{ + { + name: "successful tag retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + expectPath( + t, + "/repos/owner/repo/git/ref/tags/v1.0.0", + ).andThen( + mockResponse(t, http.StatusOK, mockTagRef), + ), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTagsByOwnerByRepoByTagSha, + expectPath( + t, + "/repos/owner/repo/git/tags/v1.0.0-tag-sha", + ).andThen( + mockResponse(t, http.StatusOK, mockTagObj), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, + expectedTag: mockTagObj, + }, + { + name: "tag reference not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: true, + expectedErrMsg: "failed to get tag reference", + }, + { + name: "tag object not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockTagRef, + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTagsByOwnerByRepoByTagSha, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: true, + expectedErrMsg: "failed to get tag object", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse and verify the result + var returnedTag github.Tag + err = json.Unmarshal([]byte(textContent.Text), &returnedTag) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) + assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) + assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) + assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) + assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) + }) + } +} + +func Test_ListReleases(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_releases", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockReleases := []*github.RepositoryRelease{ + { + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + }, + { + ID: github.Ptr(int64(2)), + TagName: github.Ptr("v0.9.0"), + Name: github.Ptr("Beta Release"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult []*github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful releases list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesByOwnerByRepo, + mockReleases, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockReleases, + }, + { + name: "releases list fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list releases", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedReleases []*github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) + require.NoError(t, err) + assert.Len(t, returnedReleases, len(tc.expectedResult)) + for i, rel := range returnedReleases { + assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) + } + }) + } +} +func Test_GetLatestRelease(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_latest_release", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful latest release fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesLatestByOwnerByRepo, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "latest release fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to get latest release", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + }) + } +} + +func Test_GetReleaseByTag(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_release_by_tag", 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, "tag") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("Release v1.0.0"), + Body: github.Ptr("This is the first stable release."), + Assets: []*github.ReleaseAsset{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("release-v1.0.0.tar.gz"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful release by tag fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing tag parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: tag", + }, + { + name: "release by tag not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v999.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v999.0.0", + }, + { + name: "server error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v1.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) + if tc.expectedResult.Body != nil { + assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) + } + if len(tc.expectedResult.Assets) > 0 { + require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) + assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) + } + }) + } +} + +func Test_filterPaths(t *testing.T) { + tests := []struct { + name string + tree []*github.TreeEntry + path string + maxResults int + expected []string + }{ + { + name: "file name", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + }, + path: "foo.txt", + maxResults: -1, + expected: []string{"folder/foo.txt", "nested/folder/foo.txt"}, + }, + { + name: "dir name", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + }, + path: "folder/", + maxResults: -1, + expected: []string{"folder/", "nested/folder/"}, + }, + { + name: "dir and file match", + tree: []*github.TreeEntry{ + {Path: github.Ptr("name"), Type: github.Ptr("tree")}, + {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + }, + path: "name", // No trailing slash can match both files and directories + maxResults: -1, + expected: []string{"name/", "name"}, + }, + { + name: "dir only match", + tree: []*github.TreeEntry{ + {Path: github.Ptr("name"), Type: github.Ptr("tree")}, + {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + }, + path: "name/", // Trialing slash ensures only directories are matched + maxResults: -1, + expected: []string{"name/"}, + }, + { + name: "max results limit 2", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 2, + expected: []string{"folder/", "nested/folder/"}, + }, + { + name: "max results limit 1", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 1, + expected: []string{"folder/"}, + }, + { + name: "max results limit 0", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 0, + expected: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := filterPaths(tc.tree, tc.path, tc.maxResults) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_resolveGitReference(t *testing.T) { + ctx := context.Background() + owner := "owner" + repo := "repo" + + tests := []struct { + name string + ref string + sha string + mockSetup func() *http.Client + expectedOutput *raw.ContentOpts + expectError bool + errorContains string + }{ + { + name: "sha takes precedence over ref", + ref: "refs/heads/main", + sha: "123sha456", + mockSetup: func() *http.Client { + // No API calls should be made when SHA is provided + return mock.NewMockedHTTPClient() + }, + expectedOutput: &raw.ContentOpts{ + SHA: "123sha456", + }, + expectError: false, + }, + { + name: "use default branch if ref and sha both empty", + ref: "", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/main") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/main", + SHA: "main-sha", + }, + expectError: false, + }, + { + name: "fully qualified ref passed through unchanged", + ref: "refs/heads/feature-branch", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/feature-branch", + SHA: "feature-sha", + }, + expectError: false, + }, + { + name: "short branch name resolves to refs/heads/", + ref: "main", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/git/ref/heads/main") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) + } else { + t.Errorf("Unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/main", + SHA: "main-sha", + }, + expectError: false, + }, + { + name: "short tag name falls back to refs/tags/ when branch not found", + ref: "v1.0.0", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) + default: + t.Errorf("Unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/tags/v1.0.0", + SHA: "tag-sha", + }, + expectError: false, + }, + { + name: "heads/ prefix gets refs/ prepended", + ref: "heads/feature-branch", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/feature-branch", + SHA: "feature-sha", + }, + expectError: false, + }, + { + name: "tags/ prefix gets refs/ prepended", + ref: "tags/v1.0.0", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/tags/v1.0.0", + SHA: "tag-sha", + }, + expectError: false, + }, + { + name: "invalid short name that doesn't exist as branch or tag", + ref: "nonexistent", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Both branch and tag attempts should return 404 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ) + }, + expectError: true, + errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag", + }, + { + name: "fully qualified pull request ref", + ref: "refs/pull/123/head", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/pull/123/head", + SHA: "pr-sha", + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockSetup()) + opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) + + if tc.expectError { + require.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, opts) + + if tc.expectedOutput.SHA != "" { + assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) + } + if tc.expectedOutput.Ref != "" { + assert.Equal(t, tc.expectedOutput.Ref, opts.Ref) + } + }) + } +} + +func Test_ListStarredRepositories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_starred_repositories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "username") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Empty(t, tool.InputSchema.Required) // All parameters are optional + + // Setup mock starred repositories + starredAt := time.Now().Add(-24 * time.Hour) + updatedAt := time.Now().Add(-2 * time.Hour) + mockStarredRepos := []*github.StarredRepository{ + { + StarredAt: &github.Timestamp{Time: starredAt}, + Repository: &github.Repository{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("awesome-repo"), + FullName: github.Ptr("owner/awesome-repo"), + Description: github.Ptr("An awesome repository"), + HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), + Language: github.Ptr("Go"), + StargazersCount: github.Ptr(100), + ForksCount: github.Ptr(25), + OpenIssuesCount: github.Ptr(5), + UpdatedAt: &github.Timestamp{Time: updatedAt}, + Private: github.Ptr(false), + Fork: github.Ptr(false), + Archived: github.Ptr(false), + DefaultBranch: github.Ptr("main"), + }, + }, + { + StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, + Repository: &github.Repository{ + ID: github.Ptr(int64(67890)), + Name: github.Ptr("cool-project"), + FullName: github.Ptr("user/cool-project"), + Description: github.Ptr("A very cool project"), + HTMLURL: github.Ptr("https://github.com/user/cool-project"), + Language: github.Ptr("Python"), + StargazersCount: github.Ptr(500), + ForksCount: github.Ptr(75), + OpenIssuesCount: github.Ptr(10), + UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, + Private: github.Ptr(false), + Fork: github.Ptr(true), + Archived: github.Ptr(false), + DefaultBranch: github.Ptr("master"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedCount int + }{ + { + name: "successful list for authenticated user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, + expectedCount: 2, + }, + { + name: "successful list for specific user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUsersStarredByUsername, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "username": "testuser", + }, + expectError: false, + expectedCount: 2, + }, + { + name: "list fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserStarred, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list starred repositories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListStarredRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NotNil(t, result) + textResult, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "Expected text content") + assert.Contains(t, textResult.Text, tc.expectedErrMsg) + } else { + require.NoError(t, err) + require.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedRepos []MinimalRepository + err = json.Unmarshal([]byte(textContent.Text), &returnedRepos) + require.NoError(t, err) + + assert.Len(t, returnedRepos, tc.expectedCount) + if tc.expectedCount > 0 { + assert.Equal(t, "awesome-repo", returnedRepos[0].Name) + assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName) + } + } + }) + } +} + +func Test_StarRepository(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "star_repository", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful star", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutUserStarredByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + }, + expectError: false, + }, + { + name: "star fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutUserStarredByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to star repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := StarRepository(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NotNil(t, result) + textResult, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "Expected text content") + assert.Contains(t, textResult.Text, tc.expectedErrMsg) + } else { + require.NoError(t, err) + require.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Successfully starred repository") + } + }) + } +} + +func Test_UnstarRepository(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "unstar_repository", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful unstar", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteUserStarredByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + }, + expectError: false, + }, + { + name: "unstar fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteUserStarredByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to unstar repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := UnstarRepository(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NotNil(t, result) + textResult, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "Expected text content") + assert.Contains(t, textResult.Text, tc.expectedErrMsg) + } else { + require.NoError(t, err) + require.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Successfully unstarred repository") + } + }) + } +} + +func Test_GetRepositoryTree(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_repository_tree", 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, "tree_sha") + assert.Contains(t, tool.InputSchema.Properties, "recursive") + assert.Contains(t, tool.InputSchema.Properties, "path_filter") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock data + mockRepo := &github.Repository{ + DefaultBranch: github.Ptr("main"), + } + mockTree := &github.Tree{ + SHA: github.Ptr("abc123"), + Truncated: github.Ptr(false), + Entries: []*github.TreeEntry{ + { + Path: github.Ptr("README.md"), + Mode: github.Ptr("100644"), + Type: github.Ptr("blob"), + SHA: github.Ptr("file1sha"), + Size: github.Ptr(123), + URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"), + }, + { + Path: github.Ptr("src/main.go"), + Mode: github.Ptr("100644"), + Type: github.Ptr("blob"), + SHA: github.Ptr("file2sha"), + Size: github.Ptr(456), + URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successfully get repository tree", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + mockResponse(t, http.StatusOK, mockRepo), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTreesByOwnerByRepoByTreeSha, + mockResponse(t, http.StatusOK, mockTree), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + }, + { + name: "successfully get repository tree with path filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + mockResponse(t, http.StatusOK, mockRepo), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTreesByOwnerByRepoByTreeSha, + mockResponse(t, http.StatusOK, mockTree), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path_filter": "src/", + }, + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to get repository info", + }, + { + name: "tree not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + mockResponse(t, http.StatusOK, mockRepo), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTreesByOwnerByRepoByTreeSha, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to get repository tree", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper) + + // Create the tool request + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } else { + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Parse the JSON response + var treeResponse map[string]interface{} + err := json.Unmarshal([]byte(textContent.Text), &treeResponse) + require.NoError(t, err) + + // Verify response structure + assert.Equal(t, "owner", treeResponse["owner"]) + assert.Equal(t, "repo", treeResponse["repo"]) + assert.Contains(t, treeResponse, "tree") + assert.Contains(t, treeResponse, "count") + assert.Contains(t, treeResponse, "sha") + assert.Contains(t, treeResponse, "truncated") + + // Check filtering if path_filter was provided + if pathFilter, exists := tc.requestArgs["path_filter"]; exists { + tree := treeResponse["tree"].([]interface{}) + for _, entry := range tree { + entryMap := entry.(map[string]interface{}) + path := entryMap["path"].(string) + assert.True(t, strings.HasPrefix(path, pathFilter.(string)), + "Path %s should start with filter %s", path, pathFilter) + } + } + } + }) + } +} diff --git a/.tools-to-be-migrated/search.go b/.tools-to-be-migrated/search.go new file mode 100644 index 000000000..5084773b2 --- /dev/null +++ b/.tools-to-be-migrated/search.go @@ -0,0 +1,365 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// SearchRepositories creates a tool to search for GitHub repositories. +func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_repositories", + mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")), + + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), + ), + mcp.WithString("sort", + mcp.Description("Sort repositories by field, defaults to best match"), + mcp.Enum("stars", "forks", "help-wanted-issues", "updated"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + mcp.WithBoolean("minimal_output", + mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), + mcp.DefaultBool(true), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := RequiredParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + order, err := OptionalParam[string](request, "order") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + 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) + } + result, resp, err := client.Search.Repositories(ctx, query, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search repositories with query '%s'", query), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 search repositories: %s", string(body))), nil + } + + // Return either minimal or full response based on parameter + var r []byte + if minimalOutput { + minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + + minimalRepos = append(minimalRepos, minimalRepo) + } + + minimalResult := &MinimalSearchRepositoriesResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalRepos, + } + + r, err = json.Marshal(minimalResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal minimal response: %w", err) + } + } else { + r, err = json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal full response: %w", err) + } + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// SearchCode creates a tool to search for code across GitHub repositories. +func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_code", + mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."), + ), + mcp.WithString("sort", + mcp.Description("Sort field ('indexed' only)"), + ), + mcp.WithString("order", + mcp.Description("Sort order for results"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := RequiredParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + order, err := OptionalParam[string](request, "order") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + result, resp, err := client.Search.Code(ctx, query, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search code with query '%s'", query), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 search code: %s", string(body))), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := RequiredParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + order, err := OptionalParam[string](request, "order") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + searchQuery := query + if !hasTypeFilter(query) { + searchQuery = "type:" + accountType + " " + query + } + result, resp, err := client.Search.Users(ctx, searchQuery, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search %ss with query '%s'", accountType, query), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + 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 search %ss: %s", accountType, string(body))), nil + } + + minimalUsers := make([]MinimalUser, 0, len(result.Users)) + + for _, user := range result.Users { + if user.Login != nil { + mu := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), + } + minimalUsers = append(minimalUsers, mu) + } + } + minimalResp := &MinimalSearchUsersResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalUsers, + } + if result.Total != nil { + minimalResp.TotalCount = *result.Total + } + if result.IncompleteResults != nil { + minimalResp.IncompleteResults = *result.IncompleteResults + } + + r, err := json.Marshal(minimalResp) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + return mcp.NewToolResultText(string(r)), nil + } +} + +// SearchUsers creates a tool to search for GitHub users. +func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_users", + mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."), + ), + mcp.WithString("sort", + mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."), + mcp.Enum("followers", "repositories", "joined"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), userOrOrgHandler("user", getClient) +} + +// SearchOrgs creates a tool to search for GitHub organizations. +func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("search_orgs", + mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")), + + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."), + ), + mcp.WithString("sort", + mcp.Description("Sort field by category"), + mcp.Enum("followers", "repositories", "joined"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), userOrOrgHandler("org", getClient) +} diff --git a/.tools-to-be-migrated/search_test.go b/.tools-to-be-migrated/search_test.go new file mode 100644 index 000000000..e14ba023f --- /dev/null +++ b/.tools-to-be-migrated/search_test.go @@ -0,0 +1,743 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_SearchRepositories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SearchRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_repositories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Setup mock search results + mockSearchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Repositories: []*github.Repository{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("repo-1"), + FullName: github.Ptr("owner/repo-1"), + HTMLURL: github.Ptr("https://github.com/owner/repo-1"), + Description: github.Ptr("Test repository 1"), + StargazersCount: github.Ptr(100), + }, + { + ID: github.Ptr(int64(67890)), + Name: github.Ptr("repo-2"), + FullName: github.Ptr("owner/repo-2"), + HTMLURL: github.Ptr("https://github.com/owner/repo-2"), + Description: github.Ptr("Test repository 2"), + StargazersCount: github.Ptr(50), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoriesSearchResult + expectedErrMsg string + }{ + { + name: "successful repository search", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + expectQueryParams(t, map[string]string{ + "q": "golang test", + "sort": "stars", + "order": "desc", + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "golang test", + "sort": "stars", + "order": "desc", + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "repository search with default pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "golang test", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search repositories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedResult MinimalSearchRepositoriesResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) + for i, repo := range returnedResult.Items { + assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) + assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) + assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) + assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) + } + + }) + } +} + +func Test_SearchRepositories_FullOutput(t *testing.T) { + mockSearchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Repositories: []*github.Repository{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("test-repo"), + FullName: github.Ptr("owner/test-repo"), + HTMLURL: github.Ptr("https://github.com/owner/test-repo"), + Description: github.Ptr("Test repository"), + StargazersCount: github.Ptr(100), + }, + }, + } + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ) + + client := github.NewClient(mockedClient) + _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "query": "golang test", + "minimal_output": false, + }) + + result, err := handlerTest(context.Background(), request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal as full GitHub API response + var returnedResult github.RepositoriesSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + + // Verify it's the full API response, not minimal + assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) + assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Repositories, 1) + assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) + assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) +} + +func Test_SearchCode(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SearchCode(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_code", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Setup mock search results + mockSearchResult := &github.CodeSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + CodeResults: []*github.CodeResult{ + { + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), + Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + }, + { + Name: github.Ptr("file2.go"), + Path: github.Ptr("path/to/file2.go"), + SHA: github.Ptr("def456abc123"), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), + Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.CodeSearchResult + expectedErrMsg string + }{ + { + name: "successful code search with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchCode, + expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "sort": "indexed", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "fmt.Println language:go", + "sort": "indexed", + "order": "desc", + "page": float64(1), + "perPage": float64(30), + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "code search with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchCode, + expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "fmt.Println language:go", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search code fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchCode, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search code", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchCode(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedResult github.CodeSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) + assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) + for i, code := range returnedResult.CodeResults { + assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) + assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) + assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) + assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) + assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) + } + }) + } +} + +func Test_SearchUsers(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SearchUsers(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_users", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Setup mock search results + mockSearchResult := &github.UsersSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Users: []*github.User{ + { + Login: github.Ptr("user1"), + ID: github.Ptr(int64(1001)), + HTMLURL: github.Ptr("https://github.com/user1"), + AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), + }, + { + Login: github.Ptr("user2"), + ID: github.Ptr(int64(1002)), + HTMLURL: github.Ptr("https://github.com/user2"), + AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), + Type: github.Ptr("User"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.UsersSearchResult + expectedErrMsg string + }{ + { + name: "successful users search with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "sort": "followers", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "location:finland language:go", + "sort": "followers", + "order": "desc", + "page": float64(1), + "perPage": float64(30), + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "users search with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "location:finland language:go", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing type:user filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user location:seattle followers:>100", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:user location:seattle followers:>100", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing type:user filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user (location:seattle OR location:california) followers:>50", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:user (location:seattle OR location:california) followers:>50", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search users fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search users", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchUsers(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedResult MinimalSearchUsersResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) + for i, user := range returnedResult.Items { + assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login) + assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID) + assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL) + assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL) + } + }) + } +} + +func Test_SearchOrgs(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "search_orgs", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + + // Setup mock search results + mockSearchResult := &github.UsersSearchResult{ + Total: github.Ptr(int(2)), + IncompleteResults: github.Ptr(false), + Users: []*github.User{ + { + Login: github.Ptr("org-1"), + ID: github.Ptr(int64(111)), + HTMLURL: github.Ptr("https://github.com/org-1"), + AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), + }, + { + Login: github.Ptr("org-2"), + ID: github.Ptr(int64(222)), + HTMLURL: github.Ptr("https://github.com/org-2"), + AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.UsersSearchResult + expectedErrMsg string + }{ + { + name: "successful org search", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:org github", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "github", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing type:org filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:org location:california followers:>1000", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:org location:california followers:>1000", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing type:org filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "org search fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "query": "invalid:query", + }, + expectError: true, + expectedErrMsg: "failed to search orgs", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedResult MinimalSearchUsersResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) + for i, org := range returnedResult.Items { + assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login) + assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID) + assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL) + assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL) + } + }) + } +} diff --git a/.tools-to-be-migrated/search_utils.go b/.tools-to-be-migrated/search_utils.go new file mode 100644 index 000000000..04cb2224f --- /dev/null +++ b/.tools-to-be-migrated/search_utils.go @@ -0,0 +1,115 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" +) + +func hasFilter(query, filterType string) bool { + // Match filter at start of string, after whitespace, or after non-word characters like '(' + pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType)) + matched, _ := regexp.MatchString(pattern, query) + return matched +} + +func hasSpecificFilter(query, filterType, filterValue string) bool { + // Match specific filter:value at start, after whitespace, or after non-word characters + // End with word boundary, whitespace, or non-word characters like ')' + pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue)) + matched, _ := regexp.MatchString(pattern, query) + return matched +} + +func hasRepoFilter(query string) bool { + return hasFilter(query, "repo") +} + +func hasTypeFilter(query string) bool { + return hasFilter(query, "type") +} + +func searchHandler( + ctx context.Context, + getClient GetClientFn, + request mcp.CallToolRequest, + searchType string, + errorPrefix string, +) (*mcp.CallToolResult, error) { + query, err := RequiredParam[string](request, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + if !hasSpecificFilter(query, "is", searchType) { + query = fmt.Sprintf("is:%s %s", searchType, query) + } + + owner, err := OptionalParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := OptionalParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + if owner != "" && repo != "" && !hasRepoFilter(query) { + query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) + } + + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + order, err := OptionalParam[string](request, "order") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.SearchOptions{ + // Default to "created" if no sort is provided, as it's a common use case. + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err) + } + result, resp, err := client.Search.Issues(ctx, query, opts) + if err != nil { + return nil, fmt.Errorf("%s: %w", errorPrefix, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err) + } + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) + } + + return mcp.NewToolResultText(string(r)), nil +} diff --git a/.tools-to-be-migrated/search_utils_test.go b/.tools-to-be-migrated/search_utils_test.go new file mode 100644 index 000000000..85f953eed --- /dev/null +++ b/.tools-to-be-migrated/search_utils_test.go @@ -0,0 +1,352 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_hasFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + expected bool + }{ + { + name: "query has is:issue filter", + query: "is:issue bug report", + filterType: "is", + expected: true, + }, + { + name: "query has repo: filter", + query: "repo:github/github-mcp-server critical bug", + filterType: "repo", + expected: true, + }, + { + name: "query has multiple is: filters", + query: "is:issue is:open bug", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the beginning", + query: "is:issue some text", + filterType: "is", + expected: true, + }, + { + name: "query has filter in the middle", + query: "some text is:issue more text", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the end", + query: "some text is:issue", + filterType: "is", + expected: true, + }, + { + name: "query does not have the filter", + query: "bug report critical", + filterType: "is", + expected: false, + }, + { + name: "query has similar text but not the filter", + query: "this issue is important", + filterType: "is", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + expected: false, + }, + { + name: "query has label: filter but looking for is:", + query: "label:bug critical", + filterType: "is", + expected: false, + }, + { + name: "query has author: filter", + query: "author:octocat bug", + filterType: "author", + expected: true, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + expected: true, + }, + { + name: "query with complex OR expression checking repo", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "repo", + expected: true, + }, + { + name: "filter in parentheses at start", + query: "(label:bug OR owner:bob) is:issue", + filterType: "label", + expected: true, + }, + { + name: "filter after opening parenthesis", + query: "is:issue (label:critical OR repo:test/test)", + filterType: "label", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasFilter(tt.query, tt.filterType) + assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) + }) + } +} + +func Test_hasRepoFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with repo: filter at beginning", + query: "repo:github/github-mcp-server is:issue", + expected: true, + }, + { + name: "query with repo: filter in middle", + query: "is:issue repo:octocat/Hello-World bug", + expected: true, + }, + { + name: "query with repo: filter at end", + query: "is:issue critical repo:owner/repo-name", + expected: true, + }, + { + name: "query with complex repo name", + query: "repo:microsoft/vscode-extension-samples bug", + expected: true, + }, + { + name: "query without repo: filter", + query: "is:issue bug critical", + expected: false, + }, + { + name: "query with malformed repo: filter (no slash)", + query: "repo:github bug", + expected: true, // hasRepoFilter only checks for repo: prefix, not format + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with multiple repo: filters", + query: "repo:github/first repo:octocat/second", + expected: true, + }, + { + name: "query with repo: in text but not as filter", + query: "this repo: is important", + expected: false, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasRepoFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +} + +func Test_hasSpecificFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + filterValue string + expected bool + }{ + { + name: "query has exact is:issue filter", + query: "is:issue bug report", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:open but looking for is:issue", + query: "is:open bug report", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has both is:issue and is:open, looking for is:issue", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has both is:issue and is:open, looking for is:open", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "open", + expected: true, + }, + { + name: "query has is:issue at the beginning", + query: "is:issue some text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue in the middle", + query: "some text is:issue more text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue at the end", + query: "some text is:issue", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query does not have is:issue", + query: "bug report critical", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has similar text but not the exact filter", + query: "this issue is important", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "partial match should not count", + query: "is:issues bug", // "issues" vs "issue" + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "complex query with parentheses", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value in parentheses at start", + query: "(is:issue OR is:pr) label:bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value after opening parenthesis", + query: "repo:test/repo (is:issue AND label:bug)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) + assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) + }) + } +} + +func Test_hasTypeFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with type:user filter at beginning", + query: "type:user location:seattle", + expected: true, + }, + { + name: "query with type:org filter in middle", + query: "location:california type:org followers:>100", + expected: true, + }, + { + name: "query with type:user filter at end", + query: "location:seattle followers:>50 type:user", + expected: true, + }, + { + name: "query without type: filter", + query: "location:seattle followers:>50", + expected: false, + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with type: in text but not as filter", + query: "this type: is important", + expected: false, + }, + { + name: "query with multiple type: filters", + query: "type:user type:org", + expected: true, + }, + { + name: "complex query with OR expression", + query: "type:user (location:seattle OR location:california)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasTypeFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +} diff --git a/.tools-to-be-migrated/secret_scanning.go b/.tools-to-be-migrated/secret_scanning.go new file mode 100644 index 000000000..866c54617 --- /dev/null +++ b/.tools-to-be-migrated/secret_scanning.go @@ -0,0 +1,163 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "get_secret_scanning_alert", + mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithNumber("alertNumber", + mcp.Required(), + mcp.Description("The number of the alert."), + ), + ), + 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 + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + 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 get alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "list_secret_scanning_alerts", + mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter by state"), + mcp.Enum("open", "resolved"), + ), + mcp.WithString("secret_type", + mcp.Description("A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter."), + ), + mcp.WithString("resolution", + mcp.Description("Filter by resolution"), + mcp.Enum("false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"), + ), + ), + 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 + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + secretType, err := OptionalParam[string](request, "secret_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resolution, err := OptionalParam[string](request, "resolution") + 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) + } + alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/secret_scanning_test.go b/.tools-to-be-migrated/secret_scanning_test.go new file mode 100644 index 000000000..4a9d50ab9 --- /dev/null +++ b/.tools-to-be-migrated/secret_scanning_test.go @@ -0,0 +1,249 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetSecretScanningAlert(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetSecretScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_secret_scanning_alert", 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, "alertNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + + // Setup mock alert for success case + mockAlert := &github.SecretScanningAlert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlert *github.SecretScanningAlert + expectedErrMsg string + }{ + { + name: "successful alert fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, + mockAlert, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: false, + expectedAlert: mockAlert, + }, + { + name: "alert fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetSecretScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlert github.Alert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + + }) + } +} + +func Test_ListSecretScanningAlerts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListSecretScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_secret_scanning_alerts", 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, "state") + assert.Contains(t, tool.InputSchema.Properties, "secret_type") + assert.Contains(t, tool.InputSchema.Properties, "resolution") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock alerts for success case + resolvedAlert := github.SecretScanningAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), + State: github.Ptr("resolved"), + Resolution: github.Ptr("false_positive"), + SecretType: github.Ptr("adafruit_io_key"), + } + openAlert := github.SecretScanningAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), + State: github.Ptr("open"), + Resolution: github.Ptr("false_positive"), + SecretType: github.Ptr("adafruit_io_key"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlerts []*github.SecretScanningAlert + expectedErrMsg string + }{ + { + name: "successful resolved alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposSecretScanningAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "state": "resolved", + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "resolved", + }, + expectError: false, + expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert}, + }, + { + name: "successful alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposSecretScanningAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, + }, + { + name: "alerts listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposSecretScanningAlertsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlerts []*github.SecretScanningAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + assert.Equal(t, *tc.expectedAlerts[i].Resolution, *alert.Resolution) + assert.Equal(t, *tc.expectedAlerts[i].SecretType, *alert.SecretType) + } + }) + } +} diff --git a/.tools-to-be-migrated/security_advisories.go b/.tools-to-be-migrated/security_advisories.go new file mode 100644 index 000000000..316b5d58c --- /dev/null +++ b/.tools-to-be-migrated/security_advisories.go @@ -0,0 +1,397 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_global_security_advisories", + mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("ghsaId", + mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), + ), + mcp.WithString("type", + mcp.Description("Advisory type."), + mcp.Enum("reviewed", "malware", "unreviewed"), + mcp.DefaultString("reviewed"), + ), + mcp.WithString("cveId", + mcp.Description("Filter by CVE ID."), + ), + mcp.WithString("ecosystem", + mcp.Description("Filter by package ecosystem."), + mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"), + ), + mcp.WithString("severity", + mcp.Description("Filter by severity."), + mcp.Enum("unknown", "low", "medium", "high", "critical"), + ), + mcp.WithArray("cwes", + mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."), + mcp.Items(map[string]any{ + "type": "string", + }), + ), + mcp.WithBoolean("isWithdrawn", + mcp.Description("Whether to only return withdrawn advisories."), + ), + mcp.WithString("affects", + mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."), + ), + mcp.WithString("published", + mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."), + ), + mcp.WithString("updated", + mcp.Description("Filter by update date or date range (ISO 8601 date or range)."), + ), + mcp.WithString("modified", + mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + ghsaID, err := OptionalParam[string](request, "ghsaId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil + } + + typ, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil + } + + cveID, err := OptionalParam[string](request, "cveId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil + } + + eco, err := OptionalParam[string](request, "ecosystem") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil + } + + sev, err := OptionalParam[string](request, "severity") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil + } + + cwes, err := OptionalParam[[]string](request, "cwes") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil + } + + isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil + } + + affects, err := OptionalParam[string](request, "affects") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil + } + + published, err := OptionalParam[string](request, "published") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil + } + + updated, err := OptionalParam[string](request, "updated") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil + } + + modified, err := OptionalParam[string](request, "modified") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil + } + + opts := &github.ListGlobalSecurityAdvisoriesOptions{} + + if ghsaID != "" { + opts.GHSAID = &ghsaID + } + if typ != "" { + opts.Type = &typ + } + if cveID != "" { + opts.CVEID = &cveID + } + if eco != "" { + opts.Ecosystem = &eco + } + if sev != "" { + opts.Severity = &sev + } + if len(cwes) > 0 { + opts.CWEs = cwes + } + + if isWithdrawn { + opts.IsWithdrawn = &isWithdrawn + } + + if affects != "" { + opts.Affects = &affects + } + if published != "" { + opts.Published = &published + } + if updated != "" { + opts.Updated = &updated + } + if modified != "" { + opts.Modified = &modified + } + + advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to list global security advisories: %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 list advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_repository_security_advisories", + mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("direction", + mcp.Description("Sort direction."), + mcp.Enum("asc", "desc"), + ), + mcp.WithString("sort", + mcp.Description("Sort field."), + mcp.Enum("created", "updated", "published"), + ), + mcp.WithString("state", + mcp.Description("Filter by advisory state."), + mcp.Enum("triage", "draft", "published", "closed"), + ), + ), 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 + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sortField, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + 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) + } + + opts := &github.ListRepositorySecurityAdvisoriesOptions{} + if direction != "" { + opts.Direction = direction + } + if sortField != "" { + opts.Sort = sortField + } + if state != "" { + opts.State = state + } + + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list repository security advisories: %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 list repository advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_global_security_advisory", + mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("ghsaId", + mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), + mcp.Required(), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + ghsaID, err := RequiredParam[string](request, "ghsaId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil + } + + advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) + if err != nil { + return nil, fmt.Errorf("failed to get advisory: %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 advisory: %s", string(body))), nil + } + + r, err := json.Marshal(advisory) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisory: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_org_repository_security_advisories", + mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("The organization login."), + ), + mcp.WithString("direction", + mcp.Description("Sort direction."), + mcp.Enum("asc", "desc"), + ), + mcp.WithString("sort", + mcp.Description("Sort field."), + mcp.Enum("created", "updated", "published"), + ), + mcp.WithString("state", + mcp.Description("Filter by advisory state."), + mcp.Enum("triage", "draft", "published", "closed"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sortField, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + 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) + } + + opts := &github.ListRepositorySecurityAdvisoriesOptions{} + if direction != "" { + opts.Direction = direction + } + if sortField != "" { + opts.Sort = sortField + } + if state != "" { + opts.State = state + } + + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("failed to list organization repository security advisories: %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 list organization repository advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/.tools-to-be-migrated/security_advisories_test.go b/.tools-to-be-migrated/security_advisories_test.go new file mode 100644 index 000000000..e083cb166 --- /dev/null +++ b/.tools-to-be-migrated/security_advisories_test.go @@ -0,0 +1,526 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v77/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListGlobalSecurityAdvisories(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_global_security_advisories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "ecosystem") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.Contains(t, tool.InputSchema.Properties, "ghsaId") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{}) + + // Setup mock advisory for success case + mockAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Test advisory"), + Description: github.Ptr("This is a test advisory."), + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.GlobalSecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetAdvisories, + []*github.GlobalSecurityAdvisory{mockAdvisory}, + ), + ), + requestArgs: map[string]interface{}{ + "type": "reviewed", + "ecosystem": "npm", + "severity": "high", + }, + expectError: false, + expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory}, + }, + { + name: "invalid severity value", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisories, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "type": "reviewed", + "severity": "extreme", + }, + expectError: true, + expectedErrMsg: "failed to list global security advisories", + }, + { + name: "API error handling", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisories, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list global security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListGlobalSecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), 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 returnedAdvisories []*github.GlobalSecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} + +func Test_GetGlobalSecurityAdvisory(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_global_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "ghsaId") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"}) + + // Setup mock advisory for success case + mockAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Test advisory"), + Description: github.Ptr("This is a test advisory."), + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisory *github.GlobalSecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetAdvisoriesByGhsaId, + mockAdvisory, + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "invalid ghsaId format", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisoriesByGhsaId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "invalid-ghsa-id", + }, + expectError: true, + expectedErrMsg: "failed to get advisory", + }, + { + name: "advisory not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisoriesByGhsaId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "failed to get advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetGlobalSecurityAdvisory(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Verify the result + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity) + }) + } +} + +func Test_ListRepositorySecurityAdvisories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_repository_security_advisories", 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, "direction") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Local endpoint pattern for repository security advisories + var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{ + Pattern: "/repos/{owner}/{repo}/security-advisories", + Method: "GET", + } + + // Setup mock advisories for success cases + adv1 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-1111-1111-1111"), + Summary: github.Ptr("Repo advisory one"), + Description: github.Ptr("First repo advisory."), + Severity: github.Ptr("high"), + } + adv2 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-2222-2222-2222"), + Summary: github.Ptr("Repo advisory two"), + Description: github.Ptr("Second repo advisory."), + Severity: github.Ptr("medium"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisories listing (no filters)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/owner/repo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, + }, + { + name: "successful advisories listing with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories", + queryParams: map[string]string{ + "direction": "desc", + "sort": "updated", + "state": "published", + }, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo", + "repo": "hello-world", + "direction": "desc", + "sort": "updated", + "state": "published", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1}, + }, + { + name: "advisories listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/owner/repo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list repository security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + textContent := getTextResult(t, result) + + var returnedAdvisories []*github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} + +func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_org_repository_security_advisories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + + // Endpoint pattern for org repository security advisories + var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/security-advisories", + Method: "GET", + } + + adv1 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), + Summary: github.Ptr("Org repo advisory 1"), + Description: github.Ptr("First advisory"), + Severity: github.Ptr("low"), + } + adv2 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), + Summary: github.Ptr("Org repo advisory 2"), + Description: github.Ptr("Second advisory"), + Severity: github.Ptr("critical"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful listing (no filters)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, + }, + { + name: "successful listing with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{ + "direction": "asc", + "sort": "created", + "state": "triage", + }, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + "direction": "asc", + "sort": "created", + "state": "triage", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1}, + }, + { + name: "listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + }, + expectError: true, + expectedErrMsg: "failed to list organization repository security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListOrgRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + textContent := getTextResult(t, result) + + var returnedAdvisories []*github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} diff --git a/pkg/github/actions.go b/pkg/github/actions.go deleted file mode 100644 index cdabea9bd..000000000 --- a/pkg/github/actions.go +++ /dev/null @@ -1,1224 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "net/http" -// "strconv" -// "strings" - -// "github.com/github/github-mcp-server/internal/profiler" -// buffer "github.com/github/github-mcp-server/pkg/buffer" -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// const ( -// DescriptionRepositoryOwner = "Repository owner" -// DescriptionRepositoryName = "Repository name" -// ) - -// // ListWorkflows creates a tool to list workflows in a repository -// func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_workflows", -// mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// 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 -// } - -// // Get optional pagination parameters -// 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) -// } - -// // Set up list options -// opts := &github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// } - -// workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list workflows: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(workflows) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -// func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_workflow_runs", -// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithString("workflow_id", -// mcp.Required(), -// mcp.Description("The workflow ID or workflow file name"), -// ), -// mcp.WithString("actor", -// mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), -// ), -// mcp.WithString("branch", -// mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), -// ), -// mcp.WithString("event", -// mcp.Description("Returns workflow runs for a specific event type"), -// mcp.Enum( -// "branch_protection_rule", -// "check_run", -// "check_suite", -// "create", -// "delete", -// "deployment", -// "deployment_status", -// "discussion", -// "discussion_comment", -// "fork", -// "gollum", -// "issue_comment", -// "issues", -// "label", -// "merge_group", -// "milestone", -// "page_build", -// "public", -// "pull_request", -// "pull_request_review", -// "pull_request_review_comment", -// "pull_request_target", -// "push", -// "registry_package", -// "release", -// "repository_dispatch", -// "schedule", -// "status", -// "watch", -// "workflow_call", -// "workflow_dispatch", -// "workflow_run", -// ), -// ), -// mcp.WithString("status", -// mcp.Description("Returns workflow runs with the check run status"), -// mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), -// ), -// 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 -// } -// workflowID, err := RequiredParam[string](request, "workflow_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional filtering parameters -// actor, err := OptionalParam[string](request, "actor") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// branch, err := OptionalParam[string](request, "branch") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// event, err := OptionalParam[string](request, "event") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// status, err := OptionalParam[string](request, "status") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional pagination parameters -// 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) -// } - -// // Set up list options -// opts := &github.ListWorkflowRunsOptions{ -// Actor: actor, -// Branch: branch, -// Event: event, -// Status: status, -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list workflow runs: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(workflowRuns) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // RunWorkflow creates a tool to run an Actions workflow -// func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("run_workflow", -// mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithString("workflow_id", -// mcp.Required(), -// mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"), -// ), -// mcp.WithString("ref", -// mcp.Required(), -// mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), -// ), -// mcp.WithObject("inputs", -// mcp.Description("Inputs the workflow accepts"), -// ), -// ), -// 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 -// } -// workflowID, err := RequiredParam[string](request, "workflow_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ref, err := RequiredParam[string](request, "ref") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional inputs parameter -// var inputs map[string]interface{} -// if requestInputs, ok := request.GetArguments()["inputs"]; ok { -// if inputsMap, ok := requestInputs.(map[string]interface{}); ok { -// inputs = inputsMap -// } -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// event := github.CreateWorkflowDispatchEventRequest{ -// Ref: ref, -// Inputs: inputs, -// } - -// var resp *github.Response -// var workflowType string - -// if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { -// resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) -// workflowType = "workflow_id" -// } else { -// resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) -// workflowType = "workflow_file" -// } - -// if err != nil { -// return nil, fmt.Errorf("failed to run workflow: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "message": "Workflow run has been queued", -// "workflow_type": workflowType, -// "workflow_id": workflowID, -// "ref": ref, -// "inputs": inputs, -// "status": resp.Status, -// "status_code": resp.StatusCode, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetWorkflowRun creates a tool to get details of a specific workflow run -// func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_workflow_run", -// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) -// if err != nil { -// return nil, fmt.Errorf("failed to get workflow run: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(workflowRun) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -// func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_workflow_run_logs", -// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// // Get the download URL for the logs -// url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) -// if err != nil { -// return nil, fmt.Errorf("failed to get workflow run logs: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// // Create response with the logs URL and information -// result := map[string]any{ -// "logs_url": url.String(), -// "message": "Workflow run logs are available for download", -// "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", -// "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", -// "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListWorkflowJobs creates a tool to list jobs for a specific workflow run -// func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_workflow_jobs", -// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// mcp.WithString("filter", -// mcp.Description("Filters jobs by their completed_at timestamp"), -// mcp.Enum("latest", "all"), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// // Get optional filtering parameters -// filter, err := OptionalParam[string](request, "filter") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional pagination parameters -// 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) -// } - -// // Set up list options -// opts := &github.ListWorkflowJobsOptions{ -// Filter: filter, -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list workflow jobs: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// // Add optimization tip for failed job debugging -// response := map[string]any{ -// "jobs": jobs, -// "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", -// } - -// r, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run -// func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_job_logs", -// mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("job_id", -// mcp.Description("The unique identifier of the workflow job (required for single job logs)"), -// ), -// mcp.WithNumber("run_id", -// mcp.Description("Workflow run ID (required when using failed_only)"), -// ), -// mcp.WithBoolean("failed_only", -// mcp.Description("When true, gets logs for all failed jobs in run_id"), -// ), -// mcp.WithBoolean("return_content", -// mcp.Description("Returns actual log content instead of URLs"), -// ), -// mcp.WithNumber("tail_lines", -// mcp.Description("Number of lines to return from the end of the log"), -// mcp.DefaultNumber(500), -// ), -// ), -// 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 -// } - -// // Get optional parameters -// jobID, err := OptionalIntParam(request, "job_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID, err := OptionalIntParam(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// failedOnly, err := OptionalParam[bool](request, "failed_only") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// returnContent, err := OptionalParam[bool](request, "return_content") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// tailLines, err := OptionalIntParam(request, "tail_lines") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// // Default to 500 lines if not specified -// if tailLines == 0 { -// tailLines = 500 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// // Validate parameters -// if failedOnly && runID == 0 { -// return mcp.NewToolResultError("run_id is required when failed_only is true"), nil -// } -// if !failedOnly && jobID == 0 { -// return mcp.NewToolResultError("job_id is required when failed_only is false"), nil -// } - -// if failedOnly && runID > 0 { -// // Handle failed-only mode: get logs for all failed jobs in the workflow run -// return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize) -// } else if jobID > 0 { -// // Handle single job mode -// return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize) -// } - -// return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil -// } -// } - -// // handleFailedJobLogs gets logs for all failed jobs in a workflow run -// func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { -// // First, get all jobs for the workflow run -// jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ -// Filter: "latest", -// }) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Filter for failed jobs -// var failedJobs []*github.WorkflowJob -// for _, job := range jobs.Jobs { -// if job.GetConclusion() == "failure" { -// failedJobs = append(failedJobs, job) -// } -// } - -// if len(failedJobs) == 0 { -// result := map[string]any{ -// "message": "No failed jobs found in this workflow run", -// "run_id": runID, -// "total_jobs": len(jobs.Jobs), -// "failed_jobs": 0, -// } -// r, _ := json.Marshal(result) -// return mcp.NewToolResultText(string(r)), nil -// } - -// // Collect logs for all failed jobs -// var logResults []map[string]any -// for _, job := range failedJobs { -// jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) -// if err != nil { -// // Continue with other jobs even if one fails -// jobResult = map[string]any{ -// "job_id": job.GetID(), -// "job_name": job.GetName(), -// "error": err.Error(), -// } -// // Enable reporting of status codes and error causes -// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling -// } - -// logResults = append(logResults, jobResult) -// } - -// result := map[string]any{ -// "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), -// "run_id": runID, -// "total_jobs": len(jobs.Jobs), -// "failed_jobs": len(failedJobs), -// "logs": logResults, -// "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// // handleSingleJobLogs gets logs for a single job -// func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { -// jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil -// } - -// r, err := json.Marshal(jobResult) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// // getJobLogData retrieves log data for a single job, either as URL or content -// func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { -// // Get the download URL for the job logs -// url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) -// if err != nil { -// return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "job_id": jobID, -// } -// if jobName != "" { -// result["job_name"] = jobName -// } - -// if returnContent { -// // Download and return the actual log content -// content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp -// if err != nil { -// // To keep the return value consistent wrap the response as a GitHub Response -// ghRes := &github.Response{ -// Response: httpResp, -// } -// return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) -// } -// result["logs_content"] = content -// result["message"] = "Job logs content retrieved successfully" -// result["original_length"] = originalLength -// } else { -// // Return just the URL -// result["logs_url"] = url.String() -// result["message"] = "Job logs are available for download" -// result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." -// } - -// return result, resp, nil -// } - -// func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { -// prof := profiler.New(nil, profiler.IsProfilingEnabled()) -// finish := prof.Start(ctx, "log_buffer_processing") - -// httpResp, err := http.Get(logURL) //nolint:gosec -// if err != nil { -// return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) -// } -// defer func() { _ = httpResp.Body.Close() }() - -// if httpResp.StatusCode != http.StatusOK { -// return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) -// } - -// bufferSize := tailLines -// if bufferSize > maxLines { -// bufferSize = maxLines -// } - -// processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) -// if err != nil { -// return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) -// } - -// lines := strings.Split(processedInput, "\n") -// if len(lines) > tailLines { -// lines = lines[len(lines)-tailLines:] -// } -// finalResult := strings.Join(lines, "\n") - -// _ = finish(len(lines), int64(len(finalResult))) - -// return finalResult, totalLines, httpResp, nil -// } - -// // RerunWorkflowRun creates a tool to re-run an entire workflow run -// func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("rerun_workflow_run", -// mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "message": "Workflow run has been queued for re-run", -// "run_id": runID, -// "status": resp.Status, -// "status_code": resp.StatusCode, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -// func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("rerun_failed_jobs", -// mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "message": "Failed jobs have been queued for re-run", -// "run_id": runID, -// "status": resp.Status, -// "status_code": resp.StatusCode, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // CancelWorkflowRun creates a tool to cancel a workflow run -// func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("cancel_workflow_run", -// mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) -// if err != nil { -// if _, ok := err.(*github.AcceptedError); !ok { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil -// } -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "message": "Workflow run has been cancelled", -// "run_id": runID, -// "status": resp.Status, -// "status_code": resp.StatusCode, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -// func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_workflow_run_artifacts", -// mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// // Get optional pagination parameters -// 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) -// } - -// // Set up list options -// opts := &github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// } - -// artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(artifacts) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -// func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("download_workflow_run_artifact", -// mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("artifact_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the artifact"), -// ), -// ), -// 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 -// } -// artifactIDInt, err := RequiredInt(request, "artifact_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// artifactID := int64(artifactIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// // Get the download URL for the artifact -// url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Create response with the download URL and information -// result := map[string]any{ -// "download_url": url.String(), -// "message": "Artifact is available for download", -// "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", -// "artifact_id": artifactID, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run -// func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("delete_workflow_run_logs", -// mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), -// ReadOnlyHint: ToBoolPtr(false), -// DestructiveHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// result := map[string]any{ -// "message": "Workflow run logs have been deleted", -// "run_id": runID, -// "status": resp.Status, -// "status_code": resp.StatusCode, -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -// func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_workflow_run_usage", -// mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryOwner), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description(DescriptionRepositoryName), -// ), -// mcp.WithNumber("run_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the workflow run"), -// ), -// ), -// 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 -// } -// runIDInt, err := RequiredInt(request, "run_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// runID := int64(runIDInt) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(usage) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go deleted file mode 100644 index a4a5c4281..000000000 --- a/pkg/github/actions_test.go +++ /dev/null @@ -1,1321 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "io" -// "net/http" -// "net/http/httptest" -// "os" -// "runtime" -// "runtime/debug" -// "strings" -// "testing" - -// "github.com/github/github-mcp-server/internal/profiler" -// buffer "github.com/github/github-mcp-server/pkg/buffer" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_ListWorkflows(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_workflows", 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, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful workflow listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsWorkflowsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// workflows := &github.Workflows{ -// TotalCount: github.Ptr(2), -// Workflows: []*github.Workflow{ -// { -// ID: github.Ptr(int64(123)), -// Name: github.Ptr("CI"), -// Path: github.Ptr(".github/workflows/ci.yml"), -// State: github.Ptr("active"), -// CreatedAt: &github.Timestamp{}, -// UpdatedAt: &github.Timestamp{}, -// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), -// BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), -// NodeID: github.Ptr("W_123"), -// }, -// { -// ID: github.Ptr(int64(456)), -// Name: github.Ptr("Deploy"), -// Path: github.Ptr(".github/workflows/deploy.yml"), -// State: github.Ptr("active"), -// CreatedAt: &github.Timestamp{}, -// UpdatedAt: &github.Timestamp{}, -// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), -// BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), -// NodeID: github.Ptr("W_456"), -// }, -// }, -// } -// w.WriteHeader(http.StatusOK) -// _ = json.NewEncoder(w).Encode(workflows) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: owner", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response github.Workflows -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.NotNil(t, response.TotalCount) -// assert.Greater(t, *response.TotalCount, 0) -// assert.NotEmpty(t, response.Workflows) -// }) -// } -// } - -// func Test_RunWorkflow(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "run_workflow", 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, "workflow_id") -// assert.Contains(t, tool.InputSchema.Properties, "ref") -// assert.Contains(t, tool.InputSchema.Properties, "inputs") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful workflow run", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "workflow_id": "12345", -// "ref": "main", -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter workflow_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "ref": "main", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: workflow_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Equal(t, "Workflow run has been queued", response["message"]) -// assert.Contains(t, response, "workflow_type") -// }) -// } -// } - -// func Test_RunWorkflow_WithFilename(t *testing.T) { -// // Test the unified RunWorkflow function with filenames -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful workflow run by filename", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "workflow_id": "ci.yml", -// "ref": "main", -// }, -// expectError: false, -// }, -// { -// name: "successful workflow run by numeric ID as string", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "workflow_id": "12345", -// "ref": "main", -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter workflow_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "ref": "main", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: workflow_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Equal(t, "Workflow run has been queued", response["message"]) -// assert.Contains(t, response, "workflow_type") -// }) -// } -// } - -// func Test_CancelWorkflowRun(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "cancel_workflow_run", 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, "run_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful workflow run cancellation", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/repos/owner/repo/actions/runs/12345/cancel", -// Method: "POST", -// }, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusAccepted) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(12345), -// }, -// expectError: false, -// }, -// { -// name: "conflict when cancelling a workflow run", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/repos/owner/repo/actions/runs/12345/cancel", -// Method: "POST", -// }, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusConflict) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(12345), -// }, -// expectError: true, -// expectedErrMsg: "failed to cancel workflow run", -// }, -// { -// name: "missing required parameter run_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: run_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Contains(t, textContent.Text, tc.expectedErrMsg) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Equal(t, "Workflow run has been cancelled", response["message"]) -// assert.Equal(t, float64(12345), response["run_id"]) -// }) -// } -// } - -// func Test_ListWorkflowRunArtifacts(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful artifacts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// artifacts := &github.ArtifactList{ -// TotalCount: github.Ptr(int64(2)), -// Artifacts: []*github.Artifact{ -// { -// ID: github.Ptr(int64(1)), -// NodeID: github.Ptr("A_1"), -// Name: github.Ptr("build-artifacts"), -// SizeInBytes: github.Ptr(int64(1024)), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), -// ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), -// Expired: github.Ptr(false), -// CreatedAt: &github.Timestamp{}, -// UpdatedAt: &github.Timestamp{}, -// ExpiresAt: &github.Timestamp{}, -// WorkflowRun: &github.ArtifactWorkflowRun{ -// ID: github.Ptr(int64(12345)), -// RepositoryID: github.Ptr(int64(1)), -// HeadRepositoryID: github.Ptr(int64(1)), -// HeadBranch: github.Ptr("main"), -// HeadSHA: github.Ptr("abc123"), -// }, -// }, -// { -// ID: github.Ptr(int64(2)), -// NodeID: github.Ptr("A_2"), -// Name: github.Ptr("test-results"), -// SizeInBytes: github.Ptr(int64(512)), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), -// ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), -// Expired: github.Ptr(false), -// CreatedAt: &github.Timestamp{}, -// UpdatedAt: &github.Timestamp{}, -// ExpiresAt: &github.Timestamp{}, -// WorkflowRun: &github.ArtifactWorkflowRun{ -// ID: github.Ptr(int64(12345)), -// RepositoryID: github.Ptr(int64(1)), -// HeadRepositoryID: github.Ptr(int64(1)), -// HeadBranch: github.Ptr("main"), -// HeadSHA: github.Ptr("abc123"), -// }, -// }, -// }, -// } -// w.WriteHeader(http.StatusOK) -// _ = json.NewEncoder(w).Encode(artifacts) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(12345), -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter run_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: run_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response github.ArtifactList -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.NotNil(t, response.TotalCount) -// assert.Greater(t, *response.TotalCount, int64(0)) -// assert.NotEmpty(t, response.Artifacts) -// }) -// } -// } - -// func Test_DownloadWorkflowRunArtifact(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful artifact download URL", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/repos/owner/repo/actions/artifacts/123/zip", -// Method: "GET", -// }, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// // GitHub returns a 302 redirect to the download URL -// w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "artifact_id": float64(123), -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter artifact_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: artifact_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Contains(t, response, "download_url") -// assert.Contains(t, response, "message") -// assert.Equal(t, "Artifact is available for download", response["message"]) -// assert.Equal(t, float64(123), response["artifact_id"]) -// }) -// } -// } - -// func Test_DeleteWorkflowRunLogs(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "delete_workflow_run_logs", 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, "run_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful logs deletion", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(12345), -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter run_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: run_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Equal(t, "Workflow run logs have been deleted", response["message"]) -// assert.Equal(t, float64(12345), response["run_id"]) -// }) -// } -// } - -// func Test_GetWorkflowRunUsage(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "get_workflow_run_usage", 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, "run_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful workflow run usage", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// usage := &github.WorkflowRunUsage{ -// Billable: &github.WorkflowRunBillMap{ -// "UBUNTU": &github.WorkflowRunBill{ -// TotalMS: github.Ptr(int64(120000)), -// Jobs: github.Ptr(2), -// JobRuns: []*github.WorkflowRunJobRun{ -// { -// JobID: github.Ptr(1), -// DurationMS: github.Ptr(int64(60000)), -// }, -// { -// JobID: github.Ptr(2), -// DurationMS: github.Ptr(int64(60000)), -// }, -// }, -// }, -// }, -// RunDurationMS: github.Ptr(int64(120000)), -// } -// w.WriteHeader(http.StatusOK) -// _ = json.NewEncoder(w).Encode(usage) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(12345), -// }, -// expectError: false, -// }, -// { -// name: "missing required parameter run_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: run_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// // Unmarshal and verify the result -// var response github.WorkflowRunUsage -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.NotNil(t, response.RunDurationMS) -// assert.NotNil(t, response.Billable) -// }) -// } -// } - -// func Test_GetJobLogs(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) - -// assert.Equal(t, "get_job_logs", 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, "job_id") -// assert.Contains(t, tool.InputSchema.Properties, "run_id") -// assert.Contains(t, tool.InputSchema.Properties, "failed_only") -// assert.Contains(t, tool.InputSchema.Properties, "return_content") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// checkResponse func(t *testing.T, response map[string]any) -// }{ -// { -// name: "successful single job logs with URL", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Location", "https://github.com/logs/job/123") -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "job_id": float64(123), -// }, -// expectError: false, -// checkResponse: func(t *testing.T, response map[string]any) { -// assert.Equal(t, float64(123), response["job_id"]) -// assert.Contains(t, response, "logs_url") -// assert.Equal(t, "Job logs are available for download", response["message"]) -// assert.Contains(t, response, "note") -// }, -// }, -// { -// name: "successful failed jobs logs", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// jobs := &github.Jobs{ -// TotalCount: github.Ptr(3), -// Jobs: []*github.WorkflowJob{ -// { -// ID: github.Ptr(int64(1)), -// Name: github.Ptr("test-job-1"), -// Conclusion: github.Ptr("success"), -// }, -// { -// ID: github.Ptr(int64(2)), -// Name: github.Ptr("test-job-2"), -// Conclusion: github.Ptr("failure"), -// }, -// { -// ID: github.Ptr(int64(3)), -// Name: github.Ptr("test-job-3"), -// Conclusion: github.Ptr("failure"), -// }, -// }, -// } -// w.WriteHeader(http.StatusOK) -// _ = json.NewEncoder(w).Encode(jobs) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(456), -// "failed_only": true, -// }, -// expectError: false, -// checkResponse: func(t *testing.T, response map[string]any) { -// assert.Equal(t, float64(456), response["run_id"]) -// assert.Equal(t, float64(3), response["total_jobs"]) -// assert.Equal(t, float64(2), response["failed_jobs"]) -// assert.Contains(t, response, "logs") -// assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) - -// logs, ok := response["logs"].([]interface{}) -// assert.True(t, ok) -// assert.Len(t, logs, 2) -// }, -// }, -// { -// name: "no failed jobs found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// jobs := &github.Jobs{ -// TotalCount: github.Ptr(2), -// Jobs: []*github.WorkflowJob{ -// { -// ID: github.Ptr(int64(1)), -// Name: github.Ptr("test-job-1"), -// Conclusion: github.Ptr("success"), -// }, -// { -// ID: github.Ptr(int64(2)), -// Name: github.Ptr("test-job-2"), -// Conclusion: github.Ptr("success"), -// }, -// }, -// } -// w.WriteHeader(http.StatusOK) -// _ = json.NewEncoder(w).Encode(jobs) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(456), -// "failed_only": true, -// }, -// expectError: false, -// checkResponse: func(t *testing.T, response map[string]any) { -// assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) -// assert.Equal(t, float64(456), response["run_id"]) -// assert.Equal(t, float64(2), response["total_jobs"]) -// assert.Equal(t, float64(0), response["failed_jobs"]) -// }, -// }, -// { -// name: "missing job_id when not using failed_only", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "job_id is required when failed_only is false", -// }, -// { -// name: "missing run_id when using failed_only", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "failed_only": true, -// }, -// expectError: true, -// expectedErrMsg: "run_id is required when failed_only is true", -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "repo": "repo", -// "job_id": float64(123), -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing required parameter repo", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "owner", -// "job_id": float64(123), -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: repo", -// }, -// { -// name: "API error when getting single job logs", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _ = json.NewEncoder(w).Encode(map[string]string{ -// "message": "Not Found", -// }) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "job_id": float64(999), -// }, -// expectError: true, -// }, -// { -// name: "API error when listing workflow jobs for failed_only", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _ = json.NewEncoder(w).Encode(map[string]string{ -// "message": "Not Found", -// }) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "run_id": float64(999), -// "failed_only": true, -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// require.Equal(t, tc.expectError, result.IsError) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// if tc.expectedErrMsg != "" { -// assert.Equal(t, tc.expectedErrMsg, textContent.Text) -// return -// } - -// if tc.expectError { -// // For API errors, just verify we got an error -// assert.True(t, result.IsError) -// return -// } - -// // Unmarshal and verify the result -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) - -// if tc.checkResponse != nil { -// tc.checkResponse(t, response) -// } -// }) -// } -// } - -// func Test_GetJobLogs_WithContentReturn(t *testing.T) { -// // Test the return_content functionality with a mock HTTP server -// logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - -// // Create a test server to serve log content -// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(logContent)) -// })) -// defer testServer.Close() - -// mockedClient := mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Location", testServer.URL) -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ) - -// client := github.NewClient(mockedClient) -// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - -// request := createMCPRequest(map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "job_id": float64(123), -// "return_content": true, -// }) - -// result, err := handler(context.Background(), request) -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) - -// assert.Equal(t, float64(123), response["job_id"]) -// assert.Equal(t, logContent, response["logs_content"]) -// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) -// assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -// } - -// func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { -// // Test the return_content functionality with a mock HTTP server -// logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" -// expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" - -// // Create a test server to serve log content -// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(logContent)) -// })) -// defer testServer.Close() - -// mockedClient := mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Location", testServer.URL) -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ) - -// client := github.NewClient(mockedClient) -// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - -// request := createMCPRequest(map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "job_id": float64(123), -// "return_content": true, -// "tail_lines": float64(1), // Requesting last 1 line -// }) - -// result, err := handler(context.Background(), request) -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) - -// assert.Equal(t, float64(123), response["job_id"]) -// assert.Equal(t, float64(3), response["original_length"]) -// assert.Equal(t, expectedLogContent, response["logs_content"]) -// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) -// assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -// } - -// func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { -// logContent := "Line 1\nLine 2\nLine 3" -// expectedLogContent := "Line 1\nLine 2\nLine 3" - -// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(logContent)) -// })) -// defer testServer.Close() - -// mockedClient := mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Location", testServer.URL) -// w.WriteHeader(http.StatusFound) -// }), -// ), -// ) - -// client := github.NewClient(mockedClient) -// _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) - -// request := createMCPRequest(map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "job_id": float64(123), -// "return_content": true, -// "tail_lines": float64(100), -// }) - -// result, err := handler(context.Background(), request) -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) -// var response map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) - -// assert.Equal(t, float64(123), response["job_id"]) -// assert.Equal(t, float64(3), response["original_length"]) -// assert.Equal(t, expectedLogContent, response["logs_content"]) -// assert.Equal(t, "Job logs content retrieved successfully", response["message"]) -// assert.NotContains(t, response, "logs_url") -// } - -// func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { -// if testing.Short() { -// t.Skip("Skipping memory profiling test in short mode") -// } - -// const logLines = 100000 -// const bufferSize = 5000 -// largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" - -// testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(largeLogContent)) -// })) -// defer testServer.Close() - -// os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") -// defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") - -// profiler.InitFromEnv(nil) -// ctx := context.Background() - -// debug.SetGCPercent(-1) -// defer debug.SetGCPercent(100) - -// for i := 0; i < 3; i++ { -// runtime.GC() -// } - -// var baselineStats runtime.MemStats -// runtime.ReadMemStats(&baselineStats) - -// profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { -// resp1, err := http.Get(testServer.URL) -// if err != nil { -// return 0, 0, err -// } -// defer resp1.Body.Close() //nolint:bodyclose -// content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose -// return totalLines, int64(len(content)), err -// }) -// require.NoError(t, err1) - -// for i := 0; i < 3; i++ { -// runtime.GC() -// } - -// profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { -// resp2, err := http.Get(testServer.URL) -// if err != nil { -// return 0, 0, err -// } -// defer resp2.Body.Close() //nolint:bodyclose - -// allContent, err := io.ReadAll(resp2.Body) -// if err != nil { -// return 0, 0, err -// } - -// allLines := strings.Split(string(allContent), "\n") -// var nonEmptyLines []string -// for _, line := range allLines { -// if line != "" { -// nonEmptyLines = append(nonEmptyLines, line) -// } -// } -// totalLines := len(nonEmptyLines) - -// var resultLines []string -// if totalLines > bufferSize { -// resultLines = nonEmptyLines[totalLines-bufferSize:] -// } else { -// resultLines = nonEmptyLines -// } - -// result := strings.Join(resultLines, "\n") -// return totalLines, int64(len(result)), nil -// }) -// require.NoError(t, err2) - -// assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, -// "Sliding window should use less memory than reading all into memory") - -// assert.Equal(t, profile1.LinesCount, profile2.LinesCount, -// "Both approaches should count the same number of input lines") -// assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, -// "Both approaches should produce similar output sizes (within 100 bytes)") - -// memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 -// t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", -// memoryReduction, -// float64(profile2.MemoryDelta)/1024/1024, -// float64(profile1.MemoryDelta)/1024/1024) - -// t.Logf("Baseline: %d bytes", baselineStats.Alloc) -// t.Logf("Sliding window: %s", profile1.String()) -// t.Logf("No window: %s", profile2.String()) -// } diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go deleted file mode 100644 index 0feca2b36..000000000 --- a/pkg/github/code_scanning.go +++ /dev/null @@ -1,169 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_code_scanning_alert", -// mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithNumber("alertNumber", -// mcp.Required(), -// mcp.Description("The number of the alert."), -// ), -// ), -// 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 -// } -// alertNumber, err := RequiredInt(request, "alertNumber") -// 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) -// } - -// alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get alert", -// 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 get alert: %s", string(body))), nil -// } - -// r, err := json.Marshal(alert) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alert: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_code_scanning_alerts", -// mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithString("state", -// mcp.Description("Filter code scanning alerts by state. Defaults to open"), -// mcp.DefaultString("open"), -// mcp.Enum("open", "closed", "dismissed", "fixed"), -// ), -// mcp.WithString("ref", -// mcp.Description("The Git reference for the results you want to list."), -// ), -// mcp.WithString("severity", -// mcp.Description("Filter code scanning alerts by severity"), -// mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"), -// ), -// mcp.WithString("tool_name", -// mcp.Description("The name of the tool used for code scanning."), -// ), -// ), -// 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 -// } -// ref, err := OptionalParam[string](request, "ref") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// state, err := OptionalParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// severity, err := OptionalParam[string](request, "severity") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// toolName, err := OptionalParam[string](request, "tool_name") -// 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) -// } -// alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list alerts", -// 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 alerts: %s", string(body))), nil -// } - -// r, err := json.Marshal(alerts) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alerts: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go deleted file mode 100644 index 95197a708..000000000 --- a/pkg/github/code_scanning_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetCodeScanningAlert(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetCodeScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_code_scanning_alert", 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, "alertNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - -// // Setup mock alert for success case -// mockAlert := &github.Alert{ -// Number: github.Ptr(42), -// State: github.Ptr("open"), -// Rule: &github.Rule{ID: github.Ptr("test-rule"), Description: github.Ptr("Test Rule Description")}, -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlert *github.Alert -// expectedErrMsg string -// }{ -// { -// name: "successful alert fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, -// mockAlert, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(42), -// }, -// expectError: false, -// expectedAlert: mockAlert, -// }, -// { -// name: "alert fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(9999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get alert", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetCodeScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlert github.Alert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) -// assert.NoError(t, err) -// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) -// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) -// assert.Equal(t, *tc.expectedAlert.Rule.ID, *returnedAlert.Rule.ID) -// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) - -// }) -// } -// } - -// func Test_ListCodeScanningAlerts(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_code_scanning_alerts", 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, "ref") -// assert.Contains(t, tool.InputSchema.Properties, "state") -// assert.Contains(t, tool.InputSchema.Properties, "severity") -// assert.Contains(t, tool.InputSchema.Properties, "tool_name") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock alerts for success case -// mockAlerts := []*github.Alert{ -// { -// Number: github.Ptr(42), -// State: github.Ptr("open"), -// Rule: &github.Rule{ID: github.Ptr("test-rule-1"), Description: github.Ptr("Test Rule 1")}, -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), -// }, -// { -// Number: github.Ptr(43), -// State: github.Ptr("fixed"), -// Rule: &github.Rule{ID: github.Ptr("test-rule-2"), Description: github.Ptr("Test Rule 2")}, -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/43"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlerts []*github.Alert -// expectedErrMsg string -// }{ -// { -// name: "successful alerts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCodeScanningAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "ref": "main", -// "state": "open", -// "severity": "high", -// "tool_name": "codeql", -// }).andThen( -// mockResponse(t, http.StatusOK, mockAlerts), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "ref": "main", -// "state": "open", -// "severity": "high", -// "tool_name": "codeql", -// }, -// expectError: false, -// expectedAlerts: mockAlerts, -// }, -// { -// name: "alerts listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCodeScanningAlertsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnauthorized) -// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list alerts", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlerts []*github.Alert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) -// assert.NoError(t, err) -// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) -// for i, alert := range returnedAlerts { -// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) -// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) -// assert.Equal(t, *tc.expectedAlerts[i].Rule.ID, *alert.Rule.ID) -// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) -// } -// }) -// } -// } diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go deleted file mode 100644 index f43da8287..000000000 --- a/pkg/github/dependabot.go +++ /dev/null @@ -1,161 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool( -// "get_dependabot_alert", -// mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithNumber("alertNumber", -// mcp.Required(), -// mcp.Description("The number of the alert."), -// ), -// ), -// 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 -// } -// alertNumber, err := RequiredInt(request, "alertNumber") -// 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) -// } - -// alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to get alert with number '%d'", alertNumber), -// 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 get alert: %s", string(body))), nil -// } - -// r, err := json.Marshal(alert) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alert: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool( -// "list_dependabot_alerts", -// mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithString("state", -// mcp.Description("Filter dependabot alerts by state. Defaults to open"), -// mcp.DefaultString("open"), -// mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), -// ), -// mcp.WithString("severity", -// mcp.Description("Filter dependabot alerts by severity"), -// mcp.Enum("low", "medium", "high", "critical"), -// ), -// ), -// 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 -// } -// state, err := OptionalParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// severity, err := OptionalParam[string](request, "severity") -// 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) -// } - -// alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ -// State: ToStringPtr(state), -// Severity: ToStringPtr(severity), -// }) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), -// 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 alerts: %s", string(body))), nil -// } - -// r, err := json.Marshal(alerts) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alerts: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go deleted file mode 100644 index ab879ace1..000000000 --- a/pkg/github/dependabot_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetDependabotAlert(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// // Validate tool schema -// assert.Equal(t, "get_dependabot_alert", 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, "alertNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - -// // Setup mock alert for success case -// mockAlert := &github.DependabotAlert{ -// Number: github.Ptr(42), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlert *github.DependabotAlert -// expectedErrMsg string -// }{ -// { -// name: "successful alert fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, -// mockAlert, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(42), -// }, -// expectError: false, -// expectedAlert: mockAlert, -// }, -// { -// name: "alert fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(9999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get alert", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlert github.DependabotAlert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) -// assert.NoError(t, err) -// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) -// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) -// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) -// }) -// } -// } - -// func Test_ListDependabotAlerts(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_dependabot_alerts", 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, "state") -// assert.Contains(t, tool.InputSchema.Properties, "severity") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock alerts for success case -// criticalAlert := github.DependabotAlert{ -// Number: github.Ptr(1), -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), -// State: github.Ptr("open"), -// SecurityAdvisory: &github.DependabotSecurityAdvisory{ -// Severity: github.Ptr("critical"), -// }, -// } -// highSeverityAlert := github.DependabotAlert{ -// Number: github.Ptr(2), -// HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), -// State: github.Ptr("fixed"), -// SecurityAdvisory: &github.DependabotSecurityAdvisory{ -// Severity: github.Ptr("high"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlerts []*github.DependabotAlert -// expectedErrMsg string -// }{ -// { -// name: "successful open alerts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposDependabotAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "state": "open", -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "open", -// }, -// expectError: false, -// expectedAlerts: []*github.DependabotAlert{&criticalAlert}, -// }, -// { -// name: "successful severity filtered listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposDependabotAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "severity": "high", -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "severity": "high", -// }, -// expectError: false, -// expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, -// }, -// { -// name: "successful all alerts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposDependabotAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{}).andThen( -// mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, -// }, -// { -// name: "alerts listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposDependabotAlertsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnauthorized) -// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list alerts", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlerts []*github.DependabotAlert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) -// assert.NoError(t, err) -// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) -// for i, alert := range returnedAlerts { -// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) -// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) -// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) -// if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && -// alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { -// assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) -// } -// } -// }) -// } -// } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go deleted file mode 100644 index 5a4148511..000000000 --- a/pkg/github/discussions.go +++ /dev/null @@ -1,531 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/go-viper/mapstructure/v2" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// "github.com/shurcooL/githubv4" -// ) - -// const DefaultGraphQLPageSize = 30 - -// // Common interface for all discussion query types -// type DiscussionQueryResult interface { -// GetDiscussionFragment() DiscussionFragment -// } - -// // Implement the interface for all query types -// func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { -// return q.Repository.Discussions -// } - -// func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { -// return q.Repository.Discussions -// } - -// func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { -// return q.Repository.Discussions -// } - -// func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { -// return q.Repository.Discussions -// } - -// type DiscussionFragment struct { -// Nodes []NodeFragment -// PageInfo PageInfoFragment -// TotalCount githubv4.Int -// } - -// type NodeFragment struct { -// Number githubv4.Int -// Title githubv4.String -// CreatedAt githubv4.DateTime -// UpdatedAt githubv4.DateTime -// Author struct { -// Login githubv4.String -// } -// Category struct { -// Name githubv4.String -// } `graphql:"category"` -// URL githubv4.String `graphql:"url"` -// } - -// type PageInfoFragment struct { -// HasNextPage bool -// HasPreviousPage bool -// StartCursor githubv4.String -// EndCursor githubv4.String -// } - -// type BasicNoOrder struct { -// Repository struct { -// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// type BasicWithOrder struct { -// Repository struct { -// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// type WithCategoryAndOrder struct { -// Repository struct { -// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// type WithCategoryNoOrder struct { -// Repository struct { -// Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { -// return &github.Discussion{ -// Number: github.Ptr(int(fragment.Number)), -// Title: github.Ptr(string(fragment.Title)), -// HTMLURL: github.Ptr(string(fragment.URL)), -// CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, -// UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, -// User: &github.User{ -// Login: github.Ptr(string(fragment.Author.Login)), -// }, -// DiscussionCategory: &github.DiscussionCategory{ -// Name: github.Ptr(string(fragment.Category.Name)), -// }, -// } -// } - -// func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { -// if categoryID != nil && useOrdering { -// return &WithCategoryAndOrder{} -// } -// if categoryID != nil && !useOrdering { -// return &WithCategoryNoOrder{} -// } -// if categoryID == nil && useOrdering { -// return &BasicWithOrder{} -// } -// return &BasicNoOrder{} -// } - -// func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_discussions", -// mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), -// ), -// mcp.WithString("category", -// mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), -// ), -// mcp.WithString("orderBy", -// mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), -// mcp.Enum("CREATED_AT", "UPDATED_AT"), -// ), -// mcp.WithString("direction", -// mcp.Description("Order direction."), -// mcp.Enum("ASC", "DESC"), -// ), -// WithCursorPagination(), -// ), -// 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 := OptionalParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// // when not provided, default to the .github repository -// // this will query discussions at the organisation level -// if repo == "" { -// repo = ".github" -// } - -// category, err := OptionalParam[string](request, "category") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// orderBy, err := OptionalParam[string](request, "orderBy") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get pagination parameters and convert to GraphQL format -// pagination, err := OptionalCursorPaginationParams(request) -// if err != nil { -// return nil, err -// } -// paginationParams, err := pagination.ToGraphQLParams() -// if err != nil { -// return nil, err -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// var categoryID *githubv4.ID -// if category != "" { -// id := githubv4.ID(category) -// categoryID = &id -// } - -// vars := map[string]interface{}{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "first": githubv4.Int(*paginationParams.First), -// } -// if paginationParams.After != nil { -// vars["after"] = githubv4.String(*paginationParams.After) -// } else { -// vars["after"] = (*githubv4.String)(nil) -// } - -// // this is an extra check in case the tool description is misinterpreted, because -// // we shouldn't use ordering unless both a 'field' and 'direction' are provided -// useOrdering := orderBy != "" && direction != "" -// if useOrdering { -// vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) -// vars["orderByDirection"] = githubv4.OrderDirection(direction) -// } - -// if categoryID != nil { -// vars["categoryId"] = *categoryID -// } - -// discussionQuery := getQueryType(useOrdering, categoryID) -// if err := client.Query(ctx, discussionQuery, vars); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Extract and convert all discussion nodes using the common interface -// var discussions []*github.Discussion -// var pageInfo PageInfoFragment -// var totalCount githubv4.Int -// if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { -// fragment := queryResult.GetDiscussionFragment() -// for _, node := range fragment.Nodes { -// discussions = append(discussions, fragmentToDiscussion(node)) -// } -// pageInfo = fragment.PageInfo -// totalCount = fragment.TotalCount -// } - -// // Create response with pagination info -// response := map[string]interface{}{ -// "discussions": discussions, -// "pageInfo": map[string]interface{}{ -// "hasNextPage": pageInfo.HasNextPage, -// "hasPreviousPage": pageInfo.HasPreviousPage, -// "startCursor": string(pageInfo.StartCursor), -// "endCursor": string(pageInfo.EndCursor), -// }, -// "totalCount": totalCount, -// } - -// out, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal discussions: %w", err) -// } -// return mcp.NewToolResultText(string(out)), nil -// } -// } - -// func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_discussion", -// mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("discussionNumber", -// mcp.Required(), -// mcp.Description("Discussion Number"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // Decode params -// var params struct { -// Owner string -// Repo string -// DiscussionNumber int32 -// } -// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// var q struct { -// Repository struct { -// Discussion struct { -// Number githubv4.Int -// Title githubv4.String -// Body githubv4.String -// CreatedAt githubv4.DateTime -// URL githubv4.String `graphql:"url"` -// Category struct { -// Name githubv4.String -// } `graphql:"category"` -// } `graphql:"discussion(number: $discussionNumber)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } -// vars := map[string]interface{}{ -// "owner": githubv4.String(params.Owner), -// "repo": githubv4.String(params.Repo), -// "discussionNumber": githubv4.Int(params.DiscussionNumber), -// } -// if err := client.Query(ctx, &q, vars); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// d := q.Repository.Discussion -// discussion := &github.Discussion{ -// Number: github.Ptr(int(d.Number)), -// Title: github.Ptr(string(d.Title)), -// Body: github.Ptr(string(d.Body)), -// HTMLURL: github.Ptr(string(d.URL)), -// CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, -// DiscussionCategory: &github.DiscussionCategory{ -// Name: github.Ptr(string(d.Category.Name)), -// }, -// } -// out, err := json.Marshal(discussion) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal discussion: %w", err) -// } - -// return mcp.NewToolResultText(string(out)), nil -// } -// } - -// func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_discussion_comments", -// mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), -// mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), -// mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), -// WithCursorPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // Decode params -// var params struct { -// Owner string -// Repo string -// DiscussionNumber int32 -// } -// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get pagination parameters and convert to GraphQL format -// pagination, err := OptionalCursorPaginationParams(request) -// if err != nil { -// return nil, err -// } - -// // Check if pagination parameters were explicitly provided -// _, perPageProvided := request.GetArguments()["perPage"] -// paginationExplicit := perPageProvided - -// paginationParams, err := pagination.ToGraphQLParams() -// if err != nil { -// return nil, err -// } - -// // Use default of 30 if pagination was not explicitly provided -// if !paginationExplicit { -// defaultFirst := int32(DefaultGraphQLPageSize) -// paginationParams.First = &defaultFirst -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// var q struct { -// Repository struct { -// Discussion struct { -// Comments struct { -// Nodes []struct { -// Body githubv4.String -// } -// PageInfo struct { -// HasNextPage githubv4.Boolean -// HasPreviousPage githubv4.Boolean -// StartCursor githubv4.String -// EndCursor githubv4.String -// } -// TotalCount int -// } `graphql:"comments(first: $first, after: $after)"` -// } `graphql:"discussion(number: $discussionNumber)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } -// vars := map[string]interface{}{ -// "owner": githubv4.String(params.Owner), -// "repo": githubv4.String(params.Repo), -// "discussionNumber": githubv4.Int(params.DiscussionNumber), -// "first": githubv4.Int(*paginationParams.First), -// } -// if paginationParams.After != nil { -// vars["after"] = githubv4.String(*paginationParams.After) -// } else { -// vars["after"] = (*githubv4.String)(nil) -// } -// if err := client.Query(ctx, &q, vars); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var comments []*github.IssueComment -// for _, c := range q.Repository.Discussion.Comments.Nodes { -// comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) -// } - -// // Create response with pagination info -// response := map[string]interface{}{ -// "comments": comments, -// "pageInfo": map[string]interface{}{ -// "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, -// "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, -// "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), -// "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), -// }, -// "totalCount": q.Repository.Discussion.Comments.TotalCount, -// } - -// out, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal comments: %w", err) -// } - -// return mcp.NewToolResultText(string(out)), nil -// } -// } - -// func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_discussion_categories", -// mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), -// ), -// ), -// 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 := OptionalParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// // when not provided, default to the .github repository -// // this will query discussion categories at the organisation level -// if repo == "" { -// repo = ".github" -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// var q struct { -// Repository struct { -// DiscussionCategories struct { -// Nodes []struct { -// ID githubv4.ID -// Name githubv4.String -// } -// PageInfo struct { -// HasNextPage githubv4.Boolean -// HasPreviousPage githubv4.Boolean -// StartCursor githubv4.String -// EndCursor githubv4.String -// } -// TotalCount int -// } `graphql:"discussionCategories(first: $first)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } -// vars := map[string]interface{}{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "first": githubv4.Int(25), -// } -// if err := client.Query(ctx, &q, vars); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var categories []map[string]string -// for _, c := range q.Repository.DiscussionCategories.Nodes { -// categories = append(categories, map[string]string{ -// "id": fmt.Sprint(c.ID), -// "name": string(c.Name), -// }) -// } - -// // Create response with pagination info -// response := map[string]interface{}{ -// "categories": categories, -// "pageInfo": map[string]interface{}{ -// "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, -// "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, -// "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), -// "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), -// }, -// "totalCount": q.Repository.DiscussionCategories.TotalCount, -// } - -// out, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) -// } -// return mcp.NewToolResultText(string(out)), nil -// } -// } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go deleted file mode 100644 index 2742fc02c..000000000 --- a/pkg/github/discussions_test.go +++ /dev/null @@ -1,778 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" -// "time" - -// "github.com/github/github-mcp-server/internal/githubv4mock" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/shurcooL/githubv4" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// var ( -// discussionsGeneral = []map[string]any{ -// {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, -// {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, -// } -// discussionsAll = []map[string]any{ -// { -// "number": 1, -// "title": "Discussion 1 title", -// "createdAt": "2023-01-01T00:00:00Z", -// "updatedAt": "2023-01-01T00:00:00Z", -// "author": map[string]any{"login": "user1"}, -// "url": "https://github.com/owner/repo/discussions/1", -// "category": map[string]any{"name": "General"}, -// }, -// { -// "number": 2, -// "title": "Discussion 2 title", -// "createdAt": "2023-02-01T00:00:00Z", -// "updatedAt": "2023-02-01T00:00:00Z", -// "author": map[string]any{"login": "user2"}, -// "url": "https://github.com/owner/repo/discussions/2", -// "category": map[string]any{"name": "Questions"}, -// }, -// { -// "number": 3, -// "title": "Discussion 3 title", -// "createdAt": "2023-03-01T00:00:00Z", -// "updatedAt": "2023-03-01T00:00:00Z", -// "author": map[string]any{"login": "user3"}, -// "url": "https://github.com/owner/repo/discussions/3", -// "category": map[string]any{"name": "General"}, -// }, -// } - -// discussionsOrgLevel = []map[string]any{ -// { -// "number": 1, -// "title": "Org Discussion 1 - Community Guidelines", -// "createdAt": "2023-01-15T00:00:00Z", -// "updatedAt": "2023-01-15T00:00:00Z", -// "author": map[string]any{"login": "org-admin"}, -// "url": "https://github.com/owner/.github/discussions/1", -// "category": map[string]any{"name": "Announcements"}, -// }, -// { -// "number": 2, -// "title": "Org Discussion 2 - Roadmap 2023", -// "createdAt": "2023-02-20T00:00:00Z", -// "updatedAt": "2023-02-20T00:00:00Z", -// "author": map[string]any{"login": "org-admin"}, -// "url": "https://github.com/owner/.github/discussions/2", -// "category": map[string]any{"name": "General"}, -// }, -// { -// "number": 3, -// "title": "Org Discussion 3 - Roadmap 2024", -// "createdAt": "2023-02-20T00:00:00Z", -// "updatedAt": "2023-02-20T00:00:00Z", -// "author": map[string]any{"login": "org-admin"}, -// "url": "https://github.com/owner/.github/discussions/3", -// "category": map[string]any{"name": "General"}, -// }, -// { -// "number": 4, -// "title": "Org Discussion 4 - Roadmap 2025", -// "createdAt": "2023-02-20T00:00:00Z", -// "updatedAt": "2023-02-20T00:00:00Z", -// "author": map[string]any{"login": "org-admin"}, -// "url": "https://github.com/owner/.github/discussions/4", -// "category": map[string]any{"name": "General"}, -// }, -// } - -// // Ordered mock responses -// discussionsOrderedCreatedAsc = []map[string]any{ -// discussionsAll[0], // Discussion 1 (created 2023-01-01) -// discussionsAll[1], // Discussion 2 (created 2023-02-01) -// discussionsAll[2], // Discussion 3 (created 2023-03-01) -// } - -// discussionsOrderedUpdatedDesc = []map[string]any{ -// discussionsAll[2], // Discussion 3 (updated 2023-03-01) -// discussionsAll[1], // Discussion 2 (updated 2023-02-01) -// discussionsAll[0], // Discussion 1 (updated 2023-01-01) -// } - -// // only 'General' category discussions ordered by created date descending -// discussionsGeneralOrderedDesc = []map[string]any{ -// discussionsGeneral[1], // Discussion 3 (created 2023-03-01) -// discussionsGeneral[0], // Discussion 1 (created 2023-01-01) -// } - -// mockResponseListAll = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsAll, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 3, -// }, -// }, -// }) -// mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsGeneral, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }) -// mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsOrderedCreatedAsc, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 3, -// }, -// }, -// }) -// mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsOrderedUpdatedDesc, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 3, -// }, -// }, -// }) -// mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsGeneralOrderedDesc, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }) - -// mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussions": map[string]any{ -// "nodes": discussionsOrgLevel, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 4, -// }, -// }, -// }) - -// mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") -// ) - -// func Test_ListDiscussions(t *testing.T) { -// mockClient := githubv4.NewClient(nil) -// toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// assert.Equal(t, "list_discussions", toolDef.Name) -// assert.NotEmpty(t, toolDef.Description) -// assert.Contains(t, toolDef.InputSchema.Properties, "owner") -// assert.Contains(t, toolDef.InputSchema.Properties, "repo") -// assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") -// assert.Contains(t, toolDef.InputSchema.Properties, "direction") -// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) - -// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling -// varsListAll := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsRepoNotFound := map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsDiscussionsFiltered := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "categoryId": "DIC_kwDOABC123", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsOrderByCreatedAsc := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "orderByField": "CREATED_AT", -// "orderByDirection": "ASC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsOrderByUpdatedDesc := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "orderByField": "UPDATED_AT", -// "orderByDirection": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsCategoryWithOrder := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "categoryId": "DIC_kwDOABC123", -// "orderByField": "CREATED_AT", -// "orderByDirection": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsOrgLevel := map[string]interface{}{ -// "owner": "owner", -// "repo": ".github", // This is what gets set when repo is not provided -// "first": float64(30), -// "after": (*string)(nil), -// } - -// tests := []struct { -// name string -// reqParams map[string]interface{} -// expectError bool -// errContains string -// expectedCount int -// verifyOrder func(t *testing.T, discussions []*github.Discussion) -// }{ -// { -// name: "list all discussions without category filter", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedCount: 3, // All discussions -// }, -// { -// name: "filter by category ID", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "category": "DIC_kwDOABC123", -// }, -// expectError: false, -// expectedCount: 2, // Only General discussions (matching the category ID) -// }, -// { -// name: "order by created at ascending", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "orderBy": "CREATED_AT", -// "direction": "ASC", -// }, -// expectError: false, -// expectedCount: 3, -// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { -// // Verify discussions are ordered by created date ascending -// require.Len(t, discussions, 3) -// assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") -// assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") -// assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") -// }, -// }, -// { -// name: "order by updated at descending", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "orderBy": "UPDATED_AT", -// "direction": "DESC", -// }, -// expectError: false, -// expectedCount: 3, -// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { -// // Verify discussions are ordered by updated date descending -// require.Len(t, discussions, 3) -// assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") -// assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") -// assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") -// }, -// }, -// { -// name: "filter by category with order", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "category": "DIC_kwDOABC123", -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// }, -// expectError: false, -// expectedCount: 2, -// verifyOrder: func(t *testing.T, discussions []*github.Discussion) { -// // Verify only General discussions, ordered by created date descending -// require.Len(t, discussions, 2) -// assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") -// assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") -// }, -// }, -// { -// name: "order by without direction (should not use ordering)", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "orderBy": "CREATED_AT", -// }, -// expectError: false, -// expectedCount: 3, -// }, -// { -// name: "direction without order by (should not use ordering)", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "direction": "DESC", -// }, -// expectError: false, -// expectedCount: 3, -// }, -// { -// name: "repository not found error", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// }, -// expectError: true, -// errContains: "repository not found", -// }, -// { -// name: "list org-level discussions (no repo provided)", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// // repo is not provided, it will default to ".github" -// }, -// expectError: false, -// expectedCount: 4, -// }, -// } - -// // Define the actual query strings that match the implementation -// qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" -// qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" -// qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" -// qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// var httpClient *http.Client - -// switch tc.name { -// case "list all discussions without category filter": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "filter by category ID": -// matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "order by created at ascending": -// matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "order by updated at descending": -// matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "filter by category with order": -// matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "order by without direction (should not use ordering)": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "direction without order by (should not use ordering)": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "repository not found error": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "list org-level discussions (no repo provided)": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// } - -// gqlClient := githubv4.NewClient(httpClient) -// _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// req := createMCPRequest(tc.reqParams) -// res, err := handler(context.Background(), req) -// text := getTextResult(t, res).Text - -// if tc.expectError { -// require.True(t, res.IsError) -// assert.Contains(t, text, tc.errContains) -// return -// } -// require.NoError(t, err) - -// // Parse the structured response with pagination info -// var response struct { -// Discussions []*github.Discussion `json:"discussions"` -// PageInfo struct { -// HasNextPage bool `json:"hasNextPage"` -// HasPreviousPage bool `json:"hasPreviousPage"` -// StartCursor string `json:"startCursor"` -// EndCursor string `json:"endCursor"` -// } `json:"pageInfo"` -// TotalCount int `json:"totalCount"` -// } -// err = json.Unmarshal([]byte(text), &response) -// require.NoError(t, err) - -// assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) - -// // Verify order if verifyOrder function is provided -// if tc.verifyOrder != nil { -// tc.verifyOrder(t, response.Discussions) -// } - -// // Verify that all returned discussions have a category if filtered -// if _, hasCategory := tc.reqParams["category"]; hasCategory { -// for _, discussion := range response.Discussions { -// require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") -// assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") -// } -// } -// }) -// } -// } - -// func Test_GetDiscussion(t *testing.T) { -// // Verify tool definition and schema -// toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) -// assert.Equal(t, "get_discussion", toolDef.Name) -// assert.NotEmpty(t, toolDef.Description) -// assert.Contains(t, toolDef.InputSchema.Properties, "owner") -// assert.Contains(t, toolDef.InputSchema.Properties, "repo") -// assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") -// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - -// // Use exact string query that matches implementation output -// qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" - -// vars := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "discussionNumber": float64(1), -// } -// tests := []struct { -// name string -// response githubv4mock.GQLResponse -// expectError bool -// expected *github.Discussion -// errContains string -// }{ -// { -// name: "successful retrieval", -// response: githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{"discussion": map[string]any{ -// "number": 1, -// "title": "Test Discussion Title", -// "body": "This is a test discussion", -// "url": "https://github.com/owner/repo/discussions/1", -// "createdAt": "2025-04-25T12:00:00Z", -// "category": map[string]any{"name": "General"}, -// }}, -// }), -// expectError: false, -// expected: &github.Discussion{ -// HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), -// Number: github.Ptr(1), -// Title: github.Ptr("Test Discussion Title"), -// Body: github.Ptr("This is a test discussion"), -// CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, -// DiscussionCategory: &github.DiscussionCategory{ -// Name: github.Ptr("General"), -// }, -// }, -// }, -// { -// name: "discussion not found", -// response: githubv4mock.ErrorResponse("discussion not found"), -// expectError: true, -// errContains: "discussion not found", -// }, -// } -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) -// httpClient := githubv4mock.NewMockedHTTPClient(matcher) -// gqlClient := githubv4.NewClient(httpClient) -// _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) -// res, err := handler(context.Background(), req) -// text := getTextResult(t, res).Text - -// if tc.expectError { -// require.True(t, res.IsError) -// assert.Contains(t, text, tc.errContains) -// return -// } - -// require.NoError(t, err) -// var out github.Discussion -// require.NoError(t, json.Unmarshal([]byte(text), &out)) -// assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) -// assert.Equal(t, *tc.expected.Number, *out.Number) -// assert.Equal(t, *tc.expected.Title, *out.Title) -// assert.Equal(t, *tc.expected.Body, *out.Body) -// // Check category label -// assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) -// }) -// } -// } - -// func Test_GetDiscussionComments(t *testing.T) { -// // Verify tool definition and schema -// toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) -// assert.Equal(t, "get_discussion_comments", toolDef.Name) -// assert.NotEmpty(t, toolDef.Description) -// assert.Contains(t, toolDef.InputSchema.Properties, "owner") -// assert.Contains(t, toolDef.InputSchema.Properties, "repo") -// assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") -// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) - -// // Use exact string query that matches implementation output -// qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" - -// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling -// vars := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "discussionNumber": float64(1), -// "first": float64(30), -// "after": (*string)(nil), -// } - -// mockResponse := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussion": map[string]any{ -// "comments": map[string]any{ -// "nodes": []map[string]any{ -// {"body": "This is the first comment"}, -// {"body": "This is the second comment"}, -// }, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }, -// }) -// matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) -// httpClient := githubv4mock.NewMockedHTTPClient(matcher) -// gqlClient := githubv4.NewClient(httpClient) -// _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// request := createMCPRequest(map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "discussionNumber": int32(1), -// }) - -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// // (Lines removed) - -// var response struct { -// Comments []*github.IssueComment `json:"comments"` -// PageInfo struct { -// HasNextPage bool `json:"hasNextPage"` -// HasPreviousPage bool `json:"hasPreviousPage"` -// StartCursor string `json:"startCursor"` -// EndCursor string `json:"endCursor"` -// } `json:"pageInfo"` -// TotalCount int `json:"totalCount"` -// } -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) -// assert.Len(t, response.Comments, 2) -// expectedBodies := []string{"This is the first comment", "This is the second comment"} -// for i, comment := range response.Comments { -// assert.Equal(t, expectedBodies[i], *comment.Body) -// } -// } - -// func Test_ListDiscussionCategories(t *testing.T) { -// mockClient := githubv4.NewClient(nil) -// toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// assert.Equal(t, "list_discussion_categories", toolDef.Name) -// assert.NotEmpty(t, toolDef.Description) -// assert.Contains(t, toolDef.Description, "or organisation") -// assert.Contains(t, toolDef.InputSchema.Properties, "owner") -// assert.Contains(t, toolDef.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) - -// // Use exact string query that matches implementation output -// qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - -// // Variables for repository-level categories -// varsRepo := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "first": float64(25), -// } - -// // Variables for organization-level categories (using .github repo) -// varsOrg := map[string]interface{}{ -// "owner": "owner", -// "repo": ".github", -// "first": float64(25), -// } - -// mockRespRepo := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussionCategories": map[string]any{ -// "nodes": []map[string]any{ -// {"id": "123", "name": "CategoryOne"}, -// {"id": "456", "name": "CategoryTwo"}, -// }, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }) - -// mockRespOrg := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "discussionCategories": map[string]any{ -// "nodes": []map[string]any{ -// {"id": "789", "name": "Announcements"}, -// {"id": "101", "name": "General"}, -// {"id": "112", "name": "Ideas"}, -// }, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 3, -// }, -// }, -// }) - -// tests := []struct { -// name string -// reqParams map[string]interface{} -// vars map[string]interface{} -// mockResponse githubv4mock.GQLResponse -// expectError bool -// expectedCount int -// expectedCategories []map[string]string -// }{ -// { -// name: "list repository-level discussion categories", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// vars: varsRepo, -// mockResponse: mockRespRepo, -// expectError: false, -// expectedCount: 2, -// expectedCategories: []map[string]string{ -// {"id": "123", "name": "CategoryOne"}, -// {"id": "456", "name": "CategoryTwo"}, -// }, -// }, -// { -// name: "list org-level discussion categories (no repo provided)", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// // repo is not provided, it will default to ".github" -// }, -// vars: varsOrg, -// mockResponse: mockRespOrg, -// expectError: false, -// expectedCount: 3, -// expectedCategories: []map[string]string{ -// {"id": "789", "name": "Announcements"}, -// {"id": "101", "name": "General"}, -// {"id": "112", "name": "Ideas"}, -// }, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse) -// httpClient := githubv4mock.NewMockedHTTPClient(matcher) -// gqlClient := githubv4.NewClient(httpClient) - -// _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// req := createMCPRequest(tc.reqParams) -// res, err := handler(context.Background(), req) -// text := getTextResult(t, res).Text - -// if tc.expectError { -// require.True(t, res.IsError) -// return -// } -// require.NoError(t, err) - -// var response struct { -// Categories []map[string]string `json:"categories"` -// PageInfo struct { -// HasNextPage bool `json:"hasNextPage"` -// HasPreviousPage bool `json:"hasPreviousPage"` -// StartCursor string `json:"startCursor"` -// EndCursor string `json:"endCursor"` -// } `json:"pageInfo"` -// TotalCount int `json:"totalCount"` -// } -// require.NoError(t, json.Unmarshal([]byte(text), &response)) -// assert.Equal(t, tc.expectedCategories, response.Categories) -// }) -// } -// } diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go deleted file mode 100644 index 284962615..000000000 --- a/pkg/github/dynamic_tools.go +++ /dev/null @@ -1,138 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" - -// "github.com/github/github-mcp-server/pkg/toolsets" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption { -// toolsetNames := make([]string, 0, len(toolsetGroup.Toolsets)) -// for name := range toolsetGroup.Toolsets { -// toolsetNames = append(toolsetNames, name) -// } -// return mcp.Enum(toolsetNames...) -// } - -// func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("enable_toolset", -// mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), -// // Not modifying GitHub data so no need to show a warning -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("toolset", -// mcp.Required(), -// mcp.Description("The name of the toolset to enable"), -// ToolsetEnum(toolsetGroup), -// ), -// ), -// func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // We need to convert the toolsets back to a map for JSON serialization -// toolsetName, err := RequiredParam[string](request, "toolset") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// toolset := toolsetGroup.Toolsets[toolsetName] -// if toolset == nil { -// return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil -// } -// if toolset.Enabled { -// return mcp.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil -// } - -// toolset.Enabled = true - -// // caution: this currently affects the global tools and notifies all clients: -// // -// // Send notification to all initialized sessions -// // s.sendNotificationToAllClients("notifications/tools/list_changed", nil) -// s.AddTools(toolset.GetActiveTools()...) - -// return mcp.NewToolResultText(fmt.Sprintf("Toolset %s enabled", toolsetName)), nil -// } -// } - -// func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_available_toolsets", -// mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// ), -// func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // We need to convert the toolsetGroup back to a map for JSON serialization - -// payload := []map[string]string{} - -// for name, ts := range toolsetGroup.Toolsets { -// { -// t := map[string]string{ -// "name": name, -// "description": ts.Description, -// "can_enable": "true", -// "currently_enabled": fmt.Sprintf("%t", ts.Enabled), -// } -// payload = append(payload, t) -// } -// } - -// r, err := json.Marshal(payload) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal features: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_toolset_tools", -// mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("toolset", -// mcp.Required(), -// mcp.Description("The name of the toolset you want to get the tools for"), -// ToolsetEnum(toolsetGroup), -// ), -// ), -// func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // We need to convert the toolsetGroup back to a map for JSON serialization -// toolsetName, err := RequiredParam[string](request, "toolset") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// toolset := toolsetGroup.Toolsets[toolsetName] -// if toolset == nil { -// return mcp.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil -// } -// payload := []map[string]string{} - -// for _, st := range toolset.GetAvailableTools() { -// tool := map[string]string{ -// "name": st.Tool.Name, -// "description": st.Tool.Description, -// "can_enable": "true", -// "toolset": toolsetName, -// } -// payload = append(payload, tool) -// } - -// r, err := json.Marshal(payload) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal features: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/gists.go b/pkg/github/gists.go deleted file mode 100644 index 9bb51ec2f..000000000 --- a/pkg/github/gists.go +++ /dev/null @@ -1,316 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// // ListGists creates a tool to list gists for a user -// func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_gists", -// mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_GISTS", "List Gists"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("username", -// mcp.Description("GitHub username (omit for authenticated user's gists)"), -// ), -// mcp.WithString("since", -// mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"), -// ), -// WithPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// username, err := OptionalParam[string](request, "username") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// since, err := OptionalParam[string](request, "since") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.GistListOptions{ -// ListOptions: github.ListOptions{ -// Page: pagination.Page, -// PerPage: pagination.PerPage, -// }, -// } - -// // Parse since timestamp if provided -// if since != "" { -// sinceTime, err := parseISOTimestamp(since) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil -// } -// opts.Since = sinceTime -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// gists, resp, err := client.Gists.List(ctx, username, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list gists: %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 list gists: %s", string(body))), nil -// } - -// r, err := json.Marshal(gists) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetGist creates a tool to get the content of a gist -// func GetGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_gist", -// mcp.WithDescription(t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_GIST", "Get Gist Content"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("gist_id", -// mcp.Required(), -// mcp.Description("The ID of the gist"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// gistID, err := RequiredParam[string](request, "gist_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) -// } - -// gist, resp, err := client.Gists.Get(ctx, gistID) -// if err != nil { -// return nil, fmt.Errorf("failed to get gist: %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 gist: %s", string(body))), nil -// } - -// r, err := json.Marshal(gist) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // CreateGist creates a tool to create a new gist -// func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("create_gist", -// mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CREATE_GIST", "Create Gist"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("description", -// mcp.Description("Description of the gist"), -// ), -// mcp.WithString("filename", -// mcp.Required(), -// mcp.Description("Filename for simple single-file gist creation"), -// ), -// mcp.WithString("content", -// mcp.Required(), -// mcp.Description("Content for simple single-file gist creation"), -// ), -// mcp.WithBoolean("public", -// mcp.Description("Whether the gist is public"), -// mcp.DefaultBool(false), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// description, err := OptionalParam[string](request, "description") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// filename, err := RequiredParam[string](request, "filename") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// content, err := RequiredParam[string](request, "content") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// public, err := OptionalParam[bool](request, "public") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// files := make(map[github.GistFilename]github.GistFile) -// files[github.GistFilename(filename)] = github.GistFile{ -// Filename: github.Ptr(filename), -// Content: github.Ptr(content), -// } - -// gist := &github.Gist{ -// Files: files, -// Public: github.Ptr(public), -// Description: github.Ptr(description), -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// createdGist, resp, err := client.Gists.Create(ctx, gist) -// if err != nil { -// return nil, fmt.Errorf("failed to create gist: %w", err) -// } -// 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 gist: %s", string(body))), nil -// } - -// minimalResponse := MinimalResponse{ -// ID: createdGist.GetID(), -// URL: createdGist.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 -// } -// } - -// // UpdateGist creates a tool to edit an existing gist -// func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("update_gist", -// mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_UPDATE_GIST", "Update Gist"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("gist_id", -// mcp.Required(), -// mcp.Description("ID of the gist to update"), -// ), -// mcp.WithString("description", -// mcp.Description("Updated description of the gist"), -// ), -// mcp.WithString("filename", -// mcp.Required(), -// mcp.Description("Filename to update or create"), -// ), -// mcp.WithString("content", -// mcp.Required(), -// mcp.Description("Content for the file"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// gistID, err := RequiredParam[string](request, "gist_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// description, err := OptionalParam[string](request, "description") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// filename, err := RequiredParam[string](request, "filename") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// content, err := RequiredParam[string](request, "content") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// files := make(map[github.GistFilename]github.GistFile) -// files[github.GistFilename(filename)] = github.GistFile{ -// Filename: github.Ptr(filename), -// Content: github.Ptr(content), -// } - -// gist := &github.Gist{ -// Files: files, -// Description: github.Ptr(description), -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) -// if err != nil { -// return nil, fmt.Errorf("failed to update gist: %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 update gist: %s", string(body))), nil -// } - -// minimalResponse := MinimalResponse{ -// ID: updatedGist.GetID(), -// URL: updatedGist.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 -// } -// } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go deleted file mode 100644 index e810f2499..000000000 --- a/pkg/github/gists_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" -// "time" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_ListGists(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_gists", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "username") -// assert.Contains(t, tool.InputSchema.Properties, "since") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Empty(t, tool.InputSchema.Required) - -// // Setup mock gists for success case -// mockGists := []*github.Gist{ -// { -// ID: github.Ptr("gist1"), -// Description: github.Ptr("First Gist"), -// HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), -// Public: github.Ptr(true), -// CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, -// Owner: &github.User{Login: github.Ptr("user")}, -// Files: map[github.GistFilename]github.GistFile{ -// "file1.txt": { -// Filename: github.Ptr("file1.txt"), -// Content: github.Ptr("content of file 1"), -// }, -// }, -// }, -// { -// ID: github.Ptr("gist2"), -// Description: github.Ptr("Second Gist"), -// HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"), -// Public: github.Ptr(false), -// CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, -// Owner: &github.User{Login: github.Ptr("testuser")}, -// Files: map[github.GistFilename]github.GistFile{ -// "file2.js": { -// Filename: github.Ptr("file2.js"), -// Content: github.Ptr("console.log('hello');"), -// }, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedGists []*github.Gist -// expectedErrMsg string -// }{ -// { -// name: "list authenticated user's gists", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetGists, -// mockGists, -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: false, -// expectedGists: mockGists, -// }, -// { -// name: "list specific user's gists", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetUsersGistsByUsername, -// mockResponse(t, http.StatusOK, mockGists), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "username": "testuser", -// }, -// expectError: false, -// expectedGists: mockGists, -// }, -// { -// name: "list gists with pagination and since parameter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetGists, -// expectQueryParams(t, map[string]string{ -// "since": "2023-01-01T00:00:00Z", -// "page": "2", -// "per_page": "5", -// }).andThen( -// mockResponse(t, http.StatusOK, mockGists), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "since": "2023-01-01T00:00:00Z", -// "page": float64(2), -// "perPage": float64(5), -// }, -// expectError: false, -// expectedGists: mockGists, -// }, -// { -// name: "invalid since parameter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetGists, -// mockGists, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "since": "invalid-date", -// }, -// expectError: true, -// expectedErrMsg: "invalid since timestamp", -// }, -// { -// name: "list gists fails with error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetGists, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnauthorized) -// _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "failed to list gists", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// } else { -// // For errors returned as part of the result, not as an error -// assert.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 returnedGists []*github.Gist -// err = json.Unmarshal([]byte(textContent.Text), &returnedGists) -// require.NoError(t, err) - -// assert.Len(t, returnedGists, len(tc.expectedGists)) -// for i, gist := range returnedGists { -// assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID) -// assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description) -// assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL) -// assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public) -// } -// }) -// } -// } - -// func Test_GetGist(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// tool, _ := GetGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "get_gist", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "gist_id") - -// assert.Contains(t, tool.InputSchema.Required, "gist_id") - -// // Setup mock gist for success case -// mockGist := github.Gist{ -// ID: github.Ptr("gist1"), -// Description: github.Ptr("First Gist"), -// HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), -// Public: github.Ptr(true), -// CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, -// Owner: &github.User{Login: github.Ptr("user")}, -// Files: map[github.GistFilename]github.GistFile{ -// github.GistFilename("file1.txt"): { -// Filename: github.Ptr("file1.txt"), -// Content: github.Ptr("content of file 1"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedGists github.Gist -// expectedErrMsg string -// }{ -// { -// name: "Successful fetching different gist", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetGistsByGistId, -// mockResponse(t, http.StatusOK, mockGist), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "gist_id": "gist1", -// }, -// expectError: false, -// expectedGists: mockGist, -// }, -// { -// name: "gist_id parameter missing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetGistsByGistId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Invalid Request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "missing required parameter: gist_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetGist(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// } else { -// // For errors returned as part of the result, not as an error -// assert.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 returnedGists github.Gist -// err = json.Unmarshal([]byte(textContent.Text), &returnedGists) -// require.NoError(t, err) - -// assert.Equal(t, *tc.expectedGists.ID, *returnedGists.ID) -// assert.Equal(t, *tc.expectedGists.Description, *returnedGists.Description) -// assert.Equal(t, *tc.expectedGists.HTMLURL, *returnedGists.HTMLURL) -// assert.Equal(t, *tc.expectedGists.Public, *returnedGists.Public) -// }) -// } -// } - -// func Test_CreateGist(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "create_gist", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "description") -// assert.Contains(t, tool.InputSchema.Properties, "filename") -// assert.Contains(t, tool.InputSchema.Properties, "content") -// assert.Contains(t, tool.InputSchema.Properties, "public") - -// // Verify required parameters -// assert.Contains(t, tool.InputSchema.Required, "filename") -// assert.Contains(t, tool.InputSchema.Required, "content") - -// // Setup mock data for test cases -// createdGist := &github.Gist{ -// ID: github.Ptr("new-gist-id"), -// Description: github.Ptr("Test Gist"), -// HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"), -// Public: github.Ptr(false), -// CreatedAt: &github.Timestamp{Time: time.Now()}, -// Owner: &github.User{Login: github.Ptr("user")}, -// Files: map[github.GistFilename]github.GistFile{ -// "test.go": { -// Filename: github.Ptr("test.go"), -// Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// expectedGist *github.Gist -// }{ -// { -// name: "create gist successfully", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostGists, -// mockResponse(t, http.StatusCreated, createdGist), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "filename": "test.go", -// "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", -// "description": "Test Gist", -// "public": false, -// }, -// expectError: false, -// expectedGist: createdGist, -// }, -// { -// name: "missing required filename", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "content": "test content", -// "description": "Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: filename", -// }, -// { -// name: "missing required content", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "filename": "test.go", -// "description": "Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: content", -// }, -// { -// name: "api returns error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostGists, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnauthorized) -// _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "filename": "test.go", -// "content": "package main", -// "description": "Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "failed to create gist", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// } else { -// // For errors returned as part of the result, not as an error -// assert.NotNil(t, result) -// textContent := getTextResult(t, result) -// assert.Contains(t, textContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// assert.NotNil(t, result) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var gist MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &gist) -// require.NoError(t, err) - -// assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) -// }) -// } -// } - -// func Test_UpdateGist(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "update_gist", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "gist_id") -// assert.Contains(t, tool.InputSchema.Properties, "description") -// assert.Contains(t, tool.InputSchema.Properties, "filename") -// assert.Contains(t, tool.InputSchema.Properties, "content") - -// // Verify required parameters -// assert.Contains(t, tool.InputSchema.Required, "gist_id") -// assert.Contains(t, tool.InputSchema.Required, "filename") -// assert.Contains(t, tool.InputSchema.Required, "content") - -// // Setup mock data for test cases -// updatedGist := &github.Gist{ -// ID: github.Ptr("existing-gist-id"), -// Description: github.Ptr("Updated Test Gist"), -// HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), -// Public: github.Ptr(true), -// UpdatedAt: &github.Timestamp{Time: time.Now()}, -// Owner: &github.User{Login: github.Ptr("user")}, -// Files: map[github.GistFilename]github.GistFile{ -// "updated.go": { -// Filename: github.Ptr("updated.go"), -// Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// expectedGist *github.Gist -// }{ -// { -// name: "update gist successfully", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchGistsByGistId, -// mockResponse(t, http.StatusOK, updatedGist), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "gist_id": "existing-gist-id", -// "filename": "updated.go", -// "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", -// "description": "Updated Test Gist", -// }, -// expectError: false, -// expectedGist: updatedGist, -// }, -// { -// name: "missing required gist_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "filename": "updated.go", -// "content": "updated content", -// "description": "Updated Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: gist_id", -// }, -// { -// name: "missing required filename", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "gist_id": "existing-gist-id", -// "content": "updated content", -// "description": "Updated Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: filename", -// }, -// { -// name: "missing required content", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "gist_id": "existing-gist-id", -// "filename": "updated.go", -// "description": "Updated Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: content", -// }, -// { -// name: "api returns error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchGistsByGistId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "gist_id": "nonexistent-gist-id", -// "filename": "updated.go", -// "content": "package main", -// "description": "Updated Test Gist", -// }, -// expectError: true, -// expectedErrMsg: "failed to update gist", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// } else { -// // For errors returned as part of the result, not as an error -// assert.NotNil(t, result) -// textContent := getTextResult(t, result) -// assert.Contains(t, textContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// assert.NotNil(t, result) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var updateResp MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &updateResp) -// require.NoError(t, err) - -// assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) -// }) -// } -// } diff --git a/pkg/github/git.go b/pkg/github/git.go deleted file mode 100644 index 07cbb25f2..000000000 --- a/pkg/github/git.go +++ /dev/null @@ -1,160 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "strings" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// // TreeEntryResponse represents a single entry in a Git tree. -// type TreeEntryResponse struct { -// Path string `json:"path"` -// Type string `json:"type"` -// Size *int `json:"size,omitempty"` -// Mode string `json:"mode"` -// SHA string `json:"sha"` -// URL string `json:"url"` -// } - -// // TreeResponse represents the response structure for a Git tree. -// type TreeResponse struct { -// SHA string `json:"sha"` -// Truncated bool `json:"truncated"` -// Tree []TreeEntryResponse `json:"tree"` -// TreeSHA string `json:"tree_sha"` -// Owner string `json:"owner"` -// Repo string `json:"repo"` -// Recursive bool `json:"recursive"` -// Count int `json:"count"` -// } - -// // GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. -// func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_repository_tree", -// mcp.WithDescription(t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("tree_sha", -// mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"), -// ), -// mcp.WithBoolean("recursive", -// mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"), -// mcp.DefaultBool(false), -// ), -// mcp.WithString("path_filter", -// mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"), -// ), -// ), -// 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 -// } -// treeSHA, err := OptionalParam[string](request, "tree_sha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// recursive, err := OptionalBoolParamWithDefault(request, "recursive", false) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pathFilter, err := OptionalParam[string](request, "path_filter") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError("failed to get GitHub client"), nil -// } - -// // If no tree_sha is provided, use the repository's default branch -// if treeSHA == "" { -// repoInfo, repoResp, err := client.Repositories.Get(ctx, owner, repo) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get repository info", -// repoResp, -// err, -// ), nil -// } -// treeSHA = *repoInfo.DefaultBranch -// } - -// // Get the tree using the GitHub Git Tree API -// tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get repository tree", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Filter tree entries if path_filter is provided -// var filteredEntries []*github.TreeEntry -// if pathFilter != "" { -// for _, entry := range tree.Entries { -// if strings.HasPrefix(entry.GetPath(), pathFilter) { -// filteredEntries = append(filteredEntries, entry) -// } -// } -// } else { -// filteredEntries = tree.Entries -// } - -// treeEntries := make([]TreeEntryResponse, len(filteredEntries)) -// for i, entry := range filteredEntries { -// treeEntries[i] = TreeEntryResponse{ -// Path: entry.GetPath(), -// Type: entry.GetType(), -// Mode: entry.GetMode(), -// SHA: entry.GetSHA(), -// URL: entry.GetURL(), -// } -// if entry.Size != nil { -// treeEntries[i].Size = entry.Size -// } -// } - -// response := TreeResponse{ -// SHA: *tree.SHA, -// Truncated: *tree.Truncated, -// Tree: treeEntries, -// TreeSHA: treeSHA, -// Owner: owner, -// Repo: repo, -// Recursive: recursive, -// Count: len(filteredEntries), -// } - -// r, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/issues.go b/pkg/github/issues.go deleted file mode 100644 index 3de63c075..000000000 --- a/pkg/github/issues.go +++ /dev/null @@ -1,1661 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" -// "strings" -// "time" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/lockdown" -// "github.com/github/github-mcp-server/pkg/sanitize" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/go-viper/mapstructure/v2" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// "github.com/shurcooL/githubv4" -// ) - -// // CloseIssueInput represents the input for closing an issue via the GraphQL API. -// // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. -// type CloseIssueInput struct { -// IssueID githubv4.ID `json:"issueId"` -// ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` -// StateReason *IssueClosedStateReason `json:"stateReason,omitempty"` -// DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"` -// } - -// // IssueClosedStateReason represents the reason an issue was closed. -// // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. -// type IssueClosedStateReason string - -// const ( -// IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" -// IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" -// IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED" -// ) - -// // fetchIssueIDs retrieves issue IDs via the GraphQL API. -// // When duplicateOf is 0, it fetches only the main issue ID. -// // When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. -// func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { -// // Build query variables common to both cases -// vars := map[string]interface{}{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers -// } - -// if duplicateOf == 0 { -// // Only fetch the main issue ID -// var query struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// if err := gqlClient.Query(ctx, &query, vars); err != nil { -// return "", "", fmt.Errorf("failed to get issue ID") -// } - -// return query.Repository.Issue.ID, "", nil -// } - -// // Fetch both issue IDs in a single query -// var query struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// DuplicateIssue struct { -// ID githubv4.ID -// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// // Add duplicate issue number to variables -// vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers - -// if err := gqlClient.Query(ctx, &query, vars); err != nil { -// return "", "", fmt.Errorf("failed to get issue ID") -// } - -// return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil -// } - -// // getCloseStateReason converts a string state reason to the appropriate enum value -// func getCloseStateReason(stateReason string) IssueClosedStateReason { -// switch stateReason { -// case "not_planned": -// return IssueClosedStateReasonNotPlanned -// case "duplicate": -// return IssueClosedStateReasonDuplicate -// default: // Default to "completed" for empty or "completed" values -// return IssueClosedStateReasonCompleted -// } -// } - -// // IssueFragment represents a fragment of an issue node in the GraphQL API. -// type IssueFragment struct { -// Number githubv4.Int -// Title githubv4.String -// Body githubv4.String -// State githubv4.String -// DatabaseID int64 - -// Author struct { -// Login githubv4.String -// } -// CreatedAt githubv4.DateTime -// UpdatedAt githubv4.DateTime -// Labels struct { -// Nodes []struct { -// Name githubv4.String -// ID githubv4.String -// Description githubv4.String -// } -// } `graphql:"labels(first: 100)"` -// Comments struct { -// TotalCount githubv4.Int -// } `graphql:"comments"` -// } - -// // Common interface for all issue query types -// type IssueQueryResult interface { -// GetIssueFragment() IssueQueryFragment -// } - -// type IssueQueryFragment struct { -// Nodes []IssueFragment `graphql:"nodes"` -// PageInfo struct { -// HasNextPage githubv4.Boolean -// HasPreviousPage githubv4.Boolean -// StartCursor githubv4.String -// EndCursor githubv4.String -// } -// TotalCount int -// } - -// // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. -// type ListIssuesQuery struct { -// Repository struct { -// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. -// type ListIssuesQueryTypeWithLabels struct { -// Repository struct { -// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. -// type ListIssuesQueryWithSince struct { -// Repository struct { -// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. -// type ListIssuesQueryTypeWithLabelsWithSince struct { -// Repository struct { -// Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// // Implement the interface for all query types -// func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { -// return q.Repository.Issues -// } - -// func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { -// return q.Repository.Issues -// } - -// func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { -// return q.Repository.Issues -// } - -// func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { -// return q.Repository.Issues -// } - -// func getIssueQueryType(hasLabels bool, hasSince bool) any { -// switch { -// case hasLabels && hasSince: -// return &ListIssuesQueryTypeWithLabelsWithSince{} -// case hasLabels: -// return &ListIssuesQueryTypeWithLabels{} -// case hasSince: -// return &ListIssuesQueryWithSince{} -// default: -// return &ListIssuesQuery{} -// } -// } - -// func fragmentToIssue(fragment IssueFragment) *github.Issue { -// // Convert GraphQL labels to GitHub API labels format -// var foundLabels []*github.Label -// for _, labelNode := range fragment.Labels.Nodes { -// foundLabels = append(foundLabels, &github.Label{ -// Name: github.Ptr(string(labelNode.Name)), -// NodeID: github.Ptr(string(labelNode.ID)), -// Description: github.Ptr(string(labelNode.Description)), -// }) -// } - -// return &github.Issue{ -// Number: github.Ptr(int(fragment.Number)), -// Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), -// CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, -// UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, -// User: &github.User{ -// Login: github.Ptr(string(fragment.Author.Login)), -// }, -// State: github.Ptr(string(fragment.State)), -// ID: github.Ptr(fragment.DatabaseID), -// Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), -// Labels: foundLabels, -// Comments: github.Ptr(int(fragment.Comments.TotalCount)), -// } -// } - -// // GetIssue creates a tool to get details of a specific issue in a GitHub repository. -// func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (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_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"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository"), -// ), -// mcp.WithNumber("issue_number", -// 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 -// } -// 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// gqlClient, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) -// } - -// switch method { -// case "get": -// return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) -// case "get_comments": -// return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) -// case "get_sub_issues": -// return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) -// case "get_labels": -// return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) -// default: -// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil -// } -// } -// } - -// func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, flags FeatureFlags) (*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() }() - -// 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 -// } - -// if flags.LockdownMode { -// if issue.User != nil { -// shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil -// } -// if shouldRemoveContent { -// return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil -// } -// } -// } - -// // Sanitize title/body on response -// if issue != nil { -// if issue.Title != nil { -// issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) -// } -// if issue.Body != nil { -// issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) -// } -// } - -// 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, _ FeatureFlags) (*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, _ FeatureFlags) (*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, _ FeatureFlags) (*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. -// func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - -// return mcp.NewTool("list_issue_types", -// mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The organization owner of the repository"), -// ), -// ), -// 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) -// if err != nil { -// return nil, fmt.Errorf("failed to list issue types: %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 list issue types: %s", string(body))), nil -// } - -// r, err := json.Marshal(issueTypes) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal issue types: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // 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. 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), -// }), -// 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 comment on"), -// ), -// mcp.WithString("body", -// mcp.Required(), -// mcp.Description("Comment content"), -// ), -// ), -// 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 -// } -// body, err := RequiredParam[string](request, "body") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// comment := &github.IssueComment{ -// Body: github.Ptr(body), -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) -// if err != nil { -// return nil, fmt.Errorf("failed to create comment: %w", err) -// } -// 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 comment: %s", string(body))), nil -// } - -// r, err := json.Marshal(createdComment) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // 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_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"), -// ), -// 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 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. 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 -// } -// 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 -// } -// replaceParent, err := OptionalParam[bool](request, "replace_parent") -// 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) -// } - -// 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 -// } -// } -// } - -// 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), -// } - -// 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 -// } - -// 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 add 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 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 -// } - -// // 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") -// } -// } - -// // 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"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("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. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter."), -// ), -// 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) { -// 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 -// } -// repo, err := RequiredParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// title, err := OptionalParam[string](request, "title") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Optional parameters -// body, err := OptionalParam[string](request, "body") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get assignees -// assignees, err := OptionalStringArrayParam(request, "assignees") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get labels -// labels, err := OptionalStringArrayParam(request, "labels") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional milestone -// milestone, err := OptionalIntParam(request, "milestone") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var milestoneNum int -// if milestone != 0 { -// milestoneNum = milestone -// } - -// // Get optional type -// issueType, err := OptionalParam[string](request, "type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// gqlClient, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GraphQL client: %w", err) -// } - -// 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 mcp.NewToolResultError(err.Error()), nil -// } -// 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 -// } -// } -// } - -// 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 -// } - -// // Create the issue request -// issueRequest := &github.IssueRequest{ -// Title: github.Ptr(title), -// Body: github.Ptr(body), -// Assignees: &assignees, -// Labels: &labels, -// } - -// if milestoneNum != 0 { -// issueRequest.Milestone = &milestoneNum -// } - -// if issueType != "" { -// issueRequest.Type = github.Ptr(issueType) -// } - -// 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 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 -// } - -// // Return minimal response with just essential information -// minimalResponse := MinimalResponse{ -// ID: fmt.Sprintf("%d", issue.GetID()), -// URL: issue.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 -// } - -// 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{} - -// // Set optional parameters if provided -// if title != "" { -// issueRequest.Title = github.Ptr(title) -// } - -// if body != "" { -// issueRequest.Body = github.Ptr(body) -// } - -// if len(labels) > 0 { -// issueRequest.Labels = &labels -// } - -// if len(assignees) > 0 { -// issueRequest.Assignees = &assignees -// } - -// if milestoneNum != 0 { -// issueRequest.Milestone = &milestoneNum -// } - -// if issueType != "" { -// issueRequest.Type = github.Ptr(issueType) -// } - -// 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 != "" { -// // 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 -// } - -// // ListIssues creates a tool to list and filter repository issues -// func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_issues", -// mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("state", -// mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), -// mcp.Enum("OPEN", "CLOSED"), -// ), -// mcp.WithArray("labels", -// mcp.Description("Filter by labels"), -// mcp.Items( -// map[string]interface{}{ -// "type": "string", -// }, -// ), -// ), -// mcp.WithString("orderBy", -// mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), -// mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), -// ), -// mcp.WithString("direction", -// mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), -// mcp.Enum("ASC", "DESC"), -// ), -// mcp.WithString("since", -// mcp.Description("Filter by date (ISO 8601 timestamp)"), -// ), -// WithCursorPagination(), -// ), -// 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 -// } - -// // Set optional parameters if provided -// state, err := OptionalParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // If the state has a value, cast into an array of strings -// var states []githubv4.IssueState -// if state != "" { -// states = append(states, githubv4.IssueState(state)) -// } else { -// states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} -// } - -// // Get labels -// labels, err := OptionalStringArrayParam(request, "labels") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// orderBy, err := OptionalParam[string](request, "orderBy") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // These variables are required for the GraphQL query to be set by default -// // If orderBy is empty, default to CREATED_AT -// if orderBy == "" { -// orderBy = "CREATED_AT" -// } -// // If direction is empty, default to DESC -// if direction == "" { -// direction = "DESC" -// } - -// since, err := OptionalParam[string](request, "since") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // There are two optional parameters: since and labels. -// var sinceTime time.Time -// var hasSince bool -// if since != "" { -// sinceTime, err = parseISOTimestamp(since) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil -// } -// hasSince = true -// } -// hasLabels := len(labels) > 0 - -// // Get pagination parameters and convert to GraphQL format -// pagination, err := OptionalCursorPaginationParams(request) -// if err != nil { -// return nil, err -// } - -// // Check if someone tried to use page-based pagination instead of cursor-based -// if _, pageProvided := request.GetArguments()["page"]; pageProvided { -// return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil -// } - -// // Check if pagination parameters were explicitly provided -// _, perPageProvided := request.GetArguments()["perPage"] -// paginationExplicit := perPageProvided - -// paginationParams, err := pagination.ToGraphQLParams() -// if err != nil { -// return nil, err -// } - -// // Use default of 30 if pagination was not explicitly provided -// if !paginationExplicit { -// defaultFirst := int32(DefaultGraphQLPageSize) -// paginationParams.First = &defaultFirst -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// vars := map[string]interface{}{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "states": states, -// "orderBy": githubv4.IssueOrderField(orderBy), -// "direction": githubv4.OrderDirection(direction), -// "first": githubv4.Int(*paginationParams.First), -// } - -// if paginationParams.After != nil { -// vars["after"] = githubv4.String(*paginationParams.After) -// } else { -// // Used within query, therefore must be set to nil and provided as $after -// vars["after"] = (*githubv4.String)(nil) -// } - -// // Ensure optional parameters are set -// if hasLabels { -// // Use query with labels filtering - convert string labels to githubv4.String slice -// labelStrings := make([]githubv4.String, len(labels)) -// for i, label := range labels { -// labelStrings[i] = githubv4.String(label) -// } -// vars["labels"] = labelStrings -// } - -// if hasSince { -// vars["since"] = githubv4.DateTime{Time: sinceTime} -// } - -// issueQuery := getIssueQueryType(hasLabels, hasSince) -// if err := client.Query(ctx, issueQuery, vars); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Extract and convert all issue nodes using the common interface -// var issues []*github.Issue -// var pageInfo struct { -// HasNextPage githubv4.Boolean -// HasPreviousPage githubv4.Boolean -// StartCursor githubv4.String -// EndCursor githubv4.String -// } -// var totalCount int - -// if queryResult, ok := issueQuery.(IssueQueryResult); ok { -// fragment := queryResult.GetIssueFragment() -// for _, issue := range fragment.Nodes { -// issues = append(issues, fragmentToIssue(issue)) -// } -// pageInfo = fragment.PageInfo -// totalCount = fragment.TotalCount -// } - -// // Create response with issues -// response := map[string]interface{}{ -// "issues": issues, -// "pageInfo": map[string]interface{}{ -// "hasNextPage": pageInfo.HasNextPage, -// "hasPreviousPage": pageInfo.HasPreviousPage, -// "startCursor": string(pageInfo.StartCursor), -// "endCursor": string(pageInfo.EndCursor), -// }, -// "totalCount": totalCount, -// } -// out, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal issues: %w", err) -// } -// return mcp.NewToolResultText(string(out)), 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 { -// summary string -// outcomes []string -// referenceLinks []string -// } - -// func (d *mvpDescription) String() string { -// var sb strings.Builder -// sb.WriteString(d.summary) -// if len(d.outcomes) > 0 { -// sb.WriteString("\n\n") -// sb.WriteString("This tool can help with the following outcomes:\n") -// for _, outcome := range d.outcomes { -// sb.WriteString(fmt.Sprintf("- %s\n", outcome)) -// } -// } - -// if len(d.referenceLinks) > 0 { -// sb.WriteString("\n\n") -// sb.WriteString("More information can be found at:\n") -// for _, link := range d.referenceLinks { -// sb.WriteString(fmt.Sprintf("- %s\n", link)) -// } -// } - -// return sb.String() -// } - -// func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// description := mvpDescription{ -// summary: "Assign Copilot to a specific issue in a GitHub repository.", -// outcomes: []string{ -// "a Pull Request created with source code changes to resolve the issue", -// }, -// referenceLinks: []string{ -// "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", -// }, -// } - -// return mcp.NewTool("assign_copilot_to_issue", -// mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), -// ReadOnlyHint: ToBoolPtr(false), -// IdempotentHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("issueNumber", -// mcp.Required(), -// mcp.Description("Issue number"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// var params struct { -// Owner string -// Repo string -// IssueNumber int32 -// } -// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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) -// } - -// // Firstly, we try to find the copilot bot in the suggested actors for the repository. -// // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe -// // it will not be on the first page of responses, thus we will keep paginating until we find it. -// type botAssignee struct { -// ID githubv4.ID -// Login string -// TypeName string `graphql:"__typename"` -// } - -// type suggestedActorsQuery struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot botAssignee `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// } - -// variables := map[string]any{ -// "owner": githubv4.String(params.Owner), -// "name": githubv4.String(params.Repo), -// "endCursor": (*githubv4.String)(nil), -// } - -// var copilotAssignee *botAssignee -// for { -// var query suggestedActorsQuery -// err := client.Query(ctx, &query, variables) -// if err != nil { -// return nil, err -// } - -// // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the -// // same name on each host. We need this in order to get the ID for later assignment. -// for _, node := range query.Repository.SuggestedActors.Nodes { -// if node.Bot.Login == "copilot-swe-agent" { -// copilotAssignee = &node.Bot -// break -// } -// } - -// if !query.Repository.SuggestedActors.PageInfo.HasNextPage { -// break -// } -// variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) -// } - -// // If we didn't find the copilot bot, we can't proceed any further. -// if copilotAssignee == nil { -// // The e2e tests depend upon this specific message to skip the test. -// return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil -// } - -// // Next let's get the GQL Node ID and current assignees for this issue because the only way to -// // assign copilot is to use replaceActorsForAssignable which requires the full list. -// var getIssueQuery struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// Assignees struct { -// Nodes []struct { -// ID githubv4.ID -// } -// } `graphql:"assignees(first: 100)"` -// } `graphql:"issue(number: $number)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// } - -// variables = map[string]any{ -// "owner": githubv4.String(params.Owner), -// "name": githubv4.String(params.Repo), -// "number": githubv4.Int(params.IssueNumber), -// } - -// if err := client.Query(ctx, &getIssueQuery, variables); err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil -// } - -// // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already -// // assigned to seems to have no impact (which is a good thing). -// var assignCopilotMutation struct { -// ReplaceActorsForAssignable struct { -// Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors -// } `graphql:"replaceActorsForAssignable(input: $input)"` -// } - -// actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) -// for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { -// actorIDs[i] = node.ID -// } -// actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID - -// if err := client.Mutate( -// ctx, -// &assignCopilotMutation, -// ReplaceActorsForAssignableInput{ -// AssignableID: getIssueQuery.Repository.Issue.ID, -// ActorIDs: actorIDs, -// }, -// nil, -// ); err != nil { -// return nil, fmt.Errorf("failed to replace actors for assignable: %w", err) -// } - -// return mcp.NewToolResultText("successfully assigned copilot to issue"), nil -// } -// } - -// type ReplaceActorsForAssignableInput struct { -// AssignableID githubv4.ID `json:"assignableId"` -// ActorIDs []githubv4.ID `json:"actorIds"` -// } - -// // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. -// // Returns the parsed time or an error if parsing fails. -// // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" -// func parseISOTimestamp(timestamp string) (time.Time, error) { -// if timestamp == "" { -// return time.Time{}, fmt.Errorf("empty timestamp") -// } - -// // Try RFC3339 format (standard ISO 8601 with time) -// t, err := time.Parse(time.RFC3339, timestamp) -// if err == nil { -// return t, nil -// } - -// // Try simple date format (YYYY-MM-DD) -// t, err = time.Parse("2006-01-02", timestamp) -// if err == nil { -// return t, nil -// } - -// // Return error with supported formats -// return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) -// } - -// func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { -// return mcp.NewPrompt("AssignCodingAgent", -// mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), -// mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), -// ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { -// repo := request.Params.Arguments["repo"] - -// messages := []mcp.PromptMessage{ -// { -// Role: "user", -// Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."), -// }, -// } -// return &mcp.GetPromptResult{ -// Messages: messages, -// }, nil -// } -// } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go deleted file mode 100644 index 569b41bf6..000000000 --- a/pkg/github/issues_test.go +++ /dev/null @@ -1,3521 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "net/http" -// "strings" -// "testing" -// "time" - -// "github.com/github/github-mcp-server/internal/githubv4mock" -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/shurcooL/githubv4" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetIssue(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// defaultGQLClient := githubv4.NewClient(nil) -// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(defaultGQLClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// 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"}) - -// // Setup mock issue for success case -// mockIssue := &github.Issue{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test Issue"), -// Body: github.Ptr("This is a test issue"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// Repository: &github.Repository{ -// Name: github.Ptr("repo"), -// Owner: &github.User{ -// Login: github.Ptr("owner"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// gqlHTTPClient *http.Client -// requestArgs map[string]interface{} -// expectHandlerError bool -// expectResultError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// lockdownEnabled bool -// }{ -// { -// name: "successful issue retrieval", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesByOwnerByRepoByIssueNumber, -// mockIssue, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectedIssue: mockIssue, -// }, -// { -// name: "issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// }, -// expectHandlerError: true, -// expectedErrMsg: "failed to get issue", -// }, -// { -// name: "lockdown enabled - private repository", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesByOwnerByRepoByIssueNumber, -// mockIssue, -// ), -// ), -// gqlHTTPClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// IsPrivate githubv4.Boolean -// Collaborators struct { -// Edges []struct { -// Permission githubv4.String -// Node struct { -// Login githubv4.String -// } -// } -// } `graphql:"collaborators(query: $username, first: 1)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "username": githubv4.String("testuser"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "isPrivate": true, -// "collaborators": map[string]any{ -// "edges": []any{}, -// }, -// }, -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectedIssue: mockIssue, -// lockdownEnabled: true, -// }, -// { -// name: "lockdown enabled - user lacks push access", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesByOwnerByRepoByIssueNumber, -// mockIssue, -// ), -// ), -// gqlHTTPClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// IsPrivate githubv4.Boolean -// Collaborators struct { -// Edges []struct { -// Permission githubv4.String -// Node struct { -// Login githubv4.String -// } -// } -// } `graphql:"collaborators(query: $username, first: 1)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "username": githubv4.String("testuser"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "isPrivate": false, -// "collaborators": map[string]any{ -// "edges": []any{ -// map[string]any{ -// "permission": "READ", -// "node": map[string]any{ -// "login": "testuser", -// }, -// }, -// }, -// }, -// }, -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectResultError: true, -// expectedErrMsg: "access to issue details is restricted by lockdown mode", -// lockdownEnabled: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) - -// var gqlClient *githubv4.Client -// if tc.gqlHTTPClient != nil { -// gqlClient = githubv4.NewClient(tc.gqlHTTPClient) -// } else { -// gqlClient = defaultGQLClient -// } - -// flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) -// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, flags) - -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectHandlerError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.NotNil(t, result) - -// if tc.expectResultError { -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// textContent := getTextResult(t, result) - -// var returnedIssue github.Issue -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) -// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) -// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) -// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) -// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) -// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) -// }) -// } -// } - -// func Test_AddIssueComment(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"}) - -// // Setup mock comment for success case -// mockComment := &github.IssueComment{ -// ID: github.Ptr(int64(123)), -// Body: github.Ptr("This is a test comment"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedComment *github.IssueComment -// expectedErrMsg string -// }{ -// { -// name: "successful comment creation", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusCreated, mockComment), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "body": "This is a test comment", -// }, -// expectError: false, -// expectedComment: mockComment, -// }, -// { -// name: "comment creation fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "body": "", -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: body", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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 returnedComment github.IssueComment -// err = json.Unmarshal([]byte(textContent.Text), &returnedComment) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) -// assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) -// assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) - -// }) -// } -// } - -// func Test_SearchIssues(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "search_issues", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// // Setup mock search results -// mockSearchResult := &github.IssuesSearchResult{ -// Total: github.Ptr(2), -// IncompleteResults: github.Ptr(false), -// Issues: []*github.Issue{ -// { -// Number: github.Ptr(42), -// Title: github.Ptr("Bug: Something is broken"), -// Body: github.Ptr("This is a bug report"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), -// Comments: github.Ptr(5), -// User: &github.User{ -// Login: github.Ptr("user1"), -// }, -// }, -// { -// Number: github.Ptr(43), -// Title: github.Ptr("Feature: Add new functionality"), -// Body: github.Ptr("This is a feature request"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), -// Comments: github.Ptr(3), -// User: &github.User{ -// Login: github.Ptr("user2"), -// }, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.IssuesSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful issues search with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:issue repo:owner/repo is:open", -// "sort": "created", -// "order": "desc", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:owner/repo is:open", -// "sort": "created", -// "order": "desc", -// "page": float64(1), -// "perPage": float64(30), -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "issues search with owner and repo parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "repo:test-owner/test-repo is:issue is:open", -// "sort": "created", -// "order": "asc", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:open", -// "owner": "test-owner", -// "repo": "test-repo", -// "sort": "created", -// "order": "asc", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "issues search with only owner parameter (should ignore it)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:issue bug", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "bug", -// "owner": "test-owner", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "issues search with only repo parameter (should ignore it)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:issue feature", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "feature", -// "repo": "test-repo", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "issues search with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetSearchIssues, -// mockSearchResult, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:issue repo:owner/repo is:open", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing is:issue filter - no duplication", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:issue repo:github/github-mcp-server critical", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:github/github-mcp-server critical", -// "owner": "different-owner", -// "repo": "different-repo", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with both is: and repo: filters already present", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:issue repo:octocat/Hello-World bug", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:issue repo:octocat/Hello-World bug", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "complex query with multiple OR operators and existing filters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "search issues fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search issues", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) -// for i, issue := range returnedResult.Issues { -// assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) -// assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) -// assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) -// assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) -// assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) -// } -// }) -// } -// } - -// func Test_CreateIssue(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// mockGQLClient := githubv4.NewClient(nil) -// tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "assignees") -// 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{"method", "owner", "repo"}) - -// // Setup mock issue for success case -// mockIssue := &github.Issue{ -// Number: github.Ptr(123), -// Title: github.Ptr("Test Issue"), -// Body: github.Ptr("This is a test issue"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, -// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, -// Milestone: &github.Milestone{Number: github.Ptr(5)}, -// Type: &github.IssueType{Name: github.Ptr("Bug")}, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// }{ -// { -// name: "successful issue creation with all fields", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesByOwnerByRepo, -// expectRequestBody(t, map[string]any{ -// "title": "Test Issue", -// "body": "This is a test issue", -// "labels": []any{"bug", "help wanted"}, -// "assignees": []any{"user1", "user2"}, -// "milestone": float64(5), -// "type": "Bug", -// }).andThen( -// mockResponse(t, http.StatusCreated, mockIssue), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "title": "Test Issue", -// "body": "This is a test issue", -// "assignees": []any{"user1", "user2"}, -// "labels": []any{"bug", "help wanted"}, -// "milestone": float64(5), -// "type": "Bug", -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "successful issue creation with minimal fields", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesByOwnerByRepo, -// mockResponse(t, http.StatusCreated, &github.Issue{ -// Number: github.Ptr(124), -// Title: github.Ptr("Minimal Issue"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), -// State: github.Ptr("open"), -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "title": "Minimal Issue", -// "assignees": nil, // Expect no failure with nil optional value. -// }, -// expectError: false, -// expectedIssue: &github.Issue{ -// Number: github.Ptr(124), -// Title: github.Ptr("Minimal Issue"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), -// State: github.Ptr("open"), -// }, -// }, -// { -// name: "issue creation fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "title": "", -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: title", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// gqlClient := githubv4.NewClient(nil) -// _, handler := IssueWrite(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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) -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var returnedIssue MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) -// require.NoError(t, err) - -// assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) -// }) -// } -// } - -// func Test_ListIssues(t *testing.T) { -// // Verify tool definition -// mockClient := githubv4.NewClient(nil) -// tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_issues", 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, "state") -// assert.Contains(t, tool.InputSchema.Properties, "labels") -// assert.Contains(t, tool.InputSchema.Properties, "orderBy") -// assert.Contains(t, tool.InputSchema.Properties, "direction") -// assert.Contains(t, tool.InputSchema.Properties, "since") -// assert.Contains(t, tool.InputSchema.Properties, "after") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Mock issues data -// mockIssuesAll := []map[string]any{ -// { -// "number": 123, -// "title": "First Issue", -// "body": "This is the first test issue", -// "state": "OPEN", -// "databaseId": 1001, -// "createdAt": "2023-01-01T00:00:00Z", -// "updatedAt": "2023-01-01T00:00:00Z", -// "author": map[string]any{"login": "user1"}, -// "labels": map[string]any{ -// "nodes": []map[string]any{ -// {"name": "bug", "id": "label1", "description": "Bug label"}, -// }, -// }, -// "comments": map[string]any{ -// "totalCount": 5, -// }, -// }, -// { -// "number": 456, -// "title": "Second Issue", -// "body": "This is the second test issue", -// "state": "OPEN", -// "databaseId": 1002, -// "createdAt": "2023-02-01T00:00:00Z", -// "updatedAt": "2023-02-01T00:00:00Z", -// "author": map[string]any{"login": "user2"}, -// "labels": map[string]any{ -// "nodes": []map[string]any{ -// {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, -// }, -// }, -// "comments": map[string]any{ -// "totalCount": 3, -// }, -// }, -// } - -// mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} -// mockIssuesClosed := []map[string]any{ -// { -// "number": 789, -// "title": "Closed Issue", -// "body": "This is a closed issue", -// "state": "CLOSED", -// "databaseId": 1003, -// "createdAt": "2023-03-01T00:00:00Z", -// "updatedAt": "2023-03-01T00:00:00Z", -// "author": map[string]any{"login": "user3"}, -// "labels": map[string]any{ -// "nodes": []map[string]any{}, -// }, -// "comments": map[string]any{ -// "totalCount": 1, -// }, -// }, -// } - -// // Mock responses -// mockResponseListAll := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issues": map[string]any{ -// "nodes": mockIssuesAll, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }) - -// mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issues": map[string]any{ -// "nodes": mockIssuesOpen, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 2, -// }, -// }, -// }) - -// mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issues": map[string]any{ -// "nodes": mockIssuesClosed, -// "pageInfo": map[string]any{ -// "hasNextPage": false, -// "hasPreviousPage": false, -// "startCursor": "", -// "endCursor": "", -// }, -// "totalCount": 1, -// }, -// }, -// }) - -// mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") - -// // Variables matching what GraphQL receives after JSON marshaling/unmarshaling -// varsListAll := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "states": []interface{}{"OPEN", "CLOSED"}, -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsOpenOnly := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "states": []interface{}{"OPEN"}, -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsClosedOnly := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "states": []interface{}{"CLOSED"}, -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsWithLabels := map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "states": []interface{}{"OPEN", "CLOSED"}, -// "labels": []interface{}{"bug", "enhancement"}, -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// varsRepoNotFound := map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// "states": []interface{}{"OPEN", "CLOSED"}, -// "orderBy": "CREATED_AT", -// "direction": "DESC", -// "first": float64(30), -// "after": (*string)(nil), -// } - -// tests := []struct { -// name string -// reqParams map[string]interface{} -// expectError bool -// errContains string -// expectedCount int -// verifyOrder func(t *testing.T, issues []*github.Issue) -// }{ -// { -// name: "list all issues", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedCount: 2, -// }, -// { -// name: "filter by open state", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "OPEN", -// }, -// expectError: false, -// expectedCount: 2, -// }, -// { -// name: "filter by closed state", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "CLOSED", -// }, -// expectError: false, -// expectedCount: 1, -// }, -// { -// name: "filter by labels", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "labels": []any{"bug", "enhancement"}, -// }, -// expectError: false, -// expectedCount: 2, -// }, -// { -// name: "repository not found error", -// reqParams: map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// }, -// expectError: true, -// errContains: "repository not found", -// }, -// } - -// // Define the actual query strings that match the implementation -// qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" -// qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// var httpClient *http.Client - -// switch tc.name { -// case "list all issues": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "filter by open state": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "filter by closed state": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "filter by labels": -// matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// case "repository not found error": -// matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) -// httpClient = githubv4mock.NewMockedHTTPClient(matcher) -// } - -// gqlClient := githubv4.NewClient(httpClient) -// _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// req := createMCPRequest(tc.reqParams) -// res, err := handler(context.Background(), req) -// text := getTextResult(t, res).Text - -// if tc.expectError { -// require.True(t, res.IsError) -// assert.Contains(t, text, tc.errContains) -// return -// } -// require.NoError(t, err) - -// // Parse the structured response with pagination info -// var response struct { -// Issues []*github.Issue `json:"issues"` -// PageInfo struct { -// HasNextPage bool `json:"hasNextPage"` -// HasPreviousPage bool `json:"hasPreviousPage"` -// StartCursor string `json:"startCursor"` -// EndCursor string `json:"endCursor"` -// } `json:"pageInfo"` -// TotalCount int `json:"totalCount"` -// } -// err = json.Unmarshal([]byte(text), &response) -// require.NoError(t, err) - -// assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) - -// // Verify order if verifyOrder function is provided -// if tc.verifyOrder != nil { -// tc.verifyOrder(t, response.Issues) -// } - -// // Verify that returned issues have expected structure -// for _, issue := range response.Issues { -// assert.NotNil(t, issue.Number, "Issue should have number") -// assert.NotNil(t, issue.Title, "Issue should have title") -// assert.NotNil(t, issue.State, "Issue should have state") -// } -// }) -// } -// } - -// func Test_UpdateIssue(t *testing.T) { -// // Verify tool definition -// mockClient := github.NewClient(nil) -// mockGQLClient := githubv4.NewClient(nil) -// tool, _ := IssueWrite(stubGetClientFn(mockClient), stubGetGQLClientFn(mockGQLClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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") -// assert.Contains(t, tool.InputSchema.Properties, "title") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "labels") -// assert.Contains(t, tool.InputSchema.Properties, "assignees") -// assert.Contains(t, tool.InputSchema.Properties, "milestone") -// assert.Contains(t, tool.InputSchema.Properties, "type") -// 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{"method", "owner", "repo"}) - -// // Mock issues for reuse across test cases -// mockBaseIssue := &github.Issue{ -// Number: github.Ptr(123), -// Title: github.Ptr("Title"), -// Body: github.Ptr("Description"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, -// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, -// Milestone: &github.Milestone{Number: github.Ptr(5)}, -// Type: &github.IssueType{Name: github.Ptr("Bug")}, -// } - -// mockUpdatedIssue := &github.Issue{ -// Number: github.Ptr(123), -// Title: github.Ptr("Updated Title"), -// Body: github.Ptr("Updated Description"), -// State: github.Ptr("closed"), -// StateReason: github.Ptr("duplicate"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, -// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, -// Milestone: &github.Milestone{Number: github.Ptr(5)}, -// Type: &github.IssueType{Name: github.Ptr("Bug")}, -// } - -// mockReopenedIssue := &github.Issue{ -// Number: github.Ptr(123), -// Title: github.Ptr("Title"), -// State: github.Ptr("open"), -// StateReason: github.Ptr("reopened"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// } - -// // Mock GraphQL responses for reuse across test cases -// issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issue": map[string]any{ -// "id": "I_kwDOA0xdyM50BPaO", -// }, -// }, -// }) - -// duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issue": map[string]any{ -// "id": "I_kwDOA0xdyM50BPaO", -// }, -// "duplicateIssue": map[string]any{ -// "id": "I_kwDOA0xdyM50BPbP", -// }, -// }, -// }) - -// closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ -// "closeIssue": map[string]any{ -// "issue": map[string]any{ -// "id": "I_kwDOA0xdyM50BPaO", -// "number": 123, -// "url": "https://github.com/owner/repo/issues/123", -// "state": "CLOSED", -// }, -// }, -// }) - -// reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{ -// "reopenIssue": map[string]any{ -// "issue": map[string]any{ -// "id": "I_kwDOA0xdyM50BPaO", -// "number": 123, -// "url": "https://github.com/owner/repo/issues/123", -// "state": "OPEN", -// }, -// }, -// }) - -// duplicateStateReason := IssueClosedStateReasonDuplicate - -// tests := []struct { -// name string -// mockedRESTClient *http.Client -// mockedGQLClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// }{ -// { -// name: "partial update of non-state fields only", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// expectRequestBody(t, map[string]interface{}{ -// "title": "Updated Title", -// "body": "Updated Description", -// }).andThen( -// mockResponse(t, http.StatusOK, mockUpdatedIssue), -// ), -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "title": "Updated Title", -// "body": "Updated Description", -// }, -// expectError: false, -// expectedIssue: mockUpdatedIssue, -// }, -// { -// name: "issue not found when updating non-state fields only", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// "title": "Updated Title", -// }, -// expectError: true, -// expectedErrMsg: "failed to update issue", -// }, -// { -// name: "close issue as duplicate", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// mockBaseIssue, -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// DuplicateIssue struct { -// ID githubv4.ID -// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "issueNumber": githubv4.Int(123), -// "duplicateOf": githubv4.Int(456), -// }, -// duplicateIssueIDQueryResponse, -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// CloseIssue struct { -// Issue struct { -// ID githubv4.ID -// Number githubv4.Int -// URL githubv4.String -// State githubv4.String -// } -// } `graphql:"closeIssue(input: $input)"` -// }{}, -// CloseIssueInput{ -// IssueID: "I_kwDOA0xdyM50BPaO", -// StateReason: &duplicateStateReason, -// DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), -// }, -// nil, -// closeSuccessResponse, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "state": "closed", -// "state_reason": "duplicate", -// "duplicate_of": float64(456), -// }, -// expectError: false, -// expectedIssue: mockUpdatedIssue, -// }, -// { -// name: "reopen issue", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// mockBaseIssue, -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `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), -// }, -// issueIDQueryResponse, -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// ReopenIssue struct { -// Issue struct { -// ID githubv4.ID -// Number githubv4.Int -// URL githubv4.String -// State githubv4.String -// } -// } `graphql:"reopenIssue(input: $input)"` -// }{}, -// githubv4.ReopenIssueInput{ -// IssueID: "I_kwDOA0xdyM50BPaO", -// }, -// nil, -// reopenSuccessResponse, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "state": "open", -// }, -// expectError: false, -// expectedIssue: mockReopenedIssue, -// }, -// { -// name: "main issue not found when trying to close it", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// mockBaseIssue, -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "issueNumber": githubv4.Int(999), -// }, -// githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// "state": "closed", -// "state_reason": "not_planned", -// }, -// expectError: true, -// expectedErrMsg: "Failed to find issues", -// }, -// { -// name: "duplicate issue not found when closing as duplicate", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// mockBaseIssue, -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// DuplicateIssue struct { -// ID githubv4.ID -// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "issueNumber": githubv4.Int(123), -// "duplicateOf": githubv4.Int(999), -// }, -// githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "state": "closed", -// "state_reason": "duplicate", -// "duplicate_of": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "Failed to find issues", -// }, -// { -// name: "close as duplicate with combined non-state updates", -// mockedRESTClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesByOwnerByRepoByIssueNumber, -// expectRequestBody(t, map[string]interface{}{ -// "title": "Updated Title", -// "body": "Updated Description", -// "labels": []any{"bug", "priority"}, -// "assignees": []any{"assignee1", "assignee2"}, -// "milestone": float64(5), -// "type": "Bug", -// }).andThen( -// mockResponse(t, http.StatusOK, &github.Issue{ -// Number: github.Ptr(123), -// Title: github.Ptr("Updated Title"), -// Body: github.Ptr("Updated Description"), -// Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, -// Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, -// Milestone: &github.Milestone{Number: github.Ptr(5)}, -// Type: &github.IssueType{Name: github.Ptr("Bug")}, -// State: github.Ptr("open"), // Still open after REST update -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// }), -// ), -// ), -// ), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// } `graphql:"issue(number: $issueNumber)"` -// DuplicateIssue struct { -// ID githubv4.ID -// } `graphql:"duplicateIssue: issue(number: $duplicateOf)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "issueNumber": githubv4.Int(123), -// "duplicateOf": githubv4.Int(456), -// }, -// duplicateIssueIDQueryResponse, -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// CloseIssue struct { -// Issue struct { -// ID githubv4.ID -// Number githubv4.Int -// URL githubv4.String -// State githubv4.String -// } -// } `graphql:"closeIssue(input: $input)"` -// }{}, -// CloseIssueInput{ -// IssueID: "I_kwDOA0xdyM50BPaO", -// StateReason: &duplicateStateReason, -// DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"), -// }, -// nil, -// closeSuccessResponse, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "title": "Updated Title", -// "body": "Updated Description", -// "labels": []any{"bug", "priority"}, -// "assignees": []any{"assignee1", "assignee2"}, -// "milestone": float64(5), -// "type": "Bug", -// "state": "closed", -// "state_reason": "duplicate", -// "duplicate_of": float64(456), -// }, -// expectError: false, -// expectedIssue: mockUpdatedIssue, -// }, -// { -// name: "duplicate_of without duplicate state_reason should fail", -// mockedRESTClient: mock.NewMockedHTTPClient(), -// mockedGQLClient: githubv4mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(123), -// "state": "closed", -// "state_reason": "completed", -// "duplicate_of": float64(456), -// }, -// expectError: true, -// expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup clients with mocks -// restClient := github.NewClient(tc.mockedRESTClient) -// gqlClient := githubv4.NewClient(tc.mockedGQLClient) -// _, handler := IssueWrite(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError || tc.expectedErrMsg != "" { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// 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 -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var updateResp MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &updateResp) -// require.NoError(t, err) - -// assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) -// }) -// } -// } - -// func Test_ParseISOTimestamp(t *testing.T) { -// tests := []struct { -// name string -// input string -// expectedErr bool -// expectedTime time.Time -// }{ -// { -// name: "valid RFC3339 format", -// input: "2023-01-15T14:30:00Z", -// expectedErr: false, -// expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC), -// }, -// { -// name: "valid date only format", -// input: "2023-01-15", -// expectedErr: false, -// expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC), -// }, -// { -// name: "empty timestamp", -// input: "", -// expectedErr: true, -// }, -// { -// name: "invalid format", -// input: "15/01/2023", -// expectedErr: true, -// }, -// { -// name: "invalid date", -// input: "2023-13-45", -// expectedErr: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// parsedTime, err := parseISOTimestamp(tc.input) - -// if tc.expectedErr { -// assert.Error(t, err) -// } else { -// assert.NoError(t, err) -// assert.Equal(t, tc.expectedTime, parsedTime) -// } -// }) -// } -// } - -// func Test_GetIssueComments(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// gqlClient := githubv4.NewClient(nil) -// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// 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.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) - -// // Setup mock comments for success case -// mockComments := []*github.IssueComment{ -// { -// ID: github.Ptr(int64(123)), -// Body: github.Ptr("This is the first comment"), -// User: &github.User{ -// Login: github.Ptr("user1"), -// }, -// CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, -// }, -// { -// ID: github.Ptr(int64(456)), -// Body: github.Ptr("This is the second comment"), -// User: &github.User{ -// Login: github.Ptr("user2"), -// }, -// CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)}, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedComments []*github.IssueComment -// expectedErrMsg string -// }{ -// { -// name: "successful comments retrieval", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, -// mockComments, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_comments", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedComments: mockComments, -// }, -// { -// name: "successful comments retrieval with pagination", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, -// expectQueryParams(t, map[string]string{ -// "page": "2", -// "per_page": "10", -// }).andThen( -// mockResponse(t, http.StatusOK, mockComments), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_comments", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedComments: mockComments, -// }, -// { -// name: "issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_comments", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get issue comments", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// gqlClient := githubv4.NewClient(nil) -// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedComments []*github.IssueComment -// err = json.Unmarshal([]byte(textContent.Text), &returnedComments) -// require.NoError(t, err) -// assert.Equal(t, len(tc.expectedComments), len(returnedComments)) -// if len(returnedComments) > 0 { -// assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) -// assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) -// } -// }) -// } -// } - -// 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// 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, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// 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() - -// // Verify tool definition -// mockClient := githubv4.NewClient(nil) -// tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "assign_copilot_to_issue", 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, "issueNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) - -// var pageOfFakeBots = func(n int) []struct{} { -// // We don't _really_ need real bots here, just objects that count as entries for the page -// bots := make([]struct{}, n) -// for i := range n { -// bots[i] = struct{}{} -// } -// return bots -// } - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful assignment when there are no existing assignees", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "issueNumber": float64(123), -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot struct { -// ID githubv4.ID -// Login githubv4.String -// TypeName string `graphql:"__typename"` -// } `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "endCursor": (*githubv4.String)(nil), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "suggestedActors": map[string]any{ -// "nodes": []any{ -// map[string]any{ -// "id": githubv4.ID("copilot-swe-agent-id"), -// "login": githubv4.String("copilot-swe-agent"), -// "__typename": "Bot", -// }, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// Assignees struct { -// Nodes []struct { -// ID githubv4.ID -// } -// } `graphql:"assignees(first: 100)"` -// } `graphql:"issue(number: $number)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "number": githubv4.Int(123), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issue": map[string]any{ -// "id": githubv4.ID("test-issue-id"), -// "assignees": map[string]any{ -// "nodes": []any{}, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// ReplaceActorsForAssignable struct { -// Typename string `graphql:"__typename"` -// } `graphql:"replaceActorsForAssignable(input: $input)"` -// }{}, -// ReplaceActorsForAssignableInput{ -// AssignableID: githubv4.ID("test-issue-id"), -// ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// { -// name: "successful assignment when there are existing assignees", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "issueNumber": float64(123), -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot struct { -// ID githubv4.ID -// Login githubv4.String -// TypeName string `graphql:"__typename"` -// } `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "endCursor": (*githubv4.String)(nil), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "suggestedActors": map[string]any{ -// "nodes": []any{ -// map[string]any{ -// "id": githubv4.ID("copilot-swe-agent-id"), -// "login": githubv4.String("copilot-swe-agent"), -// "__typename": "Bot", -// }, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// Assignees struct { -// Nodes []struct { -// ID githubv4.ID -// } -// } `graphql:"assignees(first: 100)"` -// } `graphql:"issue(number: $number)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "number": githubv4.Int(123), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issue": map[string]any{ -// "id": githubv4.ID("test-issue-id"), -// "assignees": map[string]any{ -// "nodes": []any{ -// map[string]any{ -// "id": githubv4.ID("existing-assignee-id"), -// }, -// map[string]any{ -// "id": githubv4.ID("existing-assignee-id-2"), -// }, -// }, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// ReplaceActorsForAssignable struct { -// Typename string `graphql:"__typename"` -// } `graphql:"replaceActorsForAssignable(input: $input)"` -// }{}, -// ReplaceActorsForAssignableInput{ -// AssignableID: githubv4.ID("test-issue-id"), -// ActorIDs: []githubv4.ID{ -// githubv4.ID("existing-assignee-id"), -// githubv4.ID("existing-assignee-id-2"), -// githubv4.ID("copilot-swe-agent-id"), -// }, -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// { -// name: "copilot bot not on first page of suggested actors", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "issueNumber": float64(123), -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// // First page of suggested actors -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot struct { -// ID githubv4.ID -// Login githubv4.String -// TypeName string `graphql:"__typename"` -// } `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "endCursor": (*githubv4.String)(nil), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "suggestedActors": map[string]any{ -// "nodes": pageOfFakeBots(100), -// "pageInfo": map[string]any{ -// "hasNextPage": true, -// "endCursor": githubv4.String("next-page-cursor"), -// }, -// }, -// }, -// }), -// ), -// // Second page of suggested actors -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot struct { -// ID githubv4.ID -// Login githubv4.String -// TypeName string `graphql:"__typename"` -// } `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "endCursor": githubv4.String("next-page-cursor"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "suggestedActors": map[string]any{ -// "nodes": []any{ -// map[string]any{ -// "id": githubv4.ID("copilot-swe-agent-id"), -// "login": githubv4.String("copilot-swe-agent"), -// "__typename": "Bot", -// }, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Issue struct { -// ID githubv4.ID -// Assignees struct { -// Nodes []struct { -// ID githubv4.ID -// } -// } `graphql:"assignees(first: 100)"` -// } `graphql:"issue(number: $number)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "number": githubv4.Int(123), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "issue": map[string]any{ -// "id": githubv4.ID("test-issue-id"), -// "assignees": map[string]any{ -// "nodes": []any{}, -// }, -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// ReplaceActorsForAssignable struct { -// Typename string `graphql:"__typename"` -// } `graphql:"replaceActorsForAssignable(input: $input)"` -// }{}, -// ReplaceActorsForAssignableInput{ -// AssignableID: githubv4.ID("test-issue-id"), -// ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// { -// name: "copilot not a suggested actor", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "issueNumber": float64(123), -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// SuggestedActors struct { -// Nodes []struct { -// Bot struct { -// ID githubv4.ID -// Login githubv4.String -// TypeName string `graphql:"__typename"` -// } `graphql:"... on Bot"` -// } -// PageInfo struct { -// HasNextPage bool -// EndCursor string -// } -// } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "name": githubv4.String("repo"), -// "endCursor": (*githubv4.String)(nil), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "suggestedActors": map[string]any{ -// "nodes": []any{}, -// }, -// }, -// }), -// ), -// ), -// expectToolError: true, -// expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { - -// t.Parallel() -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) -// require.Equal(t, textContent.Text, "successfully assigned copilot to issue") -// }) -// } -// } - -// func Test_AddSubIssue(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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{"method", "owner", "repo", "issue_number", "sub_issue_id"}) - -// // Setup mock issue for success case (matches GitHub API response format) -// mockIssue := &github.Issue{ -// Number: github.Ptr(42), -// Title: github.Ptr("Parent Issue"), -// Body: github.Ptr("This is the parent issue with a sub-issue"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// Labels: []*github.Label{ -// { -// Name: github.Ptr("enhancement"), -// Color: github.Ptr("84b6eb"), -// Description: github.Ptr("New feature or request"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// }{ -// { -// name: "successful sub-issue addition with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusCreated, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "replace_parent": true, -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "successful sub-issue addition with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusCreated, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(456), -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "successful sub-issue addition with replace_parent false", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusCreated, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(789), -// "replace_parent": false, -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "parent issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "failed to add sub-issue", -// }, -// { -// name: "sub-issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(999), -// }, -// expectError: false, -// expectedErrMsg: "failed to add sub-issue", -// }, -// { -// name: "validation failed - sub-issue cannot be parent of itself", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "failed to add sub-issue", -// }, -// { -// name: "insufficient permissions", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// 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), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "failed to add sub-issue", -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient( -// // 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), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing required parameter sub_issue_id", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "add", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: sub_issue_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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 returnedIssue github.Issue -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) -// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) -// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) -// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) -// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) -// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) -// }) -// } -// } - -// func Test_GetSubIssues(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// gqlClient := githubv4.NewClient(nil) -// tool, _ := IssueRead(stubGetClientFn(mockClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// 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.Contains(t, tool.InputSchema.Properties, "page") -// 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{ -// { -// Number: github.Ptr(123), -// Title: github.Ptr("Sub-issue 1"), -// Body: github.Ptr("This is the first sub-issue"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), -// User: &github.User{ -// Login: github.Ptr("user1"), -// }, -// Labels: []*github.Label{ -// { -// Name: github.Ptr("bug"), -// Color: github.Ptr("d73a4a"), -// Description: github.Ptr("Something isn't working"), -// }, -// }, -// }, -// { -// Number: github.Ptr(124), -// Title: github.Ptr("Sub-issue 2"), -// Body: github.Ptr("This is the second sub-issue"), -// State: github.Ptr("closed"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), -// User: &github.User{ -// Login: github.Ptr("user2"), -// }, -// Assignees: []*github.User{ -// {Login: github.Ptr("assignee1")}, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedSubIssues []*github.Issue -// expectedErrMsg string -// }{ -// { -// name: "successful sub-issues listing with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockSubIssues, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedSubIssues: mockSubIssues, -// }, -// { -// name: "successful sub-issues listing with pagination", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// expectQueryParams(t, map[string]string{ -// "page": "2", -// "per_page": "10", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSubIssues), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedSubIssues: mockSubIssues, -// }, -// { -// name: "successful sub-issues listing with empty result", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// []*github.Issue{}, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedSubIssues: []*github.Issue{}, -// }, -// { -// name: "parent issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// }, -// expectError: false, -// expectedErrMsg: "failed to list sub-issues", -// }, -// { -// name: "repository not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "nonexistent", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "failed to list sub-issues", -// }, -// { -// name: "sub-issues feature gone/deprecated", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "failed to list sub-issues", -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing required parameter issue_number", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_sub_issues", -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: issue_number", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// gqlClient := githubv4.NewClient(nil) -// _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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 returnedSubIssues []*github.Issue -// err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) -// require.NoError(t, err) - -// assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) -// for i, subIssue := range returnedSubIssues { -// if i < len(tc.expectedSubIssues) { -// assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) -// assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) -// assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) -// assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) -// assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) - -// if tc.expectedSubIssues[i].Body != nil { -// assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) -// } -// } -// } -// }) -// } -// } - -// func Test_RemoveSubIssue(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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{"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{ -// Number: github.Ptr(42), -// Title: github.Ptr("Parent Issue"), -// Body: github.Ptr("This is the parent issue after sub-issue removal"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// Labels: []*github.Label{ -// { -// Name: github.Ptr("enhancement"), -// Color: github.Ptr("84b6eb"), -// Description: github.Ptr("New feature or request"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// }{ -// { -// name: "successful sub-issue removal", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusOK, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "parent issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "failed to remove sub-issue", -// }, -// { -// name: "sub-issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(999), -// }, -// expectError: false, -// expectedErrMsg: "failed to remove sub-issue", -// }, -// { -// name: "bad request - invalid sub_issue_id", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(-1), -// }, -// expectError: false, -// expectedErrMsg: "failed to remove sub-issue", -// }, -// { -// name: "repository not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "nonexistent", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "failed to remove sub-issue", -// }, -// { -// name: "insufficient permissions", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, -// 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), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "failed to remove sub-issue", -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient( -// // 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), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing required parameter sub_issue_id", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "remove", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: sub_issue_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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 returnedIssue github.Issue -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) -// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) -// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) -// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) -// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) -// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) -// }) -// } -// } - -// func Test_ReprioritizeSubIssue(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SubIssueWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// 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{"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{ -// Number: github.Ptr(42), -// Title: github.Ptr("Parent Issue"), -// Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// Labels: []*github.Label{ -// { -// Name: github.Ptr("enhancement"), -// Color: github.Ptr("84b6eb"), -// Description: github.Ptr("New feature or request"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssue *github.Issue -// expectedErrMsg string -// }{ -// { -// name: "successful reprioritization with after_id", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusOK, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "successful reprioritization with before_id", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusOK, mockIssue), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "before_id": float64(789), -// }, -// expectError: false, -// expectedIssue: mockIssue, -// }, -// { -// name: "validation error - neither after_id nor before_id specified", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// }, -// expectError: false, -// expectedErrMsg: "either after_id or before_id must be specified", -// }, -// { -// name: "validation error - both after_id and before_id specified", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "after_id": float64(456), -// "before_id": float64(789), -// }, -// expectError: false, -// expectedErrMsg: "only one of after_id or before_id should be specified, not both", -// }, -// { -// name: "parent issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(999), -// "sub_issue_id": float64(123), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "failed to reprioritize sub-issue", -// }, -// { -// name: "sub-issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(999), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "failed to reprioritize sub-issue", -// }, -// { -// name: "validation failed - positioning sub-issue not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "after_id": float64(999), -// }, -// expectError: false, -// expectedErrMsg: "failed to reprioritize sub-issue", -// }, -// { -// name: "insufficient permissions", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "failed to reprioritize sub-issue", -// }, -// { -// name: "service unavailable", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, -// mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "sub_issue_id": float64(123), -// "before_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "failed to reprioritize sub-issue", -// }, -// { -// name: "missing required parameter owner", -// mockedClient: mock.NewMockedHTTPClient( -// // 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), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing required parameter sub_issue_id", -// mockedClient: mock.NewMockedHTTPClient( -// // No mocked requests needed since validation fails before HTTP call -// ), -// requestArgs: map[string]interface{}{ -// "method": "reprioritize", -// "owner": "owner", -// "repo": "repo", -// "issue_number": float64(42), -// "after_id": float64(456), -// }, -// expectError: false, -// expectedErrMsg: "missing required parameter: sub_issue_id", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SubIssueWrite(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), 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 returnedIssue github.Issue -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) -// assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) -// assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) -// assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) -// assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) -// assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) -// }) -// } -// } - -// func Test_ListIssueTypes(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_issue_types", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) - -// // Setup mock issue types for success case -// mockIssueTypes := []*github.IssueType{ -// { -// ID: github.Ptr(int64(1)), -// Name: github.Ptr("bug"), -// Description: github.Ptr("Something isn't working"), -// Color: github.Ptr("d73a4a"), -// }, -// { -// ID: github.Ptr(int64(2)), -// Name: github.Ptr("feature"), -// Description: github.Ptr("New feature or enhancement"), -// Color: github.Ptr("a2eeef"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedIssueTypes []*github.IssueType -// expectedErrMsg string -// }{ -// { -// name: "successful issue types retrieval", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/orgs/testorg/issue-types", -// Method: "GET", -// }, -// mockResponse(t, http.StatusOK, mockIssueTypes), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "testorg", -// }, -// expectError: false, -// expectedIssueTypes: mockIssueTypes, -// }, -// { -// name: "organization not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/orgs/nonexistent/issue-types", -// Method: "GET", -// }, -// mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "nonexistent", -// }, -// expectError: true, -// expectedErrMsg: "failed to list issue types", -// }, -// { -// name: "missing owner parameter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/orgs/testorg/issue-types", -// Method: "GET", -// }, -// mockResponse(t, http.StatusOK, mockIssueTypes), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: false, // This should be handled by parameter validation, error returned in result -// expectedErrMsg: "missing required parameter: owner", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } -// // Check if error is returned as tool result error -// require.NotNil(t, result) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// // Check if it's a parameter validation error (returned as tool result error) -// if result != nil && result.IsError { -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { -// return // This is expected for parameter validation errors -// } -// } - -// require.NoError(t, err) -// require.NotNil(t, result) -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedIssueTypes []*github.IssueType -// err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) -// require.NoError(t, err) - -// if tc.expectedIssueTypes != nil { -// require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) -// for i, expected := range tc.expectedIssueTypes { -// assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) -// assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) -// assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) -// assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) -// } -// } -// }) -// } -// } diff --git a/pkg/github/labels.go b/pkg/github/labels.go deleted file mode 100644 index f6027c9cd..000000000 --- a/pkg/github/labels.go +++ /dev/null @@ -1,399 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "strings" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// "github.com/shurcooL/githubv4" -// ) - -// // GetLabel retrieves a specific label by name from a GitHub repository -// func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool( -// "get_label", -// mcp.WithDescription(t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization name)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("name", -// mcp.Required(), -// mcp.Description("Label name."), -// ), -// ), -// 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 -// } - -// name, err := RequiredParam[string](request, "name") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var query struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// Color githubv4.String -// Description githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// vars := map[string]any{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "name": githubv4.String(name), -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// if err := client.Query(ctx, &query, vars); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil -// } - -// if query.Repository.Label.Name == "" { -// return mcp.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil -// } - -// label := map[string]any{ -// "id": fmt.Sprintf("%v", query.Repository.Label.ID), -// "name": string(query.Repository.Label.Name), -// "color": string(query.Repository.Label.Color), -// "description": string(query.Repository.Label.Description), -// } - -// out, err := json.Marshal(label) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal label: %w", err) -// } - -// return mcp.NewToolResultText(string(out)), nil -// } -// } - -// // 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")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization name) - required for all operations"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name - required for all operations"), -// ), -// ), -// 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 -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// var query struct { -// Repository 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:"repository(owner: $owner, name: $repo)"` -// } - -// vars := map[string]any{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// } - -// if err := client.Query(ctx, &query, vars); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil -// } - -// labels := make([]map[string]any, len(query.Repository.Labels.Nodes)) -// for i, labelNode := range query.Repository.Labels.Nodes { -// labels[i] = map[string]any{ -// "id": fmt.Sprintf("%v", labelNode.ID), -// "name": string(labelNode.Name), -// "color": string(labelNode.Color), -// "description": string(labelNode.Description), -// } -// } - -// response := map[string]any{ -// "labels": labels, -// "totalCount": int(query.Repository.Labels.TotalCount), -// } - -// out, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal labels: %w", err) -// } - -// return mcp.NewToolResultText(string(out)), nil -// } -// } - -// // LabelWrite handles create, update, and delete operations for GitHub labels -// func LabelWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool( -// "label_write", -// mcp.WithDescription(t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("method", -// mcp.Required(), -// mcp.Description("Operation to perform: 'create', 'update', or 'delete'"), -// mcp.Enum("create", "update", "delete"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization name)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("name", -// mcp.Required(), -// mcp.Description("Label name - required for all operations"), -// ), -// mcp.WithString("new_name", -// mcp.Description("New name for the label (used only with 'update' method to rename)"), -// ), -// mcp.WithString("color", -// mcp.Description("Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."), -// ), -// mcp.WithString("description", -// mcp.Description("Label description text. Optional for 'create' and 'update'."), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// // Get and validate required parameters -// method, err := RequiredParam[string](request, "method") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// method = strings.ToLower(method) - -// 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 -// } - -// name, err := RequiredParam[string](request, "name") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Get optional parameters -// newName, _ := OptionalParam[string](request, "new_name") -// color, _ := OptionalParam[string](request, "color") -// description, _ := OptionalParam[string](request, "description") - -// client, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// switch method { -// case "create": -// // Validate required params for create -// if color == "" { -// return mcp.NewToolResultError("color is required for create"), nil -// } - -// // Get repository ID -// repoID, err := getRepositoryID(ctx, client, owner, repo) -// if err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil -// } - -// input := githubv4.CreateLabelInput{ -// RepositoryID: repoID, -// Name: githubv4.String(name), -// Color: githubv4.String(color), -// } -// if description != "" { -// d := githubv4.String(description) -// input.Description = &d -// } - -// var mutation struct { -// CreateLabel struct { -// Label struct { -// Name githubv4.String -// ID githubv4.ID -// } -// } `graphql:"createLabel(input: $input)"` -// } - -// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil - -// case "update": -// // Validate required params for update -// if newName == "" && color == "" && description == "" { -// return mcp.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil -// } - -// // Get the label ID -// labelID, err := getLabelID(ctx, client, owner, repo, name) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// input := githubv4.UpdateLabelInput{ -// ID: labelID, -// } -// if newName != "" { -// n := githubv4.String(newName) -// input.Name = &n -// } -// if color != "" { -// c := githubv4.String(color) -// input.Color = &c -// } -// if description != "" { -// d := githubv4.String(description) -// input.Description = &d -// } - -// var mutation struct { -// UpdateLabel struct { -// Label struct { -// Name githubv4.String -// ID githubv4.ID -// } -// } `graphql:"updateLabel(input: $input)"` -// } - -// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil - -// case "delete": -// // Get the label ID -// labelID, err := getLabelID(ctx, client, owner, repo, name) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// input := githubv4.DeleteLabelInput{ -// ID: labelID, -// } - -// var mutation struct { -// DeleteLabel struct { -// ClientMutationID githubv4.String -// } `graphql:"deleteLabel(input: $input)"` -// } - -// if err := client.Mutate(ctx, &mutation, input, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil - -// default: -// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil -// } -// } -// } - -// // Helper function to get repository ID -// func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) { -// var repoQuery struct { -// Repository struct { -// ID githubv4.ID -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } -// vars := map[string]any{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// } -// if err := client.Query(ctx, &repoQuery, vars); err != nil { -// return "", err -// } -// return repoQuery.Repository.ID, nil -// } - -// // Helper function to get label by name -// func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) { -// var query struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } -// vars := map[string]any{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "name": githubv4.String(labelName), -// } -// if err := client.Query(ctx, &query, vars); err != nil { -// return "", err -// } -// if query.Repository.Label.Name == "" { -// return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo) -// } -// return query.Repository.Label.ID, nil -// } diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go deleted file mode 100644 index a7d71304d..000000000 --- a/pkg/github/labels_test.go +++ /dev/null @@ -1,491 +0,0 @@ -package github - -// import ( -// "context" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/githubv4mock" -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/shurcooL/githubv4" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func TestGetLabel(t *testing.T) { -// t.Parallel() - -// // Verify tool definition -// mockClient := githubv4.NewClient(nil) -// tool, _ := GetLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_label", 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, "name") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "name"}) - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful label retrieval", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "name": "bug", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// Color githubv4.String -// Description githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "name": githubv4.String("bug"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID("test-label-id"), -// "name": githubv4.String("bug"), -// "color": githubv4.String("d73a4a"), -// "description": githubv4.String("Something isn't working"), -// }, -// }, -// }), -// ), -// ), -// expectToolError: false, -// }, -// { -// name: "label not found", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "name": "nonexistent", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// Color githubv4.String -// Description githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "name": githubv4.String("nonexistent"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID(""), -// "name": githubv4.String(""), -// "color": githubv4.String(""), -// "description": githubv4.String(""), -// }, -// }, -// }), -// ), -// ), -// expectToolError: true, -// expectedToolErrMsg: "label 'nonexistent' not found in owner/repo", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := GetLabel(stubGetGQLClientFn(client), 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 TestListLabels(t *testing.T) { -// t.Parallel() - -// // Verify tool definition -// mockClient := githubv4.NewClient(nil) -// tool, _ := ListLabels(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_label", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful repository labels listing", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository 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:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": 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"), -// }, -// map[string]any{ -// "id": githubv4.ID("label-2"), -// "name": githubv4.String("enhancement"), -// "color": githubv4.String("a2eeef"), -// "description": githubv4.String("New feature or request"), -// }, -// }, -// "totalCount": githubv4.Int(2), -// }, -// }, -// }), -// ), -// ), -// expectToolError: false, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := ListLabels(stubGetGQLClientFn(client), 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 TestWriteLabel(t *testing.T) { -// t.Parallel() - -// // Verify tool definition -// mockClient := githubv4.NewClient(nil) -// tool, _ := LabelWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "label_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, "name") -// assert.Contains(t, tool.InputSchema.Properties, "new_name") -// assert.Contains(t, tool.InputSchema.Properties, "color") -// assert.Contains(t, tool.InputSchema.Properties, "description") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "name"}) - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful label creation", -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "name": "new-label", -// "color": "f29513", -// "description": "A new test label", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// ID githubv4.ID -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "id": githubv4.ID("test-repo-id"), -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// CreateLabel struct { -// Label struct { -// Name githubv4.String -// ID githubv4.ID -// } -// } `graphql:"createLabel(input: $input)"` -// }{}, -// githubv4.CreateLabelInput{ -// RepositoryID: githubv4.ID("test-repo-id"), -// Name: githubv4.String("new-label"), -// Color: githubv4.String("f29513"), -// Description: func() *githubv4.String { s := githubv4.String("A new test label"); return &s }(), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{ -// "createLabel": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID("new-label-id"), -// "name": githubv4.String("new-label"), -// }, -// }, -// }), -// ), -// ), -// expectToolError: false, -// }, -// { -// name: "create label without color", -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "name": "new-label", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient(), -// expectToolError: true, -// expectedToolErrMsg: "color is required for create", -// }, -// { -// name: "successful label update", -// requestArgs: map[string]any{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "name": "bug", -// "new_name": "defect", -// "color": "ff0000", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "name": githubv4.String("bug"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID("bug-label-id"), -// "name": githubv4.String("bug"), -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// UpdateLabel struct { -// Label struct { -// Name githubv4.String -// ID githubv4.ID -// } -// } `graphql:"updateLabel(input: $input)"` -// }{}, -// githubv4.UpdateLabelInput{ -// ID: githubv4.ID("bug-label-id"), -// Name: func() *githubv4.String { s := githubv4.String("defect"); return &s }(), -// Color: func() *githubv4.String { s := githubv4.String("ff0000"); return &s }(), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{ -// "updateLabel": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID("bug-label-id"), -// "name": githubv4.String("defect"), -// }, -// }, -// }), -// ), -// ), -// expectToolError: false, -// }, -// { -// name: "update label without any changes", -// requestArgs: map[string]any{ -// "method": "update", -// "owner": "owner", -// "repo": "repo", -// "name": "bug", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient(), -// expectToolError: true, -// expectedToolErrMsg: "at least one of new_name, color, or description must be provided for update", -// }, -// { -// name: "successful label deletion", -// requestArgs: map[string]any{ -// "method": "delete", -// "owner": "owner", -// "repo": "repo", -// "name": "bug", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// Label struct { -// ID githubv4.ID -// Name githubv4.String -// } `graphql:"label(name: $name)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "name": githubv4.String("bug"), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "label": map[string]any{ -// "id": githubv4.ID("bug-label-id"), -// "name": githubv4.String("bug"), -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// DeleteLabel struct { -// ClientMutationID githubv4.String -// } `graphql:"deleteLabel(input: $input)"` -// }{}, -// githubv4.DeleteLabelInput{ -// ID: githubv4.ID("bug-label-id"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{ -// "deleteLabel": map[string]any{ -// "clientMutationId": githubv4.String("test-mutation-id"), -// }, -// }), -// ), -// ), -// expectToolError: false, -// }, -// { -// name: "invalid method", -// requestArgs: map[string]any{ -// "method": "invalid", -// "owner": "owner", -// "repo": "repo", -// "name": "bug", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient(), -// expectToolError: true, -// expectedToolErrMsg: "unknown method: invalid", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := LabelWrite(stubGetGQLClientFn(client), 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) -// } -// }) -// } -// } diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go deleted file mode 100644 index 508e705ac..000000000 --- a/pkg/github/notifications.go +++ /dev/null @@ -1,525 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" -// "strconv" -// "time" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// const ( -// FilterDefault = "default" -// FilterIncludeRead = "include_read_notifications" -// FilterOnlyParticipating = "only_participating" -// ) - -// // ListNotifications creates a tool to list notifications for the current user. -// func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_notifications", -// mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("filter", -// mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."), -// mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating), -// ), -// mcp.WithString("since", -// mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"), -// ), -// mcp.WithString("before", -// mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"), -// ), -// mcp.WithString("owner", -// mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."), -// ), -// mcp.WithString("repo", -// mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."), -// ), -// WithPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// filter, err := OptionalParam[string](request, "filter") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// since, err := OptionalParam[string](request, "since") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// before, err := OptionalParam[string](request, "before") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// owner, err := OptionalParam[string](request, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// repo, err := OptionalParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// paginationParams, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Build options -// opts := &github.NotificationListOptions{ -// All: filter == FilterIncludeRead, -// Participating: filter == FilterOnlyParticipating, -// ListOptions: github.ListOptions{ -// Page: paginationParams.Page, -// PerPage: paginationParams.PerPage, -// }, -// } - -// // Parse time parameters if provided -// if since != "" { -// sinceTime, err := time.Parse(time.RFC3339, since) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil -// } -// opts.Since = sinceTime -// } - -// if before != "" { -// beforeTime, err := time.Parse(time.RFC3339, before) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil -// } -// opts.Before = beforeTime -// } - -// var notifications []*github.Notification -// var resp *github.Response - -// if owner != "" && repo != "" { -// notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts) -// } else { -// notifications, resp, err = client.Activity.ListNotifications(ctx, opts) -// } -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list notifications", -// 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 get notifications: %s", string(body))), nil -// } - -// // Marshal response to JSON -// r, err := json.Marshal(notifications) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // DismissNotification creates a tool to mark a notification as read/done. -// func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("dismiss_notification", -// mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("threadID", -// mcp.Required(), -// mcp.Description("The ID of the notification thread"), -// ), -// mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getclient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// threadID, err := RequiredParam[string](request, "threadID") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// state, err := RequiredParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// switch state { -// case "done": -// // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint -// var threadIDInt int64 -// threadIDInt, err = strconv.ParseInt(threadID, 10, 64) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil -// } -// resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) -// case "read": -// resp, err = client.Activity.MarkThreadRead(ctx, threadID) -// default: -// return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to mark notification as %s", state), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusResetContent && 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 mark notification as %s: %s", state, string(body))), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil -// } -// } - -// // MarkAllNotificationsRead creates a tool to mark all notifications as read. -// func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("mark_all_notifications_read", -// mcp.WithDescription(t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("lastReadAt", -// mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"), -// ), -// mcp.WithString("owner", -// mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."), -// ), -// mcp.WithString("repo", -// mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// lastReadAt, err := OptionalParam[string](request, "lastReadAt") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// owner, err := OptionalParam[string](request, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// repo, err := OptionalParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var lastReadTime time.Time -// if lastReadAt != "" { -// lastReadTime, err = time.Parse(time.RFC3339, lastReadAt) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil -// } -// } else { -// lastReadTime = time.Now() -// } - -// markReadOptions := github.Timestamp{ -// Time: lastReadTime, -// } - -// var resp *github.Response -// if owner != "" && repo != "" { -// resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions) -// } else { -// resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions) -// } -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to mark all notifications as read", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusResetContent && 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 mark all notifications as read: %s", string(body))), nil -// } - -// return mcp.NewToolResultText("All notifications marked as read"), nil -// } -// } - -// // GetNotificationDetails creates a tool to get details for a specific notification. -// func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_notification_details", -// mcp.WithDescription(t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("notificationID", -// mcp.Required(), -// mcp.Description("The ID of the notification"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// notificationID, err := RequiredParam[string](request, "notificationID") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// thread, resp, err := client.Activity.GetThread(ctx, notificationID) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to get notification details for ID '%s'", notificationID), -// 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 get notification details: %s", string(body))), nil -// } - -// r, err := json.Marshal(thread) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // Enum values for ManageNotificationSubscription action -// const ( -// NotificationActionIgnore = "ignore" -// NotificationActionWatch = "watch" -// NotificationActionDelete = "delete" -// ) - -// // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) -// func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("manage_notification_subscription", -// mcp.WithDescription(t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("notificationID", -// mcp.Required(), -// mcp.Description("The ID of the notification thread."), -// ), -// mcp.WithString("action", -// mcp.Required(), -// mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."), -// mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// notificationID, err := RequiredParam[string](request, "notificationID") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// action, err := RequiredParam[string](request, "action") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var ( -// resp *github.Response -// result any -// apiErr error -// ) - -// switch action { -// case NotificationActionIgnore: -// sub := &github.Subscription{Ignored: ToBoolPtr(true)} -// result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) -// case NotificationActionWatch: -// sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} -// result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub) -// case NotificationActionDelete: -// resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID) -// default: -// return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil -// } - -// if apiErr != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to %s notification subscription", action), -// resp, -// apiErr, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode < 200 || resp.StatusCode >= 300 { -// body, _ := io.ReadAll(resp.Body) -// return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil -// } - -// if action == NotificationActionDelete { -// // Special case for delete as there is no response body -// return mcp.NewToolResultText("Notification subscription deleted"), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// const ( -// RepositorySubscriptionActionWatch = "watch" -// RepositorySubscriptionActionIgnore = "ignore" -// RepositorySubscriptionActionDelete = "delete" -// ) - -// // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) -// func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("manage_repository_notification_subscription", -// mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The account owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithString("action", -// mcp.Required(), -// mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."), -// mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// 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 -// } -// action, err := RequiredParam[string](request, "action") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var ( -// resp *github.Response -// result any -// apiErr error -// ) - -// switch action { -// case RepositorySubscriptionActionIgnore: -// sub := &github.Subscription{Ignored: ToBoolPtr(true)} -// result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) -// case RepositorySubscriptionActionWatch: -// sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)} -// result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub) -// case RepositorySubscriptionActionDelete: -// resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo) -// default: -// return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil -// } - -// if apiErr != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to %s repository subscription", action), -// resp, -// apiErr, -// ), nil -// } -// if resp != nil { -// defer func() { _ = resp.Body.Close() }() -// } - -// // Handle non-2xx status codes -// if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { -// body, _ := io.ReadAll(resp.Body) -// return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil -// } - -// if action == RepositorySubscriptionActionDelete { -// // Special case for delete as there is no response body -// return mcp.NewToolResultText("Repository subscription deleted"), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go deleted file mode 100644 index 5825c91d3..000000000 --- a/pkg/github/notifications_test.go +++ /dev/null @@ -1,765 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_ListNotifications(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_notifications", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "filter") -// assert.Contains(t, tool.InputSchema.Properties, "since") -// assert.Contains(t, tool.InputSchema.Properties, "before") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// // All fields are optional, so Required should be empty -// assert.Empty(t, tool.InputSchema.Required) - -// mockNotification := &github.Notification{ -// ID: github.Ptr("123"), -// Reason: github.Ptr("mention"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult []*github.Notification -// expectedErrMsg string -// }{ -// { -// name: "success default filter (no params)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetNotifications, -// []*github.Notification{mockNotification}, -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: false, -// expectedResult: []*github.Notification{mockNotification}, -// }, -// { -// name: "success with filter=include_read_notifications", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetNotifications, -// []*github.Notification{mockNotification}, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "filter": "include_read_notifications", -// }, -// expectError: false, -// expectedResult: []*github.Notification{mockNotification}, -// }, -// { -// name: "success with filter=only_participating", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetNotifications, -// []*github.Notification{mockNotification}, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "filter": "only_participating", -// }, -// expectError: false, -// expectedResult: []*github.Notification{mockNotification}, -// }, -// { -// name: "success for repo notifications", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposNotificationsByOwnerByRepo, -// []*github.Notification{mockNotification}, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "filter": "default", -// "since": "2024-01-01T00:00:00Z", -// "before": "2024-01-02T00:00:00Z", -// "owner": "octocat", -// "repo": "hello-world", -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedResult: []*github.Notification{mockNotification}, -// }, -// { -// name: "error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetNotifications, -// mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "error", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// t.Logf("textContent: %s", textContent.Text) -// var returned []*github.Notification -// err = json.Unmarshal([]byte(textContent.Text), &returned) -// require.NoError(t, err) -// require.NotEmpty(t, returned) -// assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) -// }) -// } -// } - -// func Test_ManageNotificationSubscription(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "manage_notification_subscription", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "notificationID") -// assert.Contains(t, tool.InputSchema.Properties, "action") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"}) - -// mockSub := &github.Subscription{Ignored: github.Ptr(true)} -// mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectIgnored *bool -// expectDeleted bool -// expectInvalid bool -// expectedErrMsg string -// }{ -// { -// name: "ignore subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutNotificationsThreadsSubscriptionByThreadId, -// mockSub, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// "action": "ignore", -// }, -// expectError: false, -// expectIgnored: github.Ptr(true), -// }, -// { -// name: "watch subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutNotificationsThreadsSubscriptionByThreadId, -// mockSubWatch, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// "action": "watch", -// }, -// expectError: false, -// expectIgnored: github.Ptr(false), -// }, -// { -// name: "delete subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.DeleteNotificationsThreadsSubscriptionByThreadId, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// "action": "delete", -// }, -// expectError: false, -// expectDeleted: true, -// }, -// { -// name: "invalid action", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// "action": "invalid", -// }, -// expectError: false, -// expectInvalid: true, -// }, -// { -// name: "missing required notificationID", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "action": "ignore", -// }, -// expectError: true, -// }, -// { -// name: "missing required action", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.NotNil(t, result) -// text := getTextResult(t, result).Text -// switch { -// case tc.requestArgs["notificationID"] == nil: -// assert.Contains(t, text, "missing required parameter: notificationID") -// case tc.requestArgs["action"] == nil: -// assert.Contains(t, text, "missing required parameter: action") -// default: -// assert.Contains(t, text, "error") -// } -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) -// if tc.expectIgnored != nil { -// var returned github.Subscription -// err = json.Unmarshal([]byte(textContent.Text), &returned) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectIgnored, *returned.Ignored) -// } -// if tc.expectDeleted { -// assert.Contains(t, textContent.Text, "deleted") -// } -// if tc.expectInvalid { -// assert.Contains(t, textContent.Text, "Invalid action") -// } -// }) -// } -// } - -// func Test_ManageRepositoryNotificationSubscription(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "manage_repository_notification_subscription", 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, "action") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"}) - -// mockSub := &github.Subscription{Ignored: github.Ptr(true)} -// mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectIgnored *bool -// expectSubscribed *bool -// expectDeleted bool -// expectInvalid bool -// expectedErrMsg string -// }{ -// { -// name: "ignore subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutReposSubscriptionByOwnerByRepo, -// mockSub, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "action": "ignore", -// }, -// expectError: false, -// expectIgnored: github.Ptr(true), -// }, -// { -// name: "watch subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutReposSubscriptionByOwnerByRepo, -// mockWatchSub, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "action": "watch", -// }, -// expectError: false, -// expectIgnored: github.Ptr(false), -// expectSubscribed: github.Ptr(true), -// }, -// { -// name: "delete subscription", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.DeleteReposSubscriptionByOwnerByRepo, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "action": "delete", -// }, -// expectError: false, -// expectDeleted: true, -// }, -// { -// name: "invalid action", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "action": "invalid", -// }, -// expectError: false, -// expectInvalid: true, -// }, -// { -// name: "missing required owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "repo": "repo", -// "action": "ignore", -// }, -// expectError: true, -// }, -// { -// name: "missing required repo", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "action": "ignore", -// }, -// expectError: true, -// }, -// { -// name: "missing required action", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.NotNil(t, result) -// text := getTextResult(t, result).Text -// switch { -// case tc.requestArgs["owner"] == nil: -// assert.Contains(t, text, "missing required parameter: owner") -// case tc.requestArgs["repo"] == nil: -// assert.Contains(t, text, "missing required parameter: repo") -// case tc.requestArgs["action"] == nil: -// assert.Contains(t, text, "missing required parameter: action") -// default: -// assert.Contains(t, text, "error") -// } -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) -// if tc.expectIgnored != nil || tc.expectSubscribed != nil { -// var returned github.Subscription -// err = json.Unmarshal([]byte(textContent.Text), &returned) -// require.NoError(t, err) -// if tc.expectIgnored != nil { -// assert.Equal(t, *tc.expectIgnored, *returned.Ignored) -// } -// if tc.expectSubscribed != nil { -// assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed) -// } -// } -// if tc.expectDeleted { -// assert.Contains(t, textContent.Text, "deleted") -// } -// if tc.expectInvalid { -// assert.Contains(t, textContent.Text, "Invalid action") -// } -// }) -// } -// } - -// func Test_DismissNotification(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "dismiss_notification", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "threadID") -// assert.Contains(t, tool.InputSchema.Properties, "state") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectRead bool -// expectDone bool -// expectInvalid bool -// expectedErrMsg string -// }{ -// { -// name: "mark as read", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PatchNotificationsThreadsByThreadId, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "threadID": "123", -// "state": "read", -// }, -// expectError: false, -// expectRead: true, -// }, -// { -// name: "mark as done", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.DeleteNotificationsThreadsByThreadId, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "threadID": "123", -// "state": "done", -// }, -// expectError: false, -// expectDone: true, -// }, -// { -// name: "invalid threadID format", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "threadID": "notanumber", -// "state": "done", -// }, -// expectError: false, -// expectInvalid: true, -// }, -// { -// name: "missing required threadID", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "state": "read", -// }, -// expectError: true, -// }, -// { -// name: "missing required state", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "threadID": "123", -// }, -// expectError: true, -// }, -// { -// name: "invalid state value", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "threadID": "123", -// "state": "invalid", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// // The tool returns a ToolResultError with a specific message -// require.NoError(t, err) -// require.NotNil(t, result) -// text := getTextResult(t, result).Text -// switch { -// case tc.requestArgs["threadID"] == nil: -// assert.Contains(t, text, "missing required parameter: threadID") -// case tc.requestArgs["state"] == nil: -// assert.Contains(t, text, "missing required parameter: state") -// case tc.name == "invalid threadID format": -// assert.Contains(t, text, "invalid threadID format") -// case tc.name == "invalid state value": -// assert.Contains(t, text, "Invalid state. Must be one of: read, done.") -// default: -// // fallback for other errors -// assert.Contains(t, text, "error") -// } -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) -// if tc.expectRead { -// assert.Contains(t, textContent.Text, "Notification marked as read") -// } -// if tc.expectDone { -// assert.Contains(t, textContent.Text, "Notification marked as done") -// } -// if tc.expectInvalid { -// assert.Contains(t, textContent.Text, "invalid threadID format") -// } -// }) -// } -// } - -// func Test_MarkAllNotificationsRead(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "mark_all_notifications_read", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "lastReadAt") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.Empty(t, tool.InputSchema.Required) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectMarked bool -// expectedErrMsg string -// }{ -// { -// name: "success (no params)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutNotifications, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: false, -// expectMarked: true, -// }, -// { -// name: "success with lastReadAt param", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutNotifications, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "lastReadAt": "2024-01-01T00:00:00Z", -// }, -// expectError: false, -// expectMarked: true, -// }, -// { -// name: "success with owner and repo", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.PutReposNotificationsByOwnerByRepo, -// nil, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octocat", -// "repo": "hello-world", -// }, -// expectError: false, -// expectMarked: true, -// }, -// { -// name: "API error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutNotifications, -// mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "error", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// if tc.expectMarked { -// assert.Contains(t, textContent.Text, "All notifications marked as read") -// } -// }) -// } -// } - -// func Test_GetNotificationDetails(t *testing.T) { -// // Verify tool definition and schema -// mockClient := github.NewClient(nil) -// tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_notification_details", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "notificationID") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"}) - -// mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectResult *github.Notification -// expectedErrMsg string -// }{ -// { -// name: "success", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetNotificationsThreadsByThreadId, -// mockThread, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// }, -// expectError: false, -// expectResult: mockThread, -// }, -// { -// name: "not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetNotificationsThreadsByThreadId, -// mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "notificationID": "123", -// }, -// expectError: true, -// expectedErrMsg: "not found", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var returned github.Notification -// err = json.Unmarshal([]byte(textContent.Text), &returned) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectResult.ID, *returned.ID) -// }) -// } -// } diff --git a/pkg/github/projects.go b/pkg/github/projects.go deleted file mode 100644 index d48bb8a0e..000000000 --- a/pkg/github/projects.go +++ /dev/null @@ -1,1142 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" -// "net/url" -// "reflect" -// "strings" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/google/go-querystring/query" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// const ( -// ProjectUpdateFailedError = "failed to update a project item" -// ProjectAddFailedError = "failed to add a project item" -// ProjectDeleteFailedError = "failed to delete a project item" -// ProjectListFailedError = "failed to list project items" -// ) - -// func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_projects", -// mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithString("query", -// mcp.Description("Filter projects by a search query (matches title and description)"), -// ), -// mcp.WithNumber("per_page", -// mcp.Description("Number of results per page (max 100, default: 30)"), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// queryStr, err := OptionalParam[string](req, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// var projects []*github.ProjectV2 -// var queryPtr *string - -// if queryStr != "" { -// queryPtr = &queryStr -// } - -// minimalProjects := []MinimalProject{} -// opts := &github.ListProjectsOptions{ -// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, -// Query: queryPtr, -// } - -// if ownerType == "org" { -// projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) -// } else { -// projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list projects", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// for _, project := range projects { -// minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) -// } - -// 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 projects: %s", string(body))), nil -// } -// r, err := json.Marshal(minimalProjects) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_project", -// mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number"), -// ), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), -// mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// var project *github.ProjectV2 - -// if ownerType == "org" { -// project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) -// } else { -// project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) -// } -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get project", -// 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 get project: %s", string(body))), nil -// } - -// minimalProject := convertToMinimalProject(project) -// r, err := json.Marshal(minimalProject) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_project_fields", -// mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), -// mcp.Enum("user", "org")), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithNumber("per_page", -// mcp.Description("Number of results per page (max 100, default: 30)"), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// var projectFields []*github.ProjectV2Field - -// opts := &github.ListProjectsOptions{ -// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, -// } - -// if ownerType == "org" { -// projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) -// } else { -// projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list project fields", -// 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 project fields: %s", string(body))), nil -// } -// r, err := json.Marshal(projectFields) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_project_field", -// mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), mcp.Enum("user", "org")), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number.")), -// mcp.WithNumber("field_id", -// mcp.Required(), -// mcp.Description("The field's id."), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// fieldID, err := RequiredBigInt(req, "field_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// var projectField *github.ProjectV2Field - -// if ownerType == "org" { -// projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) -// } else { -// projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get project field", -// 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 get project field: %s", string(body))), nil -// } -// r, err := json.Marshal(projectField) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_project_items", -// mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), -// mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithString("query", -// mcp.Description("Search query to filter items"), -// ), -// mcp.WithNumber("per_page", -// mcp.Description("Number of results per page (max 100, default: 30)"), -// ), -// mcp.WithArray("fields", -// mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), -// mcp.WithStringItems(), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// queryStr, err := OptionalParam[string](req, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// fields, err := OptionalBigIntArrayParam(req, "fields") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// var projectItems []*github.ProjectV2Item -// var queryPtr *string - -// if queryStr != "" { -// queryPtr = &queryStr -// } - -// opts := &github.ListProjectItemsOptions{ -// Fields: fields, -// ListProjectsOptions: github.ListProjectsOptions{ -// ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage}, -// Query: queryPtr, -// }, -// } - -// if ownerType == "org" { -// projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) -// } else { -// projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// ProjectListFailedError, -// 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("%s: %s", ProjectListFailedError, string(body))), nil -// } - -// r, err := json.Marshal(projectItems) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_project_item", -// mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), -// mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithNumber("item_id", -// mcp.Required(), -// mcp.Description("The item's ID."), -// ), -// mcp.WithArray("fields", -// mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), -// mcp.WithStringItems(), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// itemID, err := RequiredBigInt(req, "item_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// fields, err := OptionalBigIntArrayParam(req, "fields") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var url string -// if ownerType == "org" { -// url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) -// } else { -// url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) -// } - -// opts := fieldSelectionOptions{} - -// if len(fields) > 0 { -// opts.Fields = fields -// } - -// url, err = addOptions(url, opts) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// projectItem := projectV2Item{} - -// httpRequest, err := client.NewRequest("GET", url, nil) -// if err != nil { -// return nil, fmt.Errorf("failed to create request: %w", err) -// } - -// resp, err := client.Do(ctx, httpRequest, &projectItem) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get project item", -// 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 get project item: %s", string(body))), nil -// } -// r, err := json.Marshal(projectItem) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("add_project_item", -// mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithString("item_type", -// mcp.Required(), -// mcp.Description("The item's type, either issue or pull_request."), -// mcp.Enum("issue", "pull_request"), -// ), -// mcp.WithNumber("item_id", -// mcp.Required(), -// mcp.Description("The numeric ID of the issue or pull request to add to the project."), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// itemID, err := RequiredBigInt(req, "item_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// itemType, err := RequiredParam[string](req, "item_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// if itemType != "issue" && itemType != "pull_request" { -// return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// newItem := &github.AddProjectItemOptions{ -// ID: itemID, -// Type: toNewProjectType(itemType), -// } - -// var resp *github.Response -// var addedItem *github.ProjectV2Item - -// if ownerType == "org" { -// addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) -// } else { -// addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// ProjectAddFailedError, -// resp, -// err, -// ), nil -// } -// 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("%s: %s", ProjectAddFailedError, string(body))), nil -// } -// r, err := json.Marshal(addedItem) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("update_project_item", -// mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), mcp.Description("Owner type"), -// mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithNumber("item_id", -// mcp.Required(), -// mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), -// ), -// mcp.WithObject("updated_field", -// mcp.Required(), -// mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// itemID, err := RequiredInt(req, "item_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// rawUpdatedField, exists := req.GetArguments()["updated_field"] -// if !exists { -// return mcp.NewToolResultError("missing required parameter: updated_field"), nil -// } - -// fieldValue, ok := rawUpdatedField.(map[string]any) -// if !ok || fieldValue == nil { -// return mcp.NewToolResultError("field_value must be an object"), nil -// } - -// updatePayload, err := buildUpdateProjectItem(fieldValue) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var projectsURL string -// if ownerType == "org" { -// projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) -// } else { -// projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) -// } -// httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{ -// Fields: []updateProjectItem{*updatePayload}, -// }) -// if err != nil { -// return nil, fmt.Errorf("failed to create request: %w", err) -// } -// updatedItem := projectV2Item{} - -// resp, err := client.Do(ctx, httpRequest, &updatedItem) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// ProjectUpdateFailedError, -// 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("%s: %s", ProjectUpdateFailedError, string(body))), nil -// } -// r, err := json.Marshal(updatedItem) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("delete_project_item", -// mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner_type", -// mcp.Required(), -// mcp.Description("Owner type"), -// mcp.Enum("user", "org"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), -// ), -// mcp.WithNumber("project_number", -// mcp.Required(), -// mcp.Description("The project's number."), -// ), -// mcp.WithNumber("item_id", -// mcp.Required(), -// mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), -// ), -// ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// owner, err := RequiredParam[string](req, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ownerType, err := RequiredParam[string](req, "owner_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// projectNumber, err := RequiredInt(req, "project_number") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// itemID, err := RequiredBigInt(req, "item_id") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// var resp *github.Response -// if ownerType == "org" { -// resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) -// } else { -// resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// ProjectDeleteFailedError, -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusNoContent { -// 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("%s: %s", ProjectDeleteFailedError, string(body))), nil -// } -// return mcp.NewToolResultText("project item successfully deleted"), nil -// } -// } - -// type fieldSelectionOptions struct { -// // Specific list of field IDs to include in the response. If not provided, only the title field is included. -// // The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875 -// Fields []int64 `url:"fields,omitempty,comma"` -// } - -// type updateProjectItemPayload struct { -// Fields []updateProjectItem `json:"fields"` -// } - -// type updateProjectItem struct { -// ID int `json:"id"` -// Value any `json:"value"` -// } - -// type projectV2ItemFieldValue struct { -// ID *int64 `json:"id,omitempty"` // The unique identifier for this field. -// Name string `json:"name,omitempty"` // The display name of the field. -// DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). -// Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item. -// } - -// type projectV2Item struct { -// ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` -// Content *projectV2ItemContent `json:"content,omitempty"` -// ContentType *string `json:"content_type,omitempty"` -// CreatedAt *github.Timestamp `json:"created_at,omitempty"` -// Creator *github.User `json:"creator,omitempty"` -// Description *string `json:"description,omitempty"` -// Fields []*projectV2ItemFieldValue `json:"fields,omitempty"` -// ID *int64 `json:"id,omitempty"` -// ItemURL *string `json:"item_url,omitempty"` -// NodeID *string `json:"node_id,omitempty"` -// ProjectURL *string `json:"project_url,omitempty"` -// Title *string `json:"title,omitempty"` -// UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` -// } - -// type projectV2ItemContent struct { -// Body *string `json:"body,omitempty"` -// ClosedAt *github.Timestamp `json:"closed_at,omitempty"` -// CreatedAt *github.Timestamp `json:"created_at,omitempty"` -// ID *int64 `json:"id,omitempty"` -// Number *int `json:"number,omitempty"` -// Repository MinimalRepository `json:"repository,omitempty"` -// State *string `json:"state,omitempty"` -// StateReason *string `json:"stateReason,omitempty"` -// Title *string `json:"title,omitempty"` -// UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` -// URL *string `json:"url,omitempty"` -// } - -// func toNewProjectType(projType string) string { -// switch strings.ToLower(projType) { -// case "issue": -// return "Issue" -// case "pull_request": -// return "PullRequest" -// default: -// return "" -// } -// } - -// func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { -// if input == nil { -// return nil, fmt.Errorf("updated_field must be an object") -// } - -// idField, ok := input["id"] -// if !ok { -// return nil, fmt.Errorf("updated_field.id is required") -// } - -// idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 -// if !ok { -// return nil, fmt.Errorf("updated_field.id must be a number") -// } - -// valueField, ok := input["value"] -// if !ok { -// return nil, fmt.Errorf("updated_field.value is required") -// } -// payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} - -// return payload, nil -// } - -// // addOptions adds the parameters in opts as URL query parameters to s. opts -// // must be a struct whose fields may contain "url" tags. -// func addOptions(s string, opts any) (string, error) { -// v := reflect.ValueOf(opts) -// if v.Kind() == reflect.Ptr && v.IsNil() { -// return s, nil -// } - -// origURL, err := url.Parse(s) -// if err != nil { -// return s, err -// } - -// origValues := origURL.Query() - -// // Use the github.com/google/go-querystring library to parse the struct -// newValues, err := query.Values(opts) -// if err != nil { -// return s, err -// } - -// // Merge the values -// for key, values := range newValues { -// for _, value := range values { -// origValues.Add(key, value) -// } -// } - -// origURL.RawQuery = origValues.Encode() -// return origURL.String(), nil -// } - -// func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { -// return mcp.NewPrompt("ManageProjectItems", -// mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")), -// mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()), -// mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()), -// mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")), -// ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { -// owner := request.Params.Arguments["owner"] -// ownerType := request.Params.Arguments["owner_type"] - -// task := "" -// if t, exists := request.Params.Arguments["task"]; exists { -// task = fmt.Sprintf("%v", t) -// } - -// messages := []mcp.PromptMessage{ -// { -// Role: "system", -// Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" + -// "**Core Capabilities:**\n" + -// "- Project discovery and field analysis\n" + -// "- Item querying with advanced filters\n" + -// "- Field value updates and management\n" + -// "- Progress reporting and insights\n\n" + -// "**Key Rules:**\n" + -// "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" + -// "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" + -// "- Use proper field IDs (not names) when updating items\n" + -// "- Provide step-by-step workflows with concrete examples\n\n" + -// "**Understanding Project Items:**\n" + -// "- Project items reference underlying content (issues or pull requests)\n" + -// "- Project tools provide: project fields, item metadata, and basic content info\n" + -// "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" + -// "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" + -// "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" + -// "**Available Tools:**\n" + -// "- **list_projects**: Discover available projects\n" + -// "- **get_project**: Get detailed project information\n" + -// "- **list_project_fields**: Get field definitions and IDs\n" + -// "- **list_project_items**: Query items with filters and field selection\n" + -// "- **get_project_item**: Get specific item details\n" + -// "- **add_project_item**: Add issues/PRs to projects\n" + -// "- **update_project_item**: Update field values\n" + -// "- **delete_project_item**: Remove items from projects"), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+ -// "Help me get started with project management tasks.", -// owner, -// ownerType, -// func() string { -// if task != "" { -// return fmt.Sprintf(" I'm specifically interested in: %s.", task) -// } -// return "" -// }())), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+ -// "**🔍 Step 1: Project Discovery**\n"+ -// "First, let's see what projects are available using **list_projects**.", owner)), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" + -// "Once you select a project, I'll help you:\n\n" + -// "1. **Get field information** using **list_project_fields**\n" + -// " - Find field IDs, names, and data types\n" + -// " - Understand available options for select fields\n" + -// " - Identify required vs. optional fields\n\n" + -// "2. **Query project items** using **list_project_items**\n" + -// " - Filter by assignees: query=\"assignee:@me\"\n" + -// " - Filter by status: query=\"status:In Progress\"\n" + -// " - Filter by labels: query=\"label:bug\"\n" + -// " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" + -// "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("How do I update field values? What about the different field types?"), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" + -// "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" + -// "**Text fields:**\n" + -// "```json\n" + -// "{\"id\": 123456, \"value\": \"Updated text content\"}\n" + -// "```\n\n" + -// "**Single-select fields:**\n" + -// "```json\n" + -// "{\"id\": 198354254, \"value\": 18498754}\n" + -// "```\n" + -// "*(Use option ID, not option name)*\n\n" + -// "**Date fields:**\n" + -// "```json\n" + -// "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" + -// "```\n\n" + -// "**Number fields:**\n" + -// "```json\n" + -// "{\"id\": 345678, \"value\": 5}\n" + -// "```\n\n" + -// "**Clear a field:**\n" + -// "```json\n" + -// "{\"id\": 123456, \"value\": null}\n" + -// "```\n\n" + -// "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("Can you show me a complete workflow example?"), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+ -// "Here's how to find and update your assigned items:\n\n"+ -// "**Step 1:** Discover projects\n\n"+ -// "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+ -// "**Step 2:** Get project fields (using project #123)\n\n"+ -// "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+ -// "*(Note the Status field ID, e.g., 198354254)*\n\n"+ -// "**Step 3:** Query your assigned items\n\n"+ -// "**list_project_items**\n"+ -// " owner=\"%s\"\n"+ -// " owner_type=\"%s\"\n"+ -// " project_number=123\n"+ -// " query=\"assignee:@me\"\n"+ -// " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+ -// "**Step 4:** Update item status\n\n"+ -// "**update_project_item**\n"+ -// " owner=\"%s\"\n"+ -// " owner_type=\"%s\"\n"+ -// " project_number=123\n"+ -// " item_id=789123\n"+ -// " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+ -// "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)), -// }, -// { -// Role: "user", -// Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"), -// }, -// { -// Role: "assistant", -// Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" + -// "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" + -// "**From project items, extract:**\n" + -// "- content.repository.name and content.repository.owner.login\n" + -// "- content.number (the issue/PR number)\n" + -// "- content_type (\"Issue\" or \"PullRequest\")\n\n" + -// "**Then use these tools for details:**\n\n" + -// "1. **Get full issue/PR details:**\n" + -// " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" + -// " - Returns: full body, labels, assignees, milestone, etc.\n\n" + -// "2. **Get recent comments:**\n" + -// " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" + -// " - Add since parameter to filter recent comments\n\n" + -// "3. **Get issue events:**\n" + -// " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" + -// " - Shows timeline: assignments, label changes, status updates\n\n" + -// "4. **For pull requests specifically:**\n" + -// " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" + -// " - **list_pull_request_reviews** for review status\n\n" + -// "**💡 Example:** To check for blockers in comments:\n" + -// "1. Get project items with query=\"assignee:@me is:open\"\n" + -// "2. For each item, extract repository and issue number from content\n" + -// "3. Use **list_comments** to get recent comments\n" + -// "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""), -// }, -// } -// return &mcp.GetPromptResult{ -// Messages: messages, -// }, nil -// } -// } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go deleted file mode 100644 index 2a63522cd..000000000 --- a/pkg/github/projects_test.go +++ /dev/null @@ -1,1649 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "io" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// gh "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_ListProjects(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_projects", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "per_page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) - -// orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} -// userProjects := []map[string]any{{"id": 2, "title": "User Project"}} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedLength int -// expectedErrMsg string -// }{ -// { -// name: "success organization", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, orgProjects), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: false, -// expectedLength: 1, -// }, -// { -// name: "success user", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, userProjects), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octocat", -// "owner_type": "user", -// }, -// expectError: false, -// expectedLength: 1, -// }, -// { -// name: "success organization with pagination & query", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// q := r.URL.Query() -// if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(orgProjects)) -// return -// } -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "per_page": float64(50), -// "query": "roadmap", -// }, -// expectError: false, -// expectedLength: 1, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: true, -// expectedErrMsg: "failed to list projects", -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner_type": "org", -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var arr []map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &arr) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedLength, len(arr)) -// }) -// } -// } - -// func Test_GetProject(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_project", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) - -// project := map[string]any{"id": 123, "title": "Project Title"} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "success organization project fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, project), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "project_number": float64(123), -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: false, -// }, -// { -// name: "success user project fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, project), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "project_number": float64(456), -// "owner": "octocat", -// "owner_type": "user", -// }, -// expectError: false, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "project_number": float64(999), -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: true, -// expectedErrMsg: "failed to get project", -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: true, -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "project_number": float64(123), -// "owner_type": "org", -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "project_number": float64(123), -// "owner": "octo-org", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing project_number" { -// assert.Contains(t, text, "missing required parameter: project_number") -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var arr map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &arr) -// require.NoError(t, err) -// }) -// } -// } - -// func Test_ListProjectFields(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_project_fields", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "per_page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - -// orgFields := []map[string]any{ -// {"id": 101, "name": "Status", "dataType": "single_select"}, -// } -// userFields := []map[string]any{ -// {"id": 201, "name": "Priority", "dataType": "single_select"}, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedLength int -// expectedErrMsg string -// }{ -// { -// name: "success organization fields", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, orgFields), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// }, -// expectedLength: 1, -// }, -// { -// name: "success user fields with per_page override", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// q := r.URL.Query() -// if q.Get("per_page") == "50" { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(userFields)) -// return -// } -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(456), -// "per_page": float64(50), -// }, -// expectedLength: 1, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(789), -// }, -// expectError: true, -// expectedErrMsg: "failed to list project fields", -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner_type": "org", -// "project_number": 10, -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "project_number": 10, -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// if tc.name == "missing project_number" { -// assert.Contains(t, text, "missing required parameter: project_number") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var fields []map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &fields) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedLength, len(fields)) -// }) -// } -// } - -// func Test_GetProjectField(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_project_field", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "field_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) - -// orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} -// userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// expectedID int -// }{ -// { -// name: "success organization field", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, orgField), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "field_id": float64(101), -// }, -// expectedID: 101, -// }, -// { -// name: "success user field", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, userField), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(456), -// "field_id": float64(202), -// }, -// expectedID: 202, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(789), -// "field_id": float64(303), -// }, -// expectError: true, -// expectedErrMsg: "failed to get project field", -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner_type": "org", -// "project_number": float64(10), -// "field_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "project_number": float64(10), -// "field_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "field_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing field_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(10), -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// if tc.name == "missing project_number" { -// assert.Contains(t, text, "missing required parameter: project_number") -// } -// if tc.name == "missing field_id" { -// assert.Contains(t, text, "missing required parameter: field_id") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var field map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &field) -// require.NoError(t, err) -// if tc.expectedID != 0 { -// assert.Equal(t, float64(tc.expectedID), field["id"]) -// } -// }) -// } -// } - -// func Test_ListProjectItems(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_project_items", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "per_page") -// assert.Contains(t, tool.InputSchema.Properties, "fields") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) - -// orgItems := []map[string]any{ -// {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ -// {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, -// {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, -// }}, -// } -// userItems := []map[string]any{ -// {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, -// {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedLength int -// expectedErrMsg string -// }{ -// { -// name: "success organization items", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, orgItems), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// }, -// expectedLength: 1, -// }, -// { -// name: "success organization items with fields", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// q := r.URL.Query() -// fieldParams := q.Get("fields") -// if fieldParams == "123,456,789" { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(orgItems)) -// return -// } -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "fields": []interface{}{"123", "456", "789"}, -// }, -// expectedLength: 1, -// }, -// { -// name: "success user items", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, userItems), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(456), -// }, -// expectedLength: 2, -// }, -// { -// name: "success with pagination and query", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// q := r.URL.Query() -// if q.Get("per_page") == "50" && q.Get("q") == "bug" { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(orgItems)) -// return -// } -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "per_page": float64(50), -// "query": "bug", -// }, -// expectedLength: 1, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(789), -// }, -// expectError: true, -// expectedErrMsg: ProjectListFailedError, -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner_type": "org", -// "project_number": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "project_number": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "octo-org", -// "owner_type": "org", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// if tc.name == "missing project_number" { -// assert.Contains(t, text, "missing required parameter: project_number") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var items []map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &items) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedLength, len(items)) -// }) -// } -// } - -// func Test_GetProjectItem(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_project_item", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "item_id") -// assert.Contains(t, tool.InputSchema.Properties, "fields") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - -// orgItem := map[string]any{ -// "id": 301, -// "content_type": "Issue", -// "project_node_id": "PR_1", -// "creator": map[string]any{"login": "octocat"}, -// } -// userItem := map[string]any{ -// "id": 501, -// "content_type": "PullRequest", -// "project_node_id": "PR_2", -// "creator": map[string]any{"login": "jane"}, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// expectedID int -// }{ -// { -// name: "success organization item", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, orgItem), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "item_id": float64(301), -// }, -// expectedID: 301, -// }, -// { -// name: "success organization item with fields", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// q := r.URL.Query() -// fieldParams := q.Get("fields") -// if fieldParams == "123,456" { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(orgItem)) -// return -// } -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "item_id": float64(301), -// "fields": []interface{}{"123", "456"}, -// }, -// expectedID: 301, -// }, -// { -// name: "success user item", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusOK, userItem), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(456), -// "item_id": float64(501), -// }, -// expectedID: 501, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(789), -// "item_id": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get project item", -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner_type": "org", -// "project_number": float64(10), -// "item_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "project_number": float64(10), -// "item_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "item_id": float64(1), -// }, -// expectError: true, -// }, -// { -// name: "missing item_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(10), -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// if tc.name == "missing owner" { -// assert.Contains(t, text, "missing required parameter: owner") -// } -// if tc.name == "missing owner_type" { -// assert.Contains(t, text, "missing required parameter: owner_type") -// } -// if tc.name == "missing project_number" { -// assert.Contains(t, text, "missing required parameter: project_number") -// } -// if tc.name == "missing item_id" { -// assert.Contains(t, text, "missing required parameter: item_id") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var item map[string]any -// err = json.Unmarshal([]byte(textContent.Text), &item) -// require.NoError(t, err) -// if tc.expectedID != 0 { -// assert.Equal(t, float64(tc.expectedID), item["id"]) -// } -// }) -// } -// } - -// func Test_AddProjectItem(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "add_project_item", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "item_type") -// assert.Contains(t, tool.InputSchema.Properties, "item_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) - -// orgItem := map[string]any{ -// "id": 601, -// "content_type": "Issue", -// "creator": map[string]any{ -// "login": "octocat", -// "id": 1, -// "html_url": "https://github.com/octocat", -// "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", -// }, -// } - -// userItem := map[string]any{ -// "id": 701, -// "content_type": "PullRequest", -// "creator": map[string]any{ -// "login": "hubot", -// "id": 2, -// "html_url": "https://github.com/hubot", -// "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// expectedID int -// expectedContentType string -// expectedCreatorLogin string -// }{ -// { -// name: "success organization issue", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// body, err := io.ReadAll(r.Body) -// assert.NoError(t, err) -// var payload struct { -// Type string `json:"type"` -// ID int `json:"id"` -// } -// assert.NoError(t, json.Unmarshal(body, &payload)) -// assert.Equal(t, "Issue", payload.Type) -// assert.Equal(t, 9876, payload.ID) -// w.WriteHeader(http.StatusCreated) -// _, _ = w.Write(mock.MustMarshal(orgItem)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(321), -// "item_type": "issue", -// "item_id": float64(9876), -// }, -// expectedID: 601, -// expectedContentType: "Issue", -// expectedCreatorLogin: "octocat", -// }, -// { -// name: "success user pull request", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// body, err := io.ReadAll(r.Body) -// assert.NoError(t, err) -// var payload struct { -// Type string `json:"type"` -// ID int `json:"id"` -// } -// assert.NoError(t, json.Unmarshal(body, &payload)) -// assert.Equal(t, "PullRequest", payload.Type) -// assert.Equal(t, 7654, payload.ID) -// w.WriteHeader(http.StatusCreated) -// _, _ = w.Write(mock.MustMarshal(userItem)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(222), -// "item_type": "pull_request", -// "item_id": float64(7654), -// }, -// expectedID: 701, -// expectedContentType: "PullRequest", -// expectedCreatorLogin: "hubot", -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(999), -// "item_type": "issue", -// "item_id": float64(8888), -// }, -// expectError: true, -// expectedErrMsg: ProjectAddFailedError, -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner_type": "org", -// "project_number": float64(1), -// "item_type": "Issue", -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "project_number": float64(1), -// "item_type": "Issue", -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "item_type": "Issue", -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing item_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing item_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_type": "Issue", -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// switch tc.name { -// case "missing owner": -// assert.Contains(t, text, "missing required parameter: owner") -// case "missing owner_type": -// assert.Contains(t, text, "missing required parameter: owner_type") -// case "missing project_number": -// assert.Contains(t, text, "missing required parameter: project_number") -// case "missing item_type": -// assert.Contains(t, text, "missing required parameter: item_type") -// case "missing item_id": -// assert.Contains(t, text, "missing required parameter: item_id") -// // case "api error": -// // assert.Contains(t, text, ProjectAddFailedError) -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var item map[string]any -// require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) -// if tc.expectedID != 0 { -// assert.Equal(t, float64(tc.expectedID), item["id"]) -// } -// if tc.expectedContentType != "" { -// assert.Equal(t, tc.expectedContentType, item["content_type"]) -// } -// if tc.expectedCreatorLogin != "" { -// creator, ok := item["creator"].(map[string]any) -// require.True(t, ok) -// assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) -// } -// }) -// } -// } - -// func Test_UpdateProjectItem(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "update_project_item", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "item_id") -// assert.Contains(t, tool.InputSchema.Properties, "updated_field") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) - -// orgUpdatedItem := map[string]any{ -// "id": 801, -// "content_type": "Issue", -// } -// userUpdatedItem := map[string]any{ -// "id": 802, -// "content_type": "PullRequest", -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// expectedID int -// }{ -// { -// name: "success organization update", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// body, err := io.ReadAll(r.Body) -// assert.NoError(t, err) -// var payload struct { -// Fields []struct { -// ID int `json:"id"` -// Value interface{} `json:"value"` -// } `json:"fields"` -// } -// assert.NoError(t, json.Unmarshal(body, &payload)) -// require.Len(t, payload.Fields, 1) -// assert.Equal(t, 101, payload.Fields[0].ID) -// assert.Equal(t, "Done", payload.Fields[0].Value) -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1001), -// "item_id": float64(5555), -// "updated_field": map[string]any{ -// "id": float64(101), -// "value": "Done", -// }, -// }, -// expectedID: 801, -// }, -// { -// name: "success user update", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// body, err := io.ReadAll(r.Body) -// assert.NoError(t, err) -// var payload struct { -// Fields []struct { -// ID int `json:"id"` -// Value interface{} `json:"value"` -// } `json:"fields"` -// } -// assert.NoError(t, json.Unmarshal(body, &payload)) -// require.Len(t, payload.Fields, 1) -// assert.Equal(t, 202, payload.Fields[0].ID) -// assert.Equal(t, 42.0, payload.Fields[0].Value) -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(2002), -// "item_id": float64(6666), -// "updated_field": map[string]any{ -// "id": float64(202), -// "value": float64(42), -// }, -// }, -// expectedID: 802, -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(3003), -// "item_id": float64(7777), -// "updated_field": map[string]any{ -// "id": float64(303), -// "value": "In Progress", -// }, -// }, -// expectError: true, -// expectedErrMsg: "failed to update a project item", -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(2), -// "field_id": float64(1), -// "new_field": map[string]any{ -// "value": "X", -// }, -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "project_number": float64(1), -// "item_id": float64(2), -// "new_field": map[string]any{ -// "id": float64(1), -// "value": "X", -// }, -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "item_id": float64(2), -// "new_field": map[string]any{ -// "id": float64(1), -// "value": "X", -// }, -// }, -// expectError: true, -// }, -// { -// name: "missing item_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "new_field": map[string]any{ -// "id": float64(1), -// "value": "X", -// }, -// }, -// expectError: true, -// }, -// { -// name: "missing field_value", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(2), -// "field_id": float64(2), -// }, -// expectError: true, -// }, -// { -// name: "new_field not object", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(2), -// "updated_field": "not-an-object", -// }, -// expectError: true, -// }, -// { -// name: "new_field missing id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(2), -// "updated_field": map[string]any{}, -// }, -// expectError: true, -// }, -// { -// name: "new_field missing value", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(2), -// "updated_field": map[string]any{ -// "id": float64(9), -// }, -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// switch tc.name { -// case "missing owner": -// assert.Contains(t, text, "missing required parameter: owner") -// case "missing owner_type": -// assert.Contains(t, text, "missing required parameter: owner_type") -// case "missing project_number": -// assert.Contains(t, text, "missing required parameter: project_number") -// case "missing item_id": -// assert.Contains(t, text, "missing required parameter: item_id") -// case "missing field_value": -// assert.Contains(t, text, "missing required parameter: updated_field") -// case "field_value not object": -// assert.Contains(t, text, "field_value must be an object") -// case "field_value missing id": -// assert.Contains(t, text, "missing required parameter: field_id") -// case "field_value missing value": -// assert.Contains(t, text, "field_value.value is required") -// } -// return -// } - -// require.False(t, result.IsError) -// textContent := getTextResult(t, result) -// var item map[string]any -// require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) -// if tc.expectedID != 0 { -// assert.Equal(t, float64(tc.expectedID), item["id"]) -// } -// }) -// } -// } - -// func Test_DeleteProjectItem(t *testing.T) { -// mockClient := gh.NewClient(nil) -// tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "delete_project_item", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner_type") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "project_number") -// assert.Contains(t, tool.InputSchema.Properties, "item_id") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// expectedText string -// }{ -// { -// name: "success organization delete", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(123), -// "item_id": float64(555), -// }, -// expectedText: "project item successfully deleted", -// }, -// { -// name: "success user delete", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octocat", -// "owner_type": "user", -// "project_number": float64(456), -// "item_id": float64(777), -// }, -// expectedText: "project item successfully deleted", -// }, -// { -// name: "api error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(321), -// "item_id": float64(999), -// }, -// expectError: true, -// expectedErrMsg: ProjectDeleteFailedError, -// }, -// { -// name: "missing owner", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner_type": "org", -// "project_number": float64(1), -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing owner_type", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "project_number": float64(1), -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing project_number", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "item_id": float64(10), -// }, -// expectError: true, -// }, -// { -// name: "missing item_id", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]any{ -// "owner": "octo-org", -// "owner_type": "org", -// "project_number": float64(1), -// }, -// expectError: true, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := gh.NewClient(tc.mockedClient) -// _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// require.NoError(t, err) -// if tc.expectError { -// require.True(t, result.IsError) -// text := getTextResult(t, result).Text -// if tc.expectedErrMsg != "" { -// assert.Contains(t, text, tc.expectedErrMsg) -// } -// switch tc.name { -// case "missing owner": -// assert.Contains(t, text, "missing required parameter: owner") -// case "missing owner_type": -// assert.Contains(t, text, "missing required parameter: owner_type") -// case "missing project_number": -// assert.Contains(t, text, "missing required parameter: project_number") -// case "missing item_id": -// assert.Contains(t, text, "missing required parameter: item_id") -// } -// return -// } - -// require.False(t, result.IsError) -// text := getTextResult(t, result).Text -// assert.Contains(t, text, tc.expectedText) -// }) -// } -// } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go deleted file mode 100644 index 6fce227ae..000000000 --- a/pkg/github/pullrequests.go +++ /dev/null @@ -1,1630 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// "github.com/go-viper/mapstructure/v2" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// "github.com/shurcooL/githubv4" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/sanitize" -// "github.com/github/github-mcp-server/pkg/translations" -// ) - -// // GetPullRequest creates a tool to get details of a specific pull request. -// func PullRequestRead(getClient GetClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("pull_request_read", -// mcp.WithDescription(t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("method", -// mcp.Required(), -// mcp.Description(`Action to specify what pull request data needs to be retrieved from GitHub. -// Possible options: -// 1. get - Get details of a specific pull request. -// 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. 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", "get_comments"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// 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 -// } -// repo, err := RequiredParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pullNumber, err := RequiredInt(request, "pullNumber") -// if err != nil { -// 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) -// } - -// switch method { - -// case "get": -// return GetPullRequest(ctx, client, owner, repo, pullNumber) -// case "get_diff": -// return GetPullRequestDiff(ctx, client, owner, repo, pullNumber) -// case "get_status": -// return GetPullRequestStatus(ctx, client, owner, repo, pullNumber) -// case "get_files": -// return GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) -// case "get_review_comments": -// 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, flags) -// default: -// return nil, fmt.Errorf("unknown method: %s", method) -// } -// } -// } - -// func GetPullRequest(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { -// pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request", -// 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 get pull request: %s", string(body))), nil -// } - -// // sanitize title/body on response -// if pr != nil { -// if pr.Title != nil { -// pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) -// } -// if pr.Body != nil { -// pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) -// } -// } - -// r, err := json.Marshal(pr) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { -// raw, resp, err := client.PullRequests.GetRaw( -// ctx, -// owner, -// repo, -// pullNumber, -// github.RawOptions{Type: github.Diff}, -// ) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request diff", -// resp, -// err, -// ), 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 pull request diff: %s", string(body))), nil -// } - -// defer func() { _ = resp.Body.Close() }() - -// // Return the raw response -// return mcp.NewToolResultText(string(raw)), nil -// } - -// func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { -// pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request", -// 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 get pull request: %s", string(body))), nil -// } - -// // Get combined status for the head SHA -// status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get combined status", -// 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 get combined status: %s", string(body))), nil -// } - -// r, err := json.Marshal(status) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { -// opts := &github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// } -// files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request files", -// 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 get pull request files: %s", string(body))), nil -// } - -// r, err := json.Marshal(files) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// func GetPullRequestReviewComments(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { -// opts := &github.PullRequestListCommentsOptions{ -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request review comments", -// 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 get pull request review 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 GetPullRequestReviews(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { -// reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get pull request reviews", -// 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 get pull request reviews: %s", string(body))), nil -// } - -// r, err := json.Marshal(reviews) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } - -// // CreatePullRequest creates a tool to create a new pull request. -// func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("create_pull_request", -// mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), -// 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("PR title"), -// ), -// mcp.WithString("body", -// mcp.Description("PR description"), -// ), -// mcp.WithString("head", -// mcp.Required(), -// mcp.Description("Branch containing changes"), -// ), -// mcp.WithString("base", -// mcp.Required(), -// mcp.Description("Branch to merge into"), -// ), -// mcp.WithBoolean("draft", -// mcp.Description("Create as draft PR"), -// ), -// mcp.WithBoolean("maintainer_can_modify", -// mcp.Description("Allow maintainer edits"), -// ), -// ), -// 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 -// } -// head, err := RequiredParam[string](request, "head") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// base, err := RequiredParam[string](request, "base") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// body, err := OptionalParam[string](request, "body") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// draft, err := OptionalParam[bool](request, "draft") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// newPR := &github.NewPullRequest{ -// Title: github.Ptr(title), -// Head: github.Ptr(head), -// Base: github.Ptr(base), -// } - -// if body != "" { -// newPR.Body = github.Ptr(body) -// } - -// newPR.Draft = github.Ptr(draft) -// newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create pull request", -// resp, -// err, -// ), nil -// } -// 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 pull request: %s", string(body))), nil -// } - -// // Return minimal response with just essential information -// minimalResponse := MinimalResponse{ -// ID: fmt.Sprintf("%d", pr.GetID()), -// URL: pr.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 -// } -// } - -// // UpdatePullRequest creates a tool to update an existing pull request. -// func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("update_pull_request", -// mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number to update"), -// ), -// mcp.WithString("title", -// mcp.Description("New title"), -// ), -// mcp.WithString("body", -// mcp.Description("New description"), -// ), -// mcp.WithString("state", -// mcp.Description("New state"), -// mcp.Enum("open", "closed"), -// ), -// mcp.WithBoolean("draft", -// mcp.Description("Mark pull request as draft (true) or ready for review (false)"), -// ), -// mcp.WithString("base", -// mcp.Description("New base branch name"), -// ), -// mcp.WithBoolean("maintainer_can_modify", -// mcp.Description("Allow maintainer edits"), -// ), -// mcp.WithArray("reviewers", -// mcp.Description("GitHub usernames to request reviews from"), -// mcp.Items(map[string]interface{}{ -// "type": "string", -// }), -// ), -// ), -// 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 -// } -// pullNumber, err := RequiredInt(request, "pullNumber") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Check if draft parameter is provided -// draftProvided := request.GetArguments()["draft"] != nil -// var draftValue bool -// if draftProvided { -// draftValue, err = OptionalParam[bool](request, "draft") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// } - -// // Build the update struct only with provided fields -// update := &github.PullRequest{} -// restUpdateNeeded := false - -// if title, ok, err := OptionalParamOK[string](request, "title"); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } else if ok { -// update.Title = github.Ptr(title) -// restUpdateNeeded = true -// } - -// if body, ok, err := OptionalParamOK[string](request, "body"); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } else if ok { -// update.Body = github.Ptr(body) -// restUpdateNeeded = true -// } - -// if state, ok, err := OptionalParamOK[string](request, "state"); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } else if ok { -// update.State = github.Ptr(state) -// restUpdateNeeded = true -// } - -// if base, ok, err := OptionalParamOK[string](request, "base"); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } else if ok { -// update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} -// restUpdateNeeded = true -// } - -// if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } else if ok { -// update.MaintainerCanModify = github.Ptr(maintainerCanModify) -// restUpdateNeeded = true -// } - -// // Handle reviewers separately -// reviewers, err := OptionalStringArrayParam(request, "reviewers") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // If no updates, no draft change, and no reviewers, return error early -// if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { -// return mcp.NewToolResultError("No update parameters provided."), nil -// } - -// // Handle REST API updates (title, body, state, base, maintainer_can_modify) -// if restUpdateNeeded { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to update pull request", -// 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 pull request: %s", string(body))), nil -// } -// } - -// // Handle draft status changes using GraphQL -// if draftProvided { -// gqlClient, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) -// } - -// var prQuery struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ -// "owner": githubv4.String(owner), -// "repo": githubv4.String(repo), -// "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers -// }) -// if err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil -// } - -// currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) - -// if currentIsDraft != draftValue { -// if draftValue { -// // Convert to draft -// var mutation struct { -// ConvertPullRequestToDraft struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } -// } `graphql:"convertPullRequestToDraft(input: $input)"` -// } - -// err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ -// PullRequestID: prQuery.Repository.PullRequest.ID, -// }, nil) -// if err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil -// } -// } else { -// // Mark as ready for review -// var mutation struct { -// MarkPullRequestReadyForReview struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } -// } `graphql:"markPullRequestReadyForReview(input: $input)"` -// } - -// err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ -// PullRequestID: prQuery.Repository.PullRequest.ID, -// }, nil) -// if err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil -// } -// } -// } -// } - -// // Handle reviewer requests -// if len(reviewers) > 0 { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// reviewersRequest := github.ReviewersRequest{ -// Reviewers: reviewers, -// } - -// _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to request reviewers", -// resp, -// err, -// ), nil -// } -// defer func() { -// if resp != nil && resp.Body != nil { -// _ = resp.Body.Close() -// } -// }() - -// if resp.StatusCode != http.StatusCreated && 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 request reviewers: %s", string(body))), nil -// } -// } - -// // Get the final state of the PR to return -// client, err := getClient(ctx) -// if err != nil { -// return nil, err -// } - -// finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil -// } -// defer func() { -// if resp != nil && resp.Body != nil { -// _ = resp.Body.Close() -// } -// }() - -// // Return minimal response with just essential information -// minimalResponse := MinimalResponse{ -// ID: fmt.Sprintf("%d", finalPR.GetID()), -// URL: finalPR.GetHTMLURL(), -// } - -// r, err := json.Marshal(minimalResponse) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListPullRequests creates a tool to list and filter repository pull requests. -// func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("list_pull_requests", -// mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("state", -// mcp.Description("Filter by state"), -// mcp.Enum("open", "closed", "all"), -// ), -// mcp.WithString("head", -// mcp.Description("Filter by head user/org and branch"), -// ), -// mcp.WithString("base", -// mcp.Description("Filter by base branch"), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort by"), -// mcp.Enum("created", "updated", "popularity", "long-running"), -// ), -// mcp.WithString("direction", -// mcp.Description("Sort direction"), -// mcp.Enum("asc", "desc"), -// ), -// 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 -// } -// state, err := OptionalParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// head, err := OptionalParam[string](request, "head") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// base, err := OptionalParam[string](request, "base") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// opts := &github.PullRequestListOptions{ -// State: state, -// Head: head, -// Base: base, -// Sort: sort, -// Direction: direction, -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list pull requests", -// 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 pull requests: %s", string(body))), nil -// } - -// // sanitize title/body on each PR -// for _, pr := range prs { -// if pr == nil { -// continue -// } -// if pr.Title != nil { -// pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) -// } -// if pr.Body != nil { -// pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) -// } -// } - -// r, err := json.Marshal(prs) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // MergePullRequest creates a tool to merge a pull request. -// func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("merge_pull_request", -// mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// mcp.WithString("commit_title", -// mcp.Description("Title for merge commit"), -// ), -// mcp.WithString("commit_message", -// mcp.Description("Extra detail for merge commit"), -// ), -// mcp.WithString("merge_method", -// mcp.Description("Merge method"), -// mcp.Enum("merge", "squash", "rebase"), -// ), -// ), -// 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 -// } -// pullNumber, err := RequiredInt(request, "pullNumber") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// commitTitle, err := OptionalParam[string](request, "commit_title") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// commitMessage, err := OptionalParam[string](request, "commit_message") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// mergeMethod, err := OptionalParam[string](request, "merge_method") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// options := &github.PullRequestOptions{ -// CommitTitle: commitTitle, -// MergeMethod: mergeMethod, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to merge pull request", -// 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 merge pull request: %s", string(body))), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // SearchPullRequests creates a tool to search for pull requests. -// func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("search_pull_requests", -// mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("query", -// mcp.Required(), -// mcp.Description("Search query using GitHub pull request search syntax"), -// ), -// mcp.WithString("owner", -// mcp.Description("Optional repository owner. If provided with repo, only pull requests for this repository are listed."), -// ), -// mcp.WithString("repo", -// mcp.Description("Optional repository name. If provided with owner, only pull requests 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, "pr", "failed to search pull requests") -// } -// } - -// // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. -// func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("update_pull_request_branch", -// mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// mcp.WithString("expectedHeadSha", -// mcp.Description("The expected SHA of the pull request's HEAD ref"), -// ), -// ), -// 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 -// } -// pullNumber, err := RequiredInt(request, "pullNumber") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// expectedHeadSHA, err := OptionalParam[string](request, "expectedHeadSha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// opts := &github.PullRequestBranchUpdateOptions{} -// if expectedHeadSHA != "" { -// opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts) -// if err != nil { -// // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, -// // and it's not a real error. -// if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { -// return mcp.NewToolResultText("Pull request branch update is in progress"), nil -// } -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to update pull request branch", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusAccepted { -// 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 pull request branch: %s", string(body))), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// type PullRequestReviewWriteParams struct { -// Method string -// Owner string -// Repo string -// PullNumber int32 -// Body string -// Event string -// CommitID *string -// } - -// func PullRequestReviewWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("pull_request_review_write", -// mcp.WithDescription(t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. - -// Available methods: -// - create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. -// - submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. -// - delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. -// `)), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up. -// // Since our other Pull Request tools are working with the REST Client, will handle the lookup -// // internally for now. -// mcp.WithString("method", -// mcp.Required(), -// mcp.Description("The write operation to perform on pull request review."), -// mcp.Enum("create", "submit_pending", "delete_pending"), -// ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// mcp.WithString("body", -// mcp.Description("Review comment text"), -// ), -// mcp.WithString("event", -// mcp.Description("Review action to perform."), -// mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"), -// ), -// mcp.WithString("commitID", -// mcp.Description("SHA of commit to review"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// var params PullRequestReviewWriteParams -// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Given our owner, repo and PR number, lookup the GQL ID of the PR. -// client, err := getGQLClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil -// } - -// switch params.Method { -// case "create": -// return CreatePullRequestReview(ctx, client, params) -// case "submit_pending": -// return SubmitPendingPullRequestReview(ctx, client, params) -// case "delete_pending": -// return DeletePendingPullRequestReview(ctx, client, params) -// default: -// return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil -// } -// } -// } - -// func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { -// var getPullRequestQuery struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// } - -// if err := client.Query(ctx, &getPullRequestQuery, map[string]any{ -// "owner": githubv4.String(params.Owner), -// "repo": githubv4.String(params.Repo), -// "prNum": githubv4.Int(params.PullNumber), -// }); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get pull request", -// err, -// ), nil -// } - -// // Now we have the GQL ID, we can create a review -// var addPullRequestReviewMutation struct { -// AddPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. -// } -// } `graphql:"addPullRequestReview(input: $input)"` -// } - -// addPullRequestReviewInput := githubv4.AddPullRequestReviewInput{ -// PullRequestID: getPullRequestQuery.Repository.PullRequest.ID, -// CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID), -// } - -// // Event and Body are provided if we submit a review -// if params.Event != "" { -// addPullRequestReviewInput.Event = newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event) -// addPullRequestReviewInput.Body = githubv4.NewString(githubv4.String(params.Body)) -// } - -// if err := client.Mutate( -// ctx, -// &addPullRequestReviewMutation, -// addPullRequestReviewInput, -// nil, -// ); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Return nothing interesting, just indicate success for the time being. -// // In future, we may want to return the review ID, but for the moment, we're not leaking -// // API implementation details to the LLM. -// if params.Event == "" { -// return mcp.NewToolResultText("pending pull request created"), nil -// } -// return mcp.NewToolResultText("pull request review submitted successfully"), nil -// } - -// func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { -// // First we'll get the current user -// var getViewerQuery struct { -// Viewer struct { -// Login githubv4.String -// } -// } - -// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get current user", -// err, -// ), nil -// } - -// var getLatestReviewForViewerQuery struct { -// Repository struct { -// PullRequest struct { -// Reviews struct { -// Nodes []struct { -// ID githubv4.ID -// State githubv4.PullRequestReviewState -// URL githubv4.URI -// } -// } `graphql:"reviews(first: 1, author: $author)"` -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// } - -// vars := map[string]any{ -// "author": githubv4.String(getViewerQuery.Viewer.Login), -// "owner": githubv4.String(params.Owner), -// "name": githubv4.String(params.Repo), -// "prNum": githubv4.Int(params.PullNumber), -// } - -// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get latest review for current user", -// err, -// ), nil -// } - -// // Validate there is one review and the state is pending -// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { -// return mcp.NewToolResultError("No pending review found for the viewer"), nil -// } - -// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] -// if review.State != githubv4.PullRequestReviewStatePending { -// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) -// return mcp.NewToolResultError(errText), nil -// } - -// // Prepare the mutation -// var submitPullRequestReviewMutation struct { -// SubmitPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. -// } -// } `graphql:"submitPullRequestReview(input: $input)"` -// } - -// if err := client.Mutate( -// ctx, -// &submitPullRequestReviewMutation, -// githubv4.SubmitPullRequestReviewInput{ -// PullRequestReviewID: &review.ID, -// Event: githubv4.PullRequestReviewEvent(params.Event), -// Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), -// }, -// nil, -// ); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to submit pull request review", -// err, -// ), nil -// } - -// // Return nothing interesting, just indicate success for the time being. -// // In future, we may want to return the review ID, but for the moment, we're not leaking -// // API implementation details to the LLM. -// return mcp.NewToolResultText("pending pull request review successfully submitted"), nil -// } - -// func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { -// // First we'll get the current user -// var getViewerQuery struct { -// Viewer struct { -// Login githubv4.String -// } -// } - -// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get current user", -// err, -// ), nil -// } - -// var getLatestReviewForViewerQuery struct { -// Repository struct { -// PullRequest struct { -// Reviews struct { -// Nodes []struct { -// ID githubv4.ID -// State githubv4.PullRequestReviewState -// URL githubv4.URI -// } -// } `graphql:"reviews(first: 1, author: $author)"` -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// } - -// vars := map[string]any{ -// "author": githubv4.String(getViewerQuery.Viewer.Login), -// "owner": githubv4.String(params.Owner), -// "name": githubv4.String(params.Repo), -// "prNum": githubv4.Int(params.PullNumber), -// } - -// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get latest review for current user", -// err, -// ), nil -// } - -// // Validate there is one review and the state is pending -// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { -// return mcp.NewToolResultError("No pending review found for the viewer"), nil -// } - -// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] -// if review.State != githubv4.PullRequestReviewStatePending { -// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) -// return mcp.NewToolResultError(errText), nil -// } - -// // Prepare the mutation -// var deletePullRequestReviewMutation struct { -// DeletePullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. -// } -// } `graphql:"deletePullRequestReview(input: $input)"` -// } - -// if err := client.Mutate( -// ctx, -// &deletePullRequestReviewMutation, -// githubv4.DeletePullRequestReviewInput{ -// PullRequestReviewID: &review.ID, -// }, -// nil, -// ); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Return nothing interesting, just indicate success for the time being. -// // In future, we may want to return the review ID, but for the moment, we're not leaking -// // API implementation details to the LLM. -// return mcp.NewToolResultText("pending pull request review successfully deleted"), nil -// } - -// // AddCommentToPendingReview creates a tool to add a comment to a pull request review. -// func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("add_comment_to_pending_review", -// mcp.WithDescription(t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to -// // add a new tool to get that ID for clients that aren't in the same context as the original pending review -// // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment -// // the latest review from a user, since only one can be active at a time. It can later be extended with -// // a pullRequestReviewID parameter if targeting other reviews is desired: -// // mcp.WithString("pullRequestReviewID", -// // mcp.Required(), -// // mcp.Description("The ID of the pull request review to add a comment to"), -// // ), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// mcp.WithString("path", -// mcp.Required(), -// mcp.Description("The relative path to the file that necessitates a comment"), -// ), -// mcp.WithString("body", -// mcp.Required(), -// mcp.Description("The text of the review comment"), -// ), -// mcp.WithString("subjectType", -// mcp.Required(), -// mcp.Description("The level at which the comment is targeted"), -// mcp.Enum("FILE", "LINE"), -// ), -// mcp.WithNumber("line", -// mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"), -// ), -// mcp.WithString("side", -// mcp.Description("The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state"), -// mcp.Enum("LEFT", "RIGHT"), -// ), -// mcp.WithNumber("startLine", -// mcp.Description("For multi-line comments, the first line of the range that the comment applies to"), -// ), -// mcp.WithString("startSide", -// mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state"), -// mcp.Enum("LEFT", "RIGHT"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// var params struct { -// Owner string -// Repo string -// PullNumber int32 -// Path string -// Body string -// SubjectType string -// Line *int32 -// Side *string -// StartLine *int32 -// StartSide *string -// } -// if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getGQLClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err) -// } - -// // First we'll get the current user -// var getViewerQuery struct { -// Viewer struct { -// Login githubv4.String -// } -// } - -// if err := client.Query(ctx, &getViewerQuery, nil); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get current user", -// err, -// ), nil -// } - -// var getLatestReviewForViewerQuery struct { -// Repository struct { -// PullRequest struct { -// Reviews struct { -// Nodes []struct { -// ID githubv4.ID -// State githubv4.PullRequestReviewState -// URL githubv4.URI -// } -// } `graphql:"reviews(first: 1, author: $author)"` -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// } - -// vars := map[string]any{ -// "author": githubv4.String(getViewerQuery.Viewer.Login), -// "owner": githubv4.String(params.Owner), -// "name": githubv4.String(params.Repo), -// "prNum": githubv4.Int(params.PullNumber), -// } - -// if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { -// return ghErrors.NewGitHubGraphQLErrorResponse(ctx, -// "failed to get latest review for current user", -// err, -// ), nil -// } - -// // Validate there is one review and the state is pending -// if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { -// return mcp.NewToolResultError("No pending review found for the viewer"), nil -// } - -// review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] -// if review.State != githubv4.PullRequestReviewStatePending { -// errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) -// return mcp.NewToolResultError(errText), nil -// } - -// // Then we can create a new review thread comment on the review. -// var addPullRequestReviewThreadMutation struct { -// AddPullRequestReviewThread struct { -// Thread struct { -// ID githubv4.ID // We don't need this, but a selector is required or GQL complains. -// } -// } `graphql:"addPullRequestReviewThread(input: $input)"` -// } - -// if err := client.Mutate( -// ctx, -// &addPullRequestReviewThreadMutation, -// githubv4.AddPullRequestReviewThreadInput{ -// Path: githubv4.String(params.Path), -// Body: githubv4.String(params.Body), -// SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), -// Line: newGQLIntPtr(params.Line), -// Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), -// StartLine: newGQLIntPtr(params.StartLine), -// StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), -// PullRequestReviewID: &review.ID, -// }, -// nil, -// ); err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Return nothing interesting, just indicate success for the time being. -// // In future, we may want to return the review ID, but for the moment, we're not leaking -// // API implementation details to the LLM. -// return mcp.NewToolResultText("pull request review comment successfully added to pending review"), nil -// } -// } - -// // RequestCopilotReview creates a tool to request a Copilot review for a pull request. -// // Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this -// // tool if the configured host does not support it. -// func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { -// return mcp.NewTool("request_copilot_review", -// mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithNumber("pullNumber", -// mcp.Required(), -// mcp.Description("Pull request number"), -// ), -// ), -// 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 -// } - -// pullNumber, err := RequiredInt(request, "pullNumber") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// _, resp, err := client.PullRequests.RequestReviewers( -// ctx, -// owner, -// repo, -// pullNumber, -// github.ReviewersRequest{ -// // The login name of the copilot reviewer bot -// Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, -// }, -// ) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to request copilot review", -// resp, -// err, -// ), nil -// } -// 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 request copilot review: %s", string(body))), nil -// } - -// // Return nothing on success, as there's not much value in returning the Pull Request itself -// return mcp.NewToolResultText(""), nil -// } -// } - -// // newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) -// // and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse -// // params from the MCP request, we need to convert them to types that are pointers of type def strings and it's -// // not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo"). -// func newGQLStringlike[T ~string](s string) *T { -// if s == "" { -// return nil -// } -// stringlike := T(s) -// return &stringlike -// } - -// func newGQLStringlikePtr[T ~string](s *string) *T { -// if s == nil { -// return nil -// } -// stringlike := T(*s) -// return &stringlike -// } - -// func newGQLIntPtr(i *int32) *githubv4.Int { -// if i == nil { -// return nil -// } -// gi := githubv4.Int(*i) -// return &gi -// } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go deleted file mode 100644 index be5894cae..000000000 --- a/pkg/github/pullrequests_test.go +++ /dev/null @@ -1,2943 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" -// "time" - -// "github.com/github/github-mcp-server/internal/githubv4mock" -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/shurcooL/githubv4" - -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetPullRequest(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// // Setup mock PR for success case -// mockPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Head: &github.PullRequestBranch{ -// SHA: github.Ptr("abcd1234"), -// Ref: github.Ptr("feature-branch"), -// }, -// Base: &github.PullRequestBranch{ -// Ref: github.Ptr("main"), -// }, -// Body: github.Ptr("This is a test PR"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedPR *github.PullRequest -// expectedErrMsg string -// }{ -// { -// name: "successful PR fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockPR, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedPR: mockPR, -// }, -// { -// name: "PR fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get pull request", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedPR github.PullRequest -// err = json.Unmarshal([]byte(textContent.Text), &returnedPR) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) -// assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) -// assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) -// assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) -// }) -// } -// } - -// func Test_UpdatePullRequest(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "update_pull_request", 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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "draft") -// assert.Contains(t, tool.InputSchema.Properties, "title") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "state") -// assert.Contains(t, tool.InputSchema.Properties, "base") -// assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") -// assert.Contains(t, tool.InputSchema.Properties, "reviewers") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - -// // Setup mock PR for success case -// mockUpdatedPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Updated Test PR Title"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Body: github.Ptr("Updated test PR body."), -// MaintainerCanModify: github.Ptr(false), -// Draft: github.Ptr(false), -// Base: &github.PullRequestBranch{ -// Ref: github.Ptr("develop"), -// }, -// } - -// mockClosedPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// State: github.Ptr("closed"), // State updated -// } - -// // Mock PR for when there are no updates but we still need a response -// mockPRWithReviewers := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// State: github.Ptr("open"), -// RequestedReviewers: []*github.User{ -// {Login: github.Ptr("reviewer1")}, -// {Login: github.Ptr("reviewer2")}, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedPR *github.PullRequest -// expectedErrMsg string -// }{ -// { -// name: "successful PR update (title, body, base, maintainer_can_modify)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposPullsByOwnerByRepoByPullNumber, -// // Expect the flat string based on previous test failure output and API docs -// expectRequestBody(t, map[string]interface{}{ -// "title": "Updated Test PR Title", -// "body": "Updated test PR body.", -// "base": "develop", -// "maintainer_can_modify": false, -// }).andThen( -// mockResponse(t, http.StatusOK, mockUpdatedPR), -// ), -// ), -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockUpdatedPR, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "title": "Updated Test PR Title", -// "body": "Updated test PR body.", -// "base": "develop", -// "maintainer_can_modify": false, -// }, -// expectError: false, -// expectedPR: mockUpdatedPR, -// }, -// { -// name: "successful PR update (state)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposPullsByOwnerByRepoByPullNumber, -// expectRequestBody(t, map[string]interface{}{ -// "state": "closed", -// }).andThen( -// mockResponse(t, http.StatusOK, mockClosedPR), -// ), -// ), -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockClosedPR, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "state": "closed", -// }, -// expectError: false, -// expectedPR: mockClosedPR, -// }, -// { -// name: "successful PR update with reviewers", -// mockedClient: mock.NewMockedHTTPClient( -// // Mock for RequestReviewers call, returning the PR with reviewers -// mock.WithRequestMatch( -// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, -// mockPRWithReviewers, -// ), -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockPRWithReviewers, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "reviewers": []interface{}{"reviewer1", "reviewer2"}, -// }, -// expectError: false, -// expectedPR: mockPRWithReviewers, -// }, -// { -// name: "successful PR update (title only)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposPullsByOwnerByRepoByPullNumber, -// expectRequestBody(t, map[string]interface{}{ -// "title": "Updated Test PR Title", -// }).andThen( -// mockResponse(t, http.StatusOK, mockUpdatedPR), -// ), -// ), -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockUpdatedPR, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "title": "Updated Test PR Title", -// }, -// expectError: false, -// expectedPR: mockUpdatedPR, -// }, -// { -// name: "no update parameters provided", -// mockedClient: mock.NewMockedHTTPClient(), // No API call expected -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// // No update fields -// }, -// expectError: false, // Error is returned in the result, not as Go error -// expectedErrMsg: "No update parameters provided", -// }, -// { -// name: "PR update fails (API error)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PatchReposPullsByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "title": "Invalid Title Causing Error", -// }, -// expectError: true, -// expectedErrMsg: "failed to update pull request", -// }, -// { -// name: "request reviewers fails", -// mockedClient: mock.NewMockedHTTPClient( -// // Then reviewer request fails -// mock.WithRequestMatchHandler( -// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "reviewers": []interface{}{"invalid-user"}, -// }, -// expectError: true, -// expectedErrMsg: "failed to request reviewers", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError || tc.expectedErrMsg != "" { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var updateResp MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &updateResp) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) -// }) -// } -// } - -// func Test_UpdatePullRequest_Draft(t *testing.T) { -// // Setup mock PR for success case -// mockUpdatedPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR Title"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Body: github.Ptr("Test PR body."), -// MaintainerCanModify: github.Ptr(false), -// Draft: github.Ptr(false), // Updated to ready for review -// Base: &github.PullRequestBranch{ -// Ref: github.Ptr("main"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedPR *github.PullRequest -// expectedErrMsg string -// }{ -// { -// name: "successful draft update to ready for review", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDOA0xdyM50BPaO", -// "isDraft": true, // Current state is draft -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// MarkPullRequestReadyForReview struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } -// } `graphql:"markPullRequestReadyForReview(input: $input)"` -// }{}, -// githubv4.MarkPullRequestReadyForReviewInput{ -// PullRequestID: "PR_kwDOA0xdyM50BPaO", -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{ -// "markPullRequestReadyForReview": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDOA0xdyM50BPaO", -// "isDraft": false, -// }, -// }, -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "draft": false, -// }, -// expectError: false, -// expectedPR: mockUpdatedPR, -// }, -// { -// name: "successful convert pull request to draft", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse(map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDOA0xdyM50BPaO", -// "isDraft": false, // Current state is draft -// }, -// }, -// }), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// ConvertPullRequestToDraft struct { -// PullRequest struct { -// ID githubv4.ID -// IsDraft githubv4.Boolean -// } -// } `graphql:"convertPullRequestToDraft(input: $input)"` -// }{}, -// githubv4.ConvertPullRequestToDraftInput{ -// PullRequestID: "PR_kwDOA0xdyM50BPaO", -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{ -// "convertPullRequestToDraft": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDOA0xdyM50BPaO", -// "isDraft": true, -// }, -// }, -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "draft": true, -// }, -// expectError: false, -// expectedPR: mockUpdatedPR, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // For draft-only tests, we need to mock both GraphQL and the final REST GET call -// restClient := github.NewClient(mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockUpdatedPR, -// ), -// )) -// gqlClient := githubv4.NewClient(tc.mockedClient) - -// _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError || tc.expectedErrMsg != "" { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// if tc.expectedErrMsg != "" { -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var updateResp MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &updateResp) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) -// }) -// } -// } - -// func Test_ListPullRequests(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_pull_requests", 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, "state") -// assert.Contains(t, tool.InputSchema.Properties, "head") -// assert.Contains(t, tool.InputSchema.Properties, "base") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "direction") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock PRs for success case -// mockPRs := []*github.PullRequest{ -// { -// Number: github.Ptr(42), -// Title: github.Ptr("First PR"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// }, -// { -// Number: github.Ptr(43), -// Title: github.Ptr("Second PR"), -// State: github.Ptr("closed"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedPRs []*github.PullRequest -// expectedErrMsg string -// }{ -// { -// name: "successful PRs listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "state": "all", -// "sort": "created", -// "direction": "desc", -// "per_page": "30", -// "page": "1", -// }).andThen( -// mockResponse(t, http.StatusOK, mockPRs), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "all", -// "sort": "created", -// "direction": "desc", -// "perPage": float64(30), -// "page": float64(1), -// }, -// expectError: false, -// expectedPRs: mockPRs, -// }, -// { -// name: "PRs listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "invalid", -// }, -// expectError: true, -// expectedErrMsg: "failed to list pull requests", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedPRs []*github.PullRequest -// err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) -// require.NoError(t, err) -// assert.Len(t, returnedPRs, 2) -// assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) -// assert.Equal(t, *tc.expectedPRs[0].Title, *returnedPRs[0].Title) -// assert.Equal(t, *tc.expectedPRs[0].State, *returnedPRs[0].State) -// assert.Equal(t, *tc.expectedPRs[1].Number, *returnedPRs[1].Number) -// assert.Equal(t, *tc.expectedPRs[1].Title, *returnedPRs[1].Title) -// assert.Equal(t, *tc.expectedPRs[1].State, *returnedPRs[1].State) -// }) -// } -// } - -// func Test_MergePullRequest(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := MergePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "merge_pull_request", 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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "commit_title") -// assert.Contains(t, tool.InputSchema.Properties, "commit_message") -// assert.Contains(t, tool.InputSchema.Properties, "merge_method") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - -// // Setup mock merge result for success case -// mockMergeResult := &github.PullRequestMergeResult{ -// Merged: github.Ptr(true), -// Message: github.Ptr("Pull Request successfully merged"), -// SHA: github.Ptr("abcd1234efgh5678"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedMergeResult *github.PullRequestMergeResult -// expectedErrMsg string -// }{ -// { -// name: "successful merge", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposPullsMergeByOwnerByRepoByPullNumber, -// expectRequestBody(t, map[string]interface{}{ -// "commit_title": "Merge PR #42", -// "commit_message": "Merging awesome feature", -// "merge_method": "squash", -// }).andThen( -// mockResponse(t, http.StatusOK, mockMergeResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "commit_title": "Merge PR #42", -// "commit_message": "Merging awesome feature", -// "merge_method": "squash", -// }, -// expectError: false, -// expectedMergeResult: mockMergeResult, -// }, -// { -// name: "merge fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposPullsMergeByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusMethodNotAllowed) -// _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: true, -// expectedErrMsg: "failed to merge pull request", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := MergePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedResult github.PullRequestMergeResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedMergeResult.Merged, *returnedResult.Merged) -// assert.Equal(t, *tc.expectedMergeResult.Message, *returnedResult.Message) -// assert.Equal(t, *tc.expectedMergeResult.SHA, *returnedResult.SHA) -// }) -// } -// } - -// func Test_SearchPullRequests(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "search_pull_requests", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// mockSearchResult := &github.IssuesSearchResult{ -// Total: github.Ptr(2), -// IncompleteResults: github.Ptr(false), -// Issues: []*github.Issue{ -// { -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR 1"), -// Body: github.Ptr("Updated tests."), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), -// Comments: github.Ptr(5), -// User: &github.User{ -// Login: github.Ptr("user1"), -// }, -// }, -// { -// Number: github.Ptr(43), -// Title: github.Ptr("Test PR 2"), -// Body: github.Ptr("Updated build scripts."), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), -// Comments: github.Ptr(3), -// User: &github.User{ -// Login: github.Ptr("user2"), -// }, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.IssuesSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful pull request search with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr repo:owner/repo is:open", -// "sort": "created", -// "order": "desc", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:owner/repo is:open", -// "sort": "created", -// "order": "desc", -// "page": float64(1), -// "perPage": float64(30), -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "pull request search with owner and repo parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "repo:test-owner/test-repo is:pr draft:false", -// "sort": "updated", -// "order": "asc", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "draft:false", -// "owner": "test-owner", -// "repo": "test-repo", -// "sort": "updated", -// "order": "asc", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "pull request search with only owner parameter (should ignore it)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr feature", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "feature", -// "owner": "test-owner", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "pull request search with only repo parameter (should ignore it)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr review-required", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "review-required", -// "repo": "test-repo", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "pull request search with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetSearchIssues, -// mockSearchResult, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:pr repo:owner/repo is:open", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing is:pr filter - no duplication", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr repo:github/github-mcp-server is:open draft:false", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:pr repo:github/github-mcp-server is:open draft:false", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr repo:github/github-mcp-server author:octocat", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "repo:github/github-mcp-server author:octocat", -// "owner": "different-owner", -// "repo": "different-repo", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "complex query with existing is:pr filter and OR operators", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// expectQueryParams( -// t, -// map[string]string{ -// "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", -// "page": "1", -// "per_page": "30", -// }, -// ).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "search pull requests fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchIssues, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search pull requests", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchPullRequests(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), 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 returnedResult github.IssuesSearchResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) -// for i, issue := range returnedResult.Issues { -// assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) -// assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) -// assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) -// assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL) -// assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login) -// } -// }) -// } - -// } - -// func Test_GetPullRequestFiles(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// // Setup mock PR files for success case -// mockFiles := []*github.CommitFile{ -// { -// Filename: github.Ptr("file1.go"), -// Status: github.Ptr("modified"), -// Additions: github.Ptr(10), -// Deletions: github.Ptr(5), -// Changes: github.Ptr(15), -// Patch: github.Ptr("@@ -1,5 +1,10 @@"), -// }, -// { -// Filename: github.Ptr("file2.go"), -// Status: github.Ptr("added"), -// Additions: github.Ptr(20), -// Deletions: github.Ptr(0), -// Changes: github.Ptr(20), -// Patch: github.Ptr("@@ -0,0 +1,20 @@"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedFiles []*github.CommitFile -// expectedErrMsg string -// }{ -// { -// name: "successful files fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, -// mockFiles, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_files", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedFiles: mockFiles, -// }, -// { -// name: "successful files fetch with pagination", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, -// mockFiles, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_files", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedFiles: mockFiles, -// }, -// { -// name: "files fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsFilesByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_files", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get pull request files", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedFiles []*github.CommitFile -// err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) -// require.NoError(t, err) -// assert.Len(t, returnedFiles, len(tc.expectedFiles)) -// for i, file := range returnedFiles { -// assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) -// assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) -// assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) -// assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) -// } -// }) -// } -// } - -// func Test_GetPullRequestStatus(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// // Setup mock PR for successful PR fetch -// mockPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Head: &github.PullRequestBranch{ -// SHA: github.Ptr("abcd1234"), -// Ref: github.Ptr("feature-branch"), -// }, -// } - -// // Setup mock status for success case -// mockStatus := &github.CombinedStatus{ -// State: github.Ptr("success"), -// TotalCount: github.Ptr(3), -// Statuses: []*github.RepoStatus{ -// { -// State: github.Ptr("success"), -// Context: github.Ptr("continuous-integration/travis-ci"), -// Description: github.Ptr("Build succeeded"), -// TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), -// }, -// { -// State: github.Ptr("success"), -// Context: github.Ptr("codecov/patch"), -// Description: github.Ptr("Coverage increased"), -// TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), -// }, -// { -// State: github.Ptr("success"), -// Context: github.Ptr("lint/golangci-lint"), -// Description: github.Ptr("No issues found"), -// TargetURL: github.Ptr("https://golangci.com/r/owner/repo/pull/42"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedStatus *github.CombinedStatus -// expectedErrMsg string -// }{ -// { -// name: "successful status fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockPR, -// ), -// mock.WithRequestMatch( -// mock.GetReposCommitsStatusByOwnerByRepoByRef, -// mockStatus, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_status", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedStatus: mockStatus, -// }, -// { -// name: "PR fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_status", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get pull request", -// }, -// { -// name: "status fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// mockPR, -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsStatusesByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_status", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: true, -// expectedErrMsg: "failed to get combined status", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedStatus github.CombinedStatus -// err = json.Unmarshal([]byte(textContent.Text), &returnedStatus) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedStatus.State, *returnedStatus.State) -// assert.Equal(t, *tc.expectedStatus.TotalCount, *returnedStatus.TotalCount) -// assert.Len(t, returnedStatus.Statuses, len(tc.expectedStatus.Statuses)) -// for i, status := range returnedStatus.Statuses { -// assert.Equal(t, *tc.expectedStatus.Statuses[i].State, *status.State) -// assert.Equal(t, *tc.expectedStatus.Statuses[i].Context, *status.Context) -// assert.Equal(t, *tc.expectedStatus.Statuses[i].Description, *status.Description) -// } -// }) -// } -// } - -// func Test_UpdatePullRequestBranch(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := UpdatePullRequestBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "update_pull_request_branch", 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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "expectedHeadSha") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - -// // Setup mock update result for success case -// mockUpdateResult := &github.PullRequestBranchUpdateResponse{ -// Message: github.Ptr("Branch was updated successfully"), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedUpdateResult *github.PullRequestBranchUpdateResponse -// expectedErrMsg string -// }{ -// { -// name: "successful branch update", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, -// expectRequestBody(t, map[string]interface{}{ -// "expected_head_sha": "abcd1234", -// }).andThen( -// mockResponse(t, http.StatusAccepted, mockUpdateResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "expectedHeadSha": "abcd1234", -// }, -// expectError: false, -// expectedUpdateResult: mockUpdateResult, -// }, -// { -// name: "branch update without expected SHA", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, -// expectRequestBody(t, map[string]interface{}{}).andThen( -// mockResponse(t, http.StatusAccepted, mockUpdateResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedUpdateResult: mockUpdateResult, -// }, -// { -// name: "branch update fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusConflict) -// _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: true, -// expectedErrMsg: "failed to update pull request branch", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := UpdatePullRequestBranch(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// assert.Contains(t, textContent.Text, "is in progress") -// }) -// } -// } - -// func Test_GetPullRequestComments(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// // Setup mock PR comments for success case -// mockComments := []*github.PullRequestComment{ -// { -// ID: github.Ptr(int64(101)), -// Body: github.Ptr("This looks good"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r101"), -// User: &github.User{ -// Login: github.Ptr("reviewer1"), -// }, -// Path: github.Ptr("file1.go"), -// Position: github.Ptr(5), -// CommitID: github.Ptr("abcdef123456"), -// CreatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, -// UpdatedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, -// }, -// { -// ID: github.Ptr(int64(102)), -// Body: github.Ptr("Please fix this"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r102"), -// User: &github.User{ -// Login: github.Ptr("reviewer2"), -// }, -// Path: github.Ptr("file2.go"), -// Position: github.Ptr(10), -// CommitID: github.Ptr("abcdef123456"), -// CreatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, -// UpdatedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedComments []*github.PullRequestComment -// expectedErrMsg string -// }{ -// { -// name: "successful comments fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, -// mockComments, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_review_comments", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedComments: mockComments, -// }, -// { -// name: "comments fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsCommentsByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_review_comments", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get pull request review comments", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedComments []*github.PullRequestComment -// err = json.Unmarshal([]byte(textContent.Text), &returnedComments) -// require.NoError(t, err) -// assert.Len(t, returnedComments, len(tc.expectedComments)) -// for i, comment := range returnedComments { -// assert.Equal(t, *tc.expectedComments[i].ID, *comment.ID) -// assert.Equal(t, *tc.expectedComments[i].Body, *comment.Body) -// assert.Equal(t, *tc.expectedComments[i].User.Login, *comment.User.Login) -// assert.Equal(t, *tc.expectedComments[i].Path, *comment.Path) -// assert.Equal(t, *tc.expectedComments[i].HTMLURL, *comment.HTMLURL) -// } -// }) -// } -// } - -// func Test_GetPullRequestReviews(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// // Setup mock PR reviews for success case -// mockReviews := []*github.PullRequestReview{ -// { -// ID: github.Ptr(int64(201)), -// State: github.Ptr("APPROVED"), -// Body: github.Ptr("LGTM"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), -// User: &github.User{ -// Login: github.Ptr("approver"), -// }, -// CommitID: github.Ptr("abcdef123456"), -// SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, -// }, -// { -// ID: github.Ptr(int64(202)), -// State: github.Ptr("CHANGES_REQUESTED"), -// Body: github.Ptr("Please address the following issues"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), -// User: &github.User{ -// Login: github.Ptr("reviewer"), -// }, -// CommitID: github.Ptr("abcdef123456"), -// SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedReviews []*github.PullRequestReview -// expectedErrMsg string -// }{ -// { -// name: "successful reviews fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, -// mockReviews, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_reviews", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// expectError: false, -// expectedReviews: mockReviews, -// }, -// { -// name: "reviews fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "method": "get_reviews", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get pull request reviews", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedReviews []*github.PullRequestReview -// err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) -// require.NoError(t, err) -// assert.Len(t, returnedReviews, len(tc.expectedReviews)) -// for i, review := range returnedReviews { -// assert.Equal(t, *tc.expectedReviews[i].ID, *review.ID) -// assert.Equal(t, *tc.expectedReviews[i].State, *review.State) -// assert.Equal(t, *tc.expectedReviews[i].Body, *review.Body) -// assert.Equal(t, *tc.expectedReviews[i].User.Login, *review.User.Login) -// assert.Equal(t, *tc.expectedReviews[i].HTMLURL, *review.HTMLURL) -// } -// }) -// } -// } - -// func Test_CreatePullRequest(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := CreatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "create_pull_request", 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, "title") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "head") -// assert.Contains(t, tool.InputSchema.Properties, "base") -// assert.Contains(t, tool.InputSchema.Properties, "draft") -// assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title", "head", "base"}) - -// // Setup mock PR for success case -// mockPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Head: &github.PullRequestBranch{ -// SHA: github.Ptr("abcd1234"), -// Ref: github.Ptr("feature-branch"), -// }, -// Base: &github.PullRequestBranch{ -// SHA: github.Ptr("efgh5678"), -// Ref: github.Ptr("main"), -// }, -// Body: github.Ptr("This is a test PR"), -// Draft: github.Ptr(false), -// MaintainerCanModify: github.Ptr(true), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedPR *github.PullRequest -// expectedErrMsg string -// }{ -// { -// name: "successful PR creation", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposPullsByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "title": "Test PR", -// "body": "This is a test PR", -// "head": "feature-branch", -// "base": "main", -// "draft": false, -// "maintainer_can_modify": true, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockPR), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "title": "Test PR", -// "body": "This is a test PR", -// "head": "feature-branch", -// "base": "main", -// "draft": false, -// "maintainer_can_modify": true, -// }, -// expectError: false, -// expectedPR: mockPR, -// }, -// { -// name: "missing required parameter", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// // missing title, head, base -// }, -// expectError: true, -// expectedErrMsg: "missing required parameter: title", -// }, -// { -// name: "PR creation fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposPullsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "title": "Test PR", -// "head": "feature-branch", -// "base": "main", -// }, -// expectError: true, -// expectedErrMsg: "failed to create pull request", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CreatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// if err != nil { -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// // If no error returned but in the 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 minimal result -// var returnedPR MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &returnedPR) -// require.NoError(t, err) -// assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) -// }) -// } -// } - -// func TestCreateAndSubmitPullRequestReview(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := githubv4.NewClient(nil) -// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_review_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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "event") -// assert.Contains(t, tool.InputSchema.Properties, "commitID") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful review creation", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse( -// map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDODKw3uc6WYN1T", -// }, -// }, -// }, -// ), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// AddPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"addPullRequestReview(input: $input)"` -// }{}, -// githubv4.AddPullRequestReviewInput{ -// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), -// Body: githubv4.NewString("This is a test review"), -// Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), -// CommitOID: githubv4.NewGitObjectID("abcd1234"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "body": "This is a test review", -// "event": "COMMENT", -// "commitID": "abcd1234", -// }, -// expectToolError: false, -// }, -// { -// name: "failure to get pull request", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.ErrorResponse("expected test failure"), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "body": "This is a test review", -// "event": "COMMENT", -// "commitID": "abcd1234", -// }, -// expectToolError: true, -// expectedToolErrMsg: "expected test failure", -// }, -// { -// name: "failure to submit review", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse( -// map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDODKw3uc6WYN1T", -// }, -// }, -// }, -// ), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// AddPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"addPullRequestReview(input: $input)"` -// }{}, -// githubv4.AddPullRequestReviewInput{ -// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), -// Body: githubv4.NewString("This is a test review"), -// Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), -// CommitOID: githubv4.NewGitObjectID("abcd1234"), -// }, -// nil, -// githubv4mock.ErrorResponse("expected test failure"), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "body": "This is a test review", -// "event": "COMMENT", -// "commitID": "abcd1234", -// }, -// expectToolError: true, -// expectedToolErrMsg: "expected test failure", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, textContent.Text, "pull request review submitted successfully") -// }) -// } -// } - -// func Test_RequestCopilotReview(t *testing.T) { -// t.Parallel() - -// mockClient := github.NewClient(nil) -// tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "request_copilot_review", 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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) - -// // Setup mock PR for success case -// mockPR := &github.PullRequest{ -// Number: github.Ptr(42), -// Title: github.Ptr("Test PR"), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), -// Head: &github.PullRequestBranch{ -// SHA: github.Ptr("abcd1234"), -// Ref: github.Ptr("feature-branch"), -// }, -// Base: &github.PullRequestBranch{ -// Ref: github.Ptr("main"), -// }, -// Body: github.Ptr("This is a test PR"), -// User: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful request", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, -// expect(t, expectations{ -// path: "/repos/owner/repo/pulls/1/requested_reviewers", -// requestBody: map[string]any{ -// "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, -// }, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockPR), -// ), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(1), -// }, -// expectError: false, -// }, -// { -// name: "request fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(999), -// }, -// expectError: true, -// expectedErrMsg: "failed to request copilot review", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// client := github.NewClient(tc.mockedClient) -// _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) -// assert.NotNil(t, result) -// assert.Len(t, result.Content, 1) - -// textContent := getTextResult(t, result) -// require.Equal(t, "", textContent.Text) -// }) -// } -// } - -// func TestCreatePendingPullRequestReview(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := githubv4.NewClient(nil) -// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_review_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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "commitID") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful review creation", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse( -// map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDODKw3uc6WYN1T", -// }, -// }, -// }, -// ), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// AddPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"addPullRequestReview(input: $input)"` -// }{}, -// githubv4.AddPullRequestReviewInput{ -// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), -// CommitOID: githubv4.NewGitObjectID("abcd1234"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "commitID": "abcd1234", -// }, -// expectToolError: false, -// }, -// { -// name: "failure to get pull request", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.ErrorResponse("expected test failure"), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "commitID": "abcd1234", -// }, -// expectToolError: true, -// expectedToolErrMsg: "expected test failure", -// }, -// { -// name: "failure to create pending review", -// mockedClient: githubv4mock.NewMockedHTTPClient( -// githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// ID githubv4.ID -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $repo)"` -// }{}, -// map[string]any{ -// "owner": githubv4.String("owner"), -// "repo": githubv4.String("repo"), -// "prNum": githubv4.Int(42), -// }, -// githubv4mock.DataResponse( -// map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "id": "PR_kwDODKw3uc6WYN1T", -// }, -// }, -// }, -// ), -// ), -// githubv4mock.NewMutationMatcher( -// struct { -// AddPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"addPullRequestReview(input: $input)"` -// }{}, -// githubv4.AddPullRequestReviewInput{ -// PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), -// CommitOID: githubv4.NewGitObjectID("abcd1234"), -// }, -// nil, -// githubv4mock.ErrorResponse("expected test failure"), -// ), -// ), -// requestArgs: map[string]any{ -// "method": "create", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "commitID": "abcd1234", -// }, -// expectToolError: true, -// expectedToolErrMsg: "expected test failure", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, "pending pull request created", textContent.Text) -// }) -// } -// } - -// func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := githubv4.NewClient(nil) -// tool, _ := AddCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "add_comment_to_pending_review", 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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "path") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.Contains(t, tool.InputSchema.Properties, "subjectType") -// assert.Contains(t, tool.InputSchema.Properties, "line") -// assert.Contains(t, tool.InputSchema.Properties, "side") -// assert.Contains(t, tool.InputSchema.Properties, "startLine") -// assert.Contains(t, tool.InputSchema.Properties, "startSide") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful line comment addition", -// requestArgs: map[string]any{ -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "path": "file.go", -// "body": "This is a test comment", -// "subjectType": "LINE", -// "line": float64(10), -// "side": "RIGHT", -// "startLine": float64(5), -// "startSide": "RIGHT", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// viewerQuery("williammartin"), -// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ -// author: "williammartin", -// owner: "owner", -// repo: "repo", -// prNum: 42, - -// reviews: []getLatestPendingReviewQueryReview{ -// { -// id: "PR_kwDODKw3uc6WYN1T", -// state: "PENDING", -// url: "https://github.com/owner/repo/pull/42", -// }, -// }, -// }), -// githubv4mock.NewMutationMatcher( -// struct { -// AddPullRequestReviewThread struct { -// Thread struct { -// ID githubv4.String // We don't need this, but a selector is required or GQL complains. -// } -// } `graphql:"addPullRequestReviewThread(input: $input)"` -// }{}, -// githubv4.AddPullRequestReviewThreadInput{ -// Path: githubv4.String("file.go"), -// Body: githubv4.String("This is a test comment"), -// SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), -// Line: githubv4.NewInt(10), -// Side: githubv4mock.Ptr(githubv4.DiffSideRight), -// StartLine: githubv4.NewInt(5), -// StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), -// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := AddCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review") -// }) -// } -// } - -// func TestSubmitPendingPullRequestReview(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := githubv4.NewClient(nil) -// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_review_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, "pullNumber") -// assert.Contains(t, tool.InputSchema.Properties, "event") -// assert.Contains(t, tool.InputSchema.Properties, "body") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]any -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful review submission", -// requestArgs: map[string]any{ -// "method": "submit_pending", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// "event": "COMMENT", -// "body": "This is a test review", -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// viewerQuery("williammartin"), -// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ -// author: "williammartin", -// owner: "owner", -// repo: "repo", -// prNum: 42, - -// reviews: []getLatestPendingReviewQueryReview{ -// { -// id: "PR_kwDODKw3uc6WYN1T", -// state: "PENDING", -// url: "https://github.com/owner/repo/pull/42", -// }, -// }, -// }), -// githubv4mock.NewMutationMatcher( -// struct { -// SubmitPullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"submitPullRequestReview(input: $input)"` -// }{}, -// githubv4.SubmitPullRequestReviewInput{ -// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), -// Event: githubv4.PullRequestReviewEventComment, -// Body: githubv4.NewString("This is a test review"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, "pending pull request review successfully submitted", textContent.Text) -// }) -// } -// } - -// func TestDeletePendingPullRequestReview(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := githubv4.NewClient(nil) -// tool, _ := PullRequestReviewWrite(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_review_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful review deletion", -// requestArgs: map[string]any{ -// "method": "delete_pending", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// mockedClient: githubv4mock.NewMockedHTTPClient( -// viewerQuery("williammartin"), -// getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ -// author: "williammartin", -// owner: "owner", -// repo: "repo", -// prNum: 42, - -// reviews: []getLatestPendingReviewQueryReview{ -// { -// id: "PR_kwDODKw3uc6WYN1T", -// state: "PENDING", -// url: "https://github.com/owner/repo/pull/42", -// }, -// }, -// }), -// githubv4mock.NewMutationMatcher( -// struct { -// DeletePullRequestReview struct { -// PullRequestReview struct { -// ID githubv4.ID -// } -// } `graphql:"deletePullRequestReview(input: $input)"` -// }{}, -// githubv4.DeletePullRequestReviewInput{ -// PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), -// }, -// nil, -// githubv4mock.DataResponse(map[string]any{}), -// ), -// ), -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := githubv4.NewClient(tc.mockedClient) -// _, handler := PullRequestReviewWrite(stubGetGQLClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, "pending pull request review successfully deleted", textContent.Text) -// }) -// } -// } - -// func TestGetPullRequestDiff(t *testing.T) { -// t.Parallel() - -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PullRequestRead(stubGetClientFn(mockClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "pull_request_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, "pullNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) - -// stubbedDiff := `diff --git a/README.md b/README.md -// index 5d6e7b2..8a4f5c3 100644 -// --- a/README.md -// +++ b/README.md -// @@ -1,4 +1,6 @@ -// # Hello-World - -// Hello World project for GitHub - -// +## New Section -// + -// +This is a new section added in the pull request.` - -// tests := []struct { -// name string -// requestArgs map[string]any -// mockedClient *http.Client -// expectToolError bool -// expectedToolErrMsg string -// }{ -// { -// name: "successful diff retrieval", -// requestArgs: map[string]any{ -// "method": "get_diff", -// "owner": "owner", -// "repo": "repo", -// "pullNumber": float64(42), -// }, -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposPullsByOwnerByRepoByPullNumber, -// // Should also expect Accept header to be application/vnd.github.v3.diff -// expectPath(t, "/repos/owner/repo/pulls/42").andThen( -// mockResponse(t, http.StatusOK, stubbedDiff), -// ), -// ), -// ), -// expectToolError: false, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// t.Parallel() - -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PullRequestRead(stubGetClientFn(client), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// if tc.expectToolError { -// require.True(t, result.IsError) -// assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) -// return -// } - -// // Parse the result and get the text content if no error -// require.Equal(t, stubbedDiff, textContent.Text) -// }) -// } -// } - -// func viewerQuery(login string) githubv4mock.Matcher { -// return githubv4mock.NewQueryMatcher( -// struct { -// Viewer struct { -// Login githubv4.String -// } `graphql:"viewer"` -// }{}, -// map[string]any{}, -// githubv4mock.DataResponse(map[string]any{ -// "viewer": map[string]any{ -// "login": login, -// }, -// }), -// ) -// } - -// type getLatestPendingReviewQueryReview struct { -// id string -// state string -// url string -// } - -// type getLatestPendingReviewQueryParams struct { -// author string -// owner string -// repo string -// prNum int32 - -// reviews []getLatestPendingReviewQueryReview -// } - -// func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher { -// return githubv4mock.NewQueryMatcher( -// struct { -// Repository struct { -// PullRequest struct { -// Reviews struct { -// Nodes []struct { -// ID githubv4.ID -// State githubv4.PullRequestReviewState -// URL githubv4.URI -// } -// } `graphql:"reviews(first: 1, author: $author)"` -// } `graphql:"pullRequest(number: $prNum)"` -// } `graphql:"repository(owner: $owner, name: $name)"` -// }{}, -// map[string]any{ -// "author": githubv4.String(p.author), -// "owner": githubv4.String(p.owner), -// "name": githubv4.String(p.repo), -// "prNum": githubv4.Int(p.prNum), -// }, -// githubv4mock.DataResponse( -// map[string]any{ -// "repository": map[string]any{ -// "pullRequest": map[string]any{ -// "reviews": map[string]any{ -// "nodes": []any{ -// map[string]any{ -// "id": p.reviews[0].id, -// "state": p.reviews[0].state, -// "url": p.reviews[0].url, -// }, -// }, -// }, -// }, -// }, -// }, -// ), -// ) -// } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go deleted file mode 100644 index 0f866bb39..000000000 --- a/pkg/github/repositories.go +++ /dev/null @@ -1,1928 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/base64" -// "encoding/json" -// "fmt" -// "io" -// "net/http" -// "net/url" -// "strings" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/raw" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_commit", -// mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("sha", -// mcp.Required(), -// mcp.Description("Commit SHA, branch name, or tag name"), -// ), -// mcp.WithBoolean("include_diff", -// mcp.Description("Whether to include file diffs and stats in the response. Default is true."), -// mcp.DefaultBool(true), -// ), -// 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 -// } -// sha, err := RequiredParam[string](request, "sha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &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) -// } -// commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to get commit: %s", sha), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 commit: %s", string(body))), nil -// } - -// // Convert to minimal commit -// minimalCommit := convertToMinimalCommit(commit, includeDiff) - -// r, err := json.Marshal(minimalCommit) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListCommits creates a tool to get commits of a branch in a repository. -// func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_commits", -// mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("sha", -// mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), -// ), -// mcp.WithString("author", -// mcp.Description("Author username or email address to filter commits by"), -// ), -// 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 -// } -// sha, err := OptionalParam[string](request, "sha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// author, err := OptionalParam[string](request, "author") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// // Set default perPage to 30 if not provided -// perPage := pagination.PerPage -// if perPage == 0 { -// perPage = 30 -// } -// opts := &github.CommitsListOptions{ -// SHA: sha, -// Author: author, -// ListOptions: github.ListOptions{ -// Page: pagination.Page, -// PerPage: perPage, -// }, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to list commits: %s", sha), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 commits: %s", string(body))), nil -// } - -// // Convert to minimal commits -// minimalCommits := make([]MinimalCommit, len(commits)) -// for i, commit := range commits { -// minimalCommits[i] = convertToMinimalCommit(commit, false) -// } - -// r, err := json.Marshal(minimalCommits) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListBranches creates a tool to list branches in a GitHub repository. -// func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_branches", -// mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// 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 -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.BranchListOptions{ -// 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) -// } - -// branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list branches", -// 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 branches: %s", string(body))), nil -// } - -// // Convert to minimal branches -// minimalBranches := make([]MinimalBranch, 0, len(branches)) -// for _, branch := range branches { -// minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) -// } - -// r, err := json.Marshal(minimalBranches) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. -// func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("create_or_update_file", -// mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("path", -// mcp.Required(), -// mcp.Description("Path where to create/update the file"), -// ), -// mcp.WithString("content", -// mcp.Required(), -// mcp.Description("Content of the file"), -// ), -// mcp.WithString("message", -// mcp.Required(), -// mcp.Description("Commit message"), -// ), -// mcp.WithString("branch", -// mcp.Required(), -// mcp.Description("Branch to create/update the file in"), -// ), -// mcp.WithString("sha", -// mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), -// ), -// ), -// 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 -// } -// path, err := RequiredParam[string](request, "path") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// content, err := RequiredParam[string](request, "content") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// message, err := RequiredParam[string](request, "message") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// branch, err := RequiredParam[string](request, "branch") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // json.Marshal encodes byte arrays with base64, which is required for the API. -// contentBytes := []byte(content) - -// // Create the file options -// opts := &github.RepositoryContentFileOptions{ -// Message: github.Ptr(message), -// Content: contentBytes, -// Branch: github.Ptr(branch), -// } - -// // If SHA is provided, set it (for updates) -// sha, err := OptionalParam[string](request, "sha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// if sha != "" { -// opts.SHA = github.Ptr(sha) -// } - -// // Create or update the file -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create/update file", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 && resp.StatusCode != 201 { -// 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/update file: %s", string(body))), nil -// } - -// r, err := json.Marshal(fileContent) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // CreateRepository creates a tool to create a new GitHub repository. -// func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("create_repository", -// mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("name", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("description", -// mcp.Description("Repository description"), -// ), -// mcp.WithString("organization", -// mcp.Description("Organization to create the repository in (omit to create in your personal account)"), -// ), -// mcp.WithBoolean("private", -// mcp.Description("Whether repo should be private"), -// ), -// mcp.WithBoolean("autoInit", -// mcp.Description("Initialize with README"), -// ), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// name, err := RequiredParam[string](request, "name") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// description, err := OptionalParam[string](request, "description") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// organization, err := OptionalParam[string](request, "organization") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// private, err := OptionalParam[bool](request, "private") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// autoInit, err := OptionalParam[bool](request, "autoInit") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// repo := &github.Repository{ -// Name: github.Ptr(name), -// Description: github.Ptr(description), -// Private: github.Ptr(private), -// AutoInit: github.Ptr(autoInit), -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create repository", -// resp, -// err, -// ), nil -// } -// 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 repository: %s", string(body))), nil -// } - -// // Return minimal response with just essential information -// minimalResponse := MinimalResponse{ -// ID: fmt.Sprintf("%d", createdRepo.GetID()), -// URL: createdRepo.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 -// } -// } - -// // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. -// func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_file_contents", -// mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("path", -// mcp.Description("Path to file/directory (directories must end with a slash '/')"), -// mcp.DefaultString("/"), -// ), -// mcp.WithString("ref", -// mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), -// ), -// mcp.WithString("sha", -// mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), -// ), -// ), -// 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 -// } -// path, err := RequiredParam[string](request, "path") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// ref, err := OptionalParam[string](request, "ref") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sha, err := OptionalParam[string](request, "sha") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return mcp.NewToolResultError("failed to get GitHub client"), nil -// } - -// rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil -// } - -// // If the path is (most likely) not to be a directory, we will -// // first try to get the raw content from the GitHub raw content API. - -// var rawAPIResponseCode int -// if path != "" && !strings.HasSuffix(path, "/") { -// // First, get file info from Contents API to retrieve SHA -// var fileSHA string -// opts := &github.RepositoryContentGetOptions{Ref: ref} -// fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) -// if respContents != nil { -// defer func() { _ = respContents.Body.Close() }() -// } -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get file SHA", -// respContents, -// err, -// ), nil -// } -// if fileContent == nil || fileContent.SHA == nil { -// return mcp.NewToolResultError("file content SHA is nil"), nil -// } -// fileSHA = *fileContent.SHA - -// rawClient, err := getRawClient(ctx) -// if err != nil { -// return mcp.NewToolResultError("failed to get GitHub raw content client"), nil -// } -// resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) -// if err != nil { -// return mcp.NewToolResultError("failed to get raw repository content"), nil -// } -// defer func() { -// _ = resp.Body.Close() -// }() - -// if resp.StatusCode == http.StatusOK { -// // If the raw content is found, return it directly -// body, err := io.ReadAll(resp.Body) -// if err != nil { -// return mcp.NewToolResultError("failed to read response body"), nil -// } -// contentType := resp.Header.Get("Content-Type") - -// var resourceURI string -// switch { -// case sha != "": -// resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) -// if err != nil { -// return nil, fmt.Errorf("failed to create resource URI: %w", err) -// } -// case ref != "": -// resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) -// if err != nil { -// return nil, fmt.Errorf("failed to create resource URI: %w", err) -// } -// default: -// resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) -// if err != nil { -// return nil, fmt.Errorf("failed to create resource URI: %w", err) -// } -// } - -// // Determine if content is text or binary -// isTextContent := strings.HasPrefix(contentType, "text/") || -// contentType == "application/json" || -// contentType == "application/xml" || -// strings.HasSuffix(contentType, "+json") || -// strings.HasSuffix(contentType, "+xml") - -// if isTextContent { -// result := mcp.TextResourceContents{ -// URI: resourceURI, -// Text: string(body), -// MIMEType: contentType, -// } -// // Include SHA in the result metadata -// if fileSHA != "" { -// return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil -// } -// return mcp.NewToolResultResource("successfully downloaded text file", result), nil -// } - -// result := mcp.BlobResourceContents{ -// URI: resourceURI, -// Blob: base64.StdEncoding.EncodeToString(body), -// MIMEType: contentType, -// } -// // Include SHA in the result metadata -// if fileSHA != "" { -// return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil -// } -// return mcp.NewToolResultResource("successfully downloaded binary file", result), nil -// } -// rawAPIResponseCode = resp.StatusCode -// } - -// if rawOpts.SHA != "" { -// ref = rawOpts.SHA -// } -// if strings.HasSuffix(path, "/") { -// opts := &github.RepositoryContentGetOptions{Ref: ref} -// _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) -// if err == nil && resp.StatusCode == http.StatusOK { -// defer func() { _ = resp.Body.Close() }() -// r, err := json.Marshal(dirContent) -// if err != nil { -// return mcp.NewToolResultError("failed to marshal response"), nil -// } -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // The path does not point to a file or directory. -// // Instead let's try to find it in the Git Tree by matching the end of the path. - -// // Step 1: Get Git Tree recursively -// tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get git tree", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Step 2: Filter tree for matching paths -// const maxMatchingFiles = 3 -// matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) -// if len(matchingFiles) > 0 { -// matchingFilesJSON, err := json.Marshal(matchingFiles) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil -// } -// resolvedRefs, err := json.Marshal(rawOpts) -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil -// } -// return mcp.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil -// } - -// return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil -// } -// } - -// // ForkRepository creates a tool to fork a repository. -// func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("fork_repository", -// mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("organization", -// mcp.Description("Organization to fork to"), -// ), -// ), -// 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 -// } -// org, err := OptionalParam[string](request, "organization") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.RepositoryCreateForkOptions{} -// if org != "" { -// opts.Organization = org -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } -// forkedRepo, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) -// if err != nil { -// // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, -// // and it's not a real error. -// if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { -// return mcp.NewToolResultText("Fork is in progress"), nil -// } -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to fork repository", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusAccepted { -// 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 fork repository: %s", string(body))), nil -// } - -// // Return minimal response with just essential information -// minimalResponse := MinimalResponse{ -// ID: fmt.Sprintf("%d", forkedRepo.GetID()), -// URL: forkedRepo.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 -// } -// } - -// // DeleteFile creates a tool to delete a file in a GitHub repository. -// // This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. -// // This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, -// // unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. -// // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, -// // both of which suit an LLM well. -// func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("delete_file", -// mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), -// ReadOnlyHint: ToBoolPtr(false), -// DestructiveHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner (username or organization)"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("path", -// mcp.Required(), -// mcp.Description("Path to the file to delete"), -// ), -// mcp.WithString("message", -// mcp.Required(), -// mcp.Description("Commit message"), -// ), -// mcp.WithString("branch", -// mcp.Required(), -// mcp.Description("Branch to delete the file from"), -// ), -// ), -// 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 -// } -// path, err := RequiredParam[string](request, "path") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// message, err := RequiredParam[string](request, "message") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// branch, err := RequiredParam[string](request, "branch") -// 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) -// } - -// // Get the reference for the branch -// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) -// if err != nil { -// return nil, fmt.Errorf("failed to get branch reference: %w", err) -// } -// defer func() { _ = resp.Body.Close() }() - -// // Get the commit object that the branch points to -// baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get base commit", -// 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 get commit: %s", string(body))), nil -// } - -// // Create a tree entry for the file deletion by setting SHA to nil -// treeEntries := []*github.TreeEntry{ -// { -// Path: github.Ptr(path), -// Mode: github.Ptr("100644"), // Regular file mode -// Type: github.Ptr("blob"), -// SHA: nil, // Setting SHA to nil deletes the file -// }, -// } - -// // Create a new tree with the deletion -// newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create tree", -// resp, -// err, -// ), nil -// } -// 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 tree: %s", string(body))), nil -// } - -// // Create a new commit with the new tree -// commit := github.Commit{ -// Message: github.Ptr(message), -// Tree: newTree, -// Parents: []*github.Commit{{SHA: baseCommit.SHA}}, -// } -// newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create commit", -// resp, -// err, -// ), nil -// } -// 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 commit: %s", string(body))), nil -// } - -// // Update the branch reference to point to the new commit -// ref.Object.SHA = newCommit.SHA -// _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ -// SHA: *newCommit.SHA, -// Force: github.Ptr(false), -// }) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to update reference", -// 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 reference: %s", string(body))), nil -// } - -// // Create a response similar to what the DeleteFile API would return -// response := map[string]interface{}{ -// "commit": newCommit, -// "content": nil, -// } - -// r, err := json.Marshal(response) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // CreateBranch creates a tool to create a new branch. -// func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("create_branch", -// mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("branch", -// mcp.Required(), -// mcp.Description("Name for new branch"), -// ), -// mcp.WithString("from_branch", -// mcp.Description("Source branch (defaults to repo default)"), -// ), -// ), -// 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 -// } -// branch, err := RequiredParam[string](request, "branch") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// fromBranch, err := OptionalParam[string](request, "from_branch") -// 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) -// } - -// // Get the source branch SHA -// var ref *github.Reference - -// if fromBranch == "" { -// // Get default branch if from_branch not specified -// repository, resp, err := client.Repositories.Get(ctx, owner, repo) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get repository", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// fromBranch = *repository.DefaultBranch -// } - -// // Get SHA of source branch -// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get reference", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Create new branch -// newRef := github.CreateRef{ -// Ref: "refs/heads/" + branch, -// SHA: *ref.Object.SHA, -// } - -// createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create branch", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(createdRef) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. -// func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("push_files", -// mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("branch", -// mcp.Required(), -// mcp.Description("Branch to push to"), -// ), -// mcp.WithArray("files", -// mcp.Required(), -// mcp.Items( -// map[string]interface{}{ -// "type": "object", -// "additionalProperties": false, -// "required": []string{"path", "content"}, -// "properties": map[string]interface{}{ -// "path": map[string]interface{}{ -// "type": "string", -// "description": "path to the file", -// }, -// "content": map[string]interface{}{ -// "type": "string", -// "description": "file content", -// }, -// }, -// }), -// mcp.Description("Array of file objects to push, each object with path (string) and content (string)"), -// ), -// mcp.WithString("message", -// mcp.Required(), -// mcp.Description("Commit message"), -// ), -// ), -// 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 -// } -// branch, err := RequiredParam[string](request, "branch") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// message, err := RequiredParam[string](request, "message") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// // Parse files parameter - this should be an array of objects with path and content -// filesObj, ok := request.GetArguments()["files"].([]interface{}) -// if !ok { -// return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// // Get the reference for the branch -// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get branch reference", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Get the commit object that the branch points to -// baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get base commit", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Create tree entries for all files -// var entries []*github.TreeEntry - -// for _, file := range filesObj { -// fileMap, ok := file.(map[string]interface{}) -// if !ok { -// return mcp.NewToolResultError("each file must be an object with path and content"), nil -// } - -// path, ok := fileMap["path"].(string) -// if !ok || path == "" { -// return mcp.NewToolResultError("each file must have a path"), nil -// } - -// content, ok := fileMap["content"].(string) -// if !ok { -// return mcp.NewToolResultError("each file must have content"), nil -// } - -// // Create a tree entry for the file -// entries = append(entries, &github.TreeEntry{ -// Path: github.Ptr(path), -// Mode: github.Ptr("100644"), // Regular file mode -// Type: github.Ptr("blob"), -// Content: github.Ptr(content), -// }) -// } - -// // Create a new tree with the file entries -// newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create tree", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Create a new commit -// commit := github.Commit{ -// Message: github.Ptr(message), -// Tree: newTree, -// Parents: []*github.Commit{{SHA: baseCommit.SHA}}, -// } -// newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to create commit", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// // Update the reference to point to the new commit -// ref.Object.SHA = newCommit.SHA -// updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ -// SHA: *newCommit.SHA, -// Force: github.Ptr(false), -// }) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to update reference", -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// r, err := json.Marshal(updatedRef) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListTags creates a tool to list tags in a GitHub repository. -// func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_tags", -// mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// 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 -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &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) -// } - -// tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to list tags", -// 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 tags: %s", string(body))), nil -// } - -// r, err := json.Marshal(tags) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetTag creates a tool to get details about a specific tag in a GitHub repository. -// func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_tag", -// mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("tag", -// mcp.Required(), -// mcp.Description("Tag name"), -// ), -// ), -// 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 -// } -// tag, err := RequiredParam[string](request, "tag") -// 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) -// } - -// // First get the tag reference -// ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get tag reference", -// 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 get tag reference: %s", string(body))), nil -// } - -// // Then get the tag object -// tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// "failed to get tag object", -// 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 get tag object: %s", string(body))), nil -// } - -// r, err := json.Marshal(tagObj) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // ListReleases creates a tool to list releases in a GitHub repository. -// func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_releases", -// mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// 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 -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &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) -// } - -// releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list releases: %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 list releases: %s", string(body))), nil -// } - -// r, err := json.Marshal(releases) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // GetLatestRelease creates a tool to get the latest release in a GitHub repository. -// func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_latest_release", -// mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// ), -// 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) -// if err != nil { -// return nil, fmt.Errorf("failed to get latest release: %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 latest release: %s", string(body))), nil -// } - -// r, err := json.Marshal(release) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_release_by_tag", -// mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// mcp.WithString("tag", -// mcp.Required(), -// mcp.Description("Tag name (e.g., 'v1.0.0')"), -// ), -// ), -// 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 -// } -// tag, err := RequiredParam[string](request, "tag") -// 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) -// } - -// release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to get release by tag: %s", tag), -// 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 get release by tag: %s", string(body))), nil -// } - -// r, err := json.Marshal(release) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // filterPaths filters the entries in a GitHub tree to find paths that -// // match the given suffix. -// // maxResults limits the number of results returned to first maxResults entries, -// // a maxResults of -1 means no limit. -// // It returns a slice of strings containing the matching paths. -// // Directories are returned with a trailing slash. -// func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { -// // Remove trailing slash for matching purposes, but flag whether we -// // only want directories. -// dirOnly := false -// if strings.HasSuffix(path, "/") { -// dirOnly = true -// path = strings.TrimSuffix(path, "/") -// } - -// matchedPaths := []string{} -// for _, entry := range entries { -// if len(matchedPaths) == maxResults { -// break // Limit the number of results to maxResults -// } -// if dirOnly && entry.GetType() != "tree" { -// continue // Skip non-directory entries if dirOnly is true -// } -// entryPath := entry.GetPath() -// if entryPath == "" { -// continue // Skip empty paths -// } -// if strings.HasSuffix(entryPath, path) { -// if entry.GetType() == "tree" { -// entryPath += "/" // Return directories with a trailing slash -// } -// matchedPaths = append(matchedPaths, entryPath) -// } -// } -// return matchedPaths -// } - -// // resolveGitReference takes a user-provided ref and sha and resolves them into a -// // definitive commit SHA and its corresponding fully-qualified reference. -// // -// // The resolution logic follows a clear priority: -// // -// // 1. If a specific commit `sha` is provided, it takes precedence and is used directly, -// // and all reference resolution is skipped. -// // -// // 2. If no `sha` is provided, the function resolves the `ref` -// // string into a fully-qualified format (e.g., "refs/heads/main") by trying -// // the following steps in order: -// // a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// // b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// // qualified and used as-is. -// // c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// // prefixed with "refs/" to make it fully-qualified. -// // d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// // first attempts to resolve it as a branch ("refs/heads/"). If that -// // returns a 404 Not Found error, it then attempts to resolve it as a tag -// // ("refs/tags/"). -// // -// // 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// // is made to fetch that reference's definitive commit SHA. -// // -// // Any unexpected (non-404) errors during the resolution process are returned -// // immediately. All API errors are logged with rich context to aid diagnostics. -// func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { -// // 1) If SHA explicitly provided, it's the highest priority. -// if sha != "" { -// return &raw.ContentOpts{Ref: "", SHA: sha}, nil -// } - -// originalRef := ref // Keep original ref for clearer error messages down the line. - -// // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. -// var reference *github.Reference -// var resp *github.Response -// var err error - -// switch { -// case originalRef == "": -// // 2a) If ref is empty, determine the default branch. -// repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) -// if err != nil { -// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) -// return nil, fmt.Errorf("failed to get repository info: %w", err) -// } -// ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) -// case strings.HasPrefix(originalRef, "refs/"): -// // 2b) Already fully qualified. The reference will be fetched at the end. -// case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): -// // 2c) Partially qualified. Make it fully qualified. -// ref = "refs/" + originalRef -// default: -// // 2d) It's a short name, so we try to resolve it to either a branch or a tag. -// branchRef := "refs/heads/" + originalRef -// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) - -// if err == nil { -// ref = branchRef // It's a branch. -// } else { -// // The branch lookup failed. Check if it was a 404 Not Found error. -// ghErr, isGhErr := err.(*github.ErrorResponse) -// if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { -// tagRef := "refs/tags/" + originalRef -// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) -// if err == nil { -// ref = tagRef // It's a tag. -// } else { -// // The tag lookup also failed. Check if it was a 404 Not Found error. -// ghErr2, isGhErr2 := err.(*github.ErrorResponse) -// if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { -// return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) -// } -// // The tag lookup failed for a different reason. -// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) -// return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) -// } -// } else { -// // The branch lookup failed for a different reason. -// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) -// return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) -// } -// } -// } - -// if reference == nil { -// reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) -// if err != nil { -// _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) -// return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) -// } -// } - -// sha = reference.GetObject().GetSHA() -// return &raw.ContentOpts{Ref: ref, SHA: sha}, nil -// } - -// // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. -// func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_starred_repositories", -// mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("username", -// mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."), -// ), -// mcp.WithString("sort", -// mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."), -// mcp.Enum("created", "updated"), -// ), -// mcp.WithString("direction", -// mcp.Description("The direction to sort the results by."), -// mcp.Enum("asc", "desc"), -// ), -// WithPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// username, err := OptionalParam[string](request, "username") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.ActivityListStarredOptions{ -// ListOptions: github.ListOptions{ -// Page: pagination.Page, -// PerPage: pagination.PerPage, -// }, -// } -// if sort != "" { -// opts.Sort = sort -// } -// if direction != "" { -// opts.Direction = direction -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// var repos []*github.StarredRepository -// var resp *github.Response -// if username == "" { -// // List starred repositories for the authenticated user -// repos, resp, err = client.Activity.ListStarred(ctx, "", opts) -// } else { -// // List starred repositories for a specific user -// repos, resp, err = client.Activity.ListStarred(ctx, username, opts) -// } - -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to list starred repositories for user '%s'", username), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 starred repositories: %s", string(body))), nil -// } - -// // Convert to minimal format -// minimalRepos := make([]MinimalRepository, 0, len(repos)) -// for _, starredRepo := range repos { -// repo := starredRepo.Repository -// minimalRepo := MinimalRepository{ -// ID: repo.GetID(), -// Name: repo.GetName(), -// FullName: repo.GetFullName(), -// Description: repo.GetDescription(), -// HTMLURL: repo.GetHTMLURL(), -// Language: repo.GetLanguage(), -// Stars: repo.GetStargazersCount(), -// Forks: repo.GetForksCount(), -// OpenIssues: repo.GetOpenIssuesCount(), -// Private: repo.GetPrivate(), -// Fork: repo.GetFork(), -// Archived: repo.GetArchived(), -// DefaultBranch: repo.GetDefaultBranch(), -// } - -// if repo.UpdatedAt != nil { -// minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") -// } - -// minimalRepos = append(minimalRepos, minimalRepo) -// } - -// r, err := json.Marshal(minimalRepos) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal starred repositories: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // StarRepository creates a tool to star a repository. -// func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("star_repository", -// mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// ), -// 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Activity.Star(ctx, owner, repo) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to star repository %s/%s", owner, repo), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 204 { -// 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 star repository: %s", string(body))), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil -// } -// } - -// // UnstarRepository creates a tool to unstar a repository. -// func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("unstar_repository", -// mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"), -// ReadOnlyHint: ToBoolPtr(false), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("Repository owner"), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("Repository name"), -// ), -// ), -// 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 -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// resp, err := client.Activity.Unstar(ctx, owner, repo) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to unstar repository %s/%s", owner, repo), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 204 { -// 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 unstar repository: %s", string(body))), nil -// } - -// return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil -// } -// } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go deleted file mode 100644 index 1b454bbc5..000000000 --- a/pkg/github/repositories_test.go +++ /dev/null @@ -1,3414 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/base64" -// "encoding/json" -// "net/http" -// "net/url" -// "strings" -// "testing" -// "time" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/raw" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetFileContents(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// mockRawClient := raw.NewClient(mockClient, &url.URL{Scheme: "https", Host: "raw.githubusercontent.com", Path: "/"}) -// tool, _ := GetFileContents(stubGetClientFn(mockClient), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_file_contents", 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, "path") -// assert.Contains(t, tool.InputSchema.Properties, "ref") -// assert.Contains(t, tool.InputSchema.Properties, "sha") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Mock response for raw content -// mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") - -// // Setup mock directory content for success case -// mockDirContent := []*github.RepositoryContent{ -// { -// Type: github.Ptr("file"), -// Name: github.Ptr("README.md"), -// Path: github.Ptr("README.md"), -// SHA: github.Ptr("abc123"), -// Size: github.Ptr(42), -// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), -// }, -// { -// Type: github.Ptr("dir"), -// Name: github.Ptr("src"), -// Path: github.Ptr("src"), -// SHA: github.Ptr("def456"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult interface{} -// expectedErrMsg string -// expectStatus int -// }{ -// { -// name: "successful text content fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// fileContent := &github.RepositoryContent{ -// Name: github.Ptr("README.md"), -// Path: github.Ptr("README.md"), -// SHA: github.Ptr("abc123"), -// Type: github.Ptr("file"), -// } -// contentBytes, _ := json.Marshal(fileContent) -// _, _ = w.Write(contentBytes) -// }), -// ), -// mock.WithRequestMatchHandler( -// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Content-Type", "text/markdown") -// _, _ = w.Write(mockRawContent) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "README.md", -// "ref": "refs/heads/main", -// }, -// expectError: false, -// expectedResult: mcp.TextResourceContents{ -// URI: "repo://owner/repo/refs/heads/main/contents/README.md", -// Text: "# Test Repository\n\nThis is a test repository.", -// MIMEType: "text/markdown", -// }, -// }, -// { -// name: "successful file blob content fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// fileContent := &github.RepositoryContent{ -// Name: github.Ptr("test.png"), -// Path: github.Ptr("test.png"), -// SHA: github.Ptr("def456"), -// Type: github.Ptr("file"), -// } -// contentBytes, _ := json.Marshal(fileContent) -// _, _ = w.Write(contentBytes) -// }), -// ), -// mock.WithRequestMatchHandler( -// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Content-Type", "image/png") -// _, _ = w.Write(mockRawContent) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "test.png", -// "ref": "refs/heads/main", -// }, -// expectError: false, -// expectedResult: mcp.BlobResourceContents{ -// URI: "repo://owner/repo/refs/heads/main/contents/test.png", -// Blob: base64.StdEncoding.EncodeToString(mockRawContent), -// MIMEType: "image/png", -// }, -// }, -// { -// name: "successful PDF file content fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// fileContent := &github.RepositoryContent{ -// Name: github.Ptr("document.pdf"), -// Path: github.Ptr("document.pdf"), -// SHA: github.Ptr("pdf123"), -// Type: github.Ptr("file"), -// } -// contentBytes, _ := json.Marshal(fileContent) -// _, _ = w.Write(contentBytes) -// }), -// ), -// mock.WithRequestMatchHandler( -// raw.GetRawReposContentsByOwnerByRepoByBranchByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.Header().Set("Content-Type", "application/pdf") -// _, _ = w.Write(mockRawContent) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "document.pdf", -// "ref": "refs/heads/main", -// }, -// expectError: false, -// expectedResult: mcp.BlobResourceContents{ -// URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", -// Blob: base64.StdEncoding.EncodeToString(mockRawContent), -// MIMEType: "application/pdf", -// }, -// }, -// { -// name: "successful directory content fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposContentsByOwnerByRepoByPath, -// expectQueryParams(t, map[string]string{}).andThen( -// mockResponse(t, http.StatusOK, mockDirContent), -// ), -// ), -// mock.WithRequestMatchHandler( -// raw.GetRawReposContentsByOwnerByRepoByPath, -// expectQueryParams(t, map[string]string{ -// "branch": "main", -// }).andThen( -// mockResponse(t, http.StatusNotFound, nil), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "src/", -// }, -// expectError: false, -// expectedResult: mockDirContent, -// }, -// { -// name: "content fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// raw.GetRawReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "nonexistent.md", -// "ref": "refs/heads/main", -// }, -// expectError: false, -// expectedResult: mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) -// _, handler := GetFileContents(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// // Use the correct result helper based on the expected type -// switch expected := tc.expectedResult.(type) { -// case mcp.TextResourceContents: -// textResource := getTextResourceResult(t, result) -// assert.Equal(t, expected, textResource) -// case mcp.BlobResourceContents: -// blobResource := getBlobResourceResult(t, result) -// assert.Equal(t, expected, blobResource) -// case []*github.RepositoryContent: -// // Directory content fetch returns a text result (JSON array) -// textContent := getTextResult(t, result) -// var returnedContents []*github.RepositoryContent -// err = json.Unmarshal([]byte(textContent.Text), &returnedContents) -// require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text) -// assert.Len(t, returnedContents, len(expected)) -// for i, content := range returnedContents { -// assert.Equal(t, *expected[i].Name, *content.Name) -// assert.Equal(t, *expected[i].Path, *content.Path) -// assert.Equal(t, *expected[i].Type, *content.Type) -// } -// case mcp.TextContent: -// textContent := getErrorResult(t, result) -// require.Equal(t, textContent, expected) -// } -// }) -// } -// } - -// func Test_ForkRepository(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ForkRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "fork_repository", 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, "organization") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock forked repo for success case -// mockForkedRepo := &github.Repository{ -// ID: github.Ptr(int64(123456)), -// Name: github.Ptr("repo"), -// FullName: github.Ptr("new-owner/repo"), -// Owner: &github.User{ -// Login: github.Ptr("new-owner"), -// }, -// HTMLURL: github.Ptr("https://github.com/new-owner/repo"), -// DefaultBranch: github.Ptr("main"), -// Fork: github.Ptr(true), -// ForksCount: github.Ptr(0), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedRepo *github.Repository -// expectedErrMsg string -// }{ -// { -// name: "successful repository fork", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposForksByOwnerByRepo, -// mockResponse(t, http.StatusAccepted, mockForkedRepo), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedRepo: mockForkedRepo, -// }, -// { -// name: "repository fork fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PostReposForksByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusForbidden) -// _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to fork repository", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ForkRepository(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// assert.Contains(t, textContent.Text, "Fork is in progress") -// }) -// } -// } - -// func Test_CreateBranch(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := CreateBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "create_branch", 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, "branch") -// assert.Contains(t, tool.InputSchema.Properties, "from_branch") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch"}) - -// // Setup mock repository for default branch test -// mockRepo := &github.Repository{ -// DefaultBranch: github.Ptr("main"), -// } - -// // Setup mock reference for from_branch tests -// mockSourceRef := &github.Reference{ -// Ref: github.Ptr("refs/heads/main"), -// Object: &github.GitObject{ -// SHA: github.Ptr("abc123def456"), -// }, -// } - -// // Setup mock created reference -// mockCreatedRef := &github.Reference{ -// Ref: github.Ptr("refs/heads/new-feature"), -// Object: &github.GitObject{ -// SHA: github.Ptr("abc123def456"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedRef *github.Reference -// expectedErrMsg string -// }{ -// { -// name: "successful branch creation with from_branch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockSourceRef, -// ), -// mock.WithRequestMatch( -// mock.PostReposGitRefsByOwnerByRepo, -// mockCreatedRef, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "new-feature", -// "from_branch": "main", -// }, -// expectError: false, -// expectedRef: mockCreatedRef, -// }, -// { -// name: "successful branch creation with default branch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposByOwnerByRepo, -// mockRepo, -// ), -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockSourceRef, -// ), -// mock.WithRequestMatchHandler( -// mock.PostReposGitRefsByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "ref": "refs/heads/new-feature", -// "sha": "abc123def456", -// }).andThen( -// mockResponse(t, http.StatusCreated, mockCreatedRef), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "new-feature", -// }, -// expectError: false, -// expectedRef: mockCreatedRef, -// }, -// { -// name: "fail to get repository", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// "branch": "new-feature", -// }, -// expectError: true, -// expectedErrMsg: "failed to get repository", -// }, -// { -// name: "fail to get reference", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "new-feature", -// "from_branch": "nonexistent-branch", -// }, -// expectError: true, -// expectedErrMsg: "failed to get reference", -// }, -// { -// name: "fail to create branch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockSourceRef, -// ), -// mock.WithRequestMatchHandler( -// mock.PostReposGitRefsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "existing-branch", -// "from_branch": "main", -// }, -// expectError: true, -// expectedErrMsg: "failed to create branch", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CreateBranch(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedRef github.Reference -// err = json.Unmarshal([]byte(textContent.Text), &returnedRef) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) -// assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) -// }) -// } -// } - -// func Test_GetCommit(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_commit", 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, "sha") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"}) - -// mockCommit := &github.RepositoryCommit{ -// SHA: github.Ptr("abc123def456"), -// Commit: &github.Commit{ -// Message: github.Ptr("First commit"), -// Author: &github.CommitAuthor{ -// Name: github.Ptr("Test User"), -// Email: github.Ptr("test@example.com"), -// Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, -// }, -// }, -// Author: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), -// Stats: &github.CommitStats{ -// Additions: github.Ptr(10), -// Deletions: github.Ptr(2), -// Total: github.Ptr(12), -// }, -// Files: []*github.CommitFile{ -// { -// Filename: github.Ptr("file1.go"), -// Status: github.Ptr("modified"), -// Additions: github.Ptr(10), -// Deletions: github.Ptr(2), -// Changes: github.Ptr(12), -// Patch: github.Ptr("@@ -1,2 +1,10 @@"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedCommit *github.RepositoryCommit -// expectedErrMsg string -// }{ -// { -// name: "successful commit fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsByOwnerByRepoByRef, -// mockResponse(t, http.StatusOK, mockCommit), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "sha": "abc123def456", -// }, -// expectError: false, -// expectedCommit: mockCommit, -// }, -// { -// name: "commit fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "sha": "nonexistent-sha", -// }, -// expectError: true, -// expectedErrMsg: "failed to get commit", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedCommit github.RepositoryCommit -// err = json.Unmarshal([]byte(textContent.Text), &returnedCommit) -// require.NoError(t, err) - -// assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA) -// assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message) -// assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login) -// assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL) -// }) -// } -// } - -// func Test_ListCommits(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_commits", 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, "sha") -// assert.Contains(t, tool.InputSchema.Properties, "author") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock commits for success case -// mockCommits := []*github.RepositoryCommit{ -// { -// SHA: github.Ptr("abc123def456"), -// Commit: &github.Commit{ -// Message: github.Ptr("First commit"), -// Author: &github.CommitAuthor{ -// Name: github.Ptr("Test User"), -// Email: github.Ptr("test@example.com"), -// Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, -// }, -// }, -// Author: &github.User{ -// Login: github.Ptr("testuser"), -// ID: github.Ptr(int64(12345)), -// HTMLURL: github.Ptr("https://github.com/testuser"), -// AvatarURL: github.Ptr("https://github.com/testuser.png"), -// }, -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), -// Stats: &github.CommitStats{ -// Additions: github.Ptr(10), -// Deletions: github.Ptr(5), -// Total: github.Ptr(15), -// }, -// Files: []*github.CommitFile{ -// { -// Filename: github.Ptr("src/main.go"), -// Status: github.Ptr("modified"), -// Additions: github.Ptr(8), -// Deletions: github.Ptr(3), -// Changes: github.Ptr(11), -// }, -// { -// Filename: github.Ptr("README.md"), -// Status: github.Ptr("added"), -// Additions: github.Ptr(2), -// Deletions: github.Ptr(2), -// Changes: github.Ptr(4), -// }, -// }, -// }, -// { -// SHA: github.Ptr("def456abc789"), -// Commit: &github.Commit{ -// Message: github.Ptr("Second commit"), -// Author: &github.CommitAuthor{ -// Name: github.Ptr("Another User"), -// Email: github.Ptr("another@example.com"), -// Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, -// }, -// }, -// Author: &github.User{ -// Login: github.Ptr("anotheruser"), -// ID: github.Ptr(int64(67890)), -// HTMLURL: github.Ptr("https://github.com/anotheruser"), -// AvatarURL: github.Ptr("https://github.com/anotheruser.png"), -// }, -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), -// Stats: &github.CommitStats{ -// Additions: github.Ptr(20), -// Deletions: github.Ptr(10), -// Total: github.Ptr(30), -// }, -// Files: []*github.CommitFile{ -// { -// Filename: github.Ptr("src/utils.go"), -// Status: github.Ptr("added"), -// Additions: github.Ptr(20), -// Deletions: github.Ptr(10), -// Changes: github.Ptr(30), -// }, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedCommits []*github.RepositoryCommit -// expectedErrMsg string -// }{ -// { -// name: "successful commits fetch with default params", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposCommitsByOwnerByRepo, -// mockCommits, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedCommits: mockCommits, -// }, -// { -// name: "successful commits fetch with branch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "author": "username", -// "sha": "main", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockCommits), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "sha": "main", -// "author": "username", -// }, -// expectError: false, -// expectedCommits: mockCommits, -// }, -// { -// name: "successful commits fetch with pagination", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "page": "2", -// "per_page": "10", -// }).andThen( -// mockResponse(t, http.StatusOK, mockCommits), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedCommits: mockCommits, -// }, -// { -// name: "commits fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposCommitsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent-repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list commits", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListCommits(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedCommits []MinimalCommit -// err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) -// require.NoError(t, err) -// assert.Len(t, returnedCommits, len(tc.expectedCommits)) -// for i, commit := range returnedCommits { -// assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) -// assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) -// if tc.expectedCommits[i].Commit != nil { -// assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) -// } -// if tc.expectedCommits[i].Author != nil { -// assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) -// } - -// // Files and stats are never included in list_commits -// assert.Nil(t, commit.Files) -// assert.Nil(t, commit.Stats) -// } -// }) -// } -// } - -// func Test_CreateOrUpdateFile(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "create_or_update_file", 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, "path") -// assert.Contains(t, tool.InputSchema.Properties, "content") -// assert.Contains(t, tool.InputSchema.Properties, "message") -// assert.Contains(t, tool.InputSchema.Properties, "branch") -// assert.Contains(t, tool.InputSchema.Properties, "sha") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) - -// // Setup mock file content response -// mockFileResponse := &github.RepositoryContentResponse{ -// Content: &github.RepositoryContent{ -// Name: github.Ptr("example.md"), -// Path: github.Ptr("docs/example.md"), -// SHA: github.Ptr("abc123def456"), -// Size: github.Ptr(42), -// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), -// DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), -// }, -// Commit: github.Commit{ -// SHA: github.Ptr("def456abc789"), -// Message: github.Ptr("Add example file"), -// Author: &github.CommitAuthor{ -// Name: github.Ptr("Test User"), -// Email: github.Ptr("test@example.com"), -// Date: &github.Timestamp{Time: time.Now()}, -// }, -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedContent *github.RepositoryContentResponse -// expectedErrMsg string -// }{ -// { -// name: "successful file creation", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposContentsByOwnerByRepoByPath, -// expectRequestBody(t, map[string]interface{}{ -// "message": "Add example file", -// "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content -// "branch": "main", -// }).andThen( -// mockResponse(t, http.StatusOK, mockFileResponse), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "docs/example.md", -// "content": "# Example\n\nThis is an example file.", -// "message": "Add example file", -// "branch": "main", -// }, -// expectError: false, -// expectedContent: mockFileResponse, -// }, -// { -// name: "successful file update with SHA", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposContentsByOwnerByRepoByPath, -// expectRequestBody(t, map[string]interface{}{ -// "message": "Update example file", -// "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content -// "branch": "main", -// "sha": "abc123def456", -// }).andThen( -// mockResponse(t, http.StatusOK, mockFileResponse), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "docs/example.md", -// "content": "# Updated Example\n\nThis file has been updated.", -// "message": "Update example file", -// "branch": "main", -// "sha": "abc123def456", -// }, -// expectError: false, -// expectedContent: mockFileResponse, -// }, -// { -// name: "file creation fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutReposContentsByOwnerByRepoByPath, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "docs/example.md", -// "content": "#Invalid Content", -// "message": "Invalid request", -// "branch": "nonexistent-branch", -// }, -// expectError: true, -// expectedErrMsg: "failed to create/update file", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CreateOrUpdateFile(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedContent github.RepositoryContentResponse -// err = json.Unmarshal([]byte(textContent.Text), &returnedContent) -// require.NoError(t, err) - -// // Verify content -// assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) -// assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) -// assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) - -// // Verify commit -// assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) -// assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) -// }) -// } -// } - -// func Test_CreateRepository(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := CreateRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "create_repository", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "name") -// assert.Contains(t, tool.InputSchema.Properties, "description") -// assert.Contains(t, tool.InputSchema.Properties, "organization") -// assert.Contains(t, tool.InputSchema.Properties, "private") -// assert.Contains(t, tool.InputSchema.Properties, "autoInit") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) - -// // Setup mock repository response -// mockRepo := &github.Repository{ -// Name: github.Ptr("test-repo"), -// Description: github.Ptr("Test repository"), -// Private: github.Ptr(true), -// HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), -// CreatedAt: &github.Timestamp{Time: time.Now()}, -// Owner: &github.User{ -// Login: github.Ptr("testuser"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedRepo *github.Repository -// expectedErrMsg string -// }{ -// { -// name: "successful repository creation with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/user/repos", -// Method: "POST", -// }, -// expectRequestBody(t, map[string]interface{}{ -// "name": "test-repo", -// "description": "Test repository", -// "private": true, -// "auto_init": true, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockRepo), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "name": "test-repo", -// "description": "Test repository", -// "private": true, -// "autoInit": true, -// }, -// expectError: false, -// expectedRepo: mockRepo, -// }, -// { -// name: "successful repository creation in organization", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/orgs/testorg/repos", -// Method: "POST", -// }, -// expectRequestBody(t, map[string]interface{}{ -// "name": "test-repo", -// "description": "Test repository", -// "private": false, -// "auto_init": true, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockRepo), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "name": "test-repo", -// "description": "Test repository", -// "organization": "testorg", -// "private": false, -// "autoInit": true, -// }, -// expectError: false, -// expectedRepo: mockRepo, -// }, -// { -// name: "successful repository creation with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/user/repos", -// Method: "POST", -// }, -// expectRequestBody(t, map[string]interface{}{ -// "name": "test-repo", -// "auto_init": false, -// "description": "", -// "private": false, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockRepo), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "name": "test-repo", -// }, -// expectError: false, -// expectedRepo: mockRepo, -// }, -// { -// name: "repository creation fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.EndpointPattern{ -// Pattern: "/user/repos", -// Method: "POST", -// }, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnprocessableEntity) -// _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "name": "invalid-repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to create repository", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := CreateRepository(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the minimal result -// var returnedRepo MinimalResponse -// err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) -// assert.NoError(t, err) - -// // Verify repository details -// assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) -// }) -// } -// } - -// func Test_PushFiles(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "push_files", 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, "branch") -// assert.Contains(t, tool.InputSchema.Properties, "files") -// assert.Contains(t, tool.InputSchema.Properties, "message") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch", "files", "message"}) - -// // Setup mock objects -// mockRef := &github.Reference{ -// Ref: github.Ptr("refs/heads/main"), -// Object: &github.GitObject{ -// SHA: github.Ptr("abc123"), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), -// }, -// } - -// mockCommit := &github.Commit{ -// SHA: github.Ptr("abc123"), -// Tree: &github.Tree{ -// SHA: github.Ptr("def456"), -// }, -// } - -// mockTree := &github.Tree{ -// SHA: github.Ptr("ghi789"), -// } - -// mockNewCommit := &github.Commit{ -// SHA: github.Ptr("jkl012"), -// Message: github.Ptr("Update multiple files"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), -// } - -// mockUpdatedRef := &github.Reference{ -// Ref: github.Ptr("refs/heads/main"), -// Object: &github.GitObject{ -// SHA: github.Ptr("jkl012"), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), -// }, -// } - -// // Define test cases -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedRef *github.Reference -// expectedErrMsg string -// }{ -// { -// name: "successful push of multiple files", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Get commit -// mock.WithRequestMatch( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockCommit, -// ), -// // Create tree -// mock.WithRequestMatchHandler( -// mock.PostReposGitTreesByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "base_tree": "def456", -// "tree": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// "mode": "100644", -// "type": "blob", -// "content": "# Updated README\n\nThis is an updated README file.", -// }, -// map[string]interface{}{ -// "path": "docs/example.md", -// "mode": "100644", -// "type": "blob", -// "content": "# Example\n\nThis is an example file.", -// }, -// }, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockTree), -// ), -// ), -// // Create commit -// mock.WithRequestMatchHandler( -// mock.PostReposGitCommitsByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "message": "Update multiple files", -// "tree": "ghi789", -// "parents": []interface{}{"abc123"}, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockNewCommit), -// ), -// ), -// // Update reference -// mock.WithRequestMatchHandler( -// mock.PatchReposGitRefsByOwnerByRepoByRef, -// expectRequestBody(t, map[string]interface{}{ -// "sha": "jkl012", -// "force": false, -// }).andThen( -// mockResponse(t, http.StatusOK, mockUpdatedRef), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// "content": "# Updated README\n\nThis is an updated README file.", -// }, -// map[string]interface{}{ -// "path": "docs/example.md", -// "content": "# Example\n\nThis is an example file.", -// }, -// }, -// "message": "Update multiple files", -// }, -// expectError: false, -// expectedRef: mockUpdatedRef, -// }, -// { -// name: "fails when files parameter is invalid", -// mockedClient: mock.NewMockedHTTPClient( -// // No requests expected -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": "invalid-files-parameter", // Not an array -// "message": "Update multiple files", -// }, -// expectError: false, // This returns a tool error, not a Go error -// expectedErrMsg: "files parameter must be an array", -// }, -// { -// name: "fails when files contains object without path", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Get commit -// mock.WithRequestMatch( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockCommit, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": []interface{}{ -// map[string]interface{}{ -// "content": "# Missing path", -// }, -// }, -// "message": "Update file", -// }, -// expectError: false, // This returns a tool error, not a Go error -// expectedErrMsg: "each file must have a path", -// }, -// { -// name: "fails when files contains object without content", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Get commit -// mock.WithRequestMatch( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockCommit, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// // Missing content -// }, -// }, -// "message": "Update file", -// }, -// expectError: false, // This returns a tool error, not a Go error -// expectedErrMsg: "each file must have content", -// }, -// { -// name: "fails to get branch reference", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockResponse(t, http.StatusNotFound, nil), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "non-existent-branch", -// "files": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// "content": "# README", -// }, -// }, -// "message": "Update file", -// }, -// expectError: true, -// expectedErrMsg: "failed to get branch reference", -// }, -// { -// name: "fails to get base commit", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Fail to get commit -// mock.WithRequestMatchHandler( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockResponse(t, http.StatusNotFound, nil), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// "content": "# README", -// }, -// }, -// "message": "Update file", -// }, -// expectError: true, -// expectedErrMsg: "failed to get base commit", -// }, -// { -// name: "fails to create tree", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Get commit -// mock.WithRequestMatch( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockCommit, -// ), -// // Fail to create tree -// mock.WithRequestMatchHandler( -// mock.PostReposGitTreesByOwnerByRepo, -// mockResponse(t, http.StatusInternalServerError, nil), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "branch": "main", -// "files": []interface{}{ -// map[string]interface{}{ -// "path": "README.md", -// "content": "# README", -// }, -// }, -// "message": "Update file", -// }, -// expectError: true, -// expectedErrMsg: "failed to create tree", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := PushFiles(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// if tc.expectedErrMsg != "" { -// require.NotNil(t, result) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedRef github.Reference -// err = json.Unmarshal([]byte(textContent.Text), &returnedRef) -// require.NoError(t, err) - -// assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref) -// assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA) -// }) -// } -// } - -// func Test_ListBranches(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_branches", 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, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock branches for success case -// mockBranches := []*github.Branch{ -// { -// Name: github.Ptr("main"), -// Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, -// }, -// { -// Name: github.Ptr("develop"), -// Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, -// }, -// } - -// // Test cases -// tests := []struct { -// name string -// args map[string]interface{} -// mockResponses []mock.MockBackendOption -// wantErr bool -// errContains string -// }{ -// { -// name: "success", -// args: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "page": float64(2), -// }, -// mockResponses: []mock.MockBackendOption{ -// mock.WithRequestMatch( -// mock.GetReposBranchesByOwnerByRepo, -// mockBranches, -// ), -// }, -// wantErr: false, -// }, -// { -// name: "missing owner", -// args: map[string]interface{}{ -// "repo": "repo", -// }, -// mockResponses: []mock.MockBackendOption{}, -// wantErr: false, -// errContains: "missing required parameter: owner", -// }, -// { -// name: "missing repo", -// args: map[string]interface{}{ -// "owner": "owner", -// }, -// mockResponses: []mock.MockBackendOption{}, -// wantErr: false, -// errContains: "missing required parameter: repo", -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// // Create mock client -// mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) -// _, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// // Create request -// request := createMCPRequest(tt.args) - -// // Call handler -// result, err := handler(context.Background(), request) -// if tt.wantErr { -// require.Error(t, err) -// if tt.errContains != "" { -// assert.Contains(t, err.Error(), tt.errContains) -// } -// return -// } - -// require.NoError(t, err) -// require.NotNil(t, result) - -// if tt.errContains != "" { -// textContent := getTextResult(t, result) -// assert.Contains(t, textContent.Text, tt.errContains) -// return -// } - -// textContent := getTextResult(t, result) -// require.NotEmpty(t, textContent.Text) - -// // Verify response -// var branches []*github.Branch -// err = json.Unmarshal([]byte(textContent.Text), &branches) -// require.NoError(t, err) -// assert.Len(t, branches, 2) -// assert.Equal(t, "main", *branches[0].Name) -// assert.Equal(t, "develop", *branches[1].Name) -// }) -// } -// } - -// func Test_DeleteFile(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "delete_file", 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, "path") -// assert.Contains(t, tool.InputSchema.Properties, "message") -// assert.Contains(t, tool.InputSchema.Properties, "branch") -// // SHA is no longer required since we're using Git Data API -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"}) - -// // Setup mock objects for Git Data API -// mockRef := &github.Reference{ -// Ref: github.Ptr("refs/heads/main"), -// Object: &github.GitObject{ -// SHA: github.Ptr("abc123"), -// }, -// } - -// mockCommit := &github.Commit{ -// SHA: github.Ptr("abc123"), -// Tree: &github.Tree{ -// SHA: github.Ptr("def456"), -// }, -// } - -// mockTree := &github.Tree{ -// SHA: github.Ptr("ghi789"), -// } - -// mockNewCommit := &github.Commit{ -// SHA: github.Ptr("jkl012"), -// Message: github.Ptr("Delete example file"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedCommitSHA string -// expectedErrMsg string -// }{ -// { -// name: "successful file deletion using Git Data API", -// mockedClient: mock.NewMockedHTTPClient( -// // Get branch reference -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockRef, -// ), -// // Get commit -// mock.WithRequestMatch( -// mock.GetReposGitCommitsByOwnerByRepoByCommitSha, -// mockCommit, -// ), -// // Create tree -// mock.WithRequestMatchHandler( -// mock.PostReposGitTreesByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "base_tree": "def456", -// "tree": []interface{}{ -// map[string]interface{}{ -// "path": "docs/example.md", -// "mode": "100644", -// "type": "blob", -// "sha": nil, -// }, -// }, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockTree), -// ), -// ), -// // Create commit -// mock.WithRequestMatchHandler( -// mock.PostReposGitCommitsByOwnerByRepo, -// expectRequestBody(t, map[string]interface{}{ -// "message": "Delete example file", -// "tree": "ghi789", -// "parents": []interface{}{"abc123"}, -// }).andThen( -// mockResponse(t, http.StatusCreated, mockNewCommit), -// ), -// ), -// // Update reference -// mock.WithRequestMatchHandler( -// mock.PatchReposGitRefsByOwnerByRepoByRef, -// expectRequestBody(t, map[string]interface{}{ -// "sha": "jkl012", -// "force": false, -// }).andThen( -// mockResponse(t, http.StatusOK, &github.Reference{ -// Ref: github.Ptr("refs/heads/main"), -// Object: &github.GitObject{ -// SHA: github.Ptr("jkl012"), -// }, -// }), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "docs/example.md", -// "message": "Delete example file", -// "branch": "main", -// }, -// expectError: false, -// expectedCommitSHA: "jkl012", -// }, -// { -// name: "file deletion fails - branch not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path": "docs/nonexistent.md", -// "message": "Delete nonexistent file", -// "branch": "nonexistent-branch", -// }, -// expectError: true, -// expectedErrMsg: "failed to get branch reference", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), 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 response map[string]interface{} -// err = json.Unmarshal([]byte(textContent.Text), &response) -// require.NoError(t, err) - -// // Verify the response contains the expected commit -// commit, ok := response["commit"].(map[string]interface{}) -// require.True(t, ok) -// commitSHA, ok := commit["sha"].(string) -// require.True(t, ok) -// assert.Equal(t, tc.expectedCommitSHA, commitSHA) -// }) -// } -// } - -// func Test_ListTags(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_tags", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock tags for success case -// mockTags := []*github.RepositoryTag{ -// { -// Name: github.Ptr("v1.0.0"), -// Commit: &github.Commit{ -// SHA: github.Ptr("v1.0.0-tag-sha"), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), -// }, -// ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), -// TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), -// }, -// { -// Name: github.Ptr("v0.9.0"), -// Commit: &github.Commit{ -// SHA: github.Ptr("v0.9.0-tag-sha"), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), -// }, -// ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), -// TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedTags []*github.RepositoryTag -// expectedErrMsg string -// }{ -// { -// name: "successful tags list", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposTagsByOwnerByRepo, -// expectPath( -// t, -// "/repos/owner/repo/tags", -// ).andThen( -// mockResponse(t, http.StatusOK, mockTags), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedTags: mockTags, -// }, -// { -// name: "list tags fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposTagsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusInternalServerError) -// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list tags", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Parse and verify the result -// var returnedTags []*github.RepositoryTag -// err = json.Unmarshal([]byte(textContent.Text), &returnedTags) -// require.NoError(t, err) - -// // Verify each tag -// require.Equal(t, len(tc.expectedTags), len(returnedTags)) -// for i, expectedTag := range tc.expectedTags { -// assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) -// assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) -// } -// }) -// } -// } - -// func Test_GetTag(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_tag", 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, "tag") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) - -// mockTagRef := &github.Reference{ -// Ref: github.Ptr("refs/tags/v1.0.0"), -// Object: &github.GitObject{ -// SHA: github.Ptr("v1.0.0-tag-sha"), -// }, -// } - -// mockTagObj := &github.Tag{ -// SHA: github.Ptr("v1.0.0-tag-sha"), -// Tag: github.Ptr("v1.0.0"), -// Message: github.Ptr("Release v1.0.0"), -// Object: &github.GitObject{ -// Type: github.Ptr("commit"), -// SHA: github.Ptr("abc123"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedTag *github.Tag -// expectedErrMsg string -// }{ -// { -// name: "successful tag retrieval", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// expectPath( -// t, -// "/repos/owner/repo/git/ref/tags/v1.0.0", -// ).andThen( -// mockResponse(t, http.StatusOK, mockTagRef), -// ), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitTagsByOwnerByRepoByTagSha, -// expectPath( -// t, -// "/repos/owner/repo/git/tags/v1.0.0-tag-sha", -// ).andThen( -// mockResponse(t, http.StatusOK, mockTagObj), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: false, -// expectedTag: mockTagObj, -// }, -// { -// name: "tag reference not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: true, -// expectedErrMsg: "failed to get tag reference", -// }, -// { -// name: "tag object not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposGitRefByOwnerByRepoByRef, -// mockTagRef, -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitTagsByOwnerByRepoByTagSha, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: true, -// expectedErrMsg: "failed to get tag object", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Parse and verify the result -// var returnedTag github.Tag -// err = json.Unmarshal([]byte(textContent.Text), &returnedTag) -// require.NoError(t, err) - -// assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) -// assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) -// assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) -// assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) -// assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) -// }) -// } -// } - -// func Test_ListReleases(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_releases", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// mockReleases := []*github.RepositoryRelease{ -// { -// ID: github.Ptr(int64(1)), -// TagName: github.Ptr("v1.0.0"), -// Name: github.Ptr("First Release"), -// }, -// { -// ID: github.Ptr(int64(2)), -// TagName: github.Ptr("v0.9.0"), -// Name: github.Ptr("Beta Release"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult []*github.RepositoryRelease -// expectedErrMsg string -// }{ -// { -// name: "successful releases list", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposReleasesByOwnerByRepo, -// mockReleases, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedResult: mockReleases, -// }, -// { -// name: "releases list fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposReleasesByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list releases", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) -// var returnedReleases []*github.RepositoryRelease -// err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) -// require.NoError(t, err) -// assert.Len(t, returnedReleases, len(tc.expectedResult)) -// for i, rel := range returnedReleases { -// assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) -// } -// }) -// } -// } -// func Test_GetLatestRelease(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "get_latest_release", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// mockRelease := &github.RepositoryRelease{ -// ID: github.Ptr(int64(1)), -// TagName: github.Ptr("v1.0.0"), -// Name: github.Ptr("First Release"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.RepositoryRelease -// expectedErrMsg string -// }{ -// { -// name: "successful latest release fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposReleasesLatestByOwnerByRepo, -// mockRelease, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedResult: mockRelease, -// }, -// { -// name: "latest release fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposReleasesLatestByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to get latest release", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) -// request := createMCPRequest(tc.requestArgs) -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// textContent := getTextResult(t, result) -// var returnedRelease github.RepositoryRelease -// err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) -// }) -// } -// } - -// func Test_GetReleaseByTag(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_release_by_tag", 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, "tag") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) - -// mockRelease := &github.RepositoryRelease{ -// ID: github.Ptr(int64(1)), -// TagName: github.Ptr("v1.0.0"), -// Name: github.Ptr("Release v1.0.0"), -// Body: github.Ptr("This is the first stable release."), -// Assets: []*github.ReleaseAsset{ -// { -// ID: github.Ptr(int64(1)), -// Name: github.Ptr("release-v1.0.0.tar.gz"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.RepositoryRelease -// expectedErrMsg string -// }{ -// { -// name: "successful release by tag fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposReleasesTagsByOwnerByRepoByTag, -// mockRelease, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: false, -// expectedResult: mockRelease, -// }, -// { -// name: "missing owner parameter", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: false, // Returns tool error, not Go error -// expectedErrMsg: "missing required parameter: owner", -// }, -// { -// name: "missing repo parameter", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "tag": "v1.0.0", -// }, -// expectError: false, // Returns tool error, not Go error -// expectedErrMsg: "missing required parameter: repo", -// }, -// { -// name: "missing tag parameter", -// mockedClient: mock.NewMockedHTTPClient(), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, // Returns tool error, not Go error -// expectedErrMsg: "missing required parameter: tag", -// }, -// { -// name: "release by tag not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposReleasesTagsByOwnerByRepoByTag, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v999.0.0", -// }, -// expectError: false, // API errors return tool errors, not Go errors -// expectedErrMsg: "failed to get release by tag: v999.0.0", -// }, -// { -// name: "server error", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposReleasesTagsByOwnerByRepoByTag, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusInternalServerError) -// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "tag": "v1.0.0", -// }, -// expectError: false, // API errors return tool errors, not Go errors -// expectedErrMsg: "failed to get release by tag: v1.0.0", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) - -// if tc.expectedErrMsg != "" { -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) - -// var returnedRelease github.RepositoryRelease -// err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) -// require.NoError(t, err) - -// assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) -// assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) -// assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) -// if tc.expectedResult.Body != nil { -// assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) -// } -// if len(tc.expectedResult.Assets) > 0 { -// require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) -// assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) -// } -// }) -// } -// } - -// func Test_filterPaths(t *testing.T) { -// tests := []struct { -// name string -// tree []*github.TreeEntry -// path string -// maxResults int -// expected []string -// }{ -// { -// name: "file name", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, -// {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, -// {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, -// {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, -// }, -// path: "foo.txt", -// maxResults: -1, -// expected: []string{"folder/foo.txt", "nested/folder/foo.txt"}, -// }, -// { -// name: "dir name", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, -// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, -// }, -// path: "folder/", -// maxResults: -1, -// expected: []string{"folder/", "nested/folder/"}, -// }, -// { -// name: "dir and file match", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("name"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("name"), Type: github.Ptr("blob")}, -// }, -// path: "name", // No trailing slash can match both files and directories -// maxResults: -1, -// expected: []string{"name/", "name"}, -// }, -// { -// name: "dir only match", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("name"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("name"), Type: github.Ptr("blob")}, -// }, -// path: "name/", // Trialing slash ensures only directories are matched -// maxResults: -1, -// expected: []string{"name/"}, -// }, -// { -// name: "max results limit 2", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, -// }, -// path: "folder/", -// maxResults: 2, -// expected: []string{"folder/", "nested/folder/"}, -// }, -// { -// name: "max results limit 1", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, -// }, -// path: "folder/", -// maxResults: 1, -// expected: []string{"folder/"}, -// }, -// { -// name: "max results limit 0", -// tree: []*github.TreeEntry{ -// {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, -// {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, -// }, -// path: "folder/", -// maxResults: 0, -// expected: []string{}, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// result := filterPaths(tc.tree, tc.path, tc.maxResults) -// assert.Equal(t, tc.expected, result) -// }) -// } -// } - -// func Test_resolveGitReference(t *testing.T) { -// ctx := context.Background() -// owner := "owner" -// repo := "repo" - -// tests := []struct { -// name string -// ref string -// sha string -// mockSetup func() *http.Client -// expectedOutput *raw.ContentOpts -// expectError bool -// errorContains string -// }{ -// { -// name: "sha takes precedence over ref", -// ref: "refs/heads/main", -// sha: "123sha456", -// mockSetup: func() *http.Client { -// // No API calls should be made when SHA is provided -// return mock.NewMockedHTTPClient() -// }, -// expectedOutput: &raw.ContentOpts{ -// SHA: "123sha456", -// }, -// expectError: false, -// }, -// { -// name: "use default branch if ref and sha both empty", -// ref: "", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) -// }), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// assert.Contains(t, r.URL.Path, "/git/ref/heads/main") -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/heads/main", -// SHA: "main-sha", -// }, -// expectError: false, -// }, -// { -// name: "fully qualified ref passed through unchanged", -// ref: "refs/heads/feature-branch", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/heads/feature-branch", -// SHA: "feature-sha", -// }, -// expectError: false, -// }, -// { -// name: "short branch name resolves to refs/heads/", -// ref: "main", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// if strings.Contains(r.URL.Path, "/git/ref/heads/main") { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) -// } else { -// t.Errorf("Unexpected path: %s", r.URL.Path) -// w.WriteHeader(http.StatusNotFound) -// } -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/heads/main", -// SHA: "main-sha", -// }, -// expectError: false, -// }, -// { -// name: "short tag name falls back to refs/tags/ when branch not found", -// ref: "v1.0.0", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// switch { -// case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"): -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) -// default: -// t.Errorf("Unexpected path: %s", r.URL.Path) -// w.WriteHeader(http.StatusNotFound) -// } -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/tags/v1.0.0", -// SHA: "tag-sha", -// }, -// expectError: false, -// }, -// { -// name: "heads/ prefix gets refs/ prepended", -// ref: "heads/feature-branch", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/heads/feature-branch", -// SHA: "feature-sha", -// }, -// expectError: false, -// }, -// { -// name: "tags/ prefix gets refs/ prepended", -// ref: "tags/v1.0.0", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/tags/v1.0.0", -// SHA: "tag-sha", -// }, -// expectError: false, -// }, -// { -// name: "invalid short name that doesn't exist as branch or tag", -// ref: "nonexistent", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// // Both branch and tag attempts should return 404 -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ) -// }, -// expectError: true, -// errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag", -// }, -// { -// name: "fully qualified pull request ref", -// ref: "refs/pull/123/head", -// sha: "", -// mockSetup: func() *http.Client { -// return mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposGitRefByOwnerByRepoByRef, -// http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -// assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`)) -// }), -// ), -// ) -// }, -// expectedOutput: &raw.ContentOpts{ -// Ref: "refs/pull/123/head", -// SHA: "pr-sha", -// }, -// expectError: false, -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockSetup()) -// opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) - -// if tc.expectError { -// require.Error(t, err) -// if tc.errorContains != "" { -// assert.Contains(t, err.Error(), tc.errorContains) -// } -// return -// } - -// require.NoError(t, err) -// require.NotNil(t, opts) - -// if tc.expectedOutput.SHA != "" { -// assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) -// } -// if tc.expectedOutput.Ref != "" { -// assert.Equal(t, tc.expectedOutput.Ref, opts.Ref) -// } -// }) -// } -// } - -// func Test_ListStarredRepositories(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "list_starred_repositories", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "username") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "direction") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Empty(t, tool.InputSchema.Required) // All parameters are optional - -// // Setup mock starred repositories -// starredAt := time.Now().Add(-24 * time.Hour) -// updatedAt := time.Now().Add(-2 * time.Hour) -// mockStarredRepos := []*github.StarredRepository{ -// { -// StarredAt: &github.Timestamp{Time: starredAt}, -// Repository: &github.Repository{ -// ID: github.Ptr(int64(12345)), -// Name: github.Ptr("awesome-repo"), -// FullName: github.Ptr("owner/awesome-repo"), -// Description: github.Ptr("An awesome repository"), -// HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), -// Language: github.Ptr("Go"), -// StargazersCount: github.Ptr(100), -// ForksCount: github.Ptr(25), -// OpenIssuesCount: github.Ptr(5), -// UpdatedAt: &github.Timestamp{Time: updatedAt}, -// Private: github.Ptr(false), -// Fork: github.Ptr(false), -// Archived: github.Ptr(false), -// DefaultBranch: github.Ptr("main"), -// }, -// }, -// { -// StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, -// Repository: &github.Repository{ -// ID: github.Ptr(int64(67890)), -// Name: github.Ptr("cool-project"), -// FullName: github.Ptr("user/cool-project"), -// Description: github.Ptr("A very cool project"), -// HTMLURL: github.Ptr("https://github.com/user/cool-project"), -// Language: github.Ptr("Python"), -// StargazersCount: github.Ptr(500), -// ForksCount: github.Ptr(75), -// OpenIssuesCount: github.Ptr(10), -// UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, -// Private: github.Ptr(false), -// Fork: github.Ptr(true), -// Archived: github.Ptr(false), -// DefaultBranch: github.Ptr("master"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// expectedCount int -// }{ -// { -// name: "successful list for authenticated user", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetUserStarred, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: false, -// expectedCount: 2, -// }, -// { -// name: "successful list for specific user", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetUsersStarredByUsername, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusOK) -// _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "username": "testuser", -// }, -// expectError: false, -// expectedCount: 2, -// }, -// { -// name: "list fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetUserStarred, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "failed to list starred repositories", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListStarredRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NotNil(t, result) -// textResult, ok := result.Content[0].(mcp.TextContent) -// require.True(t, ok, "Expected text content") -// assert.Contains(t, textResult.Text, tc.expectedErrMsg) -// } else { -// require.NoError(t, err) -// require.NotNil(t, result) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedRepos []MinimalRepository -// err = json.Unmarshal([]byte(textContent.Text), &returnedRepos) -// require.NoError(t, err) - -// assert.Len(t, returnedRepos, tc.expectedCount) -// if tc.expectedCount > 0 { -// assert.Equal(t, "awesome-repo", returnedRepos[0].Name) -// assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName) -// } -// } -// }) -// } -// } - -// func Test_StarRepository(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "star_repository", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful star", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutUserStarredByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "testowner", -// "repo": "testrepo", -// }, -// expectError: false, -// }, -// { -// name: "star fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.PutUserStarredByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "testowner", -// "repo": "nonexistent", -// }, -// expectError: true, -// expectedErrMsg: "failed to star repository", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := StarRepository(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NotNil(t, result) -// textResult, ok := result.Content[0].(mcp.TextContent) -// require.True(t, ok, "Expected text content") -// assert.Contains(t, textResult.Text, tc.expectedErrMsg) -// } else { -// require.NoError(t, err) -// require.NotNil(t, result) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) -// assert.Contains(t, textContent.Text, "Successfully starred repository") -// } -// }) -// } -// } - -// func Test_UnstarRepository(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "unstar_repository", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "owner") -// assert.Contains(t, tool.InputSchema.Properties, "repo") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successful unstar", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteUserStarredByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNoContent) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "testowner", -// "repo": "testrepo", -// }, -// expectError: false, -// }, -// { -// name: "unstar fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.DeleteUserStarredByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "testowner", -// "repo": "nonexistent", -// }, -// expectError: true, -// expectedErrMsg: "failed to unstar repository", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := UnstarRepository(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NotNil(t, result) -// textResult, ok := result.Content[0].(mcp.TextContent) -// require.True(t, ok, "Expected text content") -// assert.Contains(t, textResult.Text, tc.expectedErrMsg) -// } else { -// require.NoError(t, err) -// require.NotNil(t, result) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) -// assert.Contains(t, textContent.Text, "Successfully unstarred repository") -// } -// }) -// } -// } - -// func Test_GetRepositoryTree(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "get_repository_tree", 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, "tree_sha") -// assert.Contains(t, tool.InputSchema.Properties, "recursive") -// assert.Contains(t, tool.InputSchema.Properties, "path_filter") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock data -// mockRepo := &github.Repository{ -// DefaultBranch: github.Ptr("main"), -// } -// mockTree := &github.Tree{ -// SHA: github.Ptr("abc123"), -// Truncated: github.Ptr(false), -// Entries: []*github.TreeEntry{ -// { -// Path: github.Ptr("README.md"), -// Mode: github.Ptr("100644"), -// Type: github.Ptr("blob"), -// SHA: github.Ptr("file1sha"), -// Size: github.Ptr(123), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"), -// }, -// { -// Path: github.Ptr("src/main.go"), -// Mode: github.Ptr("100644"), -// Type: github.Ptr("blob"), -// SHA: github.Ptr("file2sha"), -// Size: github.Ptr(456), -// URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedErrMsg string -// }{ -// { -// name: "successfully get repository tree", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// mockResponse(t, http.StatusOK, mockRepo), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitTreesByOwnerByRepoByTreeSha, -// mockResponse(t, http.StatusOK, mockTree), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// }, -// { -// name: "successfully get repository tree with path filter", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// mockResponse(t, http.StatusOK, mockRepo), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitTreesByOwnerByRepoByTreeSha, -// mockResponse(t, http.StatusOK, mockTree), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "path_filter": "src/", -// }, -// }, -// { -// name: "repository not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "nonexistent", -// }, -// expectError: true, -// expectedErrMsg: "failed to get repository info", -// }, -// { -// name: "tree not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposByOwnerByRepo, -// mockResponse(t, http.StatusOK, mockRepo), -// ), -// mock.WithRequestMatchHandler( -// mock.GetReposGitTreesByOwnerByRepoByTreeSha, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to get repository tree", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// _, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper) - -// // Create the tool request -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// } else { -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content -// textContent := getTextResult(t, result) - -// // Parse the JSON response -// var treeResponse map[string]interface{} -// err := json.Unmarshal([]byte(textContent.Text), &treeResponse) -// require.NoError(t, err) - -// // Verify response structure -// assert.Equal(t, "owner", treeResponse["owner"]) -// assert.Equal(t, "repo", treeResponse["repo"]) -// assert.Contains(t, treeResponse, "tree") -// assert.Contains(t, treeResponse, "count") -// assert.Contains(t, treeResponse, "sha") -// assert.Contains(t, treeResponse, "truncated") - -// // Check filtering if path_filter was provided -// if pathFilter, exists := tc.requestArgs["path_filter"]; exists { -// tree := treeResponse["tree"].([]interface{}) -// for _, entry := range tree { -// entryMap := entry.(map[string]interface{}) -// path := entryMap["path"].(string) -// assert.True(t, strings.HasPrefix(path, pathFilter.(string)), -// "Path %s should start with filter %s", path, pathFilter) -// } -// } -// } -// }) -// } -// } diff --git a/pkg/github/search.go b/pkg/github/search.go deleted file mode 100644 index ccae0f752..000000000 --- a/pkg/github/search.go +++ /dev/null @@ -1,365 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// // SearchRepositories creates a tool to search for GitHub repositories. -// func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("search_repositories", -// mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")), - -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("query", -// mcp.Required(), -// mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort repositories by field, defaults to best match"), -// mcp.Enum("stars", "forks", "help-wanted-issues", "updated"), -// ), -// mcp.WithString("order", -// mcp.Description("Sort order"), -// mcp.Enum("asc", "desc"), -// ), -// mcp.WithBoolean("minimal_output", -// mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), -// mcp.DefaultBool(true), -// ), -// WithPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// query, err := RequiredParam[string](request, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// order, err := OptionalParam[string](request, "order") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// opts := &github.SearchOptions{ -// Sort: sort, -// Order: order, -// 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) -// } -// result, resp, err := client.Search.Repositories(ctx, query, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to search repositories with query '%s'", query), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 search repositories: %s", string(body))), nil -// } - -// // Return either minimal or full response based on parameter -// var r []byte -// if minimalOutput { -// minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) -// for _, repo := range result.Repositories { -// minimalRepo := MinimalRepository{ -// ID: repo.GetID(), -// Name: repo.GetName(), -// FullName: repo.GetFullName(), -// Description: repo.GetDescription(), -// HTMLURL: repo.GetHTMLURL(), -// Language: repo.GetLanguage(), -// Stars: repo.GetStargazersCount(), -// Forks: repo.GetForksCount(), -// OpenIssues: repo.GetOpenIssuesCount(), -// Private: repo.GetPrivate(), -// Fork: repo.GetFork(), -// Archived: repo.GetArchived(), -// DefaultBranch: repo.GetDefaultBranch(), -// } - -// if repo.UpdatedAt != nil { -// minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") -// } -// if repo.CreatedAt != nil { -// minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") -// } -// if repo.Topics != nil { -// minimalRepo.Topics = repo.Topics -// } - -// minimalRepos = append(minimalRepos, minimalRepo) -// } - -// minimalResult := &MinimalSearchRepositoriesResult{ -// TotalCount: result.GetTotal(), -// IncompleteResults: result.GetIncompleteResults(), -// Items: minimalRepos, -// } - -// r, err = json.Marshal(minimalResult) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal minimal response: %w", err) -// } -// } else { -// r, err = json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal full response: %w", err) -// } -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // SearchCode creates a tool to search for code across GitHub repositories. -// func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("search_code", -// mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("query", -// mcp.Required(), -// mcp.Description("Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more."), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort field ('indexed' only)"), -// ), -// mcp.WithString("order", -// mcp.Description("Sort order for results"), -// mcp.Enum("asc", "desc"), -// ), -// WithPagination(), -// ), -// func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// query, err := RequiredParam[string](request, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// order, err := OptionalParam[string](request, "order") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.SearchOptions{ -// Sort: sort, -// Order: order, -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// result, resp, err := client.Search.Code(ctx, query, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to search code with query '%s'", query), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 search code: %s", string(body))), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { -// return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// query, err := RequiredParam[string](request, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// order, err := OptionalParam[string](request, "order") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.SearchOptions{ -// Sort: sort, -// Order: order, -// ListOptions: github.ListOptions{ -// PerPage: pagination.PerPage, -// Page: pagination.Page, -// }, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// searchQuery := query -// if !hasTypeFilter(query) { -// searchQuery = "type:" + accountType + " " + query -// } -// result, resp, err := client.Search.Users(ctx, searchQuery, opts) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to search %ss with query '%s'", accountType, query), -// resp, -// err, -// ), nil -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != 200 { -// 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 search %ss: %s", accountType, string(body))), nil -// } - -// minimalUsers := make([]MinimalUser, 0, len(result.Users)) - -// for _, user := range result.Users { -// if user.Login != nil { -// mu := MinimalUser{ -// Login: user.GetLogin(), -// ID: user.GetID(), -// ProfileURL: user.GetHTMLURL(), -// AvatarURL: user.GetAvatarURL(), -// } -// minimalUsers = append(minimalUsers, mu) -// } -// } -// minimalResp := &MinimalSearchUsersResult{ -// TotalCount: result.GetTotal(), -// IncompleteResults: result.GetIncompleteResults(), -// Items: minimalUsers, -// } -// if result.Total != nil { -// minimalResp.TotalCount = *result.Total -// } -// if result.IncompleteResults != nil { -// minimalResp.IncompleteResults = *result.IncompleteResults -// } - -// r, err := json.Marshal(minimalResp) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal response: %w", err) -// } -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// // SearchUsers creates a tool to search for GitHub users. -// func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("search_users", -// mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("query", -// mcp.Required(), -// mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."), -// mcp.Enum("followers", "repositories", "joined"), -// ), -// mcp.WithString("order", -// mcp.Description("Sort order"), -// mcp.Enum("asc", "desc"), -// ), -// WithPagination(), -// ), userOrOrgHandler("user", getClient) -// } - -// // SearchOrgs creates a tool to search for GitHub organizations. -// func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("search_orgs", -// mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")), - -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("query", -// mcp.Required(), -// mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort field by category"), -// mcp.Enum("followers", "repositories", "joined"), -// ), -// mcp.WithString("order", -// mcp.Description("Sort order"), -// mcp.Enum("asc", "desc"), -// ), -// WithPagination(), -// ), userOrOrgHandler("org", getClient) -// } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go deleted file mode 100644 index a8e749939..000000000 --- a/pkg/github/search_test.go +++ /dev/null @@ -1,743 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/internal/toolsnaps" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_SearchRepositories(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SearchRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "search_repositories", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// // Setup mock search results -// mockSearchResult := &github.RepositoriesSearchResult{ -// Total: github.Ptr(2), -// IncompleteResults: github.Ptr(false), -// Repositories: []*github.Repository{ -// { -// ID: github.Ptr(int64(12345)), -// Name: github.Ptr("repo-1"), -// FullName: github.Ptr("owner/repo-1"), -// HTMLURL: github.Ptr("https://github.com/owner/repo-1"), -// Description: github.Ptr("Test repository 1"), -// StargazersCount: github.Ptr(100), -// }, -// { -// ID: github.Ptr(int64(67890)), -// Name: github.Ptr("repo-2"), -// FullName: github.Ptr("owner/repo-2"), -// HTMLURL: github.Ptr("https://github.com/owner/repo-2"), -// Description: github.Ptr("Test repository 2"), -// StargazersCount: github.Ptr(50), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.RepositoriesSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful repository search", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchRepositories, -// expectQueryParams(t, map[string]string{ -// "q": "golang test", -// "sort": "stars", -// "order": "desc", -// "page": "2", -// "per_page": "10", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "golang test", -// "sort": "stars", -// "order": "desc", -// "page": float64(2), -// "perPage": float64(10), -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "repository search with default pagination", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchRepositories, -// expectQueryParams(t, map[string]string{ -// "q": "golang test", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "golang test", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "search fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchRepositories, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search repositories", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedResult MinimalSearchRepositoriesResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) -// for i, repo := range returnedResult.Items { -// assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) -// assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) -// assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) -// assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) -// } - -// }) -// } -// } - -// func Test_SearchRepositories_FullOutput(t *testing.T) { -// mockSearchResult := &github.RepositoriesSearchResult{ -// Total: github.Ptr(1), -// IncompleteResults: github.Ptr(false), -// Repositories: []*github.Repository{ -// { -// ID: github.Ptr(int64(12345)), -// Name: github.Ptr("test-repo"), -// FullName: github.Ptr("owner/test-repo"), -// HTMLURL: github.Ptr("https://github.com/owner/test-repo"), -// Description: github.Ptr("Test repository"), -// StargazersCount: github.Ptr(100), -// }, -// }, -// } - -// mockedClient := mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchRepositories, -// expectQueryParams(t, map[string]string{ -// "q": "golang test", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ) - -// client := github.NewClient(mockedClient) -// _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(map[string]interface{}{ -// "query": "golang test", -// "minimal_output": false, -// }) - -// result, err := handlerTest(context.Background(), request) - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) - -// // Unmarshal as full GitHub API response -// var returnedResult github.RepositoriesSearchResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) - -// // Verify it's the full API response, not minimal -// assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) -// assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Repositories, 1) -// assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) -// assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) -// } - -// func Test_SearchCode(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SearchCode(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "search_code", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// // Setup mock search results -// mockSearchResult := &github.CodeSearchResult{ -// Total: github.Ptr(2), -// IncompleteResults: github.Ptr(false), -// CodeResults: []*github.CodeResult{ -// { -// Name: github.Ptr("file1.go"), -// Path: github.Ptr("path/to/file1.go"), -// SHA: github.Ptr("abc123def456"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), -// Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, -// }, -// { -// Name: github.Ptr("file2.go"), -// Path: github.Ptr("path/to/file2.go"), -// SHA: github.Ptr("def456abc123"), -// HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), -// Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.CodeSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful code search with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchCode, -// expectQueryParams(t, map[string]string{ -// "q": "fmt.Println language:go", -// "sort": "indexed", -// "order": "desc", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "fmt.Println language:go", -// "sort": "indexed", -// "order": "desc", -// "page": float64(1), -// "perPage": float64(30), -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "code search with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchCode, -// expectQueryParams(t, map[string]string{ -// "q": "fmt.Println language:go", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "fmt.Println language:go", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "search code fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchCode, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search code", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchCode(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedResult github.CodeSearchResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) -// for i, code := range returnedResult.CodeResults { -// assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) -// assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) -// assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) -// assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) -// assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) -// } -// }) -// } -// } - -// func Test_SearchUsers(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SearchUsers(stubGetClientFn(mockClient), translations.NullTranslationHelper) -// require.NoError(t, toolsnaps.Test(tool.Name, tool)) - -// assert.Equal(t, "search_users", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// // Setup mock search results -// mockSearchResult := &github.UsersSearchResult{ -// Total: github.Ptr(2), -// IncompleteResults: github.Ptr(false), -// Users: []*github.User{ -// { -// Login: github.Ptr("user1"), -// ID: github.Ptr(int64(1001)), -// HTMLURL: github.Ptr("https://github.com/user1"), -// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), -// }, -// { -// Login: github.Ptr("user2"), -// ID: github.Ptr(int64(1002)), -// HTMLURL: github.Ptr("https://github.com/user2"), -// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), -// Type: github.Ptr("User"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.UsersSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful users search with all parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:user location:finland language:go", -// "sort": "followers", -// "order": "desc", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "location:finland language:go", -// "sort": "followers", -// "order": "desc", -// "page": float64(1), -// "perPage": float64(30), -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "users search with minimal parameters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:user location:finland language:go", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "location:finland language:go", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing type:user filter - no duplication", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:user location:seattle followers:>100", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "type:user location:seattle followers:>100", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "complex query with existing type:user filter and OR operators", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:user (location:seattle OR location:california) followers:>50", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "type:user (location:seattle OR location:california) followers:>50", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "search users fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search users", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchUsers(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// require.NotNil(t, result) - -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedResult MinimalSearchUsersResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) -// for i, user := range returnedResult.Items { -// assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login) -// assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID) -// assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL) -// assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL) -// } -// }) -// } -// } - -// func Test_SearchOrgs(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "search_orgs", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "query") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "order") -// assert.Contains(t, tool.InputSchema.Properties, "perPage") -// assert.Contains(t, tool.InputSchema.Properties, "page") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) - -// // Setup mock search results -// mockSearchResult := &github.UsersSearchResult{ -// Total: github.Ptr(int(2)), -// IncompleteResults: github.Ptr(false), -// Users: []*github.User{ -// { -// Login: github.Ptr("org-1"), -// ID: github.Ptr(int64(111)), -// HTMLURL: github.Ptr("https://github.com/org-1"), -// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), -// }, -// { -// Login: github.Ptr("org-2"), -// ID: github.Ptr(int64(222)), -// HTMLURL: github.Ptr("https://github.com/org-2"), -// AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), -// }, -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedResult *github.UsersSearchResult -// expectedErrMsg string -// }{ -// { -// name: "successful org search", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:org github", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "github", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "query with existing type:org filter - no duplication", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:org location:california followers:>1000", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "type:org location:california followers:>1000", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "complex query with existing type:org filter and OR operators", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// expectQueryParams(t, map[string]string{ -// "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", -// "page": "1", -// "per_page": "30", -// }).andThen( -// mockResponse(t, http.StatusOK, mockSearchResult), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", -// }, -// expectError: false, -// expectedResult: mockSearchResult, -// }, -// { -// name: "org search fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetSearchUsers, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "query": "invalid:query", -// }, -// expectError: true, -// expectedErrMsg: "failed to search orgs", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.NotNil(t, result) - -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedResult MinimalSearchUsersResult -// err = json.Unmarshal([]byte(textContent.Text), &returnedResult) -// require.NoError(t, err) -// assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) -// assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) -// assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) -// for i, org := range returnedResult.Items { -// assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login) -// assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID) -// assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL) -// assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL) -// } -// }) -// } -// } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go deleted file mode 100644 index 0e3915389..000000000 --- a/pkg/github/search_utils.go +++ /dev/null @@ -1,115 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" -// "regexp" - -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// ) - -// func hasFilter(query, filterType string) bool { -// // Match filter at start of string, after whitespace, or after non-word characters like '(' -// pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType)) -// matched, _ := regexp.MatchString(pattern, query) -// return matched -// } - -// func hasSpecificFilter(query, filterType, filterValue string) bool { -// // Match specific filter:value at start, after whitespace, or after non-word characters -// // End with word boundary, whitespace, or non-word characters like ')' -// pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue)) -// matched, _ := regexp.MatchString(pattern, query) -// return matched -// } - -// func hasRepoFilter(query string) bool { -// return hasFilter(query, "repo") -// } - -// func hasTypeFilter(query string) bool { -// return hasFilter(query, "type") -// } - -// func searchHandler( -// ctx context.Context, -// getClient GetClientFn, -// request mcp.CallToolRequest, -// searchType string, -// errorPrefix string, -// ) (*mcp.CallToolResult, error) { -// query, err := RequiredParam[string](request, "query") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// if !hasSpecificFilter(query, "is", searchType) { -// query = fmt.Sprintf("is:%s %s", searchType, query) -// } - -// owner, err := OptionalParam[string](request, "owner") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// repo, err := OptionalParam[string](request, "repo") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// if owner != "" && repo != "" && !hasRepoFilter(query) { -// query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) -// } - -// sort, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// order, err := OptionalParam[string](request, "order") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// pagination, err := OptionalPaginationParams(request) -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } - -// opts := &github.SearchOptions{ -// // Default to "created" if no sort is provided, as it's a common use case. -// Sort: sort, -// Order: order, -// ListOptions: github.ListOptions{ -// Page: pagination.Page, -// PerPage: pagination.PerPage, -// }, -// } - -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err) -// } -// result, resp, err := client.Search.Issues(ctx, query, opts) -// if err != nil { -// return nil, fmt.Errorf("%s: %w", errorPrefix, err) -// } -// defer func() { _ = resp.Body.Close() }() - -// if resp.StatusCode != http.StatusOK { -// body, err := io.ReadAll(resp.Body) -// if err != nil { -// return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err) -// } -// return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil -// } - -// r, err := json.Marshal(result) -// if err != nil { -// return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go deleted file mode 100644 index 052dfe19f..000000000 --- a/pkg/github/search_utils_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package github - -// import ( -// "testing" - -// "github.com/stretchr/testify/assert" -// ) - -// func Test_hasFilter(t *testing.T) { -// tests := []struct { -// name string -// query string -// filterType string -// expected bool -// }{ -// { -// name: "query has is:issue filter", -// query: "is:issue bug report", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query has repo: filter", -// query: "repo:github/github-mcp-server critical bug", -// filterType: "repo", -// expected: true, -// }, -// { -// name: "query has multiple is: filters", -// query: "is:issue is:open bug", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query has filter at the beginning", -// query: "is:issue some text", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query has filter in the middle", -// query: "some text is:issue more text", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query has filter at the end", -// query: "some text is:issue", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query does not have the filter", -// query: "bug report critical", -// filterType: "is", -// expected: false, -// }, -// { -// name: "query has similar text but not the filter", -// query: "this issue is important", -// filterType: "is", -// expected: false, -// }, -// { -// name: "empty query", -// query: "", -// filterType: "is", -// expected: false, -// }, -// { -// name: "query has label: filter but looking for is:", -// query: "label:bug critical", -// filterType: "is", -// expected: false, -// }, -// { -// name: "query has author: filter", -// query: "author:octocat bug", -// filterType: "author", -// expected: true, -// }, -// { -// name: "query with complex OR expression", -// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", -// filterType: "is", -// expected: true, -// }, -// { -// name: "query with complex OR expression checking repo", -// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", -// filterType: "repo", -// expected: true, -// }, -// { -// name: "filter in parentheses at start", -// query: "(label:bug OR owner:bob) is:issue", -// filterType: "label", -// expected: true, -// }, -// { -// name: "filter after opening parenthesis", -// query: "is:issue (label:critical OR repo:test/test)", -// filterType: "label", -// expected: true, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// result := hasFilter(tt.query, tt.filterType) -// assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) -// }) -// } -// } - -// func Test_hasRepoFilter(t *testing.T) { -// tests := []struct { -// name string -// query string -// expected bool -// }{ -// { -// name: "query with repo: filter at beginning", -// query: "repo:github/github-mcp-server is:issue", -// expected: true, -// }, -// { -// name: "query with repo: filter in middle", -// query: "is:issue repo:octocat/Hello-World bug", -// expected: true, -// }, -// { -// name: "query with repo: filter at end", -// query: "is:issue critical repo:owner/repo-name", -// expected: true, -// }, -// { -// name: "query with complex repo name", -// query: "repo:microsoft/vscode-extension-samples bug", -// expected: true, -// }, -// { -// name: "query without repo: filter", -// query: "is:issue bug critical", -// expected: false, -// }, -// { -// name: "query with malformed repo: filter (no slash)", -// query: "repo:github bug", -// expected: true, // hasRepoFilter only checks for repo: prefix, not format -// }, -// { -// name: "empty query", -// query: "", -// expected: false, -// }, -// { -// name: "query with multiple repo: filters", -// query: "repo:github/first repo:octocat/second", -// expected: true, -// }, -// { -// name: "query with repo: in text but not as filter", -// query: "this repo: is important", -// expected: false, -// }, -// { -// name: "query with complex OR expression", -// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", -// expected: true, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// result := hasRepoFilter(tt.query) -// assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) -// }) -// } -// } - -// func Test_hasSpecificFilter(t *testing.T) { -// tests := []struct { -// name string -// query string -// filterType string -// filterValue string -// expected bool -// }{ -// { -// name: "query has exact is:issue filter", -// query: "is:issue bug report", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "query has is:open but looking for is:issue", -// query: "is:open bug report", -// filterType: "is", -// filterValue: "issue", -// expected: false, -// }, -// { -// name: "query has both is:issue and is:open, looking for is:issue", -// query: "is:issue is:open bug", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "query has both is:issue and is:open, looking for is:open", -// query: "is:issue is:open bug", -// filterType: "is", -// filterValue: "open", -// expected: true, -// }, -// { -// name: "query has is:issue at the beginning", -// query: "is:issue some text", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "query has is:issue in the middle", -// query: "some text is:issue more text", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "query has is:issue at the end", -// query: "some text is:issue", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "query does not have is:issue", -// query: "bug report critical", -// filterType: "is", -// filterValue: "issue", -// expected: false, -// }, -// { -// name: "query has similar text but not the exact filter", -// query: "this issue is important", -// filterType: "is", -// filterValue: "issue", -// expected: false, -// }, -// { -// name: "empty query", -// query: "", -// filterType: "is", -// filterValue: "issue", -// expected: false, -// }, -// { -// name: "partial match should not count", -// query: "is:issues bug", // "issues" vs "issue" -// filterType: "is", -// filterValue: "issue", -// expected: false, -// }, -// { -// name: "complex query with parentheses", -// query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "filter:value in parentheses at start", -// query: "(is:issue OR is:pr) label:bug", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// { -// name: "filter:value after opening parenthesis", -// query: "repo:test/repo (is:issue AND label:bug)", -// filterType: "is", -// filterValue: "issue", -// expected: true, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) -// assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) -// }) -// } -// } - -// func Test_hasTypeFilter(t *testing.T) { -// tests := []struct { -// name string -// query string -// expected bool -// }{ -// { -// name: "query with type:user filter at beginning", -// query: "type:user location:seattle", -// expected: true, -// }, -// { -// name: "query with type:org filter in middle", -// query: "location:california type:org followers:>100", -// expected: true, -// }, -// { -// name: "query with type:user filter at end", -// query: "location:seattle followers:>50 type:user", -// expected: true, -// }, -// { -// name: "query without type: filter", -// query: "location:seattle followers:>50", -// expected: false, -// }, -// { -// name: "empty query", -// query: "", -// expected: false, -// }, -// { -// name: "query with type: in text but not as filter", -// query: "this type: is important", -// expected: false, -// }, -// { -// name: "query with multiple type: filters", -// query: "type:user type:org", -// expected: true, -// }, -// { -// name: "complex query with OR expression", -// query: "type:user (location:seattle OR location:california)", -// expected: true, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// result := hasTypeFilter(tt.query) -// assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) -// }) -// } -// } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go deleted file mode 100644 index 70ba6ce27..000000000 --- a/pkg/github/secret_scanning.go +++ /dev/null @@ -1,163 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// ghErrors "github.com/github/github-mcp-server/pkg/errors" -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool( -// "get_secret_scanning_alert", -// mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithNumber("alertNumber", -// mcp.Required(), -// mcp.Description("The number of the alert."), -// ), -// ), -// 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 -// } -// alertNumber, err := RequiredInt(request, "alertNumber") -// 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) -// } - -// alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to get alert with number '%d'", alertNumber), -// 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 get alert: %s", string(body))), nil -// } - -// r, err := json.Marshal(alert) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alert: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool( -// "list_secret_scanning_alerts", -// mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithString("state", -// mcp.Description("Filter by state"), -// mcp.Enum("open", "resolved"), -// ), -// mcp.WithString("secret_type", -// mcp.Description("A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter."), -// ), -// mcp.WithString("resolution", -// mcp.Description("Filter by resolution"), -// mcp.Enum("false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"), -// ), -// ), -// 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 -// } -// state, err := OptionalParam[string](request, "state") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// secretType, err := OptionalParam[string](request, "secret_type") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// resolution, err := OptionalParam[string](request, "resolution") -// 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) -// } -// alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) -// if err != nil { -// return ghErrors.NewGitHubAPIErrorResponse(ctx, -// fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), -// 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 alerts: %s", string(body))), nil -// } - -// r, err := json.Marshal(alerts) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal alerts: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go deleted file mode 100644 index f70111fec..000000000 --- a/pkg/github/secret_scanning_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_GetSecretScanningAlert(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := GetSecretScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "get_secret_scanning_alert", 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, "alertNumber") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) - -// // Setup mock alert for success case -// mockAlert := &github.SecretScanningAlert{ -// Number: github.Ptr(42), -// State: github.Ptr("open"), -// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlert *github.SecretScanningAlert -// expectedErrMsg string -// }{ -// { -// name: "successful alert fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, -// mockAlert, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(42), -// }, -// expectError: false, -// expectedAlert: mockAlert, -// }, -// { -// name: "alert fetch fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "alertNumber": float64(9999), -// }, -// expectError: true, -// expectedErrMsg: "failed to get alert", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetSecretScanningAlert(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlert github.Alert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) -// assert.NoError(t, err) -// assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) -// assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) -// assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) - -// }) -// } -// } - -// func Test_ListSecretScanningAlerts(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListSecretScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_secret_scanning_alerts", 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, "state") -// assert.Contains(t, tool.InputSchema.Properties, "secret_type") -// assert.Contains(t, tool.InputSchema.Properties, "resolution") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Setup mock alerts for success case -// resolvedAlert := github.SecretScanningAlert{ -// Number: github.Ptr(2), -// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), -// State: github.Ptr("resolved"), -// Resolution: github.Ptr("false_positive"), -// SecretType: github.Ptr("adafruit_io_key"), -// } -// openAlert := github.SecretScanningAlert{ -// Number: github.Ptr(2), -// HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), -// State: github.Ptr("open"), -// Resolution: github.Ptr("false_positive"), -// SecretType: github.Ptr("adafruit_io_key"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAlerts []*github.SecretScanningAlert -// expectedErrMsg string -// }{ -// { -// name: "successful resolved alerts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposSecretScanningAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{ -// "state": "resolved", -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// "state": "resolved", -// }, -// expectError: false, -// expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert}, -// }, -// { -// name: "successful alerts listing", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposSecretScanningAlertsByOwnerByRepo, -// expectQueryParams(t, map[string]string{}).andThen( -// mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, -// }, -// { -// name: "alerts listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetReposSecretScanningAlertsByOwnerByRepo, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusUnauthorized) -// _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list alerts", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.NoError(t, err) -// require.True(t, result.IsError) -// errorContent := getErrorResult(t, result) -// assert.Contains(t, errorContent.Text, tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) -// require.False(t, result.IsError) - -// textContent := getTextResult(t, result) - -// // Unmarshal and verify the result -// var returnedAlerts []*github.SecretScanningAlert -// err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) -// assert.NoError(t, err) -// assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) -// for i, alert := range returnedAlerts { -// assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) -// assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) -// assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) -// assert.Equal(t, *tc.expectedAlerts[i].Resolution, *alert.Resolution) -// assert.Equal(t, *tc.expectedAlerts[i].SecretType, *alert.SecretType) -// } -// }) -// } -// } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go deleted file mode 100644 index 5f6c211d5..000000000 --- a/pkg/github/security_advisories.go +++ /dev/null @@ -1,397 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "fmt" -// "io" -// "net/http" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/mark3labs/mcp-go/mcp" -// "github.com/mark3labs/mcp-go/server" -// ) - -// func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_global_security_advisories", -// mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("ghsaId", -// mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), -// ), -// mcp.WithString("type", -// mcp.Description("Advisory type."), -// mcp.Enum("reviewed", "malware", "unreviewed"), -// mcp.DefaultString("reviewed"), -// ), -// mcp.WithString("cveId", -// mcp.Description("Filter by CVE ID."), -// ), -// mcp.WithString("ecosystem", -// mcp.Description("Filter by package ecosystem."), -// mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"), -// ), -// mcp.WithString("severity", -// mcp.Description("Filter by severity."), -// mcp.Enum("unknown", "low", "medium", "high", "critical"), -// ), -// mcp.WithArray("cwes", -// mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."), -// mcp.Items(map[string]any{ -// "type": "string", -// }), -// ), -// mcp.WithBoolean("isWithdrawn", -// mcp.Description("Whether to only return withdrawn advisories."), -// ), -// mcp.WithString("affects", -// mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."), -// ), -// mcp.WithString("published", -// mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."), -// ), -// mcp.WithString("updated", -// mcp.Description("Filter by update date or date range (ISO 8601 date or range)."), -// ), -// mcp.WithString("modified", -// mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."), -// ), -// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// ghsaID, err := OptionalParam[string](request, "ghsaId") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil -// } - -// typ, err := OptionalParam[string](request, "type") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil -// } - -// cveID, err := OptionalParam[string](request, "cveId") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil -// } - -// eco, err := OptionalParam[string](request, "ecosystem") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil -// } - -// sev, err := OptionalParam[string](request, "severity") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil -// } - -// cwes, err := OptionalParam[[]string](request, "cwes") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil -// } - -// isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil -// } - -// affects, err := OptionalParam[string](request, "affects") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil -// } - -// published, err := OptionalParam[string](request, "published") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil -// } - -// updated, err := OptionalParam[string](request, "updated") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil -// } - -// modified, err := OptionalParam[string](request, "modified") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil -// } - -// opts := &github.ListGlobalSecurityAdvisoriesOptions{} - -// if ghsaID != "" { -// opts.GHSAID = &ghsaID -// } -// if typ != "" { -// opts.Type = &typ -// } -// if cveID != "" { -// opts.CVEID = &cveID -// } -// if eco != "" { -// opts.Ecosystem = &eco -// } -// if sev != "" { -// opts.Severity = &sev -// } -// if len(cwes) > 0 { -// opts.CWEs = cwes -// } - -// if isWithdrawn { -// opts.IsWithdrawn = &isWithdrawn -// } - -// if affects != "" { -// opts.Affects = &affects -// } -// if published != "" { -// opts.Published = &published -// } -// if updated != "" { -// opts.Updated = &updated -// } -// if modified != "" { -// opts.Modified = &modified -// } - -// advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list global security advisories: %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 list advisories: %s", string(body))), nil -// } - -// r, err := json.Marshal(advisories) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal advisories: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_repository_security_advisories", -// mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("owner", -// mcp.Required(), -// mcp.Description("The owner of the repository."), -// ), -// mcp.WithString("repo", -// mcp.Required(), -// mcp.Description("The name of the repository."), -// ), -// mcp.WithString("direction", -// mcp.Description("Sort direction."), -// mcp.Enum("asc", "desc"), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort field."), -// mcp.Enum("created", "updated", "published"), -// ), -// mcp.WithString("state", -// mcp.Description("Filter by advisory state."), -// mcp.Enum("triage", "draft", "published", "closed"), -// ), -// ), 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 -// } - -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sortField, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// state, err := OptionalParam[string](request, "state") -// 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) -// } - -// opts := &github.ListRepositorySecurityAdvisoriesOptions{} -// if direction != "" { -// opts.Direction = direction -// } -// if sortField != "" { -// opts.Sort = sortField -// } -// if state != "" { -// opts.State = state -// } - -// advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list repository security advisories: %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 list repository advisories: %s", string(body))), nil -// } - -// r, err := json.Marshal(advisories) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal advisories: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("get_global_security_advisory", -// mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("ghsaId", -// mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), -// mcp.Required(), -// ), -// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// client, err := getClient(ctx) -// if err != nil { -// return nil, fmt.Errorf("failed to get GitHub client: %w", err) -// } - -// ghsaID, err := RequiredParam[string](request, "ghsaId") -// if err != nil { -// return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil -// } - -// advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) -// if err != nil { -// return nil, fmt.Errorf("failed to get advisory: %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 advisory: %s", string(body))), nil -// } - -// r, err := json.Marshal(advisory) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal advisory: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } - -// func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { -// return mcp.NewTool("list_org_repository_security_advisories", -// mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")), -// mcp.WithToolAnnotation(mcp.ToolAnnotation{ -// Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"), -// ReadOnlyHint: ToBoolPtr(true), -// }), -// mcp.WithString("org", -// mcp.Required(), -// mcp.Description("The organization login."), -// ), -// mcp.WithString("direction", -// mcp.Description("Sort direction."), -// mcp.Enum("asc", "desc"), -// ), -// mcp.WithString("sort", -// mcp.Description("Sort field."), -// mcp.Enum("created", "updated", "published"), -// ), -// mcp.WithString("state", -// mcp.Description("Filter by advisory state."), -// mcp.Enum("triage", "draft", "published", "closed"), -// ), -// ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { -// org, err := RequiredParam[string](request, "org") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// direction, err := OptionalParam[string](request, "direction") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// sortField, err := OptionalParam[string](request, "sort") -// if err != nil { -// return mcp.NewToolResultError(err.Error()), nil -// } -// state, err := OptionalParam[string](request, "state") -// 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) -// } - -// opts := &github.ListRepositorySecurityAdvisoriesOptions{} -// if direction != "" { -// opts.Direction = direction -// } -// if sortField != "" { -// opts.Sort = sortField -// } -// if state != "" { -// opts.State = state -// } - -// advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) -// if err != nil { -// return nil, fmt.Errorf("failed to list organization repository security advisories: %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 list organization repository advisories: %s", string(body))), nil -// } - -// r, err := json.Marshal(advisories) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal advisories: %w", err) -// } - -// return mcp.NewToolResultText(string(r)), nil -// } -// } diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go deleted file mode 100644 index d7efca0ce..000000000 --- a/pkg/github/security_advisories_test.go +++ /dev/null @@ -1,526 +0,0 @@ -package github - -// import ( -// "context" -// "encoding/json" -// "net/http" -// "testing" - -// "github.com/github/github-mcp-server/pkg/translations" -// "github.com/google/go-github/v77/github" -// "github.com/migueleliasweb/go-github-mock/src/mock" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) - -// func Test_ListGlobalSecurityAdvisories(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_global_security_advisories", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "ecosystem") -// assert.Contains(t, tool.InputSchema.Properties, "severity") -// assert.Contains(t, tool.InputSchema.Properties, "ghsaId") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{}) - -// // Setup mock advisory for success case -// mockAdvisory := &github.GlobalSecurityAdvisory{ -// SecurityAdvisory: github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), -// Summary: github.Ptr("Test advisory"), -// Description: github.Ptr("This is a test advisory."), -// Severity: github.Ptr("high"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAdvisories []*github.GlobalSecurityAdvisory -// expectedErrMsg string -// }{ -// { -// name: "successful advisory fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetAdvisories, -// []*github.GlobalSecurityAdvisory{mockAdvisory}, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "type": "reviewed", -// "ecosystem": "npm", -// "severity": "high", -// }, -// expectError: false, -// expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory}, -// }, -// { -// name: "invalid severity value", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetAdvisories, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "type": "reviewed", -// "severity": "extreme", -// }, -// expectError: true, -// expectedErrMsg: "failed to list global security advisories", -// }, -// { -// name: "API error handling", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetAdvisories, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusInternalServerError) -// _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{}, -// expectError: true, -// expectedErrMsg: "failed to list global security advisories", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := ListGlobalSecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), 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 returnedAdvisories []*github.GlobalSecurityAdvisory -// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) -// assert.NoError(t, err) -// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) -// for i, advisory := range returnedAdvisories { -// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) -// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) -// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) -// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) -// } -// }) -// } -// } - -// func Test_GetGlobalSecurityAdvisory(t *testing.T) { -// mockClient := github.NewClient(nil) -// tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "get_global_security_advisory", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "ghsaId") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"}) - -// // Setup mock advisory for success case -// mockAdvisory := &github.GlobalSecurityAdvisory{ -// SecurityAdvisory: github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), -// Summary: github.Ptr("Test advisory"), -// Description: github.Ptr("This is a test advisory."), -// Severity: github.Ptr("high"), -// }, -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAdvisory *github.GlobalSecurityAdvisory -// expectedErrMsg string -// }{ -// { -// name: "successful advisory fetch", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatch( -// mock.GetAdvisoriesByGhsaId, -// mockAdvisory, -// ), -// ), -// requestArgs: map[string]interface{}{ -// "ghsaId": "GHSA-xxxx-xxxx-xxxx", -// }, -// expectError: false, -// expectedAdvisory: mockAdvisory, -// }, -// { -// name: "invalid ghsaId format", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetAdvisoriesByGhsaId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusBadRequest) -// _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "ghsaId": "invalid-ghsa-id", -// }, -// expectError: true, -// expectedErrMsg: "failed to get advisory", -// }, -// { -// name: "advisory not found", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// mock.GetAdvisoriesByGhsaId, -// http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { -// w.WriteHeader(http.StatusNotFound) -// _, _ = w.Write([]byte(`{"message": "Not Found"}`)) -// }), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "ghsaId": "GHSA-xxxx-xxxx-xxxx", -// }, -// expectError: true, -// expectedErrMsg: "failed to get advisory", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// // Setup client with mock -// client := github.NewClient(tc.mockedClient) -// _, handler := GetGlobalSecurityAdvisory(stubGetClientFn(client), translations.NullTranslationHelper) - -// // Create call request -// request := createMCPRequest(tc.requestArgs) - -// // Call handler -// result, err := handler(context.Background(), request) - -// // Verify results -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) - -// // Parse the result and get the text content if no error -// textContent := getTextResult(t, result) - -// // Verify the result -// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID) -// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary) -// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description) -// assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity) -// }) -// } -// } - -// func Test_ListRepositorySecurityAdvisories(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_repository_security_advisories", 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, "direction") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "state") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - -// // Local endpoint pattern for repository security advisories -// var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{ -// Pattern: "/repos/{owner}/{repo}/security-advisories", -// Method: "GET", -// } - -// // Setup mock advisories for success cases -// adv1 := &github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-1111-1111-1111"), -// Summary: github.Ptr("Repo advisory one"), -// Description: github.Ptr("First repo advisory."), -// Severity: github.Ptr("high"), -// } -// adv2 := &github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-2222-2222-2222"), -// Summary: github.Ptr("Repo advisory two"), -// Description: github.Ptr("Second repo advisory."), -// Severity: github.Ptr("medium"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAdvisories []*github.SecurityAdvisory -// expectedErrMsg string -// }{ -// { -// name: "successful advisories listing (no filters)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetReposSecurityAdvisoriesByOwnerByRepo, -// expect(t, expectations{ -// path: "/repos/owner/repo/security-advisories", -// queryParams: map[string]string{}, -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: false, -// expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, -// }, -// { -// name: "successful advisories listing with filters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetReposSecurityAdvisoriesByOwnerByRepo, -// expect(t, expectations{ -// path: "/repos/octo/hello-world/security-advisories", -// queryParams: map[string]string{ -// "direction": "desc", -// "sort": "updated", -// "state": "published", -// }, -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "octo", -// "repo": "hello-world", -// "direction": "desc", -// "sort": "updated", -// "state": "published", -// }, -// expectError: false, -// expectedAdvisories: []*github.SecurityAdvisory{adv1}, -// }, -// { -// name: "advisories listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetReposSecurityAdvisoriesByOwnerByRepo, -// expect(t, expectations{ -// path: "/repos/owner/repo/security-advisories", -// queryParams: map[string]string{}, -// }).andThen( -// mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "owner": "owner", -// "repo": "repo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list repository security advisories", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// var returnedAdvisories []*github.SecurityAdvisory -// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) -// assert.NoError(t, err) -// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) -// for i, advisory := range returnedAdvisories { -// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) -// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) -// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) -// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) -// } -// }) -// } -// } - -// func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { -// // Verify tool definition once -// mockClient := github.NewClient(nil) -// tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) - -// assert.Equal(t, "list_org_repository_security_advisories", tool.Name) -// assert.NotEmpty(t, tool.Description) -// assert.Contains(t, tool.InputSchema.Properties, "org") -// assert.Contains(t, tool.InputSchema.Properties, "direction") -// assert.Contains(t, tool.InputSchema.Properties, "sort") -// assert.Contains(t, tool.InputSchema.Properties, "state") -// assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) - -// // Endpoint pattern for org repository security advisories -// var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{ -// Pattern: "/orgs/{org}/security-advisories", -// Method: "GET", -// } - -// adv1 := &github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), -// Summary: github.Ptr("Org repo advisory 1"), -// Description: github.Ptr("First advisory"), -// Severity: github.Ptr("low"), -// } -// adv2 := &github.SecurityAdvisory{ -// GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), -// Summary: github.Ptr("Org repo advisory 2"), -// Description: github.Ptr("Second advisory"), -// Severity: github.Ptr("critical"), -// } - -// tests := []struct { -// name string -// mockedClient *http.Client -// requestArgs map[string]interface{} -// expectError bool -// expectedAdvisories []*github.SecurityAdvisory -// expectedErrMsg string -// }{ -// { -// name: "successful listing (no filters)", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetOrgsSecurityAdvisoriesByOrg, -// expect(t, expectations{ -// path: "/orgs/octo/security-advisories", -// queryParams: map[string]string{}, -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "org": "octo", -// }, -// expectError: false, -// expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, -// }, -// { -// name: "successful listing with filters", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetOrgsSecurityAdvisoriesByOrg, -// expect(t, expectations{ -// path: "/orgs/octo/security-advisories", -// queryParams: map[string]string{ -// "direction": "asc", -// "sort": "created", -// "state": "triage", -// }, -// }).andThen( -// mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "org": "octo", -// "direction": "asc", -// "sort": "created", -// "state": "triage", -// }, -// expectError: false, -// expectedAdvisories: []*github.SecurityAdvisory{adv1}, -// }, -// { -// name: "listing fails", -// mockedClient: mock.NewMockedHTTPClient( -// mock.WithRequestMatchHandler( -// GetOrgsSecurityAdvisoriesByOrg, -// expect(t, expectations{ -// path: "/orgs/octo/security-advisories", -// queryParams: map[string]string{}, -// }).andThen( -// mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), -// ), -// ), -// ), -// requestArgs: map[string]interface{}{ -// "org": "octo", -// }, -// expectError: true, -// expectedErrMsg: "failed to list organization repository security advisories", -// }, -// } - -// for _, tc := range tests { -// t.Run(tc.name, func(t *testing.T) { -// client := github.NewClient(tc.mockedClient) -// _, handler := ListOrgRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper) - -// request := createMCPRequest(tc.requestArgs) - -// result, err := handler(context.Background(), request) - -// if tc.expectError { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tc.expectedErrMsg) -// return -// } - -// require.NoError(t, err) - -// textContent := getTextResult(t, result) - -// var returnedAdvisories []*github.SecurityAdvisory -// err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) -// assert.NoError(t, err) -// assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) -// for i, advisory := range returnedAdvisories { -// assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) -// assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) -// assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) -// assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) -// } -// }) -// } -// } From c946ada4eeb397a67aa18c16ab0313731d4c3ec7 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 17 Nov 2025 20:10:41 +0100 Subject: [PATCH 18/22] Move files back, use build tags --- {.tools-to-be-migrated => pkg/github}/actions.go | 2 ++ {.tools-to-be-migrated => pkg/github}/actions_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/code_scanning.go | 2 ++ {.tools-to-be-migrated => pkg/github}/code_scanning_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/dependabot.go | 2 ++ {.tools-to-be-migrated => pkg/github}/dependabot_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/discussions.go | 2 ++ {.tools-to-be-migrated => pkg/github}/discussions_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/dynamic_tools.go | 2 ++ {.tools-to-be-migrated => pkg/github}/gists.go | 2 ++ {.tools-to-be-migrated => pkg/github}/gists_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/git.go | 2 ++ {.tools-to-be-migrated => pkg/github}/issues.go | 2 ++ {.tools-to-be-migrated => pkg/github}/issues_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/labels.go | 2 ++ {.tools-to-be-migrated => pkg/github}/labels_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/notifications.go | 2 ++ {.tools-to-be-migrated => pkg/github}/notifications_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/projects.go | 2 ++ {.tools-to-be-migrated => pkg/github}/projects_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/pullrequests.go | 2 ++ {.tools-to-be-migrated => pkg/github}/pullrequests_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/repositories.go | 2 ++ {.tools-to-be-migrated => pkg/github}/repositories_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/search.go | 2 ++ {.tools-to-be-migrated => pkg/github}/search_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/search_utils.go | 2 ++ {.tools-to-be-migrated => pkg/github}/search_utils_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/secret_scanning.go | 2 ++ {.tools-to-be-migrated => pkg/github}/secret_scanning_test.go | 2 ++ {.tools-to-be-migrated => pkg/github}/security_advisories.go | 2 ++ .../github}/security_advisories_test.go | 2 ++ 32 files changed, 64 insertions(+) rename {.tools-to-be-migrated => pkg/github}/actions.go (99%) rename {.tools-to-be-migrated => pkg/github}/actions_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/code_scanning.go (99%) rename {.tools-to-be-migrated => pkg/github}/code_scanning_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/dependabot.go (99%) rename {.tools-to-be-migrated => pkg/github}/dependabot_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/discussions.go (99%) rename {.tools-to-be-migrated => pkg/github}/discussions_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/dynamic_tools.go (99%) rename {.tools-to-be-migrated => pkg/github}/gists.go (99%) rename {.tools-to-be-migrated => pkg/github}/gists_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/git.go (99%) rename {.tools-to-be-migrated => pkg/github}/issues.go (99%) rename {.tools-to-be-migrated => pkg/github}/issues_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/labels.go (99%) rename {.tools-to-be-migrated => pkg/github}/labels_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/notifications.go (99%) rename {.tools-to-be-migrated => pkg/github}/notifications_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/projects.go (99%) rename {.tools-to-be-migrated => pkg/github}/projects_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/pullrequests.go (99%) rename {.tools-to-be-migrated => pkg/github}/pullrequests_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/repositories.go (99%) rename {.tools-to-be-migrated => pkg/github}/repositories_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/search.go (99%) rename {.tools-to-be-migrated => pkg/github}/search_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/search_utils.go (99%) rename {.tools-to-be-migrated => pkg/github}/search_utils_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/secret_scanning.go (99%) rename {.tools-to-be-migrated => pkg/github}/secret_scanning_test.go (99%) rename {.tools-to-be-migrated => pkg/github}/security_advisories.go (99%) rename {.tools-to-be-migrated => pkg/github}/security_advisories_test.go (99%) diff --git a/.tools-to-be-migrated/actions.go b/pkg/github/actions.go similarity index 99% rename from .tools-to-be-migrated/actions.go rename to pkg/github/actions.go index ecf538323..811057f0c 100644 --- a/.tools-to-be-migrated/actions.go +++ b/pkg/github/actions.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/actions_test.go b/pkg/github/actions_test.go similarity index 99% rename from .tools-to-be-migrated/actions_test.go rename to pkg/github/actions_test.go index 1738bc8e5..da01887a0 100644 --- a/.tools-to-be-migrated/actions_test.go +++ b/pkg/github/actions_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/code_scanning.go b/pkg/github/code_scanning.go similarity index 99% rename from .tools-to-be-migrated/code_scanning.go rename to pkg/github/code_scanning.go index aa39cfc35..c3b278d6d 100644 --- a/.tools-to-be-migrated/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/code_scanning_test.go b/pkg/github/code_scanning_test.go similarity index 99% rename from .tools-to-be-migrated/code_scanning_test.go rename to pkg/github/code_scanning_test.go index 874d1eeda..755d7c889 100644 --- a/.tools-to-be-migrated/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/dependabot.go b/pkg/github/dependabot.go similarity index 99% rename from .tools-to-be-migrated/dependabot.go rename to pkg/github/dependabot.go index e21562c02..3cf8d3a3f 100644 --- a/.tools-to-be-migrated/dependabot.go +++ b/pkg/github/dependabot.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/dependabot_test.go b/pkg/github/dependabot_test.go similarity index 99% rename from .tools-to-be-migrated/dependabot_test.go rename to pkg/github/dependabot_test.go index 302692a3a..06cc5866f 100644 --- a/.tools-to-be-migrated/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/discussions.go b/pkg/github/discussions.go similarity index 99% rename from .tools-to-be-migrated/discussions.go rename to pkg/github/discussions.go index 3aa92f05c..64fc9b1ff 100644 --- a/.tools-to-be-migrated/discussions.go +++ b/pkg/github/discussions.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/discussions_test.go b/pkg/github/discussions_test.go similarity index 99% rename from .tools-to-be-migrated/discussions_test.go rename to pkg/github/discussions_test.go index 0930b1421..e67ebb223 100644 --- a/.tools-to-be-migrated/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/dynamic_tools.go b/pkg/github/dynamic_tools.go similarity index 99% rename from .tools-to-be-migrated/dynamic_tools.go rename to pkg/github/dynamic_tools.go index e703a885e..45a481576 100644 --- a/.tools-to-be-migrated/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/gists.go b/pkg/github/gists.go similarity index 99% rename from .tools-to-be-migrated/gists.go rename to pkg/github/gists.go index 7168f8c0e..e166bedd1 100644 --- a/.tools-to-be-migrated/gists.go +++ b/pkg/github/gists.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/gists_test.go b/pkg/github/gists_test.go similarity index 99% rename from .tools-to-be-migrated/gists_test.go rename to pkg/github/gists_test.go index e8eb6d7f4..bf7f35bed 100644 --- a/.tools-to-be-migrated/gists_test.go +++ b/pkg/github/gists_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/git.go b/pkg/github/git.go similarity index 99% rename from .tools-to-be-migrated/git.go rename to pkg/github/git.go index 5dfc8e0e8..72834d75f 100644 --- a/.tools-to-be-migrated/git.go +++ b/pkg/github/git.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/issues.go b/pkg/github/issues.go similarity index 99% rename from .tools-to-be-migrated/issues.go rename to pkg/github/issues.go index bd437bde1..6c90548cc 100644 --- a/.tools-to-be-migrated/issues.go +++ b/pkg/github/issues.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/issues_test.go b/pkg/github/issues_test.go similarity index 99% rename from .tools-to-be-migrated/issues_test.go rename to pkg/github/issues_test.go index d13b93e4b..9ab4d1cf3 100644 --- a/.tools-to-be-migrated/issues_test.go +++ b/pkg/github/issues_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/labels.go b/pkg/github/labels.go similarity index 99% rename from .tools-to-be-migrated/labels.go rename to pkg/github/labels.go index c9be7be75..42b53fc6d 100644 --- a/.tools-to-be-migrated/labels.go +++ b/pkg/github/labels.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/labels_test.go b/pkg/github/labels_test.go similarity index 99% rename from .tools-to-be-migrated/labels_test.go rename to pkg/github/labels_test.go index 6bb91da26..5055364f0 100644 --- a/.tools-to-be-migrated/labels_test.go +++ b/pkg/github/labels_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/notifications.go b/pkg/github/notifications.go similarity index 99% rename from .tools-to-be-migrated/notifications.go rename to pkg/github/notifications.go index 6dca53cca..1bc85c3e0 100644 --- a/.tools-to-be-migrated/notifications.go +++ b/pkg/github/notifications.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/notifications_test.go b/pkg/github/notifications_test.go similarity index 99% rename from .tools-to-be-migrated/notifications_test.go rename to pkg/github/notifications_test.go index 034d8d4e2..436f98415 100644 --- a/.tools-to-be-migrated/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/projects.go b/pkg/github/projects.go similarity index 99% rename from .tools-to-be-migrated/projects.go rename to pkg/github/projects.go index 21d4c1103..2cab63bd8 100644 --- a/.tools-to-be-migrated/projects.go +++ b/pkg/github/projects.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/projects_test.go b/pkg/github/projects_test.go similarity index 99% rename from .tools-to-be-migrated/projects_test.go rename to pkg/github/projects_test.go index ed198a97a..627c15479 100644 --- a/.tools-to-be-migrated/projects_test.go +++ b/pkg/github/projects_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/pullrequests.go b/pkg/github/pullrequests.go similarity index 99% rename from .tools-to-be-migrated/pullrequests.go rename to pkg/github/pullrequests.go index 24454a0c8..a4feaf7c2 100644 --- a/.tools-to-be-migrated/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/pullrequests_test.go b/pkg/github/pullrequests_test.go similarity index 99% rename from .tools-to-be-migrated/pullrequests_test.go rename to pkg/github/pullrequests_test.go index 4cc4480e9..6d05632d9 100644 --- a/.tools-to-be-migrated/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/repositories.go b/pkg/github/repositories.go similarity index 99% rename from .tools-to-be-migrated/repositories.go rename to pkg/github/repositories.go index 0d4d11bbf..81700866c 100644 --- a/.tools-to-be-migrated/repositories.go +++ b/pkg/github/repositories.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/repositories_test.go b/pkg/github/repositories_test.go similarity index 99% rename from .tools-to-be-migrated/repositories_test.go rename to pkg/github/repositories_test.go index 665af6b0a..d63079318 100644 --- a/.tools-to-be-migrated/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/search.go b/pkg/github/search.go similarity index 99% rename from .tools-to-be-migrated/search.go rename to pkg/github/search.go index 5084773b2..8b43f8e2e 100644 --- a/.tools-to-be-migrated/search.go +++ b/pkg/github/search.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/search_test.go b/pkg/github/search_test.go similarity index 99% rename from .tools-to-be-migrated/search_test.go rename to pkg/github/search_test.go index e14ba023f..3435d08e8 100644 --- a/.tools-to-be-migrated/search_test.go +++ b/pkg/github/search_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/search_utils.go b/pkg/github/search_utils.go similarity index 99% rename from .tools-to-be-migrated/search_utils.go rename to pkg/github/search_utils.go index 04cb2224f..6fdd1cef8 100644 --- a/.tools-to-be-migrated/search_utils.go +++ b/pkg/github/search_utils.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/search_utils_test.go b/pkg/github/search_utils_test.go similarity index 99% rename from .tools-to-be-migrated/search_utils_test.go rename to pkg/github/search_utils_test.go index 85f953eed..7b68c4ca2 100644 --- a/.tools-to-be-migrated/search_utils_test.go +++ b/pkg/github/search_utils_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/secret_scanning.go b/pkg/github/secret_scanning.go similarity index 99% rename from .tools-to-be-migrated/secret_scanning.go rename to pkg/github/secret_scanning.go index 866c54617..84f925fa6 100644 --- a/.tools-to-be-migrated/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/secret_scanning_test.go b/pkg/github/secret_scanning_test.go similarity index 99% rename from .tools-to-be-migrated/secret_scanning_test.go rename to pkg/github/secret_scanning_test.go index 4a9d50ab9..531b5c645 100644 --- a/.tools-to-be-migrated/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/security_advisories.go b/pkg/github/security_advisories.go similarity index 99% rename from .tools-to-be-migrated/security_advisories.go rename to pkg/github/security_advisories.go index 316b5d58c..49b34be9e 100644 --- a/.tools-to-be-migrated/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( diff --git a/.tools-to-be-migrated/security_advisories_test.go b/pkg/github/security_advisories_test.go similarity index 99% rename from .tools-to-be-migrated/security_advisories_test.go rename to pkg/github/security_advisories_test.go index e083cb166..dee8667d9 100644 --- a/.tools-to-be-migrated/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -1,3 +1,5 @@ +//go:build ignore + package github import ( From 3620126a54789dda379785e7594017997cc9e509 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 09:44:09 +0100 Subject: [PATCH 19/22] Update licenses --- go.mod | 2 +- third-party-licenses.darwin.md | 2 ++ third-party-licenses.linux.md | 2 ++ third-party-licenses.windows.md | 2 ++ .../google/jsonschema-go/jsonschema/LICENSE | 21 +++++++++++++++++++ .../modelcontextprotocol/go-sdk/LICENSE | 21 +++++++++++++++++++ 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 third-party/github.com/google/jsonschema-go/jsonschema/LICENSE create mode 100644 third-party/github.com/modelcontextprotocol/go-sdk/LICENSE diff --git a/go.mod b/go.mod index 008787299..f19132415 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/google/go-github/v77 v77.0.0 + github.com/google/jsonschema-go v0.3.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.36.0 github.com/microcosm-cc/bluemonday v1.0.27 @@ -20,7 +21,6 @@ require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect github.com/google/go-github/v71 v71.0.0 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index d4d742c6e..047833240 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -18,6 +18,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v77/github](https://pkg.go.dev/github.com/google/go-github/v77/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v77.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.3.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) @@ -28,6 +29,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.1.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index d4d742c6e..047833240 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -18,6 +18,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v77/github](https://pkg.go.dev/github.com/google/go-github/v77/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v77.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.3.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) @@ -28,6 +29,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.1.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index e7117d82c..f5c2b0f3a 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -18,6 +18,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v77/github](https://pkg.go.dev/github.com/google/go-github/v77/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v77.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.3.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) @@ -29,6 +30,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.1.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) diff --git a/third-party/github.com/google/jsonschema-go/jsonschema/LICENSE b/third-party/github.com/google/jsonschema-go/jsonschema/LICENSE new file mode 100644 index 000000000..1cb53e9df --- /dev/null +++ b/third-party/github.com/google/jsonschema-go/jsonschema/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 JSON Schema Go Project Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE new file mode 100644 index 000000000..508be9266 --- /dev/null +++ b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Go MCP SDK Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 1d257b1ebdfb415b532c459cb9795dd932fe380b Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 09:45:42 +0100 Subject: [PATCH 20/22] Update agent to use build tags --- .github/agents/go-sdk-tool-migrator.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/agents/go-sdk-tool-migrator.md b/.github/agents/go-sdk-tool-migrator.md index 23b110256..6cc59c7d2 100644 --- a/.github/agents/go-sdk-tool-migrator.md +++ b/.github/agents/go-sdk-tool-migrator.md @@ -25,11 +25,11 @@ cd migrate-go-sdk- ## Migration Process -You should focus on ONLY the toolset provided to you and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `.tools-to-be-migrated/dependabot.go` and `.tools-to-be-migrated/dependabot_test.go`. The migrated version should be placed in the `github` package directory, `pkg/github` (e.g. `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`). If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. +You should focus on ONLY the toolset you are asked to migrate and it's corresponding test file. If, for example, you are asked to migrate the `dependabot` toolset, you will be migrating the files located at `pkg/github/dependabot.go` and `pkg/github/dependabot_test.go`. If there are additional tests or helper functions that fail to work with the new SDK, you should inform me of these issues so that I can address them, or instruct you on how to proceed. When generating the migration guide, consider the following aspects: -* The initial tool file and it's corresponding test file will be fully commented out, as the tests will fail if the code is uncommented. The code should be uncommented before work begins. +* The initial tool file and it's corresponding test file will have the `//go:build ignore` build tag, as the tests will fail if the code is not ignored. The `ignore` build tag should be removed before work begins. * The import for `github.com/mark3labs/mcp-go/mcp` should be changed to `github.com/modelcontextprotocol/go-sdk/mcp` * The return type for the tool constructor function should be updated from `mcp.Tool, server.ToolHandlerFunc` to `(mcp.Tool, mcp.ToolHandlerFor[map[string]any, any])`. * The tool handler function signature should be updated to use generics, changing from `func(ctx context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` to `func(context.Context, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error)`. From 9f25ebe29949c77fda5cb69052490cd82c2a5e96 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 11:05:38 +0100 Subject: [PATCH 21/22] Remove dupe import from merge conflict --- cmd/github-mcp-server/generate_docs.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index b5b960120..94b2f120c 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -13,7 +13,6 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v77/github" gogithub "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" From faa90d2af211634d41efdeb961b9738fb263c645 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 18 Nov 2025 11:19:21 +0100 Subject: [PATCH 22/22] fix linter issues --- cmd/github-mcp-server/generate_docs.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 94b2f120c..546bd716b 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -247,10 +247,9 @@ func generateToolDoc(tool mcp.Tool) string { requiredStr = "required" } - // Get the type and description - typeStr := "unknown" - description := "" + var typeStr, description string + // Get the type and description switch prop.Type { case "array": if prop.Items != nil {