From e110366fb3bcec9f2db4e958790ca340b7ba1e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Sat, 31 May 2025 10:12:53 -0700 Subject: [PATCH 01/15] feat: initial support for discussions - Minimal --- README.md | 28 +++ pkg/github/discussions.go | 340 ++++++++++++++++++++++++++++ pkg/github/discussions_test.go | 392 +++++++++++++++++++++++++++++++++ pkg/github/tools.go | 10 + script/get-discussions | 5 + 5 files changed, 775 insertions(+) create mode 100644 pkg/github/discussions.go create mode 100644 pkg/github/discussions_test.go create mode 100755 script/get-discussions diff --git a/README.md b/README.md index 7b9e20fc3..4b7c15888 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ The following sets of tools are available (all are on by default): | ----------------------- | ------------------------------------------------------------- | | `repos` | Repository-related tools (file operations, branches, commits) | | `issues` | Issue-related tools (create, read, update, comment) | +| `discussions` | GitHub Discussions tools (list, get, comments) | | `users` | Anything relating to GitHub Users | | `pull_requests` | Pull request operations (create, merge, review) | | `code_security` | Code scanning alerts and security features | @@ -581,6 +582,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional) - `resolution`: The resolution status (string, optional) +<<<<<<< HEAD ### Notifications - **list_notifications** – List notifications for a GitHub user @@ -614,6 +616,32 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: The name of the repository (string, required) - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required) +======= +>>>>>>> 8d6b84c (doc: add support for GitHub discussions, with an example.) +### Discussions + +> [!NOTE] +> As there is no support for discussions in the native GitHub go library, this toolset is deliberately limited to a few basic functions. The plan is to first implement native support in the GitHub go library, then use it for a better and consistent support in the MCP server. + +- **list_discussions** - List discussions for a repository + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: State filter (open, closed, all) (string, optional) + - `labels`: Filter by label names (string[], optional) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_discussion** - Get a specific discussion by ID + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `discussion_id`: Discussion ID (number, required) + +- **get_discussion_comments** - Get comments from a discussion + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `discussion_id`: Discussion ID (number, required) + ## Resources ### Repository Content diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go new file mode 100644 index 000000000..55ec32dee --- /dev/null +++ b/pkg/github/discussions.go @@ -0,0 +1,340 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +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("categoryId", + mcp.Description("Category ID filter"), + ), + mcp.WithString("since", + mcp.Description("Filter by date (ISO 8601 timestamp)"), + ), + mcp.WithString("sort", + mcp.Description("Sort field"), + mcp.DefaultString("CREATED_AT"), + mcp.Enum("CREATED_AT", "UPDATED_AT"), + ), + mcp.WithString("direction", + mcp.Description("Sort direction"), + mcp.DefaultString("DESC"), + mcp.Enum("ASC", "DESC"), + ), + mcp.WithNumber("first", + mcp.Description("Number of discussions to return per page (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), + mcp.WithString("after", + mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + CategoryId string + Since string + Sort string + Direction string + First int32 + After string + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Get GraphQL client + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + // Prepare GraphQL query + var q 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(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + // Build query variables + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "categoryId": githubv4.ID(params.CategoryId), + "sort": githubv4.DiscussionOrderField(params.Sort), + "direction": githubv4.OrderDirection(params.Direction), + "first": githubv4.Int(params.First), + "after": githubv4.String(params.After), + } + // Execute query + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Map nodes to GitHub Issue objects - there is no discussion type in the GitHub API, so we use Issue to benefit from existing code + var discussions []*github.Issue + for _, n := range q.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}, + } + discussions = append(discussions, di) + } + + // Post filtering discussions based on 'since' parameter + if params.Since != "" { + sinceTime, err := time.Parse(time.RFC3339, params.Since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid 'since' timestamp: %v", err)), nil + } + var filteredDiscussions []*github.Issue + for _, d := range discussions { + if d.CreatedAt.Time.After(sinceTime) { + filteredDiscussions = append(filteredDiscussions, d) + } + } + discussions = filteredDiscussions + } + + // Marshal and return + out, err := json.Marshal(discussions) + 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("discussion_id", + mcp.Required(), + mcp.Description("Discussion ID"), + ), + ), + 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 + } + discussionID, err := RequiredInt(request, "discussion_id") + 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 + } + + var q struct { + Repository struct { + Discussion struct { + Number githubv4.Int + Body githubv4.String + State githubv4.String + CreatedAt githubv4.DateTime + URL githubv4.String `graphql:"url"` + } `graphql:"discussion(number: $discussionID)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionID": githubv4.Int(discussionID), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + d := q.Repository.Discussion + discussion := &github.Issue{ + Number: github.Ptr(int(d.Number)), + Body: github.Ptr(string(d.Body)), + State: github.Ptr(string(d.State)), + HTMLURL: github.Ptr(string(d.URL)), + CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, + } + 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("discussion_id", mcp.Required(), mcp.Description("Discussion ID")), + ), + 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 + } + discussionID, err := RequiredInt(request, "discussion_id") + 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 + } + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + } `graphql:"comments(first:100)"` + } `graphql:"discussion(number: $discussionID)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionID": githubv4.Int(discussionID), + } + 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))}) + } + + out, err := json.Marshal(comments) + 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 categorie 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) { + 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 + } + 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 + } + } `graphql:"discussionCategories(first: 30)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + 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), + }) + } + out, err := json.Marshal(categories) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + } + return mcp.NewToolResultText(string(out)), nil + } +} diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go new file mode 100644 index 000000000..8cd95a6f6 --- /dev/null +++ b/pkg/github/discussions_test.go @@ -0,0 +1,392 @@ +package github + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + discussionsAll = []map[string]any{ + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "category": map[string]any{"name": "news"}, "url": "https://github.com/owner/repo/discussions/1"}, + {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "category": map[string]any{"name": "updates"}, "url": "https://github.com/owner/repo/discussions/2"}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "category": map[string]any{"name": "questions"}, "url": "https://github.com/owner/repo/discussions/3"}, + } + mockResponseListAll = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{"nodes": discussionsAll}, + }, + }) + mockResponseCategory = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{"nodes": discussionsAll[:1]}, // Only return the first discussion for category test + }, + }) + mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") +) + +func Test_ListDiscussions(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := ListDiscussions(nil, translations.NullTranslationHelper) + assert.Equal(t, "list_discussions", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) + + // GraphQL query struct + var q 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(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + varsListAll := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "categoryId": githubv4.ID(""), + "sort": githubv4.DiscussionOrderField(""), + "direction": githubv4.OrderDirection(""), + "first": githubv4.Int(0), + "after": githubv4.String(""), + } + + varsListInvalid := map[string]interface{}{ + "owner": githubv4.String("invalid"), + "repo": githubv4.String("repo"), + "categoryId": githubv4.ID(""), + "sort": githubv4.DiscussionOrderField(""), + "direction": githubv4.OrderDirection(""), + "first": githubv4.Int(0), + "after": githubv4.String(""), + } + + varsListWithCategory := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "categoryId": githubv4.ID("12345"), + "sort": githubv4.DiscussionOrderField(""), + "direction": githubv4.OrderDirection(""), + "first": githubv4.Int(0), + "after": githubv4.String(""), + } + + tests := []struct { + name string + vars map[string]interface{} + reqParams map[string]interface{} + response githubv4mock.GQLResponse + expectError bool + expectedIds []int64 + errContains string + }{ + { + name: "list all discussions", + vars: varsListAll, + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + response: mockResponseListAll, + expectError: false, + expectedIds: []int64{1, 2, 3}, + }, + { + name: "invalid owner or repo", + vars: varsListInvalid, + reqParams: map[string]interface{}{ + "owner": "invalid", + "repo": "repo", + }, + response: mockErrorRepoNotFound, + expectError: true, + errContains: "repository not found", + }, + { + name: "list discussions with category", + vars: varsListWithCategory, + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "12345", + }, + response: mockResponseCategory, + expectError: false, + expectedIds: []int64{1}, + }, + { + name: "list discussions with since date", + vars: varsListAll, + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "since": "2023-01-10T00:00:00Z", + }, + response: mockResponseListAll, + expectError: false, + expectedIds: []int64{2, 3}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + var returnedDiscussions []*github.Issue + err = json.Unmarshal([]byte(text), &returnedDiscussions) + require.NoError(t, err) + + assert.Len(t, returnedDiscussions, len(tc.expectedIds), "Expected %d discussions, got %d", len(tc.expectedIds), len(returnedDiscussions)) + + // If no discussions are expected, skip further checks + if len(tc.expectedIds) == 0 { + return + } + + // Create a map of expected IDs for easier checking + expectedIDMap := make(map[int64]bool) + for _, id := range tc.expectedIds { + expectedIDMap[id] = true + } + + for _, discussion := range returnedDiscussions { + // Check if the discussion Number is in the expected list + assert.True(t, expectedIDMap[int64(*discussion.Number)], "Unexpected discussion Number: %d", *discussion.Number) + } + }) + } +} + +func Test_GetDiscussion(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussion_id") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"}) + + var q struct { + Repository struct { + Discussion struct { + Number githubv4.Int + Body githubv4.String + State githubv4.String + CreatedAt githubv4.DateTime + URL githubv4.String `graphql:"url"` + } `graphql:"discussion(number: $discussionID)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionID": githubv4.Int(1), + } + tests := []struct { + name string + response githubv4mock.GQLResponse + expectError bool + expected *github.Issue + errContains string + }{ + { + name: "successful retrieval", + response: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "body": "This is a test discussion", + "state": "open", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + }}, + }), + expectError: false, + expected: &github.Issue{ + HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), + Number: github.Ptr(1), + Body: github.Ptr("This is a test discussion"), + State: github.Ptr("open"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, + }, + }, + { + name: "discussion not found", + response: githubv4mock.ErrorResponse("discussion not found"), + expectError: true, + errContains: "discussion not found", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussion_id": float64(1)}) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + require.NoError(t, err) + var out github.Issue + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) + assert.Equal(t, *tc.expected.Number, *out.Number) + assert.Equal(t, *tc.expected.Body, *out.Body) + assert.Equal(t, *tc.expected.State, *out.State) + }) + } +} + +func Test_GetDiscussionComments(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion_comments", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussion_id") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"}) + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + } `graphql:"comments(first:100)"` + } `graphql:"discussion(number: $discussionID)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionID": githubv4.Int(1), + } + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + {"body": "This is the first comment"}, + {"body": "This is the second comment"}, + }, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussion_id": float64(1), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + var returnedComments []*github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Len(t, returnedComments, 2) + expectedBodies := []string{"This is the first comment", "This is the second comment"} + for i, comment := range returnedComments { + assert.Equal(t, expectedBodies[i], *comment.Body) + } +} + +func Test_ListDiscussionCategories(t *testing.T) { + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"discussionCategories(first: 30)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + } + mockResp := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + + tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussion_categories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"}) + result, err := handler(context.Background(), request) + require.NoError(t, err) + + text := getTextResult(t, result).Text + var categories []map[string]string + require.NoError(t, json.Unmarshal([]byte(text), &categories)) + assert.Len(t, categories, 2) + assert.Equal(t, "123", categories[0]["id"]) + assert.Equal(t, "CategoryOne", categories[0]["name"]) + assert.Equal(t, "456", categories[1]["id"]) + assert.Equal(t, "CategoryTwo", categories[1]["name"]) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 9c1ab34af..7a5494dc8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -104,6 +104,14 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), ) + discussions := toolsets.NewToolset("discussions", "GitHub Discussions related tools"). + AddReadTools( + toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), + toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + ) + // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -116,6 +124,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, tsg.AddToolset(secretProtection) tsg.AddToolset(notifications) tsg.AddToolset(experiments) + tsg.AddToolset(discussions) + // Enable the requested features if err := tsg.EnableToolsets(passedToolsets); err != nil { diff --git a/script/get-discussions b/script/get-discussions new file mode 100755 index 000000000..3e68abf24 --- /dev/null +++ b/script/get-discussions @@ -0,0 +1,5 @@ +#!/bin/bash + +# echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . +echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z", "sort": "CREATED_AT", "direction": "DESC"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . + From 6de3210ceb1217df873c224bc0c6e1a1f3a9a6cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Sat, 31 May 2025 09:34:54 -0700 Subject: [PATCH 02/15] Add documentation --- README.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4b7c15888..19af3d960 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ The following sets of tools are available (all are on by default): | ----------------------- | ------------------------------------------------------------- | | `repos` | Repository-related tools (file operations, branches, commits) | | `issues` | Issue-related tools (create, read, update, comment) | -| `discussions` | GitHub Discussions tools (list, get, comments) | +| `discussions` | GitHub Discussions tools (list, get, comments, categories) | | `users` | Anything relating to GitHub Users | | `pull_requests` | Pull request operations (create, merge, review) | | `code_security` | Code scanning alerts and security features | @@ -582,7 +582,6 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional) - `resolution`: The resolution status (string, optional) -<<<<<<< HEAD ### Notifications - **list_notifications** – List notifications for a GitHub user @@ -616,21 +615,17 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: The name of the repository (string, required) - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required) -======= ->>>>>>> 8d6b84c (doc: add support for GitHub discussions, with an example.) ### Discussions -> [!NOTE] -> As there is no support for discussions in the native GitHub go library, this toolset is deliberately limited to a few basic functions. The plan is to first implement native support in the GitHub go library, then use it for a better and consistent support in the MCP server. - - **list_discussions** - List discussions for a repository - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `state`: State filter (open, closed, all) (string, optional) - - `labels`: Filter by label names (string[], optional) + - `categoryId`: Filter by category ID (string, optional) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) + - `first`: Pagination - Number of records to retrieve (number, optional) + - `after`: Pagination - Cursor to start with (string, optional) + - `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional) + - `direction`: Sort direction ('ASC', 'DESC') (string, optional) - **get_discussion** - Get a specific discussion by ID - `owner`: Repository owner (string, required) @@ -642,6 +637,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `discussion_id`: Discussion ID (number, required) +- **list_discussion_categories** - List discussion categories for a repository, with their IDs and names + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + ## Resources ### Repository Content From 5a4838957e99d5d5c7bf0ac5c08e60d6038924c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Sat, 31 May 2025 11:40:06 -0700 Subject: [PATCH 03/15] Fix linting issues and improve naming consistency --- pkg/github/discussions.go | 59 +++++++++++++++------------------- pkg/github/discussions_test.go | 32 +++++++++--------- 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 55ec32dee..3a1d9aa5c 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -59,7 +59,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp var params struct { Owner string Repo string - CategoryId string + CategoryID string Since string Sort string Direction string @@ -94,7 +94,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp vars := map[string]interface{}{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), - "categoryId": githubv4.ID(params.CategoryId), + "categoryId": githubv4.ID(params.CategoryID), "sort": githubv4.DiscussionOrderField(params.Sort), "direction": githubv4.OrderDirection(params.Direction), "first": githubv4.Int(params.First), @@ -155,25 +155,21 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithNumber("discussion_id", + mcp.WithNumber("discussionNumber", mcp.Required(), - mcp.Description("Discussion ID"), + mcp.Description("Discussion Number"), ), ), 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 + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 } - discussionID, err := RequiredInt(request, "discussion_id") - if err != nil { + 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 @@ -187,13 +183,13 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper State githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` - } `graphql:"discussion(number: $discussionID)"` + } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "discussionID": githubv4.Int(discussionID), + "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 @@ -224,19 +220,16 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), - mcp.WithNumber("discussion_id", mcp.Required(), mcp.Description("Discussion ID")), + mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), ), 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 + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 } - discussionID, err := RequiredInt(request, "discussion_id") - if err != nil { + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -253,13 +246,13 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati Body githubv4.String } } `graphql:"comments(first:100)"` - } `graphql:"discussion(number: $discussionID)"` + } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "discussionID": githubv4.Int(discussionID), + "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 @@ -280,7 +273,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati 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 categorie with their id and name, for a repository")), + 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), diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 8cd95a6f6..4142e790a 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -196,8 +196,8 @@ func Test_GetDiscussion(t *testing.T) { assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussion_id") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"}) + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) var q struct { Repository struct { @@ -207,13 +207,13 @@ func Test_GetDiscussion(t *testing.T) { State githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` - } `graphql:"discussion(number: $discussionID)"` + } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "discussionID": githubv4.Int(1), + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), } tests := []struct { name string @@ -256,7 +256,7 @@ func Test_GetDiscussion(t *testing.T) { gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussion_id": float64(1)}) + req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) res, err := handler(context.Background(), req) text := getTextResult(t, res).Text @@ -284,8 +284,8 @@ func Test_GetDiscussionComments(t *testing.T) { assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussion_id") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"}) + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) var q struct { Repository struct { @@ -295,13 +295,13 @@ func Test_GetDiscussionComments(t *testing.T) { Body githubv4.String } } `graphql:"comments(first:100)"` - } `graphql:"discussion(number: $discussionID)"` + } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "discussionID": githubv4.Int(1), + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), } mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ @@ -321,9 +321,9 @@ func Test_GetDiscussionComments(t *testing.T) { _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) request := createMCPRequest(map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "discussion_id": float64(1), + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), }) result, err := handler(context.Background(), request) From 647d0b3728409b05e3bb99a2acdb72b6eec75950 Mon Sep 17 00:00:00 2001 From: Xavier RENE-CORAIL Date: Sat, 31 May 2025 11:49:42 -0700 Subject: [PATCH 04/15] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 19af3d960..97f94448a 100644 --- a/README.md +++ b/README.md @@ -630,7 +630,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **get_discussion** - Get a specific discussion by ID - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `discussion_id`: Discussion ID (number, required) + - `discussionNumber`: Discussion number (required) - **get_discussion_comments** - Get comments from a discussion - `owner`: Repository owner (string, required) From 2f90a60df8c24c75e72a0e9e91c64c0302da7156 Mon Sep 17 00:00:00 2001 From: Xavier RENE-CORAIL Date: Sat, 31 May 2025 11:49:48 -0700 Subject: [PATCH 05/15] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 97f94448a..b2943fa8e 100644 --- a/README.md +++ b/README.md @@ -635,7 +635,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **get_discussion_comments** - Get comments from a discussion - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `discussion_id`: Discussion ID (number, required) + - `discussionNumber`: Discussion number (required) - **list_discussion_categories** - List discussion categories for a repository, with their IDs and names - `owner`: Repository owner (string, required) From e10d76a8b438e999835afcc10c6c0ddcd7a46391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Wed, 4 Jun 2025 10:17:34 -0700 Subject: [PATCH 06/15] Add missing pagination parameters to ListDiscussions --- README.md | 2 ++ pkg/github/discussions.go | 26 ++++++++++++++- pkg/github/discussions_test.go | 60 +++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b2943fa8e..ac4ddd805 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,9 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `categoryId`: Filter by category ID (string, optional) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `first`: Pagination - Number of records to retrieve (number, optional) + - `last`: Pagination - Number of records to retrieve from the end (number, optional) - `after`: Pagination - Cursor to start with (string, optional) + - `before`: Pagination - Cursor to end with (string, optional) - `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional) - `direction`: Sort direction ('ASC', 'DESC') (string, optional) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 3a1d9aa5c..b86b6722e 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -50,9 +50,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.Min(1), mcp.Max(100), ), + mcp.WithNumber("last", + mcp.Description("Number of discussions to return from the end (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), mcp.WithString("after", mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), ), + mcp.WithString("before", + mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params @@ -64,11 +72,25 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp Sort string Direction string First int32 + Last int32 After string + Before string } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil } + 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 + } // Get GraphQL client client, err := getGQLClient(ctx) if err != nil { @@ -87,7 +109,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"` + } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"` } `graphql:"repository(owner: $owner, name: $repo)"` } // Build query variables @@ -98,7 +120,9 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp "sort": githubv4.DiscussionOrderField(params.Sort), "direction": githubv4.OrderDirection(params.Direction), "first": githubv4.Int(params.First), + "last": githubv4.Int(params.Last), "after": githubv4.String(params.After), + "before": githubv4.String(params.Before), } // Execute query if err := client.Query(ctx, &q, vars); err != nil { diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 4142e790a..a24ad7966 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -55,7 +55,7 @@ func Test_ListDiscussions(t *testing.T) { } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"` + } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"` } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -66,7 +66,9 @@ func Test_ListDiscussions(t *testing.T) { "sort": githubv4.DiscussionOrderField(""), "direction": githubv4.OrderDirection(""), "first": githubv4.Int(0), + "last": githubv4.Int(0), "after": githubv4.String(""), + "before": githubv4.String(""), } varsListInvalid := map[string]interface{}{ @@ -76,7 +78,9 @@ func Test_ListDiscussions(t *testing.T) { "sort": githubv4.DiscussionOrderField(""), "direction": githubv4.OrderDirection(""), "first": githubv4.Int(0), + "last": githubv4.Int(0), "after": githubv4.String(""), + "before": githubv4.String(""), } varsListWithCategory := map[string]interface{}{ @@ -86,7 +90,9 @@ func Test_ListDiscussions(t *testing.T) { "sort": githubv4.DiscussionOrderField(""), "direction": githubv4.OrderDirection(""), "first": githubv4.Int(0), + "last": githubv4.Int(0), "after": githubv4.String(""), + "before": githubv4.String(""), } tests := []struct { @@ -144,6 +150,58 @@ func Test_ListDiscussions(t *testing.T) { expectError: false, expectedIds: []int64{2, 3}, }, + { + name: "both first and last parameters provided", + vars: varsListAll, // vars don't matter since error occurs before GraphQL call + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "first": int32(10), + "last": int32(5), + }, + response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call + expectError: true, + errContains: "only one of 'first' or 'last' may be specified", + }, + { + name: "after with last parameters provided", + vars: varsListAll, // vars don't matter since error occurs before GraphQL call + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "after": "cursor123", + "last": int32(5), + }, + response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call + expectError: true, + errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?", + }, + { + name: "before with first parameters provided", + vars: varsListAll, // vars don't matter since error occurs before GraphQL call + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "before": "cursor456", + "first": int32(10), + }, + response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call + expectError: true, + errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?", + }, + { + name: "both after and before parameters provided", + vars: varsListAll, // vars don't matter since error occurs before GraphQL call + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "after": "cursor123", + "before": "cursor456", + }, + response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call + expectError: true, + errContains: "only one of 'after' or 'before' may be specified", + }, } for _, tc := range tests { From bc5abbd0c4ef91393e5ce58f155a526b80be4736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Wed, 4 Jun 2025 13:02:53 -0700 Subject: [PATCH 07/15] Add answered parameter to ListDiscussions --- README.md | 1 + pkg/github/discussions.go | 7 ++++++- pkg/github/discussions_test.go | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ac4ddd805..e3a14f6de 100644 --- a/README.md +++ b/README.md @@ -628,6 +628,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `before`: Pagination - Cursor to end with (string, optional) - `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional) - `direction`: Sort direction ('ASC', 'DESC') (string, optional) + - `answered`: Filter by whether discussions have been answered or not (boolean, optional) - **get_discussion** - Get a specific discussion by ID - `owner`: Repository owner (string, required) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index b86b6722e..eb48e911d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -61,6 +61,9 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.WithString("before", mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), ), + mcp.WithBoolean("answered", + mcp.Description("Filter by whether discussions have been answered or not"), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params @@ -75,6 +78,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp Last int32 After string Before string + Answered bool } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -109,7 +113,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"` + } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"` } `graphql:"repository(owner: $owner, name: $repo)"` } // Build query variables @@ -123,6 +127,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp "last": githubv4.Int(params.Last), "after": githubv4.String(params.After), "before": githubv4.String(params.Before), + "answered": githubv4.Boolean(params.Answered), } // Execute query if err := client.Query(ctx, &q, vars); err != nil { diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index a24ad7966..7d06eb713 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -55,7 +55,7 @@ func Test_ListDiscussions(t *testing.T) { } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"` + } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"` } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -69,6 +69,7 @@ func Test_ListDiscussions(t *testing.T) { "last": githubv4.Int(0), "after": githubv4.String(""), "before": githubv4.String(""), + "answered": githubv4.Boolean(false), } varsListInvalid := map[string]interface{}{ @@ -81,6 +82,7 @@ func Test_ListDiscussions(t *testing.T) { "last": githubv4.Int(0), "after": githubv4.String(""), "before": githubv4.String(""), + "answered": githubv4.Boolean(false), } varsListWithCategory := map[string]interface{}{ @@ -93,6 +95,7 @@ func Test_ListDiscussions(t *testing.T) { "last": githubv4.Int(0), "after": githubv4.String(""), "before": githubv4.String(""), + "answered": githubv4.Boolean(false), } tests := []struct { From ae5f1b22e0421ea80c92899af31a40a84119779f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Wed, 4 Jun 2025 16:28:40 -0700 Subject: [PATCH 08/15] Implement pagination for ListDiscussionCategories --- README.md | 4 +++ pkg/github/discussions.go | 55 +++++++++++++++++++++++++++++----- pkg/github/discussions_test.go | 10 +++++-- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e3a14f6de..7a00c5f80 100644 --- a/README.md +++ b/README.md @@ -643,6 +643,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **list_discussion_categories** - List discussion categories for a repository, with their IDs and names - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `first`: Pagination - Number of categories to return per page (number, optional, min 1, max 100) + - `last`: Pagination - Number of categories to return from the end (number, optional, min 1, max 100) + - `after`: Pagination - Cursor to start with (string, optional) + - `before`: Pagination - Cursor to end with (string, optional) ## Resources diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index eb48e911d..a6ffb35e0 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -315,16 +315,51 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl mcp.Required(), mcp.Description("Repository name"), ), + mcp.WithNumber("first", + mcp.Description("Number of categories to return per page (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), + mcp.WithNumber("last", + mcp.Description("Number of categories to return from the end (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), + mcp.WithString("after", + mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), + ), + mcp.WithString("before", + mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), + ), ), 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 + // Decode params + var params struct { + Owner string + Repo string + First int32 + Last int32 + After string + Before string } - repo, err := requiredParam[string](request, "repo") - if err != nil { + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { 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 + } + client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil @@ -336,12 +371,16 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl ID githubv4.ID Name githubv4.String } - } `graphql:"discussionCategories(first: 30)"` + } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "first": githubv4.Int(params.First), + "last": githubv4.Int(params.Last), + "after": githubv4.String(params.After), + "before": githubv4.String(params.Before), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 7d06eb713..18fdce6ee 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -410,12 +410,16 @@ func Test_ListDiscussionCategories(t *testing.T) { ID githubv4.ID Name githubv4.String } - } `graphql:"discussionCategories(first: 30)"` + } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "first": githubv4.Int(0), + "last": githubv4.Int(0), + "after": githubv4.String(""), + "before": githubv4.String(""), } mockResp := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ From 58cd74dfaaa7b028ced0470b260715809a3cf434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Thu, 5 Jun 2025 09:12:15 -0700 Subject: [PATCH 09/15] Make ListDiscussions more user-friendly by using category name filter, not id --- README.md | 2 +- pkg/github/discussions.go | 86 ++++++++++++++++++++++++++++------ pkg/github/discussions_test.go | 73 +++++++++++++++++++++++++---- 3 files changed, 136 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7a00c5f80..e549ea252 100644 --- a/README.md +++ b/README.md @@ -620,7 +620,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **list_discussions** - List discussions for a repository - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `categoryId`: Filter by category ID (string, optional) + - `category`: Filter by category name (string, optional) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `first`: Pagination - Number of records to retrieve (number, optional) - `last`: Pagination - Number of records to retrieve from the end (number, optional) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index a6ffb35e0..9a9b9b52b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -14,6 +14,56 @@ import ( "github.com/shurcooL/githubv4" ) +// GetAllDiscussionCategories retrieves all discussion categories for a repository +// by paginating through all pages and returns them as a map where the key is the +// category name and the value is the category ID. +func GetAllDiscussionCategories(ctx context.Context, client *githubv4.Client, owner, repo string) (map[string]string, error) { + categories := make(map[string]string) + var after string + hasNextPage := true + + for hasNextPage { + // Prepare GraphQL query with pagination + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"discussionCategories(first: 100, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "after": githubv4.String(after), + } + + if err := client.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to query discussion categories: %w", err) + } + + // Add categories to the map + for _, category := range q.Repository.DiscussionCategories.Nodes { + categories[string(category.Name)] = fmt.Sprint(category.ID) + } + + // Check if there are more pages + hasNextPage = bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage) + if hasNextPage { + after = string(q.Repository.DiscussionCategories.PageInfo.EndCursor) + } + } + + return categories, 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")), @@ -29,8 +79,8 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithString("categoryId", - mcp.Description("Category ID filter"), + mcp.WithString("category", + mcp.Description("Category filter (name)"), ), mcp.WithString("since", mcp.Description("Filter by date (ISO 8601 timestamp)"), @@ -68,17 +118,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params var params struct { - Owner string - Repo string - CategoryID string - Since string - Sort string - Direction string - First int32 - Last int32 - After string - Before string - Answered bool + Owner string + Repo string + Category string + Since string + Sort string + Direction string + First int32 + Last int32 + After string + Before string + Answered bool } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -116,11 +166,19 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"` } `graphql:"repository(owner: $owner, name: $repo)"` } + categories, err := GetAllDiscussionCategories(ctx, client, params.Owner, params.Repo) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get discussion categories: %v", err)), nil + } + var categoryID githubv4.ID = categories[params.Category] + if categoryID == "" && params.Category != "" { + return mcp.NewToolResultError(fmt.Sprintf("category '%s' not found", params.Category)), nil + } // Build query variables vars := map[string]interface{}{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), - "categoryId": githubv4.ID(params.CategoryID), + "categoryId": categoryID, "sort": githubv4.DiscussionOrderField(params.Sort), "direction": githubv4.OrderDirection(params.Direction), "first": githubv4.Int(params.First), diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 18fdce6ee..e8e6b675e 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -42,7 +42,48 @@ func Test_ListDiscussions(t *testing.T) { assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) - // GraphQL query struct + // mock for the call to list all categories: query struct, variables, response + var q_cat struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"discussionCategories(first: 100, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars_cat := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "after": githubv4.String(""), + } + + vars_cat_invalid := map[string]interface{}{ + "owner": githubv4.String("invalid"), + "repo": githubv4.String("repo"), + "after": githubv4.String(""), + } + + mockResp_cat := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + }, + }, + }) + + mockResp_cat_invalid := githubv4mock.ErrorResponse("repository not found") + + // mock for the call to ListDiscussions: query struct, variables, response var q struct { Repository struct { Discussions struct { @@ -88,7 +129,7 @@ func Test_ListDiscussions(t *testing.T) { varsListWithCategory := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), - "categoryId": githubv4.ID("12345"), + "categoryId": githubv4.ID("123"), "sort": githubv4.DiscussionOrderField(""), "direction": githubv4.OrderDirection(""), "first": githubv4.Int(0), @@ -98,6 +139,9 @@ func Test_ListDiscussions(t *testing.T) { "answered": githubv4.Boolean(false), } + catMatcher := githubv4mock.NewQueryMatcher(q_cat, vars_cat, mockResp_cat) + catMatcherInvalid := githubv4mock.NewQueryMatcher(q_cat, vars_cat_invalid, mockResp_cat_invalid) + tests := []struct { name string vars map[string]interface{} @@ -106,6 +150,7 @@ func Test_ListDiscussions(t *testing.T) { expectError bool expectedIds []int64 errContains string + catMatcher githubv4mock.Matcher }{ { name: "list all discussions", @@ -117,6 +162,7 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, expectError: false, expectedIds: []int64{1, 2, 3}, + catMatcher: catMatcher, }, { name: "invalid owner or repo", @@ -128,18 +174,20 @@ func Test_ListDiscussions(t *testing.T) { response: mockErrorRepoNotFound, expectError: true, errContains: "repository not found", + catMatcher: catMatcherInvalid, }, { name: "list discussions with category", vars: varsListWithCategory, reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "categoryId": "12345", + "owner": "owner", + "repo": "repo", + "category": "CategoryOne", // This should match the ID "123" in the mock response }, response: mockResponseCategory, expectError: false, expectedIds: []int64{1}, + catMatcher: catMatcher, }, { name: "list discussions with since date", @@ -152,6 +200,7 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, expectError: false, expectedIds: []int64{2, 3}, + catMatcher: catMatcher, }, { name: "both first and last parameters provided", @@ -165,6 +214,7 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call expectError: true, errContains: "only one of 'first' or 'last' may be specified", + catMatcher: catMatcher, }, { name: "after with last parameters provided", @@ -178,6 +228,7 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call expectError: true, errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?", + catMatcher: catMatcher, }, { name: "before with first parameters provided", @@ -191,6 +242,7 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call expectError: true, errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?", + catMatcher: catMatcher, }, { name: "both after and before parameters provided", @@ -204,13 +256,14 @@ func Test_ListDiscussions(t *testing.T) { response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call expectError: true, errContains: "only one of 'after' or 'before' may be specified", + catMatcher: catMatcher, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response) - httpClient := githubv4mock.NewMockedHTTPClient(matcher) + httpClient := githubv4mock.NewMockedHTTPClient(matcher, tc.catMatcher) gqlClient := githubv4.NewClient(httpClient) _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) @@ -416,10 +469,10 @@ func Test_ListDiscussionCategories(t *testing.T) { vars := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), - "first": githubv4.Int(0), - "last": githubv4.Int(0), - "after": githubv4.String(""), - "before": githubv4.String(""), + "first": githubv4.Int(0), // Default to 100 categories + "last": githubv4.Int(0), // Not used, but required by schema + "after": githubv4.String(""), // Not used, but required by schema + "before": githubv4.String(""), // Not used, but required by schema } mockResp := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ From a77b2f4bcb2bc2b0e2928afe58f4cf888d1217d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Thu, 5 Jun 2025 09:39:39 -0700 Subject: [PATCH 10/15] Fix linting errors --- pkg/github/discussions_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index e8e6b675e..892960126 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -43,7 +43,7 @@ func Test_ListDiscussions(t *testing.T) { assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) // mock for the call to list all categories: query struct, variables, response - var q_cat struct { + var qCat struct { Repository struct { DiscussionCategories struct { Nodes []struct { @@ -58,19 +58,19 @@ func Test_ListDiscussions(t *testing.T) { } `graphql:"repository(owner: $owner, name: $repo)"` } - vars_cat := map[string]interface{}{ + varsCat := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "after": githubv4.String(""), } - vars_cat_invalid := map[string]interface{}{ + varsCatInvalid := map[string]interface{}{ "owner": githubv4.String("invalid"), "repo": githubv4.String("repo"), "after": githubv4.String(""), } - mockResp_cat := githubv4mock.DataResponse(map[string]any{ + mockRespCat := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussionCategories": map[string]any{ "nodes": []map[string]any{ @@ -81,7 +81,7 @@ func Test_ListDiscussions(t *testing.T) { }, }) - mockResp_cat_invalid := githubv4mock.ErrorResponse("repository not found") + mockRespCatInvalid := githubv4mock.ErrorResponse("repository not found") // mock for the call to ListDiscussions: query struct, variables, response var q struct { @@ -139,8 +139,8 @@ func Test_ListDiscussions(t *testing.T) { "answered": githubv4.Boolean(false), } - catMatcher := githubv4mock.NewQueryMatcher(q_cat, vars_cat, mockResp_cat) - catMatcherInvalid := githubv4mock.NewQueryMatcher(q_cat, vars_cat_invalid, mockResp_cat_invalid) + catMatcher := githubv4mock.NewQueryMatcher(qCat, varsCat, mockRespCat) + catMatcherInvalid := githubv4mock.NewQueryMatcher(qCat, varsCatInvalid, mockRespCatInvalid) tests := []struct { name string From 4d26e31336a9d29ff108579e02040a773dbcd7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?= Date: Thu, 5 Jun 2025 09:45:51 -0700 Subject: [PATCH 11/15] Bump go-github to v72 --- pkg/github/discussions.go | 2 +- pkg/github/discussions_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 9a9b9b52b..54ae6d047 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v69/github" + "github.com/google/go-github/v72/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 892960126..8b10d0c92 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v69/github" + "github.com/google/go-github/v72/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From d798f7f65c942ec48afe24be7b12d77fb6e96391 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Tue, 1 Jul 2025 20:10:04 +0200 Subject: [PATCH 12/15] Initial fixes --- pkg/github/tools.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 65d661d4a..9f36cfc3d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -122,9 +122,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), - ) - - actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). + ) + + actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). AddReadTools( toolsets.NewServerTool(ListWorkflows(getClient, t)), toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), @@ -166,13 +166,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(experiments) tsg.AddToolset(discussions) - // Enable the requested features - - if err := tsg.EnableToolsets(passedToolsets); err != nil { - return nil, err - } - - return tsg, nil + return tsg } // InitDynamicToolset creates a dynamic toolset that can be used to enable other toolsets, and so requires the server and toolset group as arguments From 1dd2552f8d2968ad334e14efdca898131614c0b2 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 09:08:15 +0200 Subject: [PATCH 13/15] Fix lint issues --- pkg/github/discussions.go | 8 ++++---- pkg/github/discussions_test.go | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 54ae6d047..d308e06c8 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -69,7 +69,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp 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), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -232,7 +232,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper 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), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -303,7 +303,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati 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), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), @@ -363,7 +363,7 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl 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), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 8b10d0c92..8b0f2edd7 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -148,7 +148,7 @@ func Test_ListDiscussions(t *testing.T) { reqParams map[string]interface{} response githubv4mock.GQLResponse expectError bool - expectedIds []int64 + expectedIDs []int64 errContains string catMatcher githubv4mock.Matcher }{ @@ -161,7 +161,7 @@ func Test_ListDiscussions(t *testing.T) { }, response: mockResponseListAll, expectError: false, - expectedIds: []int64{1, 2, 3}, + expectedIDs: []int64{1, 2, 3}, catMatcher: catMatcher, }, { @@ -186,7 +186,7 @@ func Test_ListDiscussions(t *testing.T) { }, response: mockResponseCategory, expectError: false, - expectedIds: []int64{1}, + expectedIDs: []int64{1}, catMatcher: catMatcher, }, { @@ -199,7 +199,7 @@ func Test_ListDiscussions(t *testing.T) { }, response: mockResponseListAll, expectError: false, - expectedIds: []int64{2, 3}, + expectedIDs: []int64{2, 3}, catMatcher: catMatcher, }, { @@ -282,16 +282,16 @@ func Test_ListDiscussions(t *testing.T) { err = json.Unmarshal([]byte(text), &returnedDiscussions) require.NoError(t, err) - assert.Len(t, returnedDiscussions, len(tc.expectedIds), "Expected %d discussions, got %d", len(tc.expectedIds), len(returnedDiscussions)) + assert.Len(t, returnedDiscussions, len(tc.expectedIDs), "Expected %d discussions, got %d", len(tc.expectedIDs), len(returnedDiscussions)) // If no discussions are expected, skip further checks - if len(tc.expectedIds) == 0 { + if len(tc.expectedIDs) == 0 { return } // Create a map of expected IDs for easier checking expectedIDMap := make(map[int64]bool) - for _, id := range tc.expectedIds { + for _, id := range tc.expectedIDs { expectedIDMap[id] = true } From 15cb577962bdc9ac02b355af233065c616155e69 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 09:09:38 +0200 Subject: [PATCH 14/15] Update docs --- README.md | 105 ++++++++++++++++-------------------------- docs/remote-server.md | 1 + 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 81cca0d1b..6157b81ed 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ The following sets of tools are available (all are on by default): | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | `actions` | GitHub Actions workflows and CI/CD operations | | `code_security` | Code security related tools, such as GitHub Code Scanning | -| `discussions` | GitHub Discussions tools (list, get, comments, categories) | +| `discussions` | GitHub Discussions related tools | | `experiments` | Experimental features that are not considered stable yet | | `issues` | GitHub Issues related tools | | `notifications` | GitHub Notifications related tools | @@ -555,6 +555,43 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+Discussions + +- **get_discussion** - Get discussion + - `discussionNumber`: Discussion Number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **get_discussion_comments** - Get discussion comments + - `discussionNumber`: Discussion Number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **list_discussion_categories** - List discussion categories + - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional) + - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional) + - `first`: Number of categories to return per page (min 1, max 100) (number, optional) + - `last`: Number of categories to return from the end (min 1, max 100) (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **list_discussions** - List discussions + - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional) + - `answered`: Filter by whether discussions have been answered or not (boolean, optional) + - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional) + - `category`: Category filter (name) (string, optional) + - `direction`: Sort direction (string, optional) + - `first`: Number of discussions to return per page (min 1, max 100) (number, optional) + - `last`: Number of discussions to return from the end (min 1, max 100) (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `sort`: Sort field (string, optional) + +
+ +
+ Issues - **add_issue_comment** - Add comment to issue @@ -921,71 +958,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
-- **manage_repository_notification_subscription** – Manage a repository notification subscription (ignore, watch, or delete) - - `owner`: The account owner of the repository (string, required) - - `repo`: The name of the repository (string, required) - - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required) - -### Discussions - -- **list_discussions** - List discussions for a repository - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `category`: Filter by category name (string, optional) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `first`: Pagination - Number of records to retrieve (number, optional) - - `last`: Pagination - Number of records to retrieve from the end (number, optional) - - `after`: Pagination - Cursor to start with (string, optional) - - `before`: Pagination - Cursor to end with (string, optional) - - `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional) - - `direction`: Sort direction ('ASC', 'DESC') (string, optional) - - `answered`: Filter by whether discussions have been answered or not (boolean, optional) - -- **get_discussion** - Get a specific discussion by ID - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `discussionNumber`: Discussion number (required) - -- **get_discussion_comments** - Get comments from a discussion - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `discussionNumber`: Discussion number (required) - -- **list_discussion_categories** - List discussion categories for a repository, with their IDs and names - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `first`: Pagination - Number of categories to return per page (number, optional, min 1, max 100) - - `last`: Pagination - Number of categories to return from the end (number, optional, min 1, max 100) - - `after`: Pagination - Cursor to start with (string, optional) - - `before`: Pagination - Cursor to end with (string, optional) - -## Resources - -### Repository Content - -- **Get Repository Content** - Retrieves the content of a repository at a specific path. - - - **Template**: `repo://{owner}/{repo}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Branch** - Retrieves the content of a repository at a specific path for a given branch. - - - **Template**: `repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `branch`: Branch name (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Commit** - Retrieves the content of a repository at a specific path for a given commit. - - +
Users diff --git a/docs/remote-server.md b/docs/remote-server.md index 50404ec85..7b5f2c0d4 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -20,6 +20,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | From ad03f01d5103a4b5f35db67e5c8695f1068d04df Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 11:52:47 +0200 Subject: [PATCH 15/15] Simplify list discussions --- README.md | 10 +- pkg/github/discussions.go | 234 +++++++++++++-------------- pkg/github/discussions_test.go | 284 ++++++++++----------------------- 3 files changed, 196 insertions(+), 332 deletions(-) diff --git a/README.md b/README.md index 6157b81ed..b4c326c0e 100644 --- a/README.md +++ b/README.md @@ -576,17 +576,9 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - **list_discussions** - List discussions - - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional) - - `answered`: Filter by whether discussions have been answered or not (boolean, optional) - - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional) - - `category`: Category filter (name) (string, optional) - - `direction`: Sort direction (string, optional) - - `first`: Number of discussions to return per page (min 1, max 100) (number, optional) - - `last`: Number of discussions to return from the end (min 1, max 100) (number, optional) + - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `sort`: Sort field (string, optional)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index d308e06c8..d61fe969d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/github/github-mcp-server/pkg/translations" "github.com/go-viper/mapstructure/v2" @@ -80,142 +79,121 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp mcp.Description("Repository name"), ), mcp.WithString("category", - mcp.Description("Category filter (name)"), - ), - mcp.WithString("since", - mcp.Description("Filter by date (ISO 8601 timestamp)"), - ), - mcp.WithString("sort", - mcp.Description("Sort field"), - mcp.DefaultString("CREATED_AT"), - mcp.Enum("CREATED_AT", "UPDATED_AT"), - ), - mcp.WithString("direction", - mcp.Description("Sort direction"), - mcp.DefaultString("DESC"), - mcp.Enum("ASC", "DESC"), - ), - mcp.WithNumber("first", - mcp.Description("Number of discussions to return per page (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - ), - mcp.WithNumber("last", - mcp.Description("Number of discussions to return from the end (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), - ), - mcp.WithString("after", - mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), - ), - mcp.WithString("before", - mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), - ), - mcp.WithBoolean("answered", - mcp.Description("Filter by whether discussions have been answered or not"), + 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) { - // Decode params - var params struct { - Owner string - Repo string - Category string - Since string - Sort string - Direction string - First int32 - Last int32 - After string - Before string - Answered bool - } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + // Required params + owner, err := RequiredParam[string](request, "owner") + if err != nil { return mcp.NewToolResultError(err.Error()), nil } - 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 + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - if params.Before != "" && params.First != 0 { - return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil + + // Optional params + category, err := OptionalParam[string](request, "category") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - // Get GraphQL client + client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } - // Prepare GraphQL query - var q 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(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - categories, err := GetAllDiscussionCategories(ctx, client, params.Owner, params.Repo) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get discussion categories: %v", err)), nil - } - var categoryID githubv4.ID = categories[params.Category] - if categoryID == "" && params.Category != "" { - return mcp.NewToolResultError(fmt.Sprintf("category '%s' not found", params.Category)), nil - } - // Build query variables - vars := map[string]interface{}{ - "owner": githubv4.String(params.Owner), - "repo": githubv4.String(params.Repo), - "categoryId": categoryID, - "sort": githubv4.DiscussionOrderField(params.Sort), - "direction": githubv4.OrderDirection(params.Direction), - "first": githubv4.Int(params.First), - "last": githubv4.Int(params.Last), - "after": githubv4.String(params.After), - "before": githubv4.String(params.Before), - "answered": githubv4.Boolean(params.Answered), - } - // Execute query - if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), 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 } - // Map nodes to GitHub Issue objects - there is no discussion type in the GitHub API, so we use Issue to benefit from existing code + + // Now execute the discussions query var discussions []*github.Issue - for _, n := range q.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}, + 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 } - discussions = append(discussions, di) - } - // Post filtering discussions based on 'since' parameter - if params.Since != "" { - sinceTime, err := time.Parse(time.RFC3339, params.Since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("invalid 'since' timestamp: %v", err)), 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) + } + } 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)"` } - var filteredDiscussions []*github.Issue - for _, d := range discussions { - if d.CreatedAt.Time.After(sinceTime) { - filteredDiscussions = append(filteredDiscussions, d) + 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) } - discussions = filteredDiscussions } // Marshal and return @@ -270,6 +248,9 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper State 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)"` } @@ -288,6 +269,11 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper State: github.Ptr(string(d.State)), HTMLURL: github.Ptr(string(d.URL)), CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, + Labels: []*github.Label{ + { + Name: github.Ptr(fmt.Sprintf("category:%s", string(d.Category.Name))), + }, + }, } out, err := json.Marshal(discussion) if err != nil { @@ -429,16 +415,12 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl ID githubv4.ID Name githubv4.String } - } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"` + } `graphql:"discussionCategories(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String(params.Owner), - "repo": githubv4.String(params.Repo), - "first": githubv4.Int(params.First), - "last": githubv4.Int(params.Last), - "after": githubv4.String(params.After), - "before": githubv4.String(params.Before), + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 8b0f2edd7..545d604f9 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -3,6 +3,8 @@ package github import ( "context" "encoding/json" + "net/http" + "strings" "testing" "time" @@ -15,76 +17,57 @@ import ( ) var ( + discussionsGeneral = []map[string]any{ + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + } discussionsAll = []map[string]any{ - {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "category": map[string]any{"name": "news"}, "url": "https://github.com/owner/repo/discussions/1"}, - {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "category": map[string]any{"name": "updates"}, "url": "https://github.com/owner/repo/discussions/2"}, - {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "category": map[string]any{"name": "questions"}, "url": "https://github.com/owner/repo/discussions/3"}, + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, } mockResponseListAll = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{"nodes": discussionsAll}, }, }) - mockResponseCategory = githubv4mock.DataResponse(map[string]any{ + mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ - "discussions": map[string]any{"nodes": discussionsAll[:1]}, // Only return the first discussion for category test + "discussions": map[string]any{"nodes": discussionsGeneral}, }, }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") ) func Test_ListDiscussions(t *testing.T) { + mockClient := githubv4.NewClient(nil) // Verify tool definition and schema - toolDef, _ := ListDiscussions(nil, translations.NullTranslationHelper) + toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) assert.Equal(t, "list_discussions", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) - // mock for the call to list all categories: query struct, variables, response - var qCat struct { + // mock for the call to ListDiscussions without category filter + var qDiscussions struct { Repository struct { - DiscussionCategories struct { + Discussions struct { Nodes []struct { - ID githubv4.ID - Name githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - EndCursor githubv4.String + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` } - } `graphql:"discussionCategories(first: 100, after: $after)"` + } `graphql:"discussions(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } - varsCat := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "after": githubv4.String(""), - } - - varsCatInvalid := map[string]interface{}{ - "owner": githubv4.String("invalid"), - "repo": githubv4.String("repo"), - "after": githubv4.String(""), - } - - mockRespCat := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "discussionCategories": map[string]any{ - "nodes": []map[string]any{ - {"id": "123", "name": "CategoryOne"}, - {"id": "456", "name": "CategoryTwo"}, - }, - }, - }, - }) - - mockRespCatInvalid := githubv4mock.ErrorResponse("repository not found") - - // mock for the call to ListDiscussions: query struct, variables, response - var q struct { + // mock for the call to get discussions with category filter + var qDiscussionsFiltered struct { Repository struct { Discussions struct { Nodes []struct { @@ -96,174 +79,81 @@ func Test_ListDiscussions(t *testing.T) { } `graphql:"category"` URL githubv4.String `graphql:"url"` } - } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"` + } `graphql:"discussions(first: 100, categoryId: $categoryId)"` } `graphql:"repository(owner: $owner, name: $repo)"` } varsListAll := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "categoryId": githubv4.ID(""), - "sort": githubv4.DiscussionOrderField(""), - "direction": githubv4.OrderDirection(""), - "first": githubv4.Int(0), - "last": githubv4.Int(0), - "after": githubv4.String(""), - "before": githubv4.String(""), - "answered": githubv4.Boolean(false), + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), } - varsListInvalid := map[string]interface{}{ - "owner": githubv4.String("invalid"), - "repo": githubv4.String("repo"), - "categoryId": githubv4.ID(""), - "sort": githubv4.DiscussionOrderField(""), - "direction": githubv4.OrderDirection(""), - "first": githubv4.Int(0), - "last": githubv4.Int(0), - "after": githubv4.String(""), - "before": githubv4.String(""), - "answered": githubv4.Boolean(false), + varsRepoNotFound := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("nonexistent-repo"), } - varsListWithCategory := map[string]interface{}{ + varsDiscussionsFiltered := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), - "categoryId": githubv4.ID("123"), - "sort": githubv4.DiscussionOrderField(""), - "direction": githubv4.OrderDirection(""), - "first": githubv4.Int(0), - "last": githubv4.Int(0), - "after": githubv4.String(""), - "before": githubv4.String(""), - "answered": githubv4.Boolean(false), + "categoryId": githubv4.ID("DIC_kwDOABC123"), } - catMatcher := githubv4mock.NewQueryMatcher(qCat, varsCat, mockRespCat) - catMatcherInvalid := githubv4mock.NewQueryMatcher(qCat, varsCatInvalid, mockRespCatInvalid) - tests := []struct { - name string - vars map[string]interface{} - reqParams map[string]interface{} - response githubv4mock.GQLResponse - expectError bool - expectedIDs []int64 - errContains string - catMatcher githubv4mock.Matcher + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int }{ { - name: "list all discussions", - vars: varsListAll, + name: "list all discussions without category filter", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", }, - response: mockResponseListAll, - expectError: false, - expectedIDs: []int64{1, 2, 3}, - catMatcher: catMatcher, + expectError: false, + expectedCount: 3, // All discussions }, { - name: "invalid owner or repo", - vars: varsListInvalid, - reqParams: map[string]interface{}{ - "owner": "invalid", - "repo": "repo", - }, - response: mockErrorRepoNotFound, - expectError: true, - errContains: "repository not found", - catMatcher: catMatcherInvalid, - }, - { - name: "list discussions with category", - vars: varsListWithCategory, + name: "filter by category ID", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", - "category": "CategoryOne", // This should match the ID "123" in the mock response + "category": "DIC_kwDOABC123", }, - response: mockResponseCategory, - expectError: false, - expectedIDs: []int64{1}, - catMatcher: catMatcher, + expectError: false, + expectedCount: 2, // Only General discussions (matching the category ID) }, { - name: "list discussions with since date", - vars: varsListAll, + name: "repository not found error", reqParams: map[string]interface{}{ "owner": "owner", - "repo": "repo", - "since": "2023-01-10T00:00:00Z", + "repo": "nonexistent-repo", }, - response: mockResponseListAll, - expectError: false, - expectedIDs: []int64{2, 3}, - catMatcher: catMatcher, - }, - { - name: "both first and last parameters provided", - vars: varsListAll, // vars don't matter since error occurs before GraphQL call - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "first": int32(10), - "last": int32(5), - }, - response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call expectError: true, - errContains: "only one of 'first' or 'last' may be specified", - catMatcher: catMatcher, - }, - { - name: "after with last parameters provided", - vars: varsListAll, // vars don't matter since error occurs before GraphQL call - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "after": "cursor123", - "last": int32(5), - }, - response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call - expectError: true, - errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?", - catMatcher: catMatcher, - }, - { - name: "before with first parameters provided", - vars: varsListAll, // vars don't matter since error occurs before GraphQL call - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "before": "cursor456", - "first": int32(10), - }, - response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call - expectError: true, - errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?", - catMatcher: catMatcher, - }, - { - name: "both after and before parameters provided", - vars: varsListAll, // vars don't matter since error occurs before GraphQL call - reqParams: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "after": "cursor123", - "before": "cursor456", - }, - response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call - expectError: true, - errContains: "only one of 'after' or 'before' may be specified", - catMatcher: catMatcher, + errContains: "repository not found", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response) - httpClient := githubv4mock.NewMockedHTTPClient(matcher, tc.catMatcher) + var httpClient *http.Client + + switch tc.name { + case "list all discussions without category filter": + // Simple case - no category filter + matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category ID": + // Simple case - category filter using category ID directly + matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + gqlClient := githubv4.NewClient(httpClient) _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) @@ -282,22 +172,14 @@ func Test_ListDiscussions(t *testing.T) { err = json.Unmarshal([]byte(text), &returnedDiscussions) require.NoError(t, err) - assert.Len(t, returnedDiscussions, len(tc.expectedIDs), "Expected %d discussions, got %d", len(tc.expectedIDs), len(returnedDiscussions)) + assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions)) - // If no discussions are expected, skip further checks - if len(tc.expectedIDs) == 0 { - return - } - - // Create a map of expected IDs for easier checking - expectedIDMap := make(map[int64]bool) - for _, id := range tc.expectedIDs { - expectedIDMap[id] = true - } - - for _, discussion := range returnedDiscussions { - // Check if the discussion Number is in the expected list - assert.True(t, expectedIDMap[int64(*discussion.Number)], "Unexpected discussion Number: %d", *discussion.Number) + // Verify that all returned discussions have a category label if filtered + if _, hasCategory := tc.reqParams["category"]; hasCategory { + for _, discussion := range returnedDiscussions { + require.NotEmpty(t, discussion.Labels, "Discussion should have category label") + assert.True(t, strings.HasPrefix(*discussion.Labels[0].Name, "category:"), "Discussion should have category label prefix") + } } }) } @@ -321,6 +203,9 @@ func Test_GetDiscussion(t *testing.T) { State 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)"` } @@ -345,6 +230,7 @@ func Test_GetDiscussion(t *testing.T) { "state": "open", "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2025-04-25T12:00:00Z", + "category": map[string]any{"name": "General"}, }}, }), expectError: false, @@ -354,6 +240,11 @@ func Test_GetDiscussion(t *testing.T) { Body: github.Ptr("This is a test discussion"), State: github.Ptr("open"), CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, + Labels: []*github.Label{ + { + Name: github.Ptr("category:General"), + }, + }, }, }, { @@ -387,6 +278,9 @@ func Test_GetDiscussion(t *testing.T) { assert.Equal(t, *tc.expected.Number, *out.Number) assert.Equal(t, *tc.expected.Body, *out.Body) assert.Equal(t, *tc.expected.State, *out.State) + // Check category label + require.Len(t, out.Labels, 1) + assert.Equal(t, *tc.expected.Labels[0].Name, *out.Labels[0].Name) }) } } @@ -463,16 +357,12 @@ func Test_ListDiscussionCategories(t *testing.T) { ID githubv4.ID Name githubv4.String } - } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"` + } `graphql:"discussionCategories(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "first": githubv4.Int(0), // Default to 100 categories - "last": githubv4.Int(0), // Not used, but required by schema - "after": githubv4.String(""), // Not used, but required by schema - "before": githubv4.String(""), // Not used, but required by schema + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), } mockResp := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ 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