Skip to content

feat: list_discussions sort by updatedAt & createdAt, return updatedAt and author #690

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
687ee8f
added updatedAt and Author (aka User) login to query and payload
tommaso-moro Jul 14, 2025
dbbf724
added initial support for orderby and direction
tommaso-moro Jul 15, 2025
34a413d
sort by created at instead of updated at by default
tommaso-moro Jul 15, 2025
5d7230d
remove unused code
tommaso-moro Jul 15, 2025
c1afb88
refactor to map to most suitable query based on user inputs at runtime
tommaso-moro Jul 16, 2025
7eeb87c
updated readme with new description
tommaso-moro Jul 17, 2025
44f8f35
restore original categoryID code, simplify vars management
tommaso-moro Jul 17, 2025
bcb82c8
quick fix
tommaso-moro Jul 17, 2025
751dfa5
update tests to account for recent changes (author login, updated at …
tommaso-moro Jul 17, 2025
b064f7a
use switch statement for better readability
tommaso-moro Jul 17, 2025
6ab5137
remove comment
tommaso-moro Jul 17, 2025
25d39be
linting
tommaso-moro Jul 17, 2025
14dcf32
refactored logic, simplified switch statement
tommaso-moro Jul 17, 2025
49d0dea
linting
tommaso-moro Jul 17, 2025
eb78fe9
use original queries from discussions list tool for testing
tommaso-moro Jul 17, 2025
d499668
linting
tommaso-moro Jul 17, 2025
c9a0572
Merge branch 'main' into tommy/expand-discussions-tools
tommaso-moro Jul 17, 2025
de8583c
remove logging
tommaso-moro Jul 17, 2025
2783724
Merge branch 'tommy/expand-discussions-tools' of https://github.com/t…
tommaso-moro Jul 17, 2025
9f38405
Merge remote-tracking branch 'origin/main' into tommy/expand-discussi…
LuluBeatson Jul 21, 2025
9d1a665
Complete merge by re-introducing pagination to ListDiscussions
LuluBeatson Jul 21, 2025
6bea783
fix unit tests
LuluBeatson Jul 21, 2025
70dc034
Merge branch 'main' into tommy/expand-discussions-tools
LuluBeatson Jul 22, 2025
efaf9d0
refactor: less repetitive interface
LuluBeatson Jul 22, 2025
bdaef1b
Merge branch 'main' into tommy/expand-discussions-tools
LuluBeatson Jul 24, 2025
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@ The following sets of tools are available (all are on by default):
- **list_discussions** - List discussions
- `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
- `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional)
- `direction`: Order direction. (string, optional)
- `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional)
- `owner`: Repository owner (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
Expand Down
306 changes: 169 additions & 137 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,108 @@ import (

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")),
Expand All @@ -33,10 +135,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
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) {
// Required params
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand All @@ -46,12 +155,21 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
return mcp.NewToolResultError(err.Error()), nil
}

// Optional params
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 {
Expand All @@ -67,155 +185,69 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}

// If category filter is specified, use it as the category ID for server-side filtering
var categoryID *githubv4.ID
if category != "" {
id := githubv4.ID(category)
categoryID = &id
}

var out []byte

var discussions []*github.Discussion
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"`
}
PageInfo struct {
HasNextPage bool
HasPreviousPage bool
StartCursor string
EndCursor string
}
TotalCount int
} `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"categoryId": *categoryID,
"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, &query, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Map nodes to GitHub Discussion objects
for _, n := range query.Repository.Discussions.Nodes {
di := &github.Discussion{
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},
DiscussionCategory: &github.DiscussionCategory{
Name: github.Ptr(string(n.Category.Name)),
},
}
discussions = append(discussions, di)
}
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)
}

// Create response with pagination info
response := map[string]interface{}{
"discussions": discussions,
"pageInfo": map[string]interface{}{
"hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage,
"hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage,
"startCursor": query.Repository.Discussions.PageInfo.StartCursor,
"endCursor": query.Repository.Discussions.PageInfo.EndCursor,
},
"totalCount": query.Repository.Discussions.TotalCount,
}
// 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)
}

out, err = json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
}
} 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"`
}
PageInfo struct {
HasNextPage bool
HasPreviousPage bool
StartCursor string
EndCursor string
}
TotalCount int
} `graphql:"discussions(first: $first, after: $after)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
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)
}
if err := client.Query(ctx, &query, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if categoryID != nil {
vars["categoryId"] = *categoryID
}

// Map nodes to GitHub Discussion objects
for _, n := range query.Repository.Discussions.Nodes {
di := &github.Discussion{
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},
DiscussionCategory: &github.DiscussionCategory{
Name: github.Ptr(string(n.Category.Name)),
},
}
discussions = append(discussions, di)
}
discussionQuery := getQueryType(useOrdering, categoryID)
if err := client.Query(ctx, discussionQuery, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Create response with pagination info
response := map[string]interface{}{
"discussions": discussions,
"pageInfo": map[string]interface{}{
"hasNextPage": query.Repository.Discussions.PageInfo.HasNextPage,
"hasPreviousPage": query.Repository.Discussions.PageInfo.HasPreviousPage,
"startCursor": query.Repository.Discussions.PageInfo.StartCursor,
"endCursor": query.Repository.Discussions.PageInfo.EndCursor,
},
"totalCount": query.Repository.Discussions.TotalCount,
// 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
}

out, err = json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
}
// 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
}
}
Expand Down
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy