Skip to content

Commit 9430064

Browse files
zwickCopilotSamMorrowDrums
authored
Add get_parent method to issue_read (#2726)
Add an upward parent read to issue_read, the counterpart to the existing downward get_sub_issues. Uses the GraphQL Issue.parent field and returns a null parent when the issue is not a sub-issue. Kept always-on (not feature gated) to mirror get_sub_issues; the dependency tools remain flag-gated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow <info@sam-morrow.com>
1 parent 7613599 commit 9430064

4 files changed

Lines changed: 180 additions & 6 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -859,8 +859,9 @@ The following sets of tools are available:
859859
Options are:
860860
1. get - Get details of a specific issue.
861861
2. get_comments - Get issue comments.
862-
3. get_sub_issues - Get sub-issues of the issue.
863-
4. get_labels - Get labels assigned to the issue.
862+
3. get_sub_issues - Get sub-issues (children) of the issue.
863+
4. get_parent - Get the parent issue, if this issue is a sub-issue of another.
864+
5. get_labels - Get labels assigned to the issue.
864865
(string, required)
865866
- `owner`: The owner of the repository (string, required)
866867
- `page`: Page number for pagination (min 1) (number, optional)

pkg/github/__toolsnaps__/issue_read.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
"type": "number"
1212
},
1313
"method": {
14-
"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",
14+
"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",
1515
"enum": [
1616
"get",
1717
"get_comments",
1818
"get_sub_issues",
19+
"get_parent",
1920
"get_labels"
2021
],
2122
"type": "string"

pkg/github/issues.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -616,10 +616,11 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
616616
Options are:
617617
1. get - Get details of a specific issue.
618618
2. get_comments - Get issue comments.
619-
3. get_sub_issues - Get sub-issues of the issue.
620-
4. get_labels - Get labels assigned to the issue.
619+
3. get_sub_issues - Get sub-issues (children) of the issue.
620+
4. get_parent - Get the parent issue, if this issue is a sub-issue of another.
621+
5. get_labels - Get labels assigned to the issue.
621622
`,
622-
Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"},
623+
Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"},
623624
},
624625
"owner": {
625626
Type: "string",
@@ -699,6 +700,9 @@ Options are:
699700
case "get_sub_issues":
700701
result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination)
701702
return attachIFC(result), nil, err
703+
case "get_parent":
704+
result, err := GetIssueParent(ctx, gqlClient, owner, repo, issueNumber)
705+
return attachIFC(result), nil, err
702706
case "get_labels":
703707
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
704708
return attachIFC(result), nil, err
@@ -896,6 +900,52 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
896900
return utils.NewToolResultText(string(r)), nil
897901
}
898902

903+
// GetIssueParent returns the parent issue of the given issue, or a null parent
904+
// when the issue is not a sub-issue of any other issue. It reads the GraphQL
905+
// Issue.parent field, the upward counterpart to the downward get_sub_issues read.
906+
func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
907+
var query struct {
908+
Repository struct {
909+
Issue struct {
910+
Parent *struct {
911+
Number githubv4.Int
912+
Title githubv4.String
913+
State githubv4.String
914+
URL githubv4.String
915+
Repository struct {
916+
NameWithOwner githubv4.String
917+
}
918+
}
919+
} `graphql:"issue(number: $issueNumber)"`
920+
} `graphql:"repository(owner: $owner, name: $repo)"`
921+
}
922+
923+
vars := map[string]any{
924+
"owner": githubv4.String(owner),
925+
"repo": githubv4.String(repo),
926+
"issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers
927+
}
928+
929+
if err := client.Query(ctx, &query, vars); err != nil {
930+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil
931+
}
932+
933+
parent := query.Repository.Issue.Parent
934+
if parent == nil {
935+
return MarshalledTextResult(map[string]any{"parent": nil}), nil
936+
}
937+
938+
return MarshalledTextResult(map[string]any{
939+
"parent": map[string]any{
940+
"number": int(parent.Number),
941+
"title": string(parent.Title),
942+
"state": string(parent.State),
943+
"url": string(parent.URL),
944+
"repository": string(parent.Repository.NameWithOwner),
945+
},
946+
}), nil
947+
}
948+
899949
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
900950
// Get current labels on the issue using GraphQL
901951
var query struct {

pkg/github/issues_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3712,6 +3712,128 @@ func Test_GetIssueLabels(t *testing.T) {
37123712
}
37133713
}
37143714

3715+
func Test_GetIssueParent(t *testing.T) {
3716+
t.Parallel()
3717+
3718+
serverTool := IssueRead(translations.NullTranslationHelper)
3719+
3720+
parentMatcherStruct := struct {
3721+
Repository struct {
3722+
Issue struct {
3723+
Parent *struct {
3724+
Number githubv4.Int
3725+
Title githubv4.String
3726+
State githubv4.String
3727+
URL githubv4.String
3728+
Repository struct {
3729+
NameWithOwner githubv4.String
3730+
}
3731+
}
3732+
} `graphql:"issue(number: $issueNumber)"`
3733+
} `graphql:"repository(owner: $owner, name: $repo)"`
3734+
}{}
3735+
3736+
vars := map[string]any{
3737+
"owner": githubv4.String("owner"),
3738+
"repo": githubv4.String("repo"),
3739+
"issueNumber": githubv4.Int(123),
3740+
}
3741+
3742+
tests := []struct {
3743+
name string
3744+
mockedClient *http.Client
3745+
expectToolError bool
3746+
expectedText string
3747+
}{
3748+
{
3749+
name: "issue has a parent",
3750+
mockedClient: githubv4mock.NewMockedHTTPClient(
3751+
githubv4mock.NewQueryMatcher(
3752+
parentMatcherStruct,
3753+
vars,
3754+
githubv4mock.DataResponse(map[string]any{
3755+
"repository": map[string]any{
3756+
"issue": map[string]any{
3757+
"parent": map[string]any{
3758+
"number": githubv4.Int(42),
3759+
"title": githubv4.String("Parent issue"),
3760+
"state": githubv4.String("OPEN"),
3761+
"url": githubv4.String("https://github.com/owner/repo/issues/42"),
3762+
"repository": map[string]any{
3763+
"nameWithOwner": githubv4.String("owner/repo"),
3764+
},
3765+
},
3766+
},
3767+
},
3768+
}),
3769+
),
3770+
),
3771+
expectedText: `"number":42`,
3772+
},
3773+
{
3774+
name: "issue has no parent",
3775+
mockedClient: githubv4mock.NewMockedHTTPClient(
3776+
githubv4mock.NewQueryMatcher(
3777+
parentMatcherStruct,
3778+
vars,
3779+
githubv4mock.DataResponse(map[string]any{
3780+
"repository": map[string]any{
3781+
"issue": map[string]any{
3782+
"parent": nil,
3783+
},
3784+
},
3785+
}),
3786+
),
3787+
),
3788+
expectedText: `"parent":null`,
3789+
},
3790+
{
3791+
name: "graphql error",
3792+
mockedClient: githubv4mock.NewMockedHTTPClient(
3793+
githubv4mock.NewQueryMatcher(
3794+
parentMatcherStruct,
3795+
vars,
3796+
githubv4mock.ErrorResponse("issue not found"),
3797+
),
3798+
),
3799+
expectToolError: true,
3800+
},
3801+
}
3802+
3803+
for _, tc := range tests {
3804+
t.Run(tc.name, func(t *testing.T) {
3805+
gqlClient := githubv4.NewClient(tc.mockedClient)
3806+
client := mustNewGHClient(t, nil)
3807+
deps := BaseDeps{
3808+
Client: client,
3809+
GQLClient: gqlClient,
3810+
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
3811+
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
3812+
}
3813+
handler := serverTool.Handler(deps)
3814+
3815+
request := createMCPRequest(map[string]any{
3816+
"method": "get_parent",
3817+
"owner": "owner",
3818+
"repo": "repo",
3819+
"issue_number": float64(123),
3820+
})
3821+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3822+
3823+
require.NoError(t, err)
3824+
require.NotNil(t, result)
3825+
3826+
if tc.expectToolError {
3827+
assert.True(t, result.IsError)
3828+
return
3829+
}
3830+
assert.False(t, result.IsError)
3831+
textContent := getTextResult(t, result)
3832+
assert.Contains(t, textContent.Text, tc.expectedText)
3833+
})
3834+
}
3835+
}
3836+
37153837
func Test_AddSubIssue(t *testing.T) {
37163838
// Verify tool definition once
37173839
serverTool := SubIssueWrite(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)