diff --git a/README.md b/README.md index 1b72ff22f..614404c0a 100644 --- a/README.md +++ b/README.md @@ -859,8 +859,9 @@ The following sets of tools are available: Options are: 1. get - Get details of a specific issue. 2. get_comments - Get issue comments. - 3. get_sub_issues - Get sub-issues of the issue. - 4. get_labels - Get labels assigned to the issue. + 3. get_sub_issues - Get sub-issues (children) of the issue. + 4. get_parent - Get the parent issue, if this issue is a sub-issue of another. + 5. get_labels - Get labels assigned to the issue. (string, required) - `owner`: The owner of the repository (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 21aa361f5..9b882c79b 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -11,11 +11,12 @@ "type": "number" }, "method": { - "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n", "enum": [ "get", "get_comments", "get_sub_issues", + "get_parent", "get_labels" ], "type": "string" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index fa685ba67..5479f3579 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -616,10 +616,11 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { Options are: 1. get - Get details of a specific issue. 2. get_comments - Get issue comments. -3. get_sub_issues - Get sub-issues of the issue. -4. get_labels - Get labels assigned to the issue. +3. get_sub_issues - Get sub-issues (children) of the issue. +4. get_parent - Get the parent issue, if this issue is a sub-issue of another. +5. get_labels - Get labels assigned to the issue. `, - Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"}, + Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, }, "owner": { Type: "string", @@ -699,6 +700,9 @@ Options are: case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) return attachIFC(result), nil, err + case "get_parent": + result, err := GetIssueParent(ctx, gqlClient, owner, repo, issueNumber) + return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) return attachIFC(result), nil, err @@ -896,6 +900,52 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc return utils.NewToolResultText(string(r)), nil } +// GetIssueParent returns the parent issue of the given issue, or a null parent +// when the issue is not a sub-issue of any other issue. It reads the GraphQL +// Issue.parent field, the upward counterpart to the downward get_sub_issues read. +func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + var query struct { + Repository struct { + Issue struct { + Parent *struct { + Number githubv4.Int + Title githubv4.String + State githubv4.String + URL githubv4.String + Repository struct { + NameWithOwner githubv4.String + } + } + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil + } + + parent := query.Repository.Issue.Parent + if parent == nil { + return MarshalledTextResult(map[string]any{"parent": nil}), nil + } + + return MarshalledTextResult(map[string]any{ + "parent": map[string]any{ + "number": int(parent.Number), + "title": string(parent.Title), + "state": string(parent.State), + "url": string(parent.URL), + "repository": string(parent.Repository.NameWithOwner), + }, + }), nil +} + func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { // Get current labels on the issue using GraphQL var query struct { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 27fad9252..2dea639f8 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -3712,6 +3712,128 @@ func Test_GetIssueLabels(t *testing.T) { } } +func Test_GetIssueParent(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + + parentMatcherStruct := struct { + Repository struct { + Issue struct { + Parent *struct { + Number githubv4.Int + Title githubv4.String + State githubv4.String + URL githubv4.String + Repository struct { + NameWithOwner githubv4.String + } + } + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{} + + vars := map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + } + + tests := []struct { + name string + mockedClient *http.Client + expectToolError bool + expectedText string + }{ + { + name: "issue has a parent", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + parentMatcherStruct, + vars, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "parent": map[string]any{ + "number": githubv4.Int(42), + "title": githubv4.String("Parent issue"), + "state": githubv4.String("OPEN"), + "url": githubv4.String("https://github.com/owner/repo/issues/42"), + "repository": map[string]any{ + "nameWithOwner": githubv4.String("owner/repo"), + }, + }, + }, + }, + }), + ), + ), + expectedText: `"number":42`, + }, + { + name: "issue has no parent", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + parentMatcherStruct, + vars, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "parent": nil, + }, + }, + }), + ), + ), + expectedText: `"parent":null`, + }, + { + name: "graphql error", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + parentMatcherStruct, + vars, + githubv4mock.ErrorResponse("issue not found"), + ), + ), + expectToolError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClient := githubv4.NewClient(tc.mockedClient) + client := mustNewGHClient(t, nil) + deps := BaseDeps{ + Client: client, + GQLClient: gqlClient, + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get_parent", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.NotNil(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError) + return + } + assert.False(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedText) + }) + } +} + func Test_AddSubIssue(t *testing.T) { // Verify tool definition once serverTool := SubIssueWrite(translations.NullTranslationHelper)