Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion pkg/github/__toolsnaps__/issue_read.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 53 additions & 3 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
122 changes: 122 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading