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
198 changes: 93 additions & 105 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.

🔴 Pagination parameters in ListDiscussionCategories are validated but never used

The ListDiscussionCategories function accepts pagination parameters (first, last, after, before) in its tool definition and validates them, but the GraphQL query completely ignores these parameters and always fetches first: 100.

Root Cause

The function defines pagination parameters (lines 300-315), decodes them (lines 319-326), and validates them (lines 331-343), but the GraphQL query on line 356 hardcodes first: 100 and the vars map (lines 359-362) only includes owner and repo:

var q struct {
    Repository struct {
        DiscussionCategories struct {
            Nodes []struct {
                ID   githubv4.ID
                Name githubv4.String
            }
        } `graphql:"discussionCategories(first: 100)"`  // Hardcoded!
    } `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
    "owner": githubv4.String(params.Owner),
    "repo":  githubv4.String(params.Repo),
    // params.First, params.Last, params.After, params.Before are never used!
}

Impact: Users who try to paginate through discussion categories will find that their pagination parameters are silently ignored. The API always returns the first 100 categories regardless of what pagination parameters are provided. This is misleading and could cause issues for repositories with more than 100 discussion categories.

(Refers to line 356)

Prompt for agents
The ListDiscussionCategories function needs to be updated to actually use the pagination parameters (first, last, after, before) that are defined, decoded, and validated. The fix requires:

1. Modify the GraphQL query struct to use variables for pagination instead of hardcoding `first: 100`. The struct tag should be changed to something like `graphql:"discussionCategories(first: $first, after: $after)"` or use conditional query building.

2. Add the pagination parameters to the vars map:
   - If params.First is set, add it to vars
   - If params.Last is set, add it to vars
   - If params.After is set, add it to vars
   - If params.Before is set, add it to vars

3. Consider adding PageInfo to the response so users can actually use the pagination cursors.

Note: The githubv4 library may require different query structs for different pagination directions (first/after vs last/before), similar to how ListDiscussions handles the category filter with separate query functions.
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,79 @@ import (
"github.com/shurcooL/githubv4"
)

const (
descRepoOwner = "Repository owner"
descRepoName = "Repository name"
errFailedGetGQLClient = "failed to get GitHub GQL client: %v"
categoryLabelFormat = "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(categoryLabelFormat, 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 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 +95,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
mcp.Description(descRepoOwner),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
mcp.Description(descRepoName),
),
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 +114,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(errFailedGetGQLClient, 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 +152,11 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
mcp.Description(descRepoOwner),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
mcp.Description(descRepoName),
),
mcp.WithNumber("discussionNumber",
mcp.Required(),
Expand All @@ -187,7 +175,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(errFailedGetGQLClient, err)), nil
}

var q struct {
Expand Down Expand Up @@ -221,7 +209,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))),
},
},
}
Expand All @@ -241,8 +229,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(descRepoOwner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(descRepoName)),
mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
Expand All @@ -258,7 +246,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(errFailedGetGQLClient, err)), nil
}

var q struct {
Expand Down Expand Up @@ -303,11 +291,11 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
mcp.Description(descRepoOwner),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
mcp.Description(descRepoName),
),
mcp.WithNumber("first",
mcp.Description("Number of categories to return per page (min 1, max 100)"),
Expand Down Expand Up @@ -356,7 +344,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(errFailedGetGQLClient, err)), nil
}
var q struct {
Repository struct {
Expand Down