From bbf0c5c431b460f0c8f00c0d68846fc724602204 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:39:35 +0000 Subject: [PATCH 1/2] fix: address SonarQube high-severity issues (S1192, S3776) - S1192: Extract duplicated string literals in discussions.go - Added constants for 'Repository owner', 'Repository name', 'failed to get GitHub GQL client', and 'category:%s' - Replaced all occurrences with the new constants - S1192: Extract duplicated string literals in search.go - Added constants for 'failed to read response body' and 'failed to marshal response' error messages - Replaced all occurrences with the new constants - S3776: Reduce cognitive complexity in discussions.go - Extracted discussionNode type and mapDiscussionNodeToIssue helper - Extracted fetchDiscussionNodes helper function - Reduced ListDiscussions function complexity from 27 to under 15 - S3776: Reduce cognitive complexity in mcpcurl/main.go - Extracted loadToolsFromServer helper function - Reduced main function complexity from 18 to under 15 Co-Authored-By: Abhay Aggarwal --- cmd/mcpcurl/main.go | 50 +++++---- pkg/github/discussions.go | 228 ++++++++++++++++++++++---------------- pkg/github/search.go | 18 ++- 3 files changed, 176 insertions(+), 120 deletions(-) diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index bc192587a..3dea59967 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -152,6 +152,33 @@ var ( } ) +// loadToolsFromServer fetches the schema from the MCP server and adds tool commands. +// This function reduces cognitive complexity by encapsulating the schema loading logic (SonarQube S3776). +func loadToolsFromServer(serverCmd string, prettyPrint bool) { + if serverCmd == "" { + return + } + + jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) + if err != nil { + return + } + + response, err := executeServerCommand(serverCmd, jsonRequest) + if err != nil { + return + } + + var schemaResp SchemaResponse + if err := json.Unmarshal([]byte(response), &schemaResp); err != nil { + return + } + + for _, tool := range schemaResp.Result.Tools { + addCommandFromTool(toolsCmd, &tool, prettyPrint) + } +} + func main() { rootCmd.AddCommand(schemaCmd) @@ -174,25 +201,10 @@ func main() { _, _ = fmt.Fprintf(os.Stderr, "Error getting pretty flag: %v\n", err) os.Exit(1) } - // Get server command - serverCmd, err := rootCmd.Flags().GetString("stdio-server-cmd") - if err == nil && serverCmd != "" { - // Fetch schema from server - jsonRequest, err := buildJSONRPCRequest("tools/list", "", nil) - if err == nil { - response, err := executeServerCommand(serverCmd, jsonRequest) - if err == nil { - // Parse the schema response - var schemaResp SchemaResponse - if err := json.Unmarshal([]byte(response), &schemaResp); err == nil { - // Add all the generated commands as subcommands of tools - for _, tool := range schemaResp.Result.Tools { - addCommandFromTool(toolsCmd, &tool, prettyPrint) - } - } - } - } - } + + // Get server command and load tools from server + serverCmd, _ := rootCmd.Flags().GetString("stdio-server-cmd") + loadToolsFromServer(serverCmd, prettyPrint) // Execute if err := rootCmd.Execute(); err != nil { diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index a7ec8e20f..bf243e4a4 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -13,6 +13,117 @@ import ( "github.com/shurcooL/githubv4" ) +// Constants for commonly used strings to avoid duplication (SonarQube S1192) +const ( + descRepositoryOwner = "Repository owner" + descRepositoryName = "Repository name" + errFailedToGetGQLClient = "failed to get GitHub GQL client: %v" + categoryLabelFormat = "category:%s" +) + +// discussionNode represents a discussion node from GraphQL query results. +// This type is used to reduce cognitive complexity by providing a common structure +// for mapping discussion data to GitHub Issue objects (SonarQube S3776). +type discussionNode struct { + Number int + Title string + URL string + CreatedAt github.Timestamp + CategoryName string +} + +// mapDiscussionNodeToIssue converts a discussionNode to a GitHub Issue object. +func mapDiscussionNodeToIssue(node discussionNode) *github.Issue { + return &github.Issue{ + Number: github.Ptr(node.Number), + Title: github.Ptr(node.Title), + HTMLURL: github.Ptr(node.URL), + CreatedAt: &node.CreatedAt, + Labels: []*github.Label{ + { + Name: github.Ptr(fmt.Sprintf(categoryLabelFormat, node.CategoryName)), + }, + }, + } +} + +// fetchDiscussionNodes fetches discussion nodes from GitHub GraphQL API. +// This function reduces cognitive complexity by encapsulating the query logic (SonarQube S3776). +func fetchDiscussionNodes(ctx context.Context, client *githubv4.Client, owner, repo string, categoryID *githubv4.ID) ([]discussionNode, error) { + var nodes []discussionNode + + if categoryID != nil { + // Query with category filter (server-side filtering) + var query struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "categoryId": *categoryID, + } + if err := client.Query(ctx, &query, vars); err != nil { + return nil, err + } + for _, n := range query.Repository.Discussions.Nodes { + nodes = append(nodes, discussionNode{ + Number: int(n.Number), + Title: string(n.Title), + URL: string(n.URL), + CreatedAt: github.Timestamp{Time: n.CreatedAt.Time}, + CategoryName: string(n.Category.Name), + }) + } + } else { + // Query without category filter + var query struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := client.Query(ctx, &query, vars); err != nil { + return nil, err + } + for _, n := range query.Repository.Discussions.Nodes { + nodes = append(nodes, discussionNode{ + Number: int(n.Number), + Title: string(n.Title), + URL: string(n.URL), + CreatedAt: github.Timestamp{Time: n.CreatedAt.Time}, + CategoryName: string(n.Category.Name), + }) + } + } + + return nodes, nil +} + 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")), @@ -22,11 +133,11 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp }), mcp.WithString("owner", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description(descRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(descRepositoryName), ), mcp.WithString("category", mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), @@ -51,7 +162,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil } // If category filter is specified, use it as the category ID for server-side filtering @@ -61,89 +172,16 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp categoryID = &id } - // Now execute the discussions query - var discussions []*github.Issue - if categoryID != nil { - // Query with category filter (server-side filtering) - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100, categoryId: $categoryId)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "categoryId": *categoryID, - } - if err := client.Query(ctx, &query, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Map nodes to GitHub Issue objects - for _, n := range query.Repository.Discussions.Nodes { - di := &github.Issue{ - Number: github.Ptr(int(n.Number)), - Title: github.Ptr(string(n.Title)), - HTMLURL: github.Ptr(string(n.URL)), - CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, - Labels: []*github.Label{ - { - Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))), - }, - }, - } - discussions = append(discussions, di) - } - } else { - // Query without category filter - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - } - if err := client.Query(ctx, &query, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Fetch discussions using the appropriate query based on category filter + nodes, err := fetchDiscussionNodes(ctx, client, owner, repo, categoryID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Map nodes to GitHub Issue objects - for _, n := range query.Repository.Discussions.Nodes { - di := &github.Issue{ - Number: github.Ptr(int(n.Number)), - Title: github.Ptr(string(n.Title)), - HTMLURL: github.Ptr(string(n.URL)), - CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, - Labels: []*github.Label{ - { - Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))), - }, - }, - } - discussions = append(discussions, di) - } + // Map nodes to GitHub Issue objects using helper function + discussions := make([]*github.Issue, 0, len(nodes)) + for _, node := range nodes { + discussions = append(discussions, mapDiscussionNodeToIssue(node)) } // Marshal and return @@ -164,11 +202,11 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper }), mcp.WithString("owner", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description(descRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(descRepositoryName), ), mcp.WithNumber("discussionNumber", mcp.Required(), @@ -187,7 +225,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper } client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil } var q struct { @@ -221,7 +259,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, Labels: []*github.Label{ { - Name: github.Ptr(fmt.Sprintf("category:%s", string(d.Category.Name))), + Name: github.Ptr(fmt.Sprintf(categoryLabelFormat, string(d.Category.Name))), }, }, } @@ -241,8 +279,8 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati 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.WithString("owner", mcp.Required(), mcp.Description(descRepositoryOwner)), + mcp.WithString("repo", mcp.Required(), mcp.Description(descRepositoryName)), mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -258,7 +296,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil } var q struct { @@ -303,11 +341,11 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl }), mcp.WithString("owner", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description(descRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(descRepositoryName), ), mcp.WithNumber("first", mcp.Description("Number of categories to return per page (min 1, max 100)"), @@ -356,7 +394,7 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil } var q struct { Repository struct { diff --git a/pkg/github/search.go b/pkg/github/search.go index 5106b84d8..be30063ef 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -13,6 +13,12 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// Constants for commonly used error messages to avoid duplication (SonarQube S1192) +const ( + errFailedToReadResponseBody = "failed to read response body: %w" + errFailedToMarshalResponse = "failed to marshal response: %w" +) + // 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", @@ -61,14 +67,14 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF 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 nil, fmt.Errorf(errFailedToReadResponseBody, err) } return mcp.NewToolResultError(fmt.Sprintf("failed to search repositories: %s", string(body))), nil } r, err := json.Marshal(result) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf(errFailedToMarshalResponse, err) } return mcp.NewToolResultText(string(r)), nil @@ -141,14 +147,14 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to 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 nil, fmt.Errorf(errFailedToReadResponseBody, 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 nil, fmt.Errorf(errFailedToMarshalResponse, err) } return mcp.NewToolResultText(string(r)), nil @@ -215,7 +221,7 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand 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 nil, fmt.Errorf(errFailedToReadResponseBody, err) } return mcp.NewToolResultError(fmt.Sprintf("failed to search %ss: %s", accountType, string(body))), nil } @@ -251,7 +257,7 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand r, err := json.Marshal(minimalResp) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf(errFailedToMarshalResponse, err) } return mcp.NewToolResultText(string(r)), nil } From d509e138daf09a53f9e25119e8aee7dbc4849fff Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:43:32 +0000 Subject: [PATCH 2/2] refactor: reduce code duplication in fetchDiscussionNodes - Extract gqlDiscussionNode type to share GraphQL node structure - Add toDiscussionNode method for consistent conversion - Add mapGQLNodesToDiscussionNodes helper function - Split fetchDiscussionNodes into fetchDiscussionsWithCategory and fetchDiscussionsWithoutCategory for cleaner separation This addresses SonarCloud's code duplication check (18.4% -> reduced). Co-Authored-By: Abhay Aggarwal --- pkg/github/discussions.go | 141 +++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 69 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index bf243e4a4..8bcd7c753 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -32,6 +32,29 @@ type discussionNode struct { CategoryName string } +// gqlDiscussionNode represents the common structure for GraphQL discussion nodes. +// This type is used to reduce code duplication when mapping query results (SonarQube S1192). +type gqlDiscussionNode struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` +} + +// toDiscussionNode converts a GraphQL discussion node to a discussionNode. +func (n gqlDiscussionNode) toDiscussionNode() discussionNode { + return discussionNode{ + Number: int(n.Number), + Title: string(n.Title), + URL: string(n.URL), + CreatedAt: github.Timestamp{Time: n.CreatedAt.Time}, + CategoryName: string(n.Category.Name), + } +} + // mapDiscussionNodeToIssue converts a discussionNode to a GitHub Issue object. func mapDiscussionNodeToIssue(node discussionNode) *github.Issue { return &github.Issue{ @@ -47,81 +70,61 @@ func mapDiscussionNodeToIssue(node discussionNode) *github.Issue { } } +// mapGQLNodesToDiscussionNodes converts a slice of GraphQL nodes to discussionNodes. +func mapGQLNodesToDiscussionNodes(gqlNodes []gqlDiscussionNode) []discussionNode { + nodes := make([]discussionNode, 0, len(gqlNodes)) + for _, n := range gqlNodes { + nodes = append(nodes, n.toDiscussionNode()) + } + return nodes +} + // fetchDiscussionNodes fetches discussion nodes from GitHub GraphQL API. // This function reduces cognitive complexity by encapsulating the query logic (SonarQube S3776). func fetchDiscussionNodes(ctx context.Context, client *githubv4.Client, owner, repo string, categoryID *githubv4.ID) ([]discussionNode, error) { - var nodes []discussionNode - if categoryID != nil { - // Query with category filter (server-side filtering) - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100, categoryId: $categoryId)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "categoryId": *categoryID, - } - if err := client.Query(ctx, &query, vars); err != nil { - return nil, err - } - for _, n := range query.Repository.Discussions.Nodes { - nodes = append(nodes, discussionNode{ - Number: int(n.Number), - Title: string(n.Title), - URL: string(n.URL), - CreatedAt: github.Timestamp{Time: n.CreatedAt.Time}, - CategoryName: string(n.Category.Name), - }) - } - } else { - // Query without category filter - var query struct { - Repository struct { - Discussions struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - CreatedAt githubv4.DateTime - Category struct { - Name githubv4.String - } `graphql:"category"` - URL githubv4.String `graphql:"url"` - } - } `graphql:"discussions(first: 100)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - } - if err := client.Query(ctx, &query, vars); err != nil { - return nil, err - } - for _, n := range query.Repository.Discussions.Nodes { - nodes = append(nodes, discussionNode{ - Number: int(n.Number), - Title: string(n.Title), - URL: string(n.URL), - CreatedAt: github.Timestamp{Time: n.CreatedAt.Time}, - CategoryName: string(n.Category.Name), - }) - } + return fetchDiscussionsWithCategory(ctx, client, owner, repo, *categoryID) } + return fetchDiscussionsWithoutCategory(ctx, client, owner, repo) +} - return nodes, nil +// fetchDiscussionsWithCategory fetches discussions filtered by category. +func fetchDiscussionsWithCategory(ctx context.Context, client *githubv4.Client, owner, repo string, categoryID githubv4.ID) ([]discussionNode, error) { + var query struct { + Repository struct { + Discussions struct { + Nodes []gqlDiscussionNode + } `graphql:"discussions(first: 100, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "categoryId": categoryID, + } + if err := client.Query(ctx, &query, vars); err != nil { + return nil, err + } + return mapGQLNodesToDiscussionNodes(query.Repository.Discussions.Nodes), nil +} + +// fetchDiscussionsWithoutCategory fetches all discussions without category filter. +func fetchDiscussionsWithoutCategory(ctx context.Context, client *githubv4.Client, owner, repo string) ([]discussionNode, error) { + var query struct { + Repository struct { + Discussions struct { + Nodes []gqlDiscussionNode + } `graphql:"discussions(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := client.Query(ctx, &query, vars); err != nil { + return nil, err + } + return mapGQLNodesToDiscussionNodes(query.Repository.Discussions.Nodes), nil } func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {