Skip to content

Commit 9e5d6c5

Browse files
committed
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.
1 parent fa2d802 commit 9e5d6c5

File tree

5 files changed

+269
-0
lines changed

5 files changed

+269
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,12 @@ Possible options:
10421042

10431043
<summary>Repositories</summary>
10441044

1045+
- **add_repository_collaborator** - Add repository collaborator
1046+
- `owner`: Repository owner (string, required)
1047+
- `permission`: Permission level to grant. Defaults to 'push' when not specified. (string, optional)
1048+
- `repo`: Repository name (string, required)
1049+
- `username`: Username of the collaborator to add (string, required)
1050+
10451051
- **create_branch** - Create branch
10461052
- `branch`: Name for new branch (string, required)
10471053
- `from_branch`: Source branch (defaults to repo default) (string, optional)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"annotations": {
3+
"title": "Add repository collaborator"
4+
},
5+
"description": "Add a collaborator to a GitHub repository and set their permission level",
6+
"inputSchema": {
7+
"type": "object",
8+
"required": [
9+
"owner",
10+
"repo",
11+
"username"
12+
],
13+
"properties": {
14+
"owner": {
15+
"type": "string",
16+
"description": "Repository owner"
17+
},
18+
"permission": {
19+
"type": "string",
20+
"description": "Permission level to grant. Defaults to 'push' when not specified.",
21+
"enum": [
22+
"pull",
23+
"triage",
24+
"push",
25+
"maintain",
26+
"admin"
27+
]
28+
},
29+
"repo": {
30+
"type": "string",
31+
"description": "Repository name"
32+
},
33+
"username": {
34+
"type": "string",
35+
"description": "Username of the collaborator to add"
36+
}
37+
}
38+
},
39+
"name": "add_repository_collaborator"
40+
}

pkg/github/repositories.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,3 +2113,113 @@ func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFun
21132113

21142114
return tool, handler
21152115
}
2116+
2117+
// AddRepositoryCollaborator creates a tool to add a collaborator to a repository with a specific permission level.
2118+
func AddRepositoryCollaborator(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
2119+
tool := mcp.Tool{
2120+
Name: "add_repository_collaborator",
2121+
Description: t("TOOL_ADD_REPOSITORY_COLLABORATOR_DESCRIPTION", "Add a collaborator to a GitHub repository and set their permission level"),
2122+
Annotations: &mcp.ToolAnnotations{
2123+
Title: t("TOOL_ADD_REPOSITORY_COLLABORATOR_USER_TITLE", "Add repository collaborator"),
2124+
ReadOnlyHint: false,
2125+
},
2126+
InputSchema: &jsonschema.Schema{
2127+
Type: "object",
2128+
Properties: map[string]*jsonschema.Schema{
2129+
"owner": {
2130+
Type: "string",
2131+
Description: "Repository owner",
2132+
},
2133+
"repo": {
2134+
Type: "string",
2135+
Description: "Repository name",
2136+
},
2137+
"username": {
2138+
Type: "string",
2139+
Description: "Username of the collaborator to add",
2140+
},
2141+
"permission": {
2142+
Type: "string",
2143+
Description: "Permission level to grant. Defaults to 'push' when not specified.",
2144+
Enum: []any{"pull", "triage", "push", "maintain", "admin"},
2145+
},
2146+
},
2147+
Required: []string{"owner", "repo", "username"},
2148+
},
2149+
}
2150+
2151+
handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2152+
owner, err := RequiredParam[string](args, "owner")
2153+
if err != nil {
2154+
return utils.NewToolResultError(err.Error()), nil, nil
2155+
}
2156+
repo, err := RequiredParam[string](args, "repo")
2157+
if err != nil {
2158+
return utils.NewToolResultError(err.Error()), nil, nil
2159+
}
2160+
username, err := RequiredParam[string](args, "username")
2161+
if err != nil {
2162+
return utils.NewToolResultError(err.Error()), nil, nil
2163+
}
2164+
permission, err := OptionalParam[string](args, "permission")
2165+
if err != nil {
2166+
return utils.NewToolResultError(err.Error()), nil, nil
2167+
}
2168+
2169+
var opts *github.RepositoryAddCollaboratorOptions
2170+
if permission != "" {
2171+
opts = &github.RepositoryAddCollaboratorOptions{
2172+
Permission: permission,
2173+
}
2174+
}
2175+
2176+
client, err := getClient(ctx)
2177+
if err != nil {
2178+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
2179+
}
2180+
2181+
invitation, resp, err := client.Repositories.AddCollaborator(ctx, owner, repo, username, opts)
2182+
if err != nil {
2183+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
2184+
fmt.Sprintf("failed to add collaborator %s to %s/%s", username, owner, repo),
2185+
resp,
2186+
err,
2187+
), nil, nil
2188+
}
2189+
defer func() { _ = resp.Body.Close() }()
2190+
2191+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted {
2192+
body, err := io.ReadAll(resp.Body)
2193+
if err != nil {
2194+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
2195+
}
2196+
return utils.NewToolResultError(fmt.Sprintf("failed to add collaborator: %s", string(body))), nil, nil
2197+
}
2198+
2199+
effectivePermission := permission
2200+
if effectivePermission == "" && invitation != nil {
2201+
effectivePermission = invitation.GetPermissions()
2202+
}
2203+
2204+
var message string
2205+
switch resp.StatusCode {
2206+
case http.StatusCreated, http.StatusAccepted:
2207+
message = fmt.Sprintf("Invitation sent to %s for %s/%s", username, owner, repo)
2208+
if effectivePermission != "" {
2209+
message += fmt.Sprintf(" with %s permission", effectivePermission)
2210+
}
2211+
if invitation != nil && invitation.GetID() != 0 {
2212+
message += fmt.Sprintf(" (invitation id %d)", invitation.GetID())
2213+
}
2214+
case http.StatusNoContent:
2215+
message = fmt.Sprintf("%s already has access to %s/%s", username, owner, repo)
2216+
if effectivePermission != "" {
2217+
message += fmt.Sprintf(" (permission %s)", effectivePermission)
2218+
}
2219+
}
2220+
2221+
return utils.NewToolResultText(message), nil, nil
2222+
})
2223+
2224+
return tool, handler
2225+
}

pkg/github/repositories_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3293,6 +3293,118 @@ func Test_UnstarRepository(t *testing.T) {
32933293
}
32943294
}
32953295

3296+
func Test_AddRepositoryCollaborator(t *testing.T) {
3297+
// Verify tool definition once
3298+
mockClient := github.NewClient(nil)
3299+
tool, _ := AddRepositoryCollaborator(stubGetClientFn(mockClient), translations.NullTranslationHelper)
3300+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
3301+
3302+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
3303+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
3304+
3305+
assert.Equal(t, "add_repository_collaborator", tool.Name)
3306+
assert.NotEmpty(t, tool.Description)
3307+
assert.Contains(t, schema.Properties, "owner")
3308+
assert.Contains(t, schema.Properties, "repo")
3309+
assert.Contains(t, schema.Properties, "username")
3310+
assert.Contains(t, schema.Properties, "permission")
3311+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "username"})
3312+
3313+
tests := []struct {
3314+
name string
3315+
mockedClient *http.Client
3316+
requestArgs map[string]interface{}
3317+
expectError bool
3318+
expectedErrMsg string
3319+
expectedText string
3320+
}{
3321+
{
3322+
name: "invitation created with permission",
3323+
mockedClient: mock.NewMockedHTTPClient(
3324+
mock.WithRequestMatchHandler(
3325+
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
3326+
expect(t, expectations{
3327+
path: "/repos/octo/test-repo/collaborators/new-user",
3328+
requestBody: map[string]any{
3329+
"permission": "maintain",
3330+
},
3331+
}).andThen(func(w http.ResponseWriter, _ *http.Request) {
3332+
w.WriteHeader(http.StatusCreated)
3333+
_, _ = w.Write([]byte(`{"id": 42, "permissions": "maintain"}`))
3334+
}),
3335+
),
3336+
),
3337+
requestArgs: map[string]interface{}{
3338+
"owner": "octo",
3339+
"repo": "test-repo",
3340+
"username": "new-user",
3341+
"permission": "maintain",
3342+
},
3343+
expectedText: "Invitation sent to new-user",
3344+
},
3345+
{
3346+
name: "already collaborator",
3347+
mockedClient: mock.NewMockedHTTPClient(
3348+
mock.WithRequestMatchHandler(
3349+
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
3350+
expectPath(t, "/repos/octo/test-repo/collaborators/existing").andThen(func(w http.ResponseWriter, _ *http.Request) {
3351+
w.WriteHeader(http.StatusNoContent)
3352+
}),
3353+
),
3354+
),
3355+
requestArgs: map[string]interface{}{
3356+
"owner": "octo",
3357+
"repo": "test-repo",
3358+
"username": "existing",
3359+
},
3360+
expectedText: "already has access",
3361+
},
3362+
{
3363+
name: "API error",
3364+
mockedClient: mock.NewMockedHTTPClient(
3365+
mock.WithRequestMatchHandler(
3366+
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
3367+
mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}),
3368+
),
3369+
),
3370+
requestArgs: map[string]interface{}{
3371+
"owner": "octo",
3372+
"repo": "test-repo",
3373+
"username": "blocked-user",
3374+
"permission": "push",
3375+
},
3376+
expectError: true,
3377+
expectedErrMsg: "failed to add collaborator",
3378+
},
3379+
}
3380+
3381+
for _, tc := range tests {
3382+
t.Run(tc.name, func(t *testing.T) {
3383+
client := github.NewClient(tc.mockedClient)
3384+
_, handler := AddRepositoryCollaborator(stubGetClientFn(client), translations.NullTranslationHelper)
3385+
3386+
request := createMCPRequest(tc.requestArgs)
3387+
result, _, err := handler(context.Background(), &request, tc.requestArgs)
3388+
3389+
if tc.expectError {
3390+
require.NotNil(t, result)
3391+
textResult := getTextResult(t, result)
3392+
assert.Contains(t, textResult.Text, tc.expectedErrMsg)
3393+
return
3394+
}
3395+
3396+
require.NoError(t, err)
3397+
require.NotNil(t, result)
3398+
3399+
textContent := getTextResult(t, result)
3400+
assert.Contains(t, textContent.Text, tc.expectedText)
3401+
if perm, ok := tc.requestArgs["permission"]; ok && perm != "" {
3402+
assert.Contains(t, textContent.Text, perm.(string))
3403+
}
3404+
})
3405+
}
3406+
}
3407+
32963408
func Test_RepositoriesGetRepositoryTree(t *testing.T) {
32973409
// Verify tool definition once
32983410
mockClient := github.NewClient(nil)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
186186
toolsets.NewServerTool(CreateBranch(getClient, t)),
187187
toolsets.NewServerTool(PushFiles(getClient, t)),
188188
toolsets.NewServerTool(DeleteFile(getClient, t)),
189+
toolsets.NewServerTool(AddRepositoryCollaborator(getClient, t)),
189190
).
190191
AddResourceTemplates(
191192
toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),

0 commit comments

Comments
 (0)