a939565160
* added updatedAt and Author (aka User) login to query and payload * added initial support for orderby and direction * sort by created at instead of updated at by default * remove unused code * refactor to map to most suitable query based on user inputs at runtime * updated readme with new description * restore original categoryID code, simplify vars management * quick fix * update tests to account for recent changes (author login, updated at date) * use switch statement for better readability * remove comment * linting * refactored logic, simplified switch statement * linting * use original queries from discussions list tool for testing * linting * remove logging * Complete merge by re-introducing pagination to ListDiscussions * fix unit tests * refactor: less repetitive interface --------- Co-authored-by: LuluBeatson <lulubeatson@github.com> Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com>
522 lines
17 KiB
Go
522 lines
17 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"github.com/google/go-github/v73/github"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
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")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
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) {
|
|
owner, err := RequiredParam[string](request, "owner")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
repo, err := RequiredParam[string](request, "repo")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
paginationParams, err := pagination.ToGraphQLParams()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client, err := getGQLClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
|
}
|
|
|
|
var categoryID *githubv4.ID
|
|
if category != "" {
|
|
id := githubv4.ID(category)
|
|
categoryID = &id
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if categoryID != nil {
|
|
vars["categoryId"] = *categoryID
|
|
}
|
|
|
|
discussionQuery := getQueryType(useOrdering, categoryID)
|
|
if err := client.Query(ctx, discussionQuery, vars); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_discussion",
|
|
mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithNumber("discussionNumber",
|
|
mcp.Required(),
|
|
mcp.Description("Discussion Number"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
// Decode params
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
DiscussionNumber int32
|
|
}
|
|
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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
|
|
}
|
|
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
Number githubv4.Int
|
|
Body githubv4.String
|
|
CreatedAt githubv4.DateTime
|
|
URL githubv4.String `graphql:"url"`
|
|
Category struct {
|
|
Name githubv4.String
|
|
} `graphql:"category"`
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]interface{}{
|
|
"owner": githubv4.String(params.Owner),
|
|
"repo": githubv4.String(params.Repo),
|
|
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
d := q.Repository.Discussion
|
|
discussion := &github.Discussion{
|
|
Number: github.Ptr(int(d.Number)),
|
|
Body: github.Ptr(string(d.Body)),
|
|
HTMLURL: github.Ptr(string(d.URL)),
|
|
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
|
|
DiscussionCategory: &github.DiscussionCategory{
|
|
Name: github.Ptr(string(d.Category.Name)),
|
|
},
|
|
}
|
|
out, err := json.Marshal(discussion)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal discussion: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(out)), nil
|
|
}
|
|
}
|
|
|
|
func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_discussion_comments",
|
|
mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
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.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
|
|
WithCursorPagination(),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
// Decode params
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
DiscussionNumber int32
|
|
}
|
|
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Get pagination parameters and convert to GraphQL format
|
|
pagination, err := OptionalCursorPaginationParams(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if pagination parameters were explicitly provided
|
|
_, perPageProvided := request.GetArguments()["perPage"]
|
|
paginationExplicit := perPageProvided
|
|
|
|
paginationParams, err := pagination.ToGraphQLParams()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Use default of 30 if pagination was not explicitly provided
|
|
if !paginationExplicit {
|
|
defaultFirst := int32(DefaultGraphQLPageSize)
|
|
paginationParams.First = &defaultFirst
|
|
}
|
|
|
|
client, err := getGQLClient(ctx)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
|
}
|
|
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
Comments struct {
|
|
Nodes []struct {
|
|
Body githubv4.String
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
TotalCount int
|
|
} `graphql:"comments(first: $first, after: $after)"`
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]interface{}{
|
|
"owner": githubv4.String(params.Owner),
|
|
"repo": githubv4.String(params.Repo),
|
|
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
|
"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, &q, vars); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var comments []*github.IssueComment
|
|
for _, c := range q.Repository.Discussion.Comments.Nodes {
|
|
comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
|
|
}
|
|
|
|
// Create response with pagination info
|
|
response := map[string]interface{}{
|
|
"comments": comments,
|
|
"pageInfo": map[string]interface{}{
|
|
"hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage,
|
|
"hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage,
|
|
"startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor),
|
|
"endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor),
|
|
},
|
|
"totalCount": q.Repository.Discussion.Comments.TotalCount,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal comments: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(out)), nil
|
|
}
|
|
}
|
|
|
|
func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("list_discussion_categories",
|
|
mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
|
|
ReadOnlyHint: ToBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
// Decode params
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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
|
|
}
|
|
|
|
var q struct {
|
|
Repository struct {
|
|
DiscussionCategories struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Name githubv4.String
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
TotalCount int
|
|
} `graphql:"discussionCategories(first: $first)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]interface{}{
|
|
"owner": githubv4.String(params.Owner),
|
|
"repo": githubv4.String(params.Repo),
|
|
"first": githubv4.Int(25),
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var categories []map[string]string
|
|
for _, c := range q.Repository.DiscussionCategories.Nodes {
|
|
categories = append(categories, map[string]string{
|
|
"id": fmt.Sprint(c.ID),
|
|
"name": string(c.Name),
|
|
})
|
|
}
|
|
|
|
// Create response with pagination info
|
|
response := map[string]interface{}{
|
|
"categories": categories,
|
|
"pageInfo": map[string]interface{}{
|
|
"hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage,
|
|
"hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage,
|
|
"startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor),
|
|
"endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor),
|
|
},
|
|
"totalCount": q.Repository.DiscussionCategories.TotalCount,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
|
|
}
|
|
return mcp.NewToolResultText(string(out)), nil
|
|
}
|
|
}
|