diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 43eca9fad..872dca623 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,11 +16,11 @@ on: branches: ["main", "next"] workflow_dispatch: inputs: - description: - required: false - description: "Description of the run." - type: string - default: "Manual run" + description: + required: false + description: "Description of the run." + type: string + default: "Manual run" env: # Use docker.io for Docker Hub if empty @@ -112,6 +112,7 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker @@ -127,4 +128,3 @@ jobs: # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 92ed52581..dfba7f9dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,16 @@ FROM golang:1.25.4-alpine AS build ARG VERSION="dev" +ARG COMMIT="unknown" # Set the working directory WORKDIR /build -# Install git -RUN --mount=type=cache,target=/var/cache/apk \ - apk add git - # Build the server # go build automatically download required module dependencies to /go/pkg/mod RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,target=. \ - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -o /bin/github-mcp-server cmd/github-mcp-server/main.go # Make a stage to run the app diff --git a/pkg/github/__toolsnaps__/add_discussion_comment.snap b/pkg/github/__toolsnaps__/add_discussion_comment.snap new file mode 100644 index 000000000..3178d50cd --- /dev/null +++ b/pkg/github/__toolsnaps__/add_discussion_comment.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Add discussion comment" + }, + "description": "Add a comment to a discussion.", + "inputSchema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "Comment body (Markdown)" + }, + "discussionNumber": { + "type": "number", + "description": "Discussion Number" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "reply_to_id": { + "type": "string", + "description": "Optional discussion comment node ID to reply to." + }, + "repo": { + "type": "string", + "description": "Repository name" + } + }, + "required": [ + "owner", + "repo", + "discussionNumber", + "body" + ] + }, + "name": "add_discussion_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_discussion.snap b/pkg/github/__toolsnaps__/create_discussion.snap new file mode 100644 index 000000000..fd3079e03 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_discussion.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "title": "Create discussion" + }, + "description": "Create a new discussion in a repository.", + "inputSchema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "Discussion body (Markdown)" + }, + "category_id": { + "type": "string", + "description": "Discussion category node ID. If provided, this is used directly." + }, + "category_name": { + "type": "string", + "description": "Discussion category name. If provided, it will be resolved to a category ID." + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "title": { + "type": "string", + "description": "Discussion title" + } + }, + "required": [ + "owner", + "repo", + "title", + "body" + ] + }, + "name": "create_discussion" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_discussion_comment.snap b/pkg/github/__toolsnaps__/delete_discussion_comment.snap new file mode 100644 index 000000000..b0c84bcc3 --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_discussion_comment.snap @@ -0,0 +1,19 @@ +{ + "annotations": { + "title": "Delete discussion comment" + }, + "description": "Delete an existing discussion comment.", + "inputSchema": { + "type": "object", + "properties": { + "comment_id": { + "type": "string", + "description": "Discussion comment node ID" + } + }, + "required": [ + "comment_id" + ] + }, + "name": "delete_discussion_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_discussion.snap b/pkg/github/__toolsnaps__/update_discussion.snap new file mode 100644 index 000000000..d764b70fa --- /dev/null +++ b/pkg/github/__toolsnaps__/update_discussion.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "title": "Update discussion" + }, + "description": "Update a discussion (title/body/category) in a repository.", + "inputSchema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "New discussion body (optional)" + }, + "category_id": { + "type": "string", + "description": "New discussion category node ID (optional). If provided, this is used directly." + }, + "category_name": { + "type": "string", + "description": "New discussion category name (optional). If provided, it will be resolved to a category ID." + }, + "discussionNumber": { + "type": "number", + "description": "Discussion Number" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "title": { + "type": "string", + "description": "New discussion title (optional)" + } + }, + "required": [ + "owner", + "repo", + "discussionNumber" + ] + }, + "name": "update_discussion" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_discussion_comment.snap b/pkg/github/__toolsnaps__/update_discussion_comment.snap new file mode 100644 index 000000000..4f0e1af35 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_discussion_comment.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "title": "Update discussion comment" + }, + "description": "Update an existing discussion comment.", + "inputSchema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "description": "New comment body (Markdown)" + }, + "comment_id": { + "type": "string", + "description": "Discussion comment node ID" + } + }, + "required": [ + "comment_id", + "body" + ] + }, + "name": "update_discussion_comment" +} \ No newline at end of file diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index c891ba294..b83e02ead 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" @@ -448,7 +449,9 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Discussion struct { Comments struct { Nodes []struct { + ID githubv4.ID Body githubv4.String + URL githubv4.String `graphql:"url"` } PageInfo struct { HasNextPage githubv4.Boolean @@ -476,9 +479,13 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(err.Error()), nil, nil } - var comments []*github.IssueComment + var comments []map[string]any for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + comments = append(comments, map[string]any{ + "id": fmt.Sprint(c.ID), + "body": string(c.Body), + "url": string(c.URL), + }) } // Create response with pagination info @@ -602,3 +609,500 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se }, ) } + +func CreateDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "create_discussion", + Description: t("TOOL_CREATE_DISCUSSION_DESCRIPTION", "Create a new discussion in a repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_DISCUSSION_USER_TITLE", "Create discussion"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "title": { + Type: "string", + Description: "Discussion title", + }, + "body": { + Type: "string", + Description: "Discussion body (Markdown)", + }, + "category_id": { + Type: "string", + Description: "Discussion category node ID. If provided, this is used directly.", + }, + "category_name": { + Type: "string", + Description: "Discussion category name. If provided, it will be resolved to a category ID.", + }, + }, + Required: []string{"owner", "repo", "title", "body"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + Owner string + Repo string + Title string + Body string + CategoryID string `mapstructure:"category_id"` + CategoryName string `mapstructure:"category_name"` + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + repoID, err := getRepositoryID(ctx, client, params.Owner, params.Repo) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + categoryID, err := resolveDiscussionCategoryID(ctx, client, params.Owner, params.Repo, params.CategoryID, params.CategoryName) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var mutation struct { + CreateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String `graphql:"url"` + } + } `graphql:"createDiscussion(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, githubv4.CreateDiscussionInput{ + RepositoryID: repoID, + Title: githubv4.String(params.Title), + Body: githubv4.String(params.Body), + CategoryID: *categoryID, + }, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(map[string]any{ + "id": fmt.Sprint(mutation.CreateDiscussion.Discussion.ID), + "number": int(mutation.CreateDiscussion.Discussion.Number), + "url": string(mutation.CreateDiscussion.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal create discussion response: %w", err) + } + return utils.NewToolResultText(string(out)), nil, nil + }, + ) +} + +func UpdateDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "update_discussion", + Description: t("TOOL_UPDATE_DISCUSSION_DESCRIPTION", "Update a discussion (title/body/category) in a repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_DISCUSSION_USER_TITLE", "Update discussion"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion Number", + }, + "title": { + Type: "string", + Description: "New discussion title (optional)", + }, + "body": { + Type: "string", + Description: "New discussion body (optional)", + }, + "category_id": { + Type: "string", + Description: "New discussion category node ID (optional). If provided, this is used directly.", + }, + "category_name": { + Type: "string", + Description: "New discussion category name (optional). If provided, it will be resolved to a category ID.", + }, + }, + Required: []string{"owner", "repo", "discussionNumber"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + Owner string + Repo string + DiscussionNumber int32 + Title string + Body string + CategoryID string `mapstructure:"category_id"` + CategoryName string `mapstructure:"category_name"` + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if params.Title == "" && params.Body == "" && params.CategoryID == "" && params.CategoryName == "" { + return utils.NewToolResultError("at least one of title, body, category_id, or category_name must be provided"), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + discussionID, err := getDiscussionID(ctx, client, params.Owner, params.Repo, params.DiscussionNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var title *githubv4.String + if params.Title != "" { + s := githubv4.String(params.Title) + title = &s + } + var body *githubv4.String + if params.Body != "" { + s := githubv4.String(params.Body) + body = &s + } + + var categoryID *githubv4.ID + if params.CategoryID != "" || params.CategoryName != "" { + resolved, err := resolveDiscussionCategoryID(ctx, client, params.Owner, params.Repo, params.CategoryID, params.CategoryName) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + categoryID = resolved + } + + var mutation struct { + UpdateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussion(input: $input)"` + } + + input := githubv4.UpdateDiscussionInput{ + DiscussionID: discussionID, + Title: title, + Body: body, + CategoryID: categoryID, + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(map[string]any{ + "id": fmt.Sprint(mutation.UpdateDiscussion.Discussion.ID), + "number": int(mutation.UpdateDiscussion.Discussion.Number), + "url": string(mutation.UpdateDiscussion.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal update discussion response: %w", err) + } + return utils.NewToolResultText(string(out)), nil, nil + }, + ) +} + +func AddDiscussionComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "add_discussion_comment", + Description: t("TOOL_ADD_DISCUSSION_COMMENT_DESCRIPTION", "Add a comment to a discussion."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_DISCUSSION_COMMENT_USER_TITLE", "Add discussion comment"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion Number", + }, + "body": { + Type: "string", + Description: "Comment body (Markdown)", + }, + "reply_to_id": { + Type: "string", + Description: "Optional discussion comment node ID to reply to.", + }, + }, + Required: []string{"owner", "repo", "discussionNumber", "body"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + Owner string + Repo string + DiscussionNumber int32 + Body string + ReplyToID string `mapstructure:"reply_to_id"` + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + discussionID, err := getDiscussionID(ctx, client, params.Owner, params.Repo, params.DiscussionNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var replyToID *githubv4.ID + if params.ReplyToID != "" { + id := githubv4.ID(params.ReplyToID) + replyToID = &id + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, githubv4.AddDiscussionCommentInput{ + DiscussionID: discussionID, + Body: githubv4.String(params.Body), + ReplyToID: replyToID, + }, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(map[string]any{ + "id": fmt.Sprint(mutation.AddDiscussionComment.Comment.ID), + "url": string(mutation.AddDiscussionComment.Comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal add discussion comment response: %w", err) + } + return utils.NewToolResultText(string(out)), nil, nil + }, + ) +} + +func UpdateDiscussionComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "update_discussion_comment", + Description: t("TOOL_UPDATE_DISCUSSION_COMMENT_DESCRIPTION", "Update an existing discussion comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_DISCUSSION_COMMENT_USER_TITLE", "Update discussion comment"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "comment_id": { + Type: "string", + Description: "Discussion comment node ID", + }, + "body": { + Type: "string", + Description: "New comment body (Markdown)", + }, + }, + Required: []string{"comment_id", "body"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + CommentID string `mapstructure:"comment_id"` + Body string + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + var mutation struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID(params.CommentID), + Body: githubv4.String(params.Body), + }, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(map[string]any{ + "id": fmt.Sprint(mutation.UpdateDiscussionComment.Comment.ID), + "url": string(mutation.UpdateDiscussionComment.Comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal update discussion comment response: %w", err) + } + return utils.NewToolResultText(string(out)), nil, nil + }, + ) +} + +func DeleteDiscussionComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "delete_discussion_comment", + Description: t("TOOL_DELETE_DISCUSSION_COMMENT_DESCRIPTION", "Delete an existing discussion comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DELETE_DISCUSSION_COMMENT_USER_TITLE", "Delete discussion comment"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "comment_id": { + Type: "string", + Description: "Discussion comment node ID", + }, + }, + Required: []string{"comment_id"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + CommentID string `mapstructure:"comment_id"` + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + var mutation struct { + DeleteDiscussionComment struct { + ClientMutationID githubv4.String + } `graphql:"deleteDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID(params.CommentID), + }, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + return utils.NewToolResultText("discussion comment deleted successfully"), nil, nil + }, + ) +} + +func getDiscussionID(ctx context.Context, client *githubv4.Client, owner string, repo string, discussionNumber int32) (githubv4.ID, error) { + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), + } + if err := client.Query(ctx, &q, vars); err != nil { + return "", fmt.Errorf("failed to get discussion ID: %w", err) + } + return q.Repository.Discussion.ID, nil +} + +func resolveDiscussionCategoryID(ctx context.Context, client *githubv4.Client, owner string, repo string, categoryID string, categoryName string) (*githubv4.ID, error) { + if categoryID != "" { + id := githubv4.ID(categoryID) + return &id, nil + } + if categoryName == "" { + return nil, fmt.Errorf("either category_id or category_name is required") + } + + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"discussionCategories(first: $first)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(100), + } + if err := client.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list discussion categories: %w", err) + } + + for _, c := range q.Repository.DiscussionCategories.Nodes { + if strings.EqualFold(string(c.Name), categoryName) { + id := c.ID + return &id, nil + } + } + + return nil, fmt.Errorf("discussion category %q not found; use list_discussion_categories to see available categories", categoryName) +} diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 0ec998280..ee2545c39 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -606,7 +606,7 @@ func Test_GetDiscussionComments(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]interface{}{ @@ -622,8 +622,8 @@ func Test_GetDiscussionComments(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "This is the first comment"}, - {"body": "This is the second comment"}, + {"id": "DC_1", "body": "This is the first comment", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-1"}, + {"id": "DC_2", "body": "This is the second comment", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-2"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -657,7 +657,11 @@ func Test_GetDiscussionComments(t *testing.T) { // (Lines removed) var response struct { - Comments []*github.IssueComment `json:"comments"` + Comments []struct { + ID string `json:"id"` + Body string `json:"body"` + URL string `json:"url"` + } `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` @@ -671,7 +675,9 @@ func Test_GetDiscussionComments(t *testing.T) { assert.Len(t, response.Comments, 2) expectedBodies := []string{"This is the first comment", "This is the second comment"} for i, comment := range response.Comments { - assert.Equal(t, expectedBodies[i], *comment.Body) + assert.Equal(t, expectedBodies[i], comment.Body) + assert.NotEmpty(t, comment.ID) + assert.Contains(t, comment.URL, "https://github.com/") } } @@ -819,3 +825,431 @@ func Test_ListDiscussionCategories(t *testing.T) { }) } } + +func Test_CreateDiscussion(t *testing.T) { + toolDef := CreateDiscussion(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_discussion", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "create_discussion tool should not be read-only") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "title") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "category_id") + assert.Contains(t, schema.Properties, "category_name") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "title", "body"}) + + t.Run("create with category_id", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("repo-id"), + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String `graphql:"url"` + } + } `graphql:"createDiscussion(input: $input)"` + }{}, + githubv4.CreateDiscussionInput{ + RepositoryID: githubv4.ID("repo-id"), + Title: githubv4.String("My title"), + Body: githubv4.String("My body"), + CategoryID: githubv4.ID("DIC_1"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createDiscussion": map[string]any{ + "discussion": map[string]any{ + "id": githubv4.ID("DISC_1"), + "number": githubv4.Int(1), + "url": githubv4.String("https://github.com/owner/repo/discussions/1"), + }, + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "My title", + "body": "My body", + "category_id": "DIC_1", + }) + + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, res).Text), &out)) + assert.Equal(t, "DISC_1", out["id"]) + assert.Equal(t, float64(1), out["number"]) + assert.Equal(t, "https://github.com/owner/repo/discussions/1", out["url"]) + }) + + t.Run("create with category_name", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("repo-id"), + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"discussionCategories(first: $first)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "first": githubv4.Int(100), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": githubv4.ID("CAT_GENERAL"), "name": githubv4.String("General")}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String `graphql:"url"` + } + } `graphql:"createDiscussion(input: $input)"` + }{}, + githubv4.CreateDiscussionInput{ + RepositoryID: githubv4.ID("repo-id"), + Title: githubv4.String("My title"), + Body: githubv4.String("My body"), + CategoryID: githubv4.ID("CAT_GENERAL"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createDiscussion": map[string]any{ + "discussion": map[string]any{ + "id": githubv4.ID("DISC_2"), + "number": githubv4.Int(2), + "url": githubv4.String("https://github.com/owner/repo/discussions/2"), + }, + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "My title", + "body": "My body", + "category_name": "General", + }) + + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, res).Text), &out)) + assert.Equal(t, "DISC_2", out["id"]) + assert.Equal(t, float64(2), out["number"]) + assert.Equal(t, "https://github.com/owner/repo/discussions/2", out["url"]) + }) +} + +func Test_UpdateDiscussion(t *testing.T) { + toolDef := UpdateDiscussion(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_discussion", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "update_discussion tool should not be read-only") + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": githubv4.ID("DISC_ID"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussion(input: $input)"` + }{}, + githubv4.UpdateDiscussionInput{ + DiscussionID: githubv4.ID("DISC_ID"), + Title: func() *githubv4.String { s := githubv4.String("New title"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateDiscussion": map[string]any{ + "discussion": map[string]any{ + "id": githubv4.ID("DISC_ID"), + "number": githubv4.Int(1), + "url": githubv4.String("https://github.com/owner/repo/discussions/1"), + }, + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "title": "New title", + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, res).Text), &out)) + assert.Equal(t, "DISC_ID", out["id"]) + assert.Equal(t, float64(1), out["number"]) + assert.Equal(t, "https://github.com/owner/repo/discussions/1", out["url"]) +} + +func Test_AddDiscussionComment(t *testing.T) { + toolDef := AddDiscussionComment(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_discussion_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "add_discussion_comment tool should not be read-only") + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": githubv4.ID("DISC_ID"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("DISC_ID"), + Body: githubv4.String("Hello"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": githubv4.ID("DC_1"), + "url": githubv4.String("https://github.com/owner/repo/discussions/1#discussioncomment-1"), + }, + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "Hello", + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, res).Text), &out)) + assert.Equal(t, "DC_1", out["id"]) + assert.Equal(t, "https://github.com/owner/repo/discussions/1#discussioncomment-1", out["url"]) +} + +func Test_UpdateDiscussionComment(t *testing.T) { + toolDef := UpdateDiscussionComment(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_discussion_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "update_discussion_comment tool should not be read-only") + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_1"), + Body: githubv4.String("Updated"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": githubv4.ID("DC_1"), + "url": githubv4.String("https://github.com/owner/repo/discussions/1#discussioncomment-1"), + }, + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "comment_id": "DC_1", + "body": "Updated", + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, res).Text), &out)) + assert.Equal(t, "DC_1", out["id"]) +} + +func Test_DeleteDiscussionComment(t *testing.T) { + toolDef := DeleteDiscussionComment(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_discussion_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "delete_discussion_comment tool should not be read-only") + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + ClientMutationID githubv4.String + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_1"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteDiscussionComment": map[string]any{ + "clientMutationId": githubv4.String("mut"), + }, + }), + ), + ) + + deps := BaseDeps{GQLClient: githubv4.NewClient(mockedClient)} + handler := toolDef.Handler(deps) + + req := createMCPRequest(map[string]any{ + "comment_id": "DC_1", + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Equal(t, "discussion comment deleted successfully", getTextResult(t, res).Text) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f6d4afa80..6600ec2bd 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -234,6 +234,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetDiscussion(t), GetDiscussionComments(t), ListDiscussionCategories(t), + CreateDiscussion(t), + UpdateDiscussion(t), + AddDiscussionComment(t), + UpdateDiscussionComment(t), + DeleteDiscussionComment(t), // Actions tools ListWorkflows(t),