Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 111 additions & 118 deletions pkg/github/discussions.go
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 ListDiscussionCategories pagination parameters are validated but silently ignored in the GraphQL query

The ListDiscussionCategories function accepts pagination parameters (first, last, after, before), decodes them from the request, and validates them via the newly extracted validatePaginationParams function, but the GraphQL query at line 361 hardcodes discussionCategories(first: 100) and never uses any of the user-supplied pagination values.

Root Cause and Impact

The params.First, params.Last, params.After, and params.Before fields are decoded at pkg/github/discussions.go:334-341 and validated at line 346, but the GraphQL query struct tag at line 361 is hardcoded:

`graphql:"discussionCategories(first: 100)"`

And the vars map at lines 364-367 only includes owner and repo:

vars := map[string]interface{}{
    "owner": githubv4.String(params.Owner),
    "repo":  githubv4.String(params.Repo),
}

Impact: Users who pass first, last, after, or before parameters will receive no error (validation passes), but their pagination preferences are completely ignored. The query always returns the first 100 categories. This is particularly misleading now that validatePaginationParams has been extracted as a shared utility, suggesting pagination is properly implemented.

(Refers to line 361)

Prompt for agents
In pkg/github/discussions.go, the ListDiscussionCategories function (starting around line 301) accepts first, last, after, and before pagination parameters, validates them at line 346, but the GraphQL query at line 361 hardcodes `discussionCategories(first: 100)` and the vars map at lines 364-367 doesn't include any pagination variables.

To fix this, you need to:
1. Dynamically construct the GraphQL query struct tag based on which pagination parameters are provided (first/last, after/before), or use separate query structs for different pagination combinations (similar to how fetchDiscussionsWithCategory vs fetchDiscussionsWithoutCategory work).
2. Include the pagination variables in the vars map passed to client.Query.
3. If no pagination parameters are provided, default to first: 100 as the current behavior.

Alternatively, if pagination is not intended to be supported yet, remove the first/last/after/before parameters from the tool schema definition (lines 316-331) and remove the validatePaginationParams call.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,95 @@ import (
"github.com/shurcooL/githubv4"
)

const (
descRepositoryOwner = "Repository owner"
descRepositoryName = "Repository name"
errFailedToGetGQLClient = "failed to get GitHub GQL client: %v"
labelCategoryFormat = "category:%s"
)

type discussionNode struct {
Number githubv4.Int
Title githubv4.String
CreatedAt githubv4.DateTime
Category struct {
Name githubv4.String
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}

func mapDiscussionNodesToIssues(nodes []discussionNode) []*github.Issue {
discussions := make([]*github.Issue, 0, len(nodes))
for _, n := range 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(labelCategoryFormat, string(n.Category.Name))),
},
},
}
discussions = append(discussions, di)
}
return discussions
}

func fetchDiscussionsWithCategory(ctx context.Context, client *githubv4.Client, owner, repo string, categoryID githubv4.ID) ([]discussionNode, error) {
var query struct {
Repository struct {
Discussions struct {
Nodes []discussionNode
} `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 query.Repository.Discussions.Nodes, nil
}

func fetchDiscussionsWithoutCategory(ctx context.Context, client *githubv4.Client, owner, repo string) ([]discussionNode, error) {
var query struct {
Repository struct {
Discussions struct {
Nodes []discussionNode
} `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 query.Repository.Discussions.Nodes, nil
}

func validatePaginationParams(first, last int32, after, before string) error {
if first != 0 && last != 0 {
return fmt.Errorf("only one of 'first' or 'last' may be specified")
}
if after != "" && before != "" {
return fmt.Errorf("only one of 'after' or 'before' may be specified")
}
if after != "" && last != 0 {
return fmt.Errorf("'after' cannot be used with 'last'. Did you mean to use 'before' instead?")
}
if before != "" && first != 0 {
return fmt.Errorf("'before' cannot be used with 'first'. Did you mean to use 'after' instead?")
}
return 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")),
Expand All @@ -22,18 +111,17 @@ 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."),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Required params
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand All @@ -42,111 +130,27 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Optional params
category, err := OptionalParam[string](request, "category")
if 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
return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil
}

// If category filter is specified, use it as the category ID for server-side filtering
var categoryID *githubv4.ID
var nodes []discussionNode
if category != "" {
id := githubv4.ID(category)
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)
}
nodes, err = fetchDiscussionsWithCategory(ctx, client, owner, repo, githubv4.ID(category))
} 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
}

// 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)
}
nodes, err = fetchDiscussionsWithoutCategory(ctx, client, owner, repo)
}
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Marshal and return
discussions := mapDiscussionNodesToIssues(nodes)
out, err := json.Marshal(discussions)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
Expand All @@ -164,11 +168,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(),
Expand All @@ -187,7 +191,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 {
Expand Down Expand Up @@ -221,7 +225,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(labelCategoryFormat, string(d.Category.Name))),
},
},
}
Expand All @@ -241,8 +245,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) {
Expand All @@ -258,7 +262,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 {
Expand Down Expand Up @@ -303,11 +307,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)"),
Expand All @@ -327,7 +331,6 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Decode params
var params struct {
Owner string
Repo string
Expand All @@ -340,23 +343,13 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
return mcp.NewToolResultError(err.Error()), nil
}

// Validate pagination parameters
if params.First != 0 && params.Last != 0 {
return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil
}
if params.After != "" && params.Before != "" {
return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil
}
if params.After != "" && params.Last != 0 {
return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil
}
if params.Before != "" && params.First != 0 {
return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil
if err := validatePaginationParams(params.First, params.Last, params.After, params.Before); 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
return mcp.NewToolResultError(fmt.Sprintf(errFailedToGetGQLClient, err)), nil
}
var q struct {
Repository struct {
Expand Down