From 2af853809b13e75cfc546167dfc5172b0480f8fd Mon Sep 17 00:00:00 2001 From: Iryna Kulakova <52420926+IrynaKulakova@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:33:36 +0200 Subject: [PATCH 1/4] Encourage issue creation for contribution requests (#1003) * Encourage issue creation for contribution requests * Update CONTRIBUTING.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2307f6a28..4ad4ece12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,7 @@ We can't guarantee that every tool, feature, or pull request will be approved or To increase the chances your request is accepted: * Include real use cases or examples that demonstrate practical value +* Please create an issue outlining the scenario and potential impact, so we can triage it promptly and prioritize accordingly. * If your request stalls, you can open a Discussion post and link to your issue or PR * We actively revisit requests that gain strong community engagement (👍s, comments, or evidence of real-world use) From b2faa1c37f6633ca43ec5ff4ea8115f076baf801 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 29 Aug 2025 07:46:11 -0600 Subject: [PATCH 2/4] Clarify Visual Studio version and setup instructions (#787) Updated Visual Studio requirements and configuration steps for GitHub Copilot integration. Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com> --- .../install-other-copilot-ides.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/installation-guides/install-other-copilot-ides.md b/docs/installation-guides/install-other-copilot-ides.md index 38b48bbbd..a3200179c 100644 --- a/docs/installation-guides/install-other-copilot-ides.md +++ b/docs/installation-guides/install-other-copilot-ides.md @@ -12,33 +12,34 @@ Quick setup guide for the GitHub MCP server in GitHub Copilot across different I ## Visual Studio -Requires Visual Studio 2022 version 17.14 or later. +Requires Visual Studio 2022 version 17.14.9 or later. ### Remote Server (Recommended) The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required. #### Configuration -1. Go to **Tools** → **Options** → **GitHub** → **Copilot** → **MCP Servers** +1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory. 2. Add this configuration: ```json { "servers": { "github": { - "url": "https://api.githubcopilot.com/mcp/", - "authorization_token": "Bearer YOUR_GITHUB_PAT" + "url": "https://api.githubcopilot.com/mcp/" } } } ``` -3. Restart Visual Studio +3. Save the file. Wait for CodeLens to update to offer a way to authenticate to the new server, activate that and pick the GitHub account to authenticate with. +4. In the GitHub Copilot Chat window, switch to Agent mode. +5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server. ### Local Server For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running. #### Configuration -1. Create an `.mcp.json` file in your solution directory +1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory. 2. Add this configuration: ```json { @@ -65,9 +66,11 @@ For users who prefer to run the GitHub MCP server locally. Requires Docker insta } } ``` -3. Save the file and restart Visual Studio +3. Save the file. Wait for CodeLens to update to offer a way to provide user inputs, activate that and paste in a PAT you generate from https://github.com/settings/tokens. +4. In the GitHub Copilot Chat window, switch to Agent mode. +5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server. -**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers?view=vs-2022) +**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/visualstudio/ide/mcp-servers) --- From 358a4150813508719405add8a85c6806ca7a8b95 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 1 Sep 2025 18:13:36 +0100 Subject: [PATCH 3/4] Add minimal response to CRUD tools, `repositories` and `search` toolsets (#988) * add comprehensive minimal response where appropriate * remove unneeded comments * remove incorrect diff param * update docs * rm comment * Update pkg/github/repositories.go Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com> * update toolsnaps and docs * change minimal_output to use new OptionalBoolParamWithDefault * Update pkg/github/repositories.go Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com> * refactor minimal conversion funcs to minimal_types.go * consolidate response structs and remove unneeded message field * consolidate response further * remove CloneURL field * Update pkg/github/repositories.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/github/server.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix undefined * change incorrect comment * remove old err var declaration * Update pkg/github/repositories.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix syntax issue * update toolsnaps --------- Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 + pkg/github/__toolsnaps__/get_commit.snap | 5 + .../__toolsnaps__/search_repositories.snap | 5 + pkg/github/gists.go | 12 +- pkg/github/gists_test.go | 35 +-- pkg/github/issues.go | 14 +- pkg/github/issues_test.go | 74 +------ pkg/github/minimal_types.go | 204 ++++++++++++++++++ pkg/github/pullrequests.go | 14 +- pkg/github/pullrequests_test.go | 65 +----- pkg/github/repositories.go | 43 +++- pkg/github/repositories_test.go | 75 +++++-- pkg/github/search.go | 76 +++++-- pkg/github/search_test.go | 75 ++++++- pkg/github/server.go | 15 ++ 15 files changed, 510 insertions(+), 204 deletions(-) create mode 100644 pkg/github/minimal_types.go diff --git a/README.md b/README.md index a6e740e66..b9f31ee48 100644 --- a/README.md +++ b/README.md @@ -830,6 +830,7 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -898,6 +899,7 @@ The following sets of tools are available (all are on by default): - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required) diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index af0038110..1c2ecc9a3 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -6,6 +6,11 @@ "description": "Get details for a commit from a GitHub repository", "inputSchema": { "properties": { + "include_diff": { + "default": true, + "description": "Whether to include file diffs and stats in the response. Default is true.", + "type": "boolean" + }, "owner": { "description": "Repository owner", "type": "string" diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index d283a2cc0..f350c8e2b 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -6,6 +6,11 @@ "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.", "inputSchema": { "properties": { + "minimal_output": { + "default": true, + "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + "type": "boolean" + }, "page": { "description": "Page number for pagination (min 1)", "minimum": 1, diff --git a/pkg/github/gists.go b/pkg/github/gists.go index fce34f6a8..3f1645f3e 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -165,7 +165,11 @@ func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil } - r, err := json.Marshal(createdGist) + minimalResponse := MinimalResponse{ + URL: createdGist.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -249,7 +253,11 @@ func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil } - r, err := json.Marshal(updatedGist) + minimalResponse := MinimalResponse{ + URL: updatedGist.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 49d63a252..9b8b4eb6e 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -321,23 +321,12 @@ func Test_CreateGist(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the result - var gist *github.Gist + // Unmarshal and verify the minimal result + var gist MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &gist) require.NoError(t, err) - assert.Equal(t, *tc.expectedGist.ID, *gist.ID) - assert.Equal(t, *tc.expectedGist.Description, *gist.Description) - assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) - assert.Equal(t, *tc.expectedGist.Public, *gist.Public) - - // Verify file content - for filename, expectedFile := range tc.expectedGist.Files { - actualFile, exists := gist.Files[filename] - assert.True(t, exists) - assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) - assert.Equal(t, *expectedFile.Content, *actualFile.Content) - } + assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) }) } } @@ -486,22 +475,12 @@ func Test_UpdateGist(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the result - var gist *github.Gist - err = json.Unmarshal([]byte(textContent.Text), &gist) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedGist.ID, *gist.ID) - assert.Equal(t, *tc.expectedGist.Description, *gist.Description) - assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) - - // Verify file content - for filename, expectedFile := range tc.expectedGist.Files { - actualFile, exists := gist.Files[filename] - assert.True(t, exists) - assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) - assert.Equal(t, *expectedFile.Content, *actualFile.Content) - } + assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) }) } } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 89375ae90..01ce7b42e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -872,7 +872,12 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil } - r, err := json.Marshal(issue) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: issue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -1242,7 +1247,12 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil } - r, err := json.Marshal(updatedIssue) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: updatedIssue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7c4983c64..5a0d409a6 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -712,39 +712,12 @@ func Test_CreateIssue(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedIssue github.Issue + // Unmarshal and verify the minimal result + var returnedIssue MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - - if tc.expectedIssue.Body != nil { - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - } - - if tc.expectedIssue.Type != nil { - assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) - } - - // Check assignees if expected - if len(tc.expectedIssue.Assignees) > 0 { - assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees)) - for i, assignee := range returnedIssue.Assignees { - assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) - } - } - - // Check labels if expected - if len(tc.expectedIssue.Labels) > 0 { - assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels)) - for i, label := range returnedIssue.Labels { - assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) - } - } + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) }) } } @@ -1233,45 +1206,12 @@ func Test_UpdateIssue(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - - if tc.expectedIssue.Body != nil { - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - } - - if tc.expectedIssue.Type != nil { - assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) - } - - // Check assignees if expected - if len(tc.expectedIssue.Assignees) > 0 { - assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees)) - for i, assignee := range returnedIssue.Assignees { - assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) - } - } - - // Check labels if expected - if len(tc.expectedIssue.Labels) > 0 { - assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels)) - for i, label := range returnedIssue.Labels { - assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) - } - } - - // Check milestone if expected - if tc.expectedIssue.Milestone != nil { - assert.NotNil(t, returnedIssue.Milestone) - assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number) - } + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) }) } } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go new file mode 100644 index 000000000..0c3c220aa --- /dev/null +++ b/pkg/github/minimal_types.go @@ -0,0 +1,204 @@ +package github + +import "github.com/google/go-github/v74/github" + +// MinimalUser is the output type for user and organization search results. +type MinimalUser struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details +} + +// MinimalSearchUsersResult is the trimmed output type for user search results. +type MinimalSearchUsersResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalUser `json:"items"` +} + +// MinimalRepository is the trimmed output type for repository objects to reduce verbosity. +type MinimalRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` + HTMLURL string `json:"html_url"` + Language string `json:"language,omitempty"` + Stars int `json:"stargazers_count"` + Forks int `json:"forks_count"` + OpenIssues int `json:"open_issues_count"` + UpdatedAt string `json:"updated_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Topics []string `json:"topics,omitempty"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +// MinimalSearchRepositoriesResult is the trimmed output type for repository search results. +type MinimalSearchRepositoriesResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalRepository `json:"items"` +} + +// MinimalCommitAuthor represents commit author information. +type MinimalCommitAuthor struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Date string `json:"date,omitempty"` +} + +// MinimalCommitInfo represents core commit information. +type MinimalCommitInfo struct { + Message string `json:"message"` + Author *MinimalCommitAuthor `json:"author,omitempty"` + Committer *MinimalCommitAuthor `json:"committer,omitempty"` +} + +// MinimalCommitStats represents commit statistics. +type MinimalCommitStats struct { + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Total int `json:"total,omitempty"` +} + +// MinimalCommitFile represents a file changed in a commit. +type MinimalCommitFile struct { + Filename string `json:"filename"` + Status string `json:"status,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Changes int `json:"changes,omitempty"` +} + +// MinimalCommit is the trimmed output type for commit objects. +type MinimalCommit struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + Commit *MinimalCommitInfo `json:"commit,omitempty"` + Author *MinimalUser `json:"author,omitempty"` + Committer *MinimalUser `json:"committer,omitempty"` + Stats *MinimalCommitStats `json:"stats,omitempty"` + Files []MinimalCommitFile `json:"files,omitempty"` +} + +// MinimalRelease is the trimmed output type for release objects. +type MinimalRelease struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name,omitempty"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at,omitempty"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Author *MinimalUser `json:"author,omitempty"` +} + +// MinimalBranch is the trimmed output type for branch objects. +type MinimalBranch struct { + Name string `json:"name"` + SHA string `json:"sha"` + Protected bool `json:"protected"` +} + +// MinimalResponse represents a minimal response for all CRUD operations. +// Success is implicit in the HTTP response status, and all other information +// can be derived from the URL or fetched separately if needed. +type MinimalResponse struct { + URL string `json:"url"` +} + +// Helper functions + +// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit +func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { + minimalCommit := MinimalCommit{ + SHA: commit.GetSHA(), + HTMLURL: commit.GetHTMLURL(), + } + + if commit.Commit != nil { + minimalCommit.Commit = &MinimalCommitInfo{ + Message: commit.Commit.GetMessage(), + } + + if commit.Commit.Author != nil { + minimalCommit.Commit.Author = &MinimalCommitAuthor{ + Name: commit.Commit.Author.GetName(), + Email: commit.Commit.Author.GetEmail(), + } + if commit.Commit.Author.Date != nil { + minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format("2006-01-02T15:04:05Z") + } + } + + if commit.Commit.Committer != nil { + minimalCommit.Commit.Committer = &MinimalCommitAuthor{ + Name: commit.Commit.Committer.GetName(), + Email: commit.Commit.Committer.GetEmail(), + } + if commit.Commit.Committer.Date != nil { + minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format("2006-01-02T15:04:05Z") + } + } + } + + if commit.Author != nil { + minimalCommit.Author = &MinimalUser{ + Login: commit.Author.GetLogin(), + ID: commit.Author.GetID(), + ProfileURL: commit.Author.GetHTMLURL(), + AvatarURL: commit.Author.GetAvatarURL(), + } + } + + if commit.Committer != nil { + minimalCommit.Committer = &MinimalUser{ + Login: commit.Committer.GetLogin(), + ID: commit.Committer.GetID(), + ProfileURL: commit.Committer.GetHTMLURL(), + AvatarURL: commit.Committer.GetAvatarURL(), + } + } + + // Only include stats and files if includeDiffs is true + if includeDiffs { + if commit.Stats != nil { + minimalCommit.Stats = &MinimalCommitStats{ + Additions: commit.Stats.GetAdditions(), + Deletions: commit.Stats.GetDeletions(), + Total: commit.Stats.GetTotal(), + } + } + + if len(commit.Files) > 0 { + minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) + for _, file := range commit.Files { + minimalFile := MinimalCommitFile{ + Filename: file.GetFilename(), + Status: file.GetStatus(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + Changes: file.GetChanges(), + } + minimalCommit.Files = append(minimalCommit.Files, minimalFile) + } + } + } + + return minimalCommit +} + +// convertToMinimalBranch converts a GitHub API Branch to MinimalBranch +func convertToMinimalBranch(branch *github.Branch) MinimalBranch { + return MinimalBranch{ + Name: branch.GetName(), + SHA: branch.GetCommit().GetSHA(), + Protected: branch.GetProtected(), + } +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 63c5594d3..d7547519d 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -193,7 +193,12 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(body))), nil } - r, err := json.Marshal(pr) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: pr.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -464,7 +469,12 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra } }() - r, err := json.Marshal(finalPR) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: finalPR.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index ed6921477..ea2df97f4 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -381,47 +381,11 @@ func Test_UpdatePullRequest(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the successful result - var returnedPR github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - if tc.expectedPR.Title != nil { - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - } - if tc.expectedPR.Body != nil { - assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body) - } - if tc.expectedPR.State != nil { - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - } - if tc.expectedPR.Base != nil && tc.expectedPR.Base.Ref != nil { - assert.NotNil(t, returnedPR.Base) - assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref) - } - if tc.expectedPR.MaintainerCanModify != nil { - assert.Equal(t, *tc.expectedPR.MaintainerCanModify, *returnedPR.MaintainerCanModify) - } - - // Check reviewers if they exist in the expected PR - if len(tc.expectedPR.RequestedReviewers) > 0 { - assert.NotNil(t, returnedPR.RequestedReviewers) - assert.Equal(t, len(tc.expectedPR.RequestedReviewers), len(returnedPR.RequestedReviewers)) - - // Create maps of reviewer logins for easy comparison - expectedReviewers := make(map[string]bool) - for _, reviewer := range tc.expectedPR.RequestedReviewers { - expectedReviewers[*reviewer.Login] = true - } - - actualReviewers := make(map[string]bool) - for _, reviewer := range returnedPR.RequestedReviewers { - actualReviewers[*reviewer.Login] = true - } - - // Compare the maps - assert.Equal(t, expectedReviewers, actualReviewers) - } + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) }) } } @@ -599,11 +563,11 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { textContent := getTextResult(t, result) - // Unmarshal and verify the successful result - var returnedPR github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) }) } } @@ -1988,18 +1952,11 @@ func Test_CreatePullRequest(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedPR github.PullRequest + // Unmarshal and verify the minimal result + var returnedPR MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) - assert.Equal(t, *tc.expectedPR.Head.SHA, *returnedPR.Head.SHA) - assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref) - assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body) - assert.Equal(t, *tc.expectedPR.User.Login, *returnedPR.User.Login) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) }) } } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index de2c6d01f..dce8501db 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -37,6 +37,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too mcp.Required(), mcp.Description("Commit SHA, branch name, or tag name"), ), + mcp.WithBoolean("include_diff", + mcp.Description("Whether to include file diffs and stats in the response. Default is true."), + mcp.DefaultBool(true), + ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -52,6 +56,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too if err != nil { return mcp.NewToolResultError(err.Error()), nil } + includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -84,7 +92,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil } - r, err := json.Marshal(commit) + // Convert to minimal commit + minimalCommit := convertToMinimalCommit(commit, includeDiff) + + r, err := json.Marshal(minimalCommit) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -174,7 +185,13 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil } - r, err := json.Marshal(commits) + // Convert to minimal commits + minimalCommits := make([]MinimalCommit, len(commits)) + for i, commit := range commits { + minimalCommits[i] = convertToMinimalCommit(commit, false) + } + + r, err := json.Marshal(minimalCommits) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -245,7 +262,13 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil } - r, err := json.Marshal(branches) + // Convert to minimal branches + minimalBranches := make([]MinimalBranch, 0, len(branches)) + for _, branch := range branches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(minimalBranches) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -436,7 +459,12 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(fmt.Sprintf("failed to create repository: %s", string(body))), nil } - r, err := json.Marshal(createdRepo) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: createdRepo.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -707,7 +735,12 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewToolResultError(fmt.Sprintf("failed to fork repository: %s", string(body))), nil } - r, err := json.Marshal(forkedRepo) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: forkedRepo.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index f5ebfd32b..6db069874 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -737,9 +737,33 @@ func Test_ListCommits(t *testing.T) { }, }, Author: &github.User{ - Login: github.Ptr("testuser"), + Login: github.Ptr("testuser"), + ID: github.Ptr(int64(12345)), + HTMLURL: github.Ptr("https://github.com/testuser"), + AvatarURL: github.Ptr("https://github.com/testuser.png"), }, HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(5), + Total: github.Ptr(15), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/main.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(8), + Deletions: github.Ptr(3), + Changes: github.Ptr(11), + }, + { + Filename: github.Ptr("README.md"), + Status: github.Ptr("added"), + Additions: github.Ptr(2), + Deletions: github.Ptr(2), + Changes: github.Ptr(4), + }, + }, }, { SHA: github.Ptr("def456abc789"), @@ -752,9 +776,26 @@ func Test_ListCommits(t *testing.T) { }, }, Author: &github.User{ - Login: github.Ptr("anotheruser"), + Login: github.Ptr("anotheruser"), + ID: github.Ptr(int64(67890)), + HTMLURL: github.Ptr("https://github.com/anotheruser"), + AvatarURL: github.Ptr("https://github.com/anotheruser.png"), }, HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + Stats: &github.CommitStats{ + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Total: github.Ptr(30), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/utils.go"), + Status: github.Ptr("added"), + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Changes: github.Ptr(30), + }, + }, }, } @@ -875,16 +916,23 @@ func Test_ListCommits(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedCommits []*github.RepositoryCommit + var returnedCommits []MinimalCommit err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) require.NoError(t, err) assert.Len(t, returnedCommits, len(tc.expectedCommits)) for i, commit := range returnedCommits { - assert.Equal(t, *tc.expectedCommits[i].Author, *commit.Author) - assert.Equal(t, *tc.expectedCommits[i].SHA, *commit.SHA) - assert.Equal(t, *tc.expectedCommits[i].Commit.Message, *commit.Commit.Message) - assert.Equal(t, *tc.expectedCommits[i].Author.Login, *commit.Author.Login) - assert.Equal(t, *tc.expectedCommits[i].HTMLURL, *commit.HTMLURL) + assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) + assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) + if tc.expectedCommits[i].Commit != nil { + assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) + } + if tc.expectedCommits[i].Author != nil { + assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) + } + + // Files and stats are never included in list_commits + assert.Nil(t, commit.Files) + assert.Nil(t, commit.Stats) } }) } @@ -1077,7 +1125,6 @@ func Test_CreateRepository(t *testing.T) { Description: github.Ptr("Test repository"), Private: github.Ptr(true), HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), - CloneURL: github.Ptr("https://github.com/testuser/test-repo.git"), CreatedAt: &github.Timestamp{Time: time.Now()}, Owner: &github.User{ Login: github.Ptr("testuser"), @@ -1192,17 +1239,13 @@ func Test_CreateRepository(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedRepo github.Repository + // Unmarshal and verify the minimal result + var returnedRepo MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) assert.NoError(t, err) // Verify repository details - assert.Equal(t, *tc.expectedRepo.Name, *returnedRepo.Name) - assert.Equal(t, *tc.expectedRepo.Description, *returnedRepo.Description) - assert.Equal(t, *tc.expectedRepo.Private, *returnedRepo.Private) - assert.Equal(t, *tc.expectedRepo.HTMLURL, *returnedRepo.HTMLURL) - assert.Equal(t, *tc.expectedRepo.Owner.Login, *returnedRepo.Owner.Login) + assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) }) } } diff --git a/pkg/github/search.go b/pkg/github/search.go index 248f17e17..55e4cf8b4 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -26,6 +26,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF mcp.Required(), mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), ), + mcp.WithBoolean("minimal_output", + mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), + mcp.DefaultBool(true), + ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -37,7 +41,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF if err != nil { return mcp.NewToolResultError(err.Error()), nil } - + minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } opts := &github.SearchOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, @@ -67,9 +74,55 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(fmt.Sprintf("failed to search repositories: %s", string(body))), nil } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + // Return either minimal or full response based on parameter + var r []byte + if minimalOutput { + minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + + minimalRepos = append(minimalRepos, minimalRepo) + } + + minimalResult := &MinimalSearchRepositoriesResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalRepos, + } + + r, err = json.Marshal(minimalResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal minimal response: %w", err) + } + } else { + r, err = json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal full response: %w", err) + } } return mcp.NewToolResultText(string(r)), nil @@ -156,21 +209,6 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to } } -// MinimalUser is the output type for user and organization search results. -type MinimalUser struct { - Login string `json:"login"` - ID int64 `json:"id,omitempty"` - ProfileURL string `json:"profile_url,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details -} - -type MinimalSearchUsersResult struct { - TotalCount int `json:"total_count"` - IncompleteResults bool `json:"incomplete_results"` - Items []MinimalUser `json:"items"` -} - func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, err := RequiredParam[string](request, "query") diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index cfc87c02b..91ca45af5 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -148,23 +148,80 @@ func Test_SearchRepositories(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedResult github.RepositoriesSearchResult + var returnedResult MinimalSearchRepositoriesResult err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Repositories, len(tc.expectedResult.Repositories)) - for i, repo := range returnedResult.Repositories { - assert.Equal(t, *tc.expectedResult.Repositories[i].ID, *repo.ID) - assert.Equal(t, *tc.expectedResult.Repositories[i].Name, *repo.Name) - assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, *repo.FullName) - assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, *repo.HTMLURL) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) + for i, repo := range returnedResult.Items { + assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) + assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) + assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) + assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) } }) } } +func Test_SearchRepositories_FullOutput(t *testing.T) { + mockSearchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Repositories: []*github.Repository{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("test-repo"), + FullName: github.Ptr("owner/test-repo"), + HTMLURL: github.Ptr("https://github.com/owner/test-repo"), + Description: github.Ptr("Test repository"), + StargazersCount: github.Ptr(100), + }, + }, + } + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ) + + client := github.NewClient(mockedClient) + _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "query": "golang test", + "minimal_output": false, + }) + + result, err := handlerTest(context.Background(), request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal as full GitHub API response + var returnedResult github.RepositoriesSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + + // Verify it's the full API response, not minimal + assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) + assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Repositories, 1) + assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) + assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) +} + func Test_SearchCode(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/server.go b/pkg/github/server.go index 80a1bbac6..16d28643c 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -144,6 +144,21 @@ func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, e return v, nil } +// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request +// similar to optionalBoolParam, but it also takes a default value. +func OptionalBoolParamWithDefault(r mcp.CallToolRequest, p string, d bool) (bool, error) { + args := r.GetArguments() + _, ok := args[p] + v, err := OptionalParam[bool](r, p) + if err != nil { + return false, err + } + if !ok { + return d, nil + } + return v, nil +} + // OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value From 09deac45d4f0bf00d8d78d41334267a594f92935 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 2 Sep 2025 11:30:13 +0100 Subject: [PATCH 4/4] initial org repo create support (#1023) --- README.md | 1 + .../__toolsnaps__/create_repository.snap | 6 +++- pkg/github/repositories.go | 11 +++++-- pkg/github/repositories_test.go | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b9f31ee48..8f0eba5ad 100644 --- a/README.md +++ b/README.md @@ -815,6 +815,7 @@ The following sets of tools are available (all are on by default): - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) + - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - `private`: Whether repo should be private (boolean, optional) - **delete_file** - Delete file diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index aaba75f3c..6ed2dbf41 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -3,7 +3,7 @@ "title": "Create repository", "readOnlyHint": false }, - "description": "Create a new GitHub repository in your account", + "description": "Create a new GitHub repository in your account or specified organization", "inputSchema": { "properties": { "autoInit": { @@ -18,6 +18,10 @@ "description": "Repository name", "type": "string" }, + "organization": { + "description": "Organization to create the repository in (omit to create in your personal account)", + "type": "string" + }, "private": { "description": "Whether repo should be private", "type": "boolean" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index dce8501db..cef227ba5 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -393,7 +393,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_repository", - mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")), + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), ReadOnlyHint: ToBoolPtr(false), @@ -405,6 +405,9 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun mcp.WithString("description", mcp.Description("Repository description"), ), + mcp.WithString("organization", + mcp.Description("Organization to create the repository in (omit to create in your personal account)"), + ), mcp.WithBoolean("private", mcp.Description("Whether repo should be private"), ), @@ -421,6 +424,10 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + organization, err := OptionalParam[string](request, "organization") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } private, err := OptionalParam[bool](request, "private") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -441,7 +448,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - createdRepo, resp, err := client.Repositories.Create(ctx, "", repo) + createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 6db069874..468d7c29b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1115,6 +1115,7 @@ func Test_CreateRepository(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "name") assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "organization") assert.Contains(t, tool.InputSchema.Properties, "private") assert.Contains(t, tool.InputSchema.Properties, "autoInit") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) @@ -1166,6 +1167,34 @@ func Test_CreateRepository(t *testing.T) { expectError: false, expectedRepo: mockRepo, }, + { + name: "successful repository creation in organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/repos", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": false, + "auto_init": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "organization": "testorg", + "private": false, + "autoInit": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, { name: "successful repository creation with minimal parameters", mockedClient: mock.NewMockedHTTPClient(