Skip to content

Commit 9c527bf

Browse files
committed
Create 'remove sub-issue' tool
1 parent 9b8ebc0 commit 9c527bf

File tree

3 files changed

+368
-0
lines changed

3 files changed

+368
-0
lines changed

pkg/github/issues.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,109 @@ func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc)
364364
}
365365
}
366366

367+
// RemoveSubIssue creates a tool to remove a sub-issue from a parent issue.
368+
func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
369+
return mcp.NewTool("remove_sub_issue",
370+
mcp.WithDescription(t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue in a GitHub repository.")),
371+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
372+
Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove sub-issue"),
373+
ReadOnlyHint: toBoolPtr(false),
374+
}),
375+
mcp.WithString("owner",
376+
mcp.Required(),
377+
mcp.Description("Repository owner"),
378+
),
379+
mcp.WithString("repo",
380+
mcp.Required(),
381+
mcp.Description("Repository name"),
382+
),
383+
mcp.WithNumber("issue_number",
384+
mcp.Required(),
385+
mcp.Description("The number of the parent issue"),
386+
),
387+
mcp.WithNumber("sub_issue_id",
388+
mcp.Required(),
389+
mcp.Description("The ID of the sub-issue to remove"),
390+
),
391+
),
392+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
393+
owner, err := requiredParam[string](request, "owner")
394+
if err != nil {
395+
return mcp.NewToolResultError(err.Error()), nil
396+
}
397+
repo, err := requiredParam[string](request, "repo")
398+
if err != nil {
399+
return mcp.NewToolResultError(err.Error()), nil
400+
}
401+
issueNumber, err := RequiredInt(request, "issue_number")
402+
if err != nil {
403+
return mcp.NewToolResultError(err.Error()), nil
404+
}
405+
subIssueID, err := RequiredInt(request, "sub_issue_id")
406+
if err != nil {
407+
return mcp.NewToolResultError(err.Error()), nil
408+
}
409+
410+
client, err := getClient(ctx)
411+
if err != nil {
412+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
413+
}
414+
415+
// Create the request body
416+
requestBody := map[string]interface{}{
417+
"sub_issue_id": subIssueID,
418+
}
419+
420+
// Since the go-github library might not have sub-issues support yet,
421+
// we'll make a direct HTTP request using the client's HTTP client
422+
reqBodyBytes, err := json.Marshal(requestBody)
423+
if err != nil {
424+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
425+
}
426+
427+
url := fmt.Sprintf("%srepos/%s/%s/issues/%d/sub_issue",
428+
client.BaseURL.String(), owner, repo, issueNumber)
429+
req, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(reqBodyBytes)))
430+
if err != nil {
431+
return nil, fmt.Errorf("failed to create request: %w", err)
432+
}
433+
434+
req.Header.Set("Accept", "application/vnd.github+json")
435+
req.Header.Set("Content-Type", "application/json")
436+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
437+
438+
// Use the same authentication as the GitHub client
439+
httpClient := client.Client()
440+
resp, err := httpClient.Do(req)
441+
if err != nil {
442+
return nil, fmt.Errorf("failed to remove sub-issue: %w", err)
443+
}
444+
defer func() { _ = resp.Body.Close() }()
445+
446+
body, err := io.ReadAll(resp.Body)
447+
if err != nil {
448+
return nil, fmt.Errorf("failed to read response body: %w", err)
449+
}
450+
451+
if resp.StatusCode != http.StatusOK {
452+
return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil
453+
}
454+
455+
// Parse and re-marshal to ensure consistent formatting
456+
var result interface{}
457+
if err := json.Unmarshal(body, &result); err != nil {
458+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
459+
}
460+
461+
r, err := json.Marshal(result)
462+
if err != nil {
463+
return nil, fmt.Errorf("failed to marshal response: %w", err)
464+
}
465+
466+
return mcp.NewToolResultText(string(r)), nil
467+
}
468+
}
469+
367470
// SearchIssues creates a tool to search for issues and pull requests.
368471
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
369472
return mcp.NewTool("search_issues",

pkg/github/issues_test.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,3 +2129,267 @@ func Test_ListSubIssues(t *testing.T) {
21292129
})
21302130
}
21312131
}
2132+
2133+
func Test_RemoveSubIssue(t *testing.T) {
2134+
// Verify tool definition once
2135+
mockClient := github.NewClient(nil)
2136+
tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
2137+
2138+
assert.Equal(t, "remove_sub_issue", tool.Name)
2139+
assert.NotEmpty(t, tool.Description)
2140+
assert.Contains(t, tool.InputSchema.Properties, "owner")
2141+
assert.Contains(t, tool.InputSchema.Properties, "repo")
2142+
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
2143+
assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
2144+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
2145+
2146+
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
2147+
mockIssue := &github.Issue{
2148+
Number: github.Ptr(42),
2149+
Title: github.Ptr("Parent Issue"),
2150+
Body: github.Ptr("This is the parent issue after sub-issue removal"),
2151+
State: github.Ptr("open"),
2152+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
2153+
User: &github.User{
2154+
Login: github.Ptr("testuser"),
2155+
},
2156+
Labels: []*github.Label{
2157+
{
2158+
Name: github.Ptr("enhancement"),
2159+
Color: github.Ptr("84b6eb"),
2160+
Description: github.Ptr("New feature or request"),
2161+
},
2162+
},
2163+
}
2164+
2165+
tests := []struct {
2166+
name string
2167+
mockedClient *http.Client
2168+
requestArgs map[string]interface{}
2169+
expectError bool
2170+
expectedIssue *github.Issue
2171+
expectedErrMsg string
2172+
}{
2173+
{
2174+
name: "successful sub-issue removal",
2175+
mockedClient: mock.NewMockedHTTPClient(
2176+
mock.WithRequestMatchHandler(
2177+
mock.EndpointPattern{
2178+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2179+
Method: "DELETE",
2180+
},
2181+
expectRequestBody(t, map[string]interface{}{
2182+
"sub_issue_id": float64(123),
2183+
}).andThen(
2184+
mockResponse(t, http.StatusOK, mockIssue),
2185+
),
2186+
),
2187+
),
2188+
requestArgs: map[string]interface{}{
2189+
"owner": "owner",
2190+
"repo": "repo",
2191+
"issue_number": float64(42),
2192+
"sub_issue_id": float64(123),
2193+
},
2194+
expectError: false,
2195+
expectedIssue: mockIssue,
2196+
},
2197+
{
2198+
name: "parent issue not found",
2199+
mockedClient: mock.NewMockedHTTPClient(
2200+
mock.WithRequestMatchHandler(
2201+
mock.EndpointPattern{
2202+
Pattern: "/repos/owner/repo/issues/999/sub_issue",
2203+
Method: "DELETE",
2204+
},
2205+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2206+
w.WriteHeader(http.StatusNotFound)
2207+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2208+
}),
2209+
),
2210+
),
2211+
requestArgs: map[string]interface{}{
2212+
"owner": "owner",
2213+
"repo": "repo",
2214+
"issue_number": float64(999),
2215+
"sub_issue_id": float64(123),
2216+
},
2217+
expectError: false,
2218+
expectedErrMsg: "failed to remove sub-issue",
2219+
},
2220+
{
2221+
name: "sub-issue not found",
2222+
mockedClient: mock.NewMockedHTTPClient(
2223+
mock.WithRequestMatchHandler(
2224+
mock.EndpointPattern{
2225+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2226+
Method: "DELETE",
2227+
},
2228+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2229+
w.WriteHeader(http.StatusNotFound)
2230+
_, _ = w.Write([]byte(`{"message": "Sub-issue not found"}`))
2231+
}),
2232+
),
2233+
),
2234+
requestArgs: map[string]interface{}{
2235+
"owner": "owner",
2236+
"repo": "repo",
2237+
"issue_number": float64(42),
2238+
"sub_issue_id": float64(999),
2239+
},
2240+
expectError: false,
2241+
expectedErrMsg: "failed to remove sub-issue",
2242+
},
2243+
{
2244+
name: "bad request - invalid sub_issue_id",
2245+
mockedClient: mock.NewMockedHTTPClient(
2246+
mock.WithRequestMatchHandler(
2247+
mock.EndpointPattern{
2248+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2249+
Method: "DELETE",
2250+
},
2251+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2252+
w.WriteHeader(http.StatusBadRequest)
2253+
_, _ = w.Write([]byte(`{"message": "Invalid sub_issue_id"}`))
2254+
}),
2255+
),
2256+
),
2257+
requestArgs: map[string]interface{}{
2258+
"owner": "owner",
2259+
"repo": "repo",
2260+
"issue_number": float64(42),
2261+
"sub_issue_id": float64(-1),
2262+
},
2263+
expectError: false,
2264+
expectedErrMsg: "failed to remove sub-issue",
2265+
},
2266+
{
2267+
name: "repository not found",
2268+
mockedClient: mock.NewMockedHTTPClient(
2269+
mock.WithRequestMatchHandler(
2270+
mock.EndpointPattern{
2271+
Pattern: "/repos/nonexistent/repo/issues/42/sub_issue",
2272+
Method: "DELETE",
2273+
},
2274+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2275+
w.WriteHeader(http.StatusNotFound)
2276+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
2277+
}),
2278+
),
2279+
),
2280+
requestArgs: map[string]interface{}{
2281+
"owner": "nonexistent",
2282+
"repo": "repo",
2283+
"issue_number": float64(42),
2284+
"sub_issue_id": float64(123),
2285+
},
2286+
expectError: false,
2287+
expectedErrMsg: "failed to remove sub-issue",
2288+
},
2289+
{
2290+
name: "insufficient permissions",
2291+
mockedClient: mock.NewMockedHTTPClient(
2292+
mock.WithRequestMatchHandler(
2293+
mock.EndpointPattern{
2294+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2295+
Method: "DELETE",
2296+
},
2297+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2298+
w.WriteHeader(http.StatusForbidden)
2299+
_, _ = w.Write([]byte(`{"message": "Must have write access to repository"}`))
2300+
}),
2301+
),
2302+
),
2303+
requestArgs: map[string]interface{}{
2304+
"owner": "owner",
2305+
"repo": "repo",
2306+
"issue_number": float64(42),
2307+
"sub_issue_id": float64(123),
2308+
},
2309+
expectError: false,
2310+
expectedErrMsg: "failed to remove sub-issue",
2311+
},
2312+
{
2313+
name: "missing required parameter owner",
2314+
mockedClient: mock.NewMockedHTTPClient(
2315+
mock.WithRequestMatchHandler(
2316+
mock.EndpointPattern{
2317+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2318+
Method: "DELETE",
2319+
},
2320+
mockResponse(t, http.StatusOK, mockIssue),
2321+
),
2322+
),
2323+
requestArgs: map[string]interface{}{
2324+
"repo": "repo",
2325+
"issue_number": float64(42),
2326+
"sub_issue_id": float64(123),
2327+
},
2328+
expectError: false,
2329+
expectedErrMsg: "missing required parameter: owner",
2330+
},
2331+
{
2332+
name: "missing required parameter sub_issue_id",
2333+
mockedClient: mock.NewMockedHTTPClient(
2334+
mock.WithRequestMatchHandler(
2335+
mock.EndpointPattern{
2336+
Pattern: "/repos/owner/repo/issues/42/sub_issue",
2337+
Method: "DELETE",
2338+
},
2339+
mockResponse(t, http.StatusOK, mockIssue),
2340+
),
2341+
),
2342+
requestArgs: map[string]interface{}{
2343+
"owner": "owner",
2344+
"repo": "repo",
2345+
"issue_number": float64(42),
2346+
},
2347+
expectError: false,
2348+
expectedErrMsg: "missing required parameter: sub_issue_id",
2349+
},
2350+
}
2351+
2352+
for _, tc := range tests {
2353+
t.Run(tc.name, func(t *testing.T) {
2354+
// Setup client with mock
2355+
client := github.NewClient(tc.mockedClient)
2356+
_, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
2357+
2358+
// Create call request
2359+
request := createMCPRequest(tc.requestArgs)
2360+
2361+
// Call handler
2362+
result, err := handler(context.Background(), request)
2363+
2364+
// Verify results
2365+
if tc.expectError {
2366+
require.Error(t, err)
2367+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
2368+
return
2369+
}
2370+
2371+
if tc.expectedErrMsg != "" {
2372+
require.NotNil(t, result)
2373+
textContent := getTextResult(t, result)
2374+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
2375+
return
2376+
}
2377+
2378+
require.NoError(t, err)
2379+
2380+
// Parse the result and get the text content if no error
2381+
textContent := getTextResult(t, result)
2382+
2383+
// Unmarshal and verify the result
2384+
var returnedIssue github.Issue
2385+
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
2386+
require.NoError(t, err)
2387+
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
2388+
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
2389+
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
2390+
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
2391+
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
2392+
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
2393+
})
2394+
}
2395+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
6161
toolsets.NewServerTool(UpdateIssue(getClient, t)),
6262
toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
6363
toolsets.NewServerTool(AddSubIssue(getClient, t)),
64+
toolsets.NewServerTool(RemoveSubIssue(getClient, t)),
6465
)
6566
users := toolsets.NewToolset("users", "GitHub User related tools").
6667
AddReadTools(

0 commit comments

Comments
 (0)