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
142 changes: 70 additions & 72 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ import (
"github.com/shurcooL/githubv4"
)

const (
descRepoOwner = "Repository owner"
descRepoName = "Repository name"
errGQLClientFmt = "failed to get GitHub GQL client: %v"
categoryLabelFmt = "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 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,11 +37,11 @@ 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."),
Expand All @@ -51,7 +66,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(errGQLClientFmt, err)), nil
}

// If category filter is specified, use it as the category ID for server-side filtering
Expand All @@ -63,20 +78,13 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp

// Now execute the discussions query
var discussions []*github.Issue
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"`
}
Nodes []discussionNode
} `graphql:"discussions(first: 100, categoryId: $categoryId)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
Expand All @@ -89,35 +97,13 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
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 = query.Repository.Discussions.Nodes
} 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"`
}
Nodes []discussionNode
} `graphql:"discussions(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
Expand All @@ -129,21 +115,12 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
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 = query.Repository.Discussions.Nodes
}

// Map nodes to GitHub Issues
for _, n := range nodes {
discussions = append(discussions, newIssueFromNode(n.Number, n.Title, n.CreatedAt, n.Category.Name, n.URL))
}

// Marshal and return
Expand All @@ -164,11 +141,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 +164,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(errGQLClientFmt, err)), nil
}

var q struct {
Expand Down Expand Up @@ -221,7 +198,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(categoryLabelFmt, string(d.Category.Name))),
},
},
}
Expand All @@ -241,8 +218,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 +235,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(errGQLClientFmt, err)), nil
}

var q struct {
Expand Down Expand Up @@ -303,11 +280,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 @@ -341,22 +318,13 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
}

// 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(errGQLClientFmt, err)), nil
}
var q struct {
Repository struct {
Expand Down Expand Up @@ -389,3 +357,33 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
return mcp.NewToolResultText(string(out)), 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 newIssueFromNode(number githubv4.Int, title githubv4.String, createdAt githubv4.DateTime, categoryName githubv4.String, url githubv4.String) *github.Issue {
return &github.Issue{
Number: github.Ptr(int(number)),
Title: github.Ptr(string(title)),
HTMLURL: github.Ptr(string(url)),
CreatedAt: &github.Timestamp{Time: createdAt.Time},
Labels: []*github.Label{
{
Name: github.Ptr(fmt.Sprintf(categoryLabelFmt, string(categoryName))),
},
},
}
}