Skip to content

Commit d9b1b70

Browse files
zwickCopilot
andcommitted
Add get_parent method to issue_read
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>
1 parent bdc7180 commit d9b1b70

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
@@ -733,10 +733,11 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
733733
Options are:
734734
1. get - Get details of a specific issue.
735735
2. get_comments - Get issue comments.
736-
3. get_sub_issues - Get sub-issues of the issue.
737-
4. get_labels - Get labels assigned to the issue.
736+
3. get_sub_issues - Get sub-issues (children) of the issue.
737+
4. get_parent - Get the parent issue, if this issue is a sub-issue of another.
738+
5. get_labels - Get labels assigned to the issue.
738739
`,
739-
Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"},
740+
Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"},
740741
},
741742
"owner": {
742743
Type: "string",
@@ -816,6 +817,9 @@ Options are:
816817
case "get_sub_issues":
817818
result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination)
818819
return attachIFC(result), nil, err
820+
case "get_parent":
821+
result, err := GetIssueParent(ctx, gqlClient, owner, repo, issueNumber)
822+
return attachIFC(result), nil, err
819823
case "get_labels":
820824
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
821825
return attachIFC(result), nil, err
@@ -1015,6 +1019,52 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
10151019
return utils.NewToolResultText(string(r)), nil
10161020
}
10171021

1022+
// GetIssueParent returns the parent issue of the given issue, or a null parent
1023+
// when the issue is not a sub-issue of any other issue. It reads the GraphQL
1024+
// Issue.parent field, the upward counterpart to the downward get_sub_issues read.
1025+
func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
1026+
var query struct {
1027+
Repository struct {
1028+
Issue struct {
1029+
Parent *struct {
1030+
Number githubv4.Int
1031+
Title githubv4.String
1032+
State githubv4.String
1033+
URL githubv4.String
1034+
Repository struct {
1035+
NameWithOwner githubv4.String
1036+
}
1037+
}
1038+
} `graphql:"issue(number: $issueNumber)"`
1039+
} `graphql:"repository(owner: $owner, name: $repo)"`
1040+
}
1041+
1042+
vars := map[string]any{
1043+
"owner": githubv4.String(owner),
1044+
"repo": githubv4.String(repo),
1045+
"issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers
1046+
}
1047+
1048+
if err := client.Query(ctx, &query, vars); err != nil {
1049+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil
1050+
}
1051+
1052+
parent := query.Repository.Issue.Parent
1053+
if parent == nil {
1054+
return MarshalledTextResult(map[string]any{"parent": nil}), nil
1055+
}
1056+
1057+
return MarshalledTextResult(map[string]any{
1058+
"parent": map[string]any{
1059+
"number": int(parent.Number),
1060+
"title": string(parent.Title),
1061+
"state": string(parent.State),
1062+
"url": string(parent.URL),
1063+
"repository": string(parent.Repository.NameWithOwner),
1064+
},
1065+
}), nil
1066+
}
1067+
10181068
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
10191069
// Get current labels on the issue using GraphQL
10201070
var query struct {

pkg/github/issues_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3822,6 +3822,128 @@ func Test_GetIssueLabels(t *testing.T) {
38223822
}
38233823
}
38243824

3825+
func Test_GetIssueParent(t *testing.T) {
3826+
t.Parallel()
3827+
3828+
serverTool := IssueRead(translations.NullTranslationHelper)
3829+
3830+
parentMatcherStruct := struct {
3831+
Repository struct {
3832+
Issue struct {
3833+
Parent *struct {
3834+
Number githubv4.Int
3835+
Title githubv4.String
3836+
State githubv4.String
3837+
URL githubv4.String
3838+
Repository struct {
3839+
NameWithOwner githubv4.String
3840+
}
3841+
}
3842+
} `graphql:"issue(number: $issueNumber)"`
3843+
} `graphql:"repository(owner: $owner, name: $repo)"`
3844+
}{}
3845+
3846+
vars := map[string]any{
3847+
"owner": githubv4.String("owner"),
3848+
"repo": githubv4.String("repo"),
3849+
"issueNumber": githubv4.Int(123),
3850+
}
3851+
3852+
tests := []struct {
3853+
name string
3854+
mockedClient *http.Client
3855+
expectToolError bool
3856+
expectedText string
3857+
}{
3858+
{
3859+
name: "issue has a parent",
3860+
mockedClient: githubv4mock.NewMockedHTTPClient(
3861+
githubv4mock.NewQueryMatcher(
3862+
parentMatcherStruct,
3863+
vars,
3864+
githubv4mock.DataResponse(map[string]any{
3865+
"repository": map[string]any{
3866+
"issue": map[string]any{
3867+
"parent": map[string]any{
3868+
"number": githubv4.Int(42),
3869+
"title": githubv4.String("Parent issue"),
3870+
"state": githubv4.String("OPEN"),
3871+
"url": githubv4.String("https://github.com/owner/repo/issues/42"),
3872+
"repository": map[string]any{
3873+
"nameWithOwner": githubv4.String("owner/repo"),
3874+
},
3875+
},
3876+
},
3877+
},
3878+
}),
3879+
),
3880+
),
3881+
expectedText: `"number":42`,
3882+
},
3883+
{
3884+
name: "issue has no parent",
3885+
mockedClient: githubv4mock.NewMockedHTTPClient(
3886+
githubv4mock.NewQueryMatcher(
3887+
parentMatcherStruct,
3888+
vars,
3889+
githubv4mock.DataResponse(map[string]any{
3890+
"repository": map[string]any{
3891+
"issue": map[string]any{
3892+
"parent": nil,
3893+
},
3894+
},
3895+
}),
3896+
),
3897+
),
3898+
expectedText: `"parent":null`,
3899+
},
3900+
{
3901+
name: "graphql error",
3902+
mockedClient: githubv4mock.NewMockedHTTPClient(
3903+
githubv4mock.NewQueryMatcher(
3904+
parentMatcherStruct,
3905+
vars,
3906+
githubv4mock.ErrorResponse("issue not found"),
3907+
),
3908+
),
3909+
expectToolError: true,
3910+
},
3911+
}
3912+
3913+
for _, tc := range tests {
3914+
t.Run(tc.name, func(t *testing.T) {
3915+
gqlClient := githubv4.NewClient(tc.mockedClient)
3916+
client := mustNewGHClient(t, nil)
3917+
deps := BaseDeps{
3918+
Client: client,
3919+
GQLClient: gqlClient,
3920+
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
3921+
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
3922+
}
3923+
handler := serverTool.Handler(deps)
3924+
3925+
request := createMCPRequest(map[string]any{
3926+
"method": "get_parent",
3927+
"owner": "owner",
3928+
"repo": "repo",
3929+
"issue_number": float64(123),
3930+
})
3931+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3932+
3933+
require.NoError(t, err)
3934+
require.NotNil(t, result)
3935+
3936+
if tc.expectToolError {
3937+
assert.True(t, result.IsError)
3938+
return
3939+
}
3940+
assert.False(t, result.IsError)
3941+
textContent := getTextResult(t, result)
3942+
assert.Contains(t, textContent.Text, tc.expectedText)
3943+
})
3944+
}
3945+
}
3946+
38253947
func Test_AddSubIssue(t *testing.T) {
38263948
// Verify tool definition once
38273949
serverTool := SubIssueWrite(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)