From 9e5d6c50c05ab29464ce70a1fd9537c0fea8546e Mon Sep 17 00:00:00 2001 From: Jack Arturo Date: Thu, 4 Dec 2025 23:15:42 +0100 Subject: [PATCH] Add add_repository_collaborator tool for managing repo access This adds a new tool to the repos toolset that enables managing repository collaborators via the GitHub API. The tool supports: - Adding new collaborators with an invitation - Setting permission levels (pull, triage, push, maintain, admin) - Handling cases where the user already has access - Proper error handling for API failures The implementation follows the existing patterns in the codebase and includes comprehensive tests with mocked HTTP responses for all main scenarios. --- README.md | 6 + .../add_repository_collaborator.snap | 40 +++++++ pkg/github/repositories.go | 110 +++++++++++++++++ pkg/github/repositories_test.go | 112 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 269 insertions(+) create mode 100644 pkg/github/__toolsnaps__/add_repository_collaborator.snap diff --git a/README.md b/README.md index ed6c6f549..9d08e8d9f 100644 --- a/README.md +++ b/README.md @@ -1042,6 +1042,12 @@ Possible options: Repositories +- **add_repository_collaborator** - Add repository collaborator + - `owner`: Repository owner (string, required) + - `permission`: Permission level to grant. Defaults to 'push' when not specified. (string, optional) + - `repo`: Repository name (string, required) + - `username`: Username of the collaborator to add (string, required) + - **create_branch** - Create branch - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) diff --git a/pkg/github/__toolsnaps__/add_repository_collaborator.snap b/pkg/github/__toolsnaps__/add_repository_collaborator.snap new file mode 100644 index 000000000..5ed1a14e1 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_repository_collaborator.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "title": "Add repository collaborator" + }, + "description": "Add a collaborator to a GitHub repository and set their permission level", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "username" + ], + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "permission": { + "type": "string", + "description": "Permission level to grant. Defaults to 'push' when not specified.", + "enum": [ + "pull", + "triage", + "push", + "maintain", + "admin" + ] + }, + "repo": { + "type": "string", + "description": "Repository name" + }, + "username": { + "type": "string", + "description": "Username of the collaborator to add" + } + } + }, + "name": "add_repository_collaborator" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index dbf24e8e3..ef57bf50c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2113,3 +2113,113 @@ func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFun return tool, handler } + +// AddRepositoryCollaborator creates a tool to add a collaborator to a repository with a specific permission level. +func AddRepositoryCollaborator(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "add_repository_collaborator", + Description: t("TOOL_ADD_REPOSITORY_COLLABORATOR_DESCRIPTION", "Add a collaborator to a GitHub repository and set their permission level"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_REPOSITORY_COLLABORATOR_USER_TITLE", "Add repository collaborator"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "username": { + Type: "string", + Description: "Username of the collaborator to add", + }, + "permission": { + Type: "string", + Description: "Permission level to grant. Defaults to 'push' when not specified.", + Enum: []any{"pull", "triage", "push", "maintain", "admin"}, + }, + }, + Required: []string{"owner", "repo", "username"}, + }, + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + username, err := RequiredParam[string](args, "username") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + permission, err := OptionalParam[string](args, "permission") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var opts *github.RepositoryAddCollaboratorOptions + if permission != "" { + opts = &github.RepositoryAddCollaboratorOptions{ + Permission: permission, + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + invitation, resp, err := client.Repositories.AddCollaborator(ctx, owner, repo, username, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to add collaborator %s to %s/%s", username, owner, repo), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return utils.NewToolResultError(fmt.Sprintf("failed to add collaborator: %s", string(body))), nil, nil + } + + effectivePermission := permission + if effectivePermission == "" && invitation != nil { + effectivePermission = invitation.GetPermissions() + } + + var message string + switch resp.StatusCode { + case http.StatusCreated, http.StatusAccepted: + message = fmt.Sprintf("Invitation sent to %s for %s/%s", username, owner, repo) + if effectivePermission != "" { + message += fmt.Sprintf(" with %s permission", effectivePermission) + } + if invitation != nil && invitation.GetID() != 0 { + message += fmt.Sprintf(" (invitation id %d)", invitation.GetID()) + } + case http.StatusNoContent: + message = fmt.Sprintf("%s already has access to %s/%s", username, owner, repo) + if effectivePermission != "" { + message += fmt.Sprintf(" (permission %s)", effectivePermission) + } + } + + return utils.NewToolResultText(message), nil, nil + }) + + return tool, handler +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 7e76d4230..608284094 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -3293,6 +3293,118 @@ func Test_UnstarRepository(t *testing.T) { } } +func Test_AddRepositoryCollaborator(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := AddRepositoryCollaborator(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "add_repository_collaborator", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "username") + assert.Contains(t, schema.Properties, "permission") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "username"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedText string + }{ + { + name: "invitation created with permission", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposCollaboratorsByOwnerByRepoByUsername, + expect(t, expectations{ + path: "/repos/octo/test-repo/collaborators/new-user", + requestBody: map[string]any{ + "permission": "maintain", + }, + }).andThen(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": 42, "permissions": "maintain"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo", + "repo": "test-repo", + "username": "new-user", + "permission": "maintain", + }, + expectedText: "Invitation sent to new-user", + }, + { + name: "already collaborator", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposCollaboratorsByOwnerByRepoByUsername, + expectPath(t, "/repos/octo/test-repo/collaborators/existing").andThen(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo", + "repo": "test-repo", + "username": "existing", + }, + expectedText: "already has access", + }, + { + name: "API error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PutReposCollaboratorsByOwnerByRepoByUsername, + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo", + "repo": "test-repo", + "username": "blocked-user", + "permission": "push", + }, + expectError: true, + expectedErrMsg: "failed to add collaborator", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := AddRepositoryCollaborator(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, _, err := handler(context.Background(), &request, tc.requestArgs) + + if tc.expectError { + require.NotNil(t, result) + textResult := getTextResult(t, result) + assert.Contains(t, textResult.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedText) + if perm, ok := tc.requestArgs["permission"]; ok && perm != "" { + assert.Contains(t, textContent.Text, perm.(string)) + } + }) + } +} + func Test_RepositoriesGetRepositoryTree(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d37af98b8..0a17c3a81 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -186,6 +186,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)), toolsets.NewServerTool(DeleteFile(getClient, t)), + toolsets.NewServerTool(AddRepositoryCollaborator(getClient, t)), ). AddResourceTemplates( toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),