Skip to content

Commit e854a95

Browse files
Add create_discussion tool to discussions toolset
This PR adds a new CreateDiscussion tool that allows users to create discussions in a GitHub repository or at the organisation level. Changes: - Add CreateDiscussion function with new NewTool() pattern in discussions.go - Add getDiscussionRepositoryID helper function - Add comprehensive unit tests in discussions_test.go - Add toolsnap for create_discussion tool
1 parent c1f8d4c commit e854a95

3 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"annotations": {
3+
"title": "Create discussion"
4+
},
5+
"description": "Create a new discussion in a repository or organisation.",
6+
"inputSchema": {
7+
"properties": {
8+
"body": {
9+
"description": "Discussion body text in markdown format",
10+
"type": "string"
11+
},
12+
"categoryId": {
13+
"description": "Category ID where the discussion should be created (obtainable via list_discussion_categories)",
14+
"type": "string"
15+
},
16+
"owner": {
17+
"description": "Repository owner",
18+
"type": "string"
19+
},
20+
"repo": {
21+
"description": "Repository name. If not provided, the discussion will be created at the organisation level.",
22+
"type": "string"
23+
},
24+
"title": {
25+
"description": "Discussion title",
26+
"type": "string"
27+
}
28+
},
29+
"required": [
30+
"owner",
31+
"categoryId",
32+
"title",
33+
"body"
34+
],
35+
"type": "object"
36+
},
37+
"name": "create_discussion"
38+
}

pkg/github/discussions.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,140 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve
507507
)
508508
}
509509

510+
// getDiscussionRepositoryID fetches the repository ID needed for createDiscussion mutation
511+
func getDiscussionRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) {
512+
var repoQuery struct {
513+
Repository struct {
514+
ID githubv4.ID
515+
} `graphql:"repository(owner: $owner, name: $repo)"`
516+
}
517+
vars := map[string]any{
518+
"owner": githubv4.String(owner),
519+
"repo": githubv4.String(repo),
520+
}
521+
if err := client.Query(ctx, &repoQuery, vars); err != nil {
522+
return "", err
523+
}
524+
return repoQuery.Repository.ID, nil
525+
}
526+
527+
func CreateDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
528+
return NewTool(
529+
ToolsetMetadataDiscussions,
530+
mcp.Tool{
531+
Name: "create_discussion",
532+
Description: t("TOOL_CREATE_DISCUSSION_DESCRIPTION", "Create a new discussion in a repository or organisation."),
533+
Annotations: &mcp.ToolAnnotations{
534+
Title: t("TOOL_CREATE_DISCUSSION_USER_TITLE", "Create discussion"),
535+
},
536+
InputSchema: &jsonschema.Schema{
537+
Type: "object",
538+
Properties: map[string]*jsonschema.Schema{
539+
"owner": {
540+
Type: "string",
541+
Description: "Repository owner",
542+
},
543+
"repo": {
544+
Type: "string",
545+
Description: "Repository name. If not provided, the discussion will be created at the organisation level.",
546+
},
547+
"categoryId": {
548+
Type: "string",
549+
Description: "Category ID where the discussion should be created (obtainable via list_discussion_categories)",
550+
},
551+
"title": {
552+
Type: "string",
553+
Description: "Discussion title",
554+
},
555+
"body": {
556+
Type: "string",
557+
Description: "Discussion body text in markdown format",
558+
},
559+
},
560+
Required: []string{"owner", "categoryId", "title", "body"},
561+
},
562+
},
563+
[]scopes.Scope{scopes.Repo},
564+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
565+
owner, err := RequiredParam[string](args, "owner")
566+
if err != nil {
567+
return utils.NewToolResultError(err.Error()), nil, nil
568+
}
569+
repo, err := OptionalParam[string](args, "repo")
570+
if err != nil {
571+
return utils.NewToolResultError(err.Error()), nil, nil
572+
}
573+
// when not provided, default to the .github repository
574+
// this will create the discussion at the organisation level
575+
if repo == "" {
576+
repo = ".github"
577+
}
578+
579+
categoryID, err := RequiredParam[string](args, "categoryId")
580+
if err != nil {
581+
return utils.NewToolResultError(err.Error()), nil, nil
582+
}
583+
584+
title, err := RequiredParam[string](args, "title")
585+
if err != nil {
586+
return utils.NewToolResultError(err.Error()), nil, nil
587+
}
588+
589+
body, err := RequiredParam[string](args, "body")
590+
if err != nil {
591+
return utils.NewToolResultError(err.Error()), nil, nil
592+
}
593+
594+
client, err := deps.GetGQLClient(ctx)
595+
if err != nil {
596+
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
597+
}
598+
599+
// Get repository ID first
600+
repoID, err := getDiscussionRepositoryID(ctx, client, owner, repo)
601+
if err != nil {
602+
return utils.NewToolResultError(fmt.Sprintf("failed to get repository ID: %v", err)), nil, nil
603+
}
604+
605+
// Define the mutation
606+
var mutation struct {
607+
CreateDiscussion struct {
608+
Discussion struct {
609+
ID githubv4.ID
610+
Number githubv4.Int
611+
URL githubv4.String
612+
}
613+
} `graphql:"createDiscussion(input: $input)"`
614+
}
615+
616+
input := githubv4.CreateDiscussionInput{
617+
RepositoryID: repoID,
618+
CategoryID: githubv4.ID(categoryID),
619+
Title: githubv4.String(title),
620+
Body: githubv4.String(body),
621+
}
622+
623+
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
624+
return utils.NewToolResultError(fmt.Sprintf("failed to create discussion: %v", err)), nil, nil
625+
}
626+
627+
// Build response
628+
response := map[string]interface{}{
629+
"id": fmt.Sprint(mutation.CreateDiscussion.Discussion.ID),
630+
"number": int(mutation.CreateDiscussion.Discussion.Number),
631+
"url": string(mutation.CreateDiscussion.Discussion.URL),
632+
}
633+
634+
out, err := json.Marshal(response)
635+
if err != nil {
636+
return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
637+
}
638+
639+
return utils.NewToolResultText(string(out)), nil, nil
640+
},
641+
)
642+
}
643+
510644
func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool {
511645
return NewTool(
512646
ToolsetMetadataDiscussions,

pkg/github/discussions_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,3 +819,167 @@ func Test_ListDiscussionCategories(t *testing.T) {
819819
})
820820
}
821821
}
822+
823+
func Test_CreateDiscussion(t *testing.T) {
824+
t.Parallel()
825+
826+
toolDef := CreateDiscussion(translations.NullTranslationHelper)
827+
tool := toolDef.Tool
828+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
829+
830+
assert.Equal(t, "create_discussion", tool.Name)
831+
assert.NotEmpty(t, tool.Description)
832+
assert.Contains(t, tool.Description, "Create")
833+
834+
// Verify tool schema with type assertion
835+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
836+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
837+
assert.Equal(t, "object", schema.Type)
838+
assert.Contains(t, schema.Properties, "owner")
839+
assert.Contains(t, schema.Properties, "repo")
840+
assert.Contains(t, schema.Properties, "categoryId")
841+
assert.Contains(t, schema.Properties, "title")
842+
assert.Contains(t, schema.Properties, "body")
843+
assert.ElementsMatch(t, schema.Required, []string{"owner", "categoryId", "title", "body"})
844+
845+
// Query for getting repository ID
846+
qGetRepoID := struct {
847+
Repository struct {
848+
ID githubv4.ID
849+
} `graphql:"repository(owner: $owner, name: $repo)"`
850+
}{}
851+
852+
// Mutation for creating discussion
853+
qCreateDiscussion := struct {
854+
CreateDiscussion struct {
855+
Discussion struct {
856+
ID githubv4.ID
857+
Number githubv4.Int
858+
URL githubv4.String
859+
}
860+
} `graphql:"createDiscussion(input: $input)"`
861+
}{}
862+
863+
tests := []struct {
864+
name string
865+
reqParams map[string]any
866+
repoVars map[string]any
867+
repoResponse githubv4mock.GQLResponse
868+
mutInput githubv4.CreateDiscussionInput
869+
mutResponse githubv4mock.GQLResponse
870+
expectError bool
871+
expectedID string
872+
expectedNum int
873+
expectedURL string
874+
}{
875+
{
876+
name: "successful discussion creation",
877+
reqParams: map[string]any{
878+
"owner": "test-owner",
879+
"repo": "test-repo",
880+
"categoryId": "cat-123",
881+
"title": "Test Discussion",
882+
"body": "This is the body of the test discussion",
883+
},
884+
repoVars: map[string]any{
885+
"owner": githubv4.String("test-owner"),
886+
"repo": githubv4.String("test-repo"),
887+
},
888+
repoResponse: githubv4mock.DataResponse(map[string]any{
889+
"repository": map[string]any{
890+
"id": "repo-id-123",
891+
},
892+
}),
893+
mutInput: githubv4.CreateDiscussionInput{
894+
RepositoryID: githubv4.ID("repo-id-123"),
895+
CategoryID: githubv4.ID("cat-123"),
896+
Title: githubv4.String("Test Discussion"),
897+
Body: githubv4.String("This is the body of the test discussion"),
898+
},
899+
mutResponse: githubv4mock.DataResponse(map[string]any{
900+
"createDiscussion": map[string]any{
901+
"discussion": map[string]any{
902+
"id": "disc-123",
903+
"number": 42,
904+
"url": "https://github.com/test-owner/test-repo/discussions/42",
905+
},
906+
},
907+
}),
908+
expectError: false,
909+
expectedID: "disc-123",
910+
expectedNum: 42,
911+
expectedURL: "https://github.com/test-owner/test-repo/discussions/42",
912+
},
913+
{
914+
name: "org level discussion (no repo specified)",
915+
reqParams: map[string]any{
916+
"owner": "test-org",
917+
"categoryId": "cat-456",
918+
"title": "Org Discussion",
919+
"body": "An org-level discussion body",
920+
},
921+
repoVars: map[string]any{
922+
"owner": githubv4.String("test-org"),
923+
"repo": githubv4.String(".github"),
924+
},
925+
repoResponse: githubv4mock.DataResponse(map[string]any{
926+
"repository": map[string]any{
927+
"id": "org-repo-id",
928+
},
929+
}),
930+
mutInput: githubv4.CreateDiscussionInput{
931+
RepositoryID: githubv4.ID("org-repo-id"),
932+
CategoryID: githubv4.ID("cat-456"),
933+
Title: githubv4.String("Org Discussion"),
934+
Body: githubv4.String("An org-level discussion body"),
935+
},
936+
mutResponse: githubv4mock.DataResponse(map[string]any{
937+
"createDiscussion": map[string]any{
938+
"discussion": map[string]any{
939+
"id": "org-disc-1",
940+
"number": 1,
941+
"url": "https://github.com/test-org/.github/discussions/1",
942+
},
943+
},
944+
}),
945+
expectError: false,
946+
expectedID: "org-disc-1",
947+
expectedNum: 1,
948+
expectedURL: "https://github.com/test-org/.github/discussions/1",
949+
},
950+
}
951+
952+
for _, tc := range tests {
953+
t.Run(tc.name, func(t *testing.T) {
954+
// Create matchers for the sequence of GraphQL calls
955+
repoMatcher := githubv4mock.NewQueryMatcher(qGetRepoID, tc.repoVars, tc.repoResponse)
956+
mutMatcher := githubv4mock.NewMutationMatcher(qCreateDiscussion, tc.mutInput, nil, tc.mutResponse)
957+
httpClient := githubv4mock.NewMockedHTTPClient(repoMatcher, mutMatcher)
958+
gqlClient := githubv4.NewClient(httpClient)
959+
960+
deps := BaseDeps{GQLClient: gqlClient}
961+
handler := toolDef.Handler(deps)
962+
963+
req := createMCPRequest(tc.reqParams)
964+
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
965+
966+
if tc.expectError {
967+
require.True(t, res.IsError)
968+
return
969+
}
970+
require.NoError(t, err)
971+
972+
text := getTextResult(t, res).Text
973+
974+
var response struct {
975+
ID string `json:"id"`
976+
Number int `json:"number"`
977+
URL string `json:"url"`
978+
}
979+
require.NoError(t, json.Unmarshal([]byte(text), &response))
980+
assert.Equal(t, tc.expectedID, response.ID)
981+
assert.Equal(t, tc.expectedNum, response.Number)
982+
assert.Equal(t, tc.expectedURL, response.URL)
983+
})
984+
}
985+
}

0 commit comments

Comments
 (0)