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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy