Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 44 additions & 20 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,15 @@ type DiscussionFragment struct {
}

type NodeFragment struct {
Number githubv4.Int
Title githubv4.String
CreatedAt githubv4.DateTime
UpdatedAt githubv4.DateTime
Author struct {
Number githubv4.Int
Title githubv4.String
CreatedAt githubv4.DateTime
UpdatedAt githubv4.DateTime
State githubv4.String
IsAnswered githubv4.Boolean
AnsweredAt *githubv4.DateTime
AnswerChosenAt *githubv4.DateTime
Author struct {
Login githubv4.String
}
Category struct {
Expand Down Expand Up @@ -294,12 +298,16 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
var q struct {
Repository struct {
Discussion struct {
Number githubv4.Int
Title githubv4.String
Body githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
Category struct {
Number githubv4.Int
Title githubv4.String
Body githubv4.String
CreatedAt githubv4.DateTime
State githubv4.String
IsAnswered githubv4.Boolean
AnsweredAt *githubv4.DateTime
AnswerChosenAt *githubv4.DateTime
URL githubv4.String `graphql:"url"`
Category struct {
Name githubv4.String
} `graphql:"category"`
} `graphql:"discussion(number: $discussionNumber)"`
Expand All @@ -314,17 +322,33 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
return mcp.NewToolResultError(err.Error()), nil
}
d := q.Repository.Discussion
discussion := &github.Discussion{
Number: github.Ptr(int(d.Number)),
Title: github.Ptr(string(d.Title)),
Body: github.Ptr(string(d.Body)),
HTMLURL: github.Ptr(string(d.URL)),
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
DiscussionCategory: &github.DiscussionCategory{
Name: github.Ptr(string(d.Category.Name)),

// Build response as map to include fields not present in go-github's Discussion struct.
// The go-github library's Discussion type lacks isAnswered and answeredAt fields,
// so we use map[string]interface{} for the response (consistent with other functions
// like ListDiscussions and GetDiscussionComments).
response := map[string]interface{}{
"number": int(d.Number),
"title": string(d.Title),
"body": string(d.Body),
"url": string(d.URL),
"state": string(d.State),
"isAnswered": bool(d.IsAnswered),
"createdAt": d.CreatedAt.Time,
"category": map[string]interface{}{
"name": string(d.Category.Name),
},
}
out, err := json.Marshal(discussion)

// Add optional timestamp fields if present
if d.AnsweredAt != nil {
response["answeredAt"] = d.AnsweredAt.Time
}
if d.AnswerChosenAt != nil {
response["answerChosenAt"] = d.AnswerChosenAt.Time
}

out, err := json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussion: %w", err)
}
Expand Down
177 changes: 97 additions & 80 deletions pkg/github/discussions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"testing"
"time"

"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/translations"
Expand All @@ -17,75 +16,89 @@ import (

var (
discussionsGeneral = []map[string]any{
{"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "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", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
{"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "state": "OPEN", "isAnswered": false, "author": map[string]any{"login": "user1"}, "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", "updatedAt": "2023-02-01T00:00:00Z", "state": "OPEN", "isAnswered": false, "author": map[string]any{"login": "user1"}, "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",
"updatedAt": "2023-01-01T00:00:00Z",
"author": map[string]any{"login": "user1"},
"url": "https://github.com/owner/repo/discussions/1",
"category": map[string]any{"name": "General"},
"number": 1,
"title": "Discussion 1 title",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "user1"},
"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",
"updatedAt": "2023-02-01T00:00:00Z",
"author": map[string]any{"login": "user2"},
"url": "https://github.com/owner/repo/discussions/2",
"category": map[string]any{"name": "Questions"},
"number": 2,
"title": "Discussion 2 title",
"createdAt": "2023-02-01T00:00:00Z",
"updatedAt": "2023-02-01T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "user2"},
"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",
"updatedAt": "2023-03-01T00:00:00Z",
"author": map[string]any{"login": "user3"},
"url": "https://github.com/owner/repo/discussions/3",
"category": map[string]any{"name": "General"},
"number": 3,
"title": "Discussion 3 title",
"createdAt": "2023-03-01T00:00:00Z",
"updatedAt": "2023-03-01T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "user3"},
"url": "https://github.com/owner/repo/discussions/3",
"category": map[string]any{"name": "General"},
},
}

discussionsOrgLevel = []map[string]any{
{
"number": 1,
"title": "Org Discussion 1 - Community Guidelines",
"createdAt": "2023-01-15T00:00:00Z",
"updatedAt": "2023-01-15T00:00:00Z",
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/1",
"category": map[string]any{"name": "Announcements"},
"number": 1,
"title": "Org Discussion 1 - Community Guidelines",
"createdAt": "2023-01-15T00:00:00Z",
"updatedAt": "2023-01-15T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/1",
"category": map[string]any{"name": "Announcements"},
},
{
"number": 2,
"title": "Org Discussion 2 - Roadmap 2023",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/2",
"category": map[string]any{"name": "General"},
"number": 2,
"title": "Org Discussion 2 - Roadmap 2023",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/2",
"category": map[string]any{"name": "General"},
},
{
"number": 3,
"title": "Org Discussion 3 - Roadmap 2024",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/3",
"category": map[string]any{"name": "General"},
"number": 3,
"title": "Org Discussion 3 - Roadmap 2024",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/3",
"category": map[string]any{"name": "General"},
},
{
"number": 4,
"title": "Org Discussion 4 - Roadmap 2025",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/4",
"category": map[string]any{"name": "General"},
"number": 4,
"title": "Org Discussion 4 - Roadmap 2025",
"createdAt": "2023-02-20T00:00:00Z",
"updatedAt": "2023-02-20T00:00:00Z",
"state": "OPEN",
"isAnswered": false,
"author": map[string]any{"login": "org-admin"},
"url": "https://github.com/owner/.github/discussions/4",
"category": map[string]any{"name": "General"},
},
}

Expand Down Expand Up @@ -388,10 +401,10 @@ func Test_ListDiscussions(t *testing.T) {
}

// Define the actual query strings that match the implementation
qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,state,isAnswered,answeredAt,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,state,isAnswered,answeredAt,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,state,isAnswered,answeredAt,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,state,isAnswered,answeredAt,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -484,7 +497,7 @@ func Test_GetDiscussion(t *testing.T) {
assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})

// Use exact string query that matches implementation output
qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}"
qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,state,isAnswered,answeredAt,answerChosenAt,url,category{name}}}}"

vars := map[string]interface{}{
"owner": "owner",
Expand All @@ -495,31 +508,31 @@ func Test_GetDiscussion(t *testing.T) {
name string
response githubv4mock.GQLResponse
expectError bool
expected *github.Discussion
expected map[string]interface{}
errContains string
}{
{
name: "successful retrieval",
response: githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{"discussion": map[string]any{
"number": 1,
"title": "Test Discussion Title",
"body": "This is a test discussion",
"url": "https://github.com/owner/repo/discussions/1",
"createdAt": "2025-04-25T12:00:00Z",
"category": map[string]any{"name": "General"},
"number": 1,
"title": "Test Discussion Title",
"body": "This is a test discussion",
"url": "https://github.com/owner/repo/discussions/1",
"createdAt": "2025-04-25T12:00:00Z",
"state": "OPEN",
"isAnswered": false,
"category": map[string]any{"name": "General"},
}},
}),
expectError: false,
expected: &github.Discussion{
HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"),
Number: github.Ptr(1),
Title: github.Ptr("Test Discussion Title"),
Body: github.Ptr("This is a test discussion"),
CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)},
DiscussionCategory: &github.DiscussionCategory{
Name: github.Ptr("General"),
},
expected: map[string]interface{}{
"number": float64(1),
"title": "Test Discussion Title",
"body": "This is a test discussion",
"url": "https://github.com/owner/repo/discussions/1",
"state": "OPEN",
"isAnswered": false,
},
},
{
Expand Down Expand Up @@ -547,14 +560,18 @@ func Test_GetDiscussion(t *testing.T) {
}

require.NoError(t, err)
var out github.Discussion
var out map[string]interface{}
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.Title, *out.Title)
assert.Equal(t, *tc.expected.Body, *out.Body)
// Check category label
assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name)
assert.Equal(t, tc.expected["number"], out["number"])
assert.Equal(t, tc.expected["title"], out["title"])
assert.Equal(t, tc.expected["body"], out["body"])
assert.Equal(t, tc.expected["url"], out["url"])
assert.Equal(t, tc.expected["state"], out["state"])
assert.Equal(t, tc.expected["isAnswered"], out["isAnswered"])
// Check category is present
category, ok := out["category"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "General", category["name"])
})
}
}
Expand Down