Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -891,7 +891,7 @@ The following sets of tools are available:
- `issue_number`: The number of the issue (number, required)
- `method`: The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.
2. get_comments - Get issue comments.
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.
Expand Down Expand Up @@ -968,7 +968,8 @@ The following sets of tools are available:
- 'add' - add a sub-issue to a parent issue in a GitHub repository.
- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.
- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.
(string, required)
Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.
(string, required)
- `owner`: Repository owner (string, required)
- `replace_parent`: When true, replaces the sub-issue's current parent issue. Use with 'add' method only. (boolean, optional)
- `repo`: Repository name (string, required)
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/issue_read.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"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 (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",
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.\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",
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/sub_issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"type": "number"
},
"method": {
"description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t",
"description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\nWrites issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n",
"type": "string"
},
"owner": {
Expand Down
175 changes: 158 additions & 17 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/sanitize"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -612,14 +613,13 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
2. get_comments - Get issue comments.
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.
`,
Description: "The read operation to perform on a single issue.\n" +
"Options are:\n" +
"1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.\n" +
"2. get_comments - Get issue comments.\n" +
"3. get_sub_issues - Get sub-issues (children) of the issue.\n" +
"4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" +
"5. get_labels - Get labels assigned to the issue.\n",
Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"},
},
"owner": {
Expand Down Expand Up @@ -762,20 +762,69 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies,
minimalIssue := convertToMinimalIssue(issue)

// Always drop the verbose REST IssueFieldValues; enrich with the GraphQL
// field_values view instead.
// field_values view and the hierarchy relationship signals instead. The
// enrichment is best-effort: a failure here must never fail `get`.
minimalIssue.IssueFieldValues = nil
if issue != nil && issue.NodeID != nil && *issue.NodeID != "" {
gqlClient, err := deps.GetGQLClient(ctx)
if err == nil {
if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil {
minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID]
if enrichment, err := fetchIssueReadEnrichment(ctx, gqlClient, *issue.NodeID); err == nil {
applyIssueReadEnrichment(ctx, &minimalIssue, enrichment, cache, flags.LockdownMode)
}
}
}

return MarshalledTextResult(minimalIssue), nil
}

// applyIssueReadEnrichment populates the hierarchy relationship signals (has_parent/has_children,
// parent, sub_issues_summary) and field_values onto the minimal issue. In lockdown mode the parent
// reference is omitted unless the parent content can be verified as safe; has_parent and the numeric
// counts are structural routing signals and are always safe to surface.
func applyIssueReadEnrichment(ctx context.Context, minimalIssue *MinimalIssue, enrichment *issueReadEnrichment, cache *lockdown.RepoAccessCache, lockdownMode bool) {
if enrichment == nil {
return
}

minimalIssue.FieldValues = enrichment.FieldValues
minimalIssue.HasParent = ToBoolPtr(enrichment.Parent != nil)
minimalIssue.HasChildren = ToBoolPtr(enrichment.SubIssuesSummary.Total > 0)

if parent := enrichment.Parent; parent != nil {
// Surface the parent reference only when it is safe to expose. Under lockdown an
// unverified (possibly cross-repo) parent is omitted entirely, mirroring how unsafe
// comments and sub-issues are filtered out. has_parent still routes an agent to
// get_parent if it needs to follow up.
if !lockdownMode || isSafeParentContent(ctx, cache, parent) {
ref := parent.Ref
minimalIssue.Parent = &ref
}
}

if enrichment.SubIssuesSummary.Total > 0 {
summary := enrichment.SubIssuesSummary
minimalIssue.SubIssuesSummary = &summary
}
}

// isSafeParentContent reports whether the parent issue reference can be exposed under lockdown mode.
// It fails closed: any inability to positively verify safe content (missing cache, missing author,
// unparseable repository, or a lookup error) results in the parent reference being omitted.
func isSafeParentContent(ctx context.Context, cache *lockdown.RepoAccessCache, parent *issueReadParent) bool {
if cache == nil || parent.AuthorLogin == "" {
return false
}
owner, repo, ok := strings.Cut(parent.Ref.Repository, "/")
if !ok || owner == "" || repo == "" {
return false
}
safe, err := cache.IsSafeContent(ctx, parent.AuthorLogin, owner, repo)
if err != nil {
return false
}
return safe
}

func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
Expand Down Expand Up @@ -1313,12 +1362,12 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The action to perform on a single sub-issue
Options are:
- 'add' - add a sub-issue to a parent issue in a GitHub repository.
- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.
- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.
`,
Description: "The action to perform on a single sub-issue\n" +
"Options are:\n" +
"- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n" +
"- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n" +
"- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n" +
"Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n",
},
"owner": {
Type: "string",
Expand Down Expand Up @@ -1756,6 +1805,98 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie
return result, nil
}

// issueReadEnrichmentQuery fetches, in a single GraphQL round-trip, the custom field values,
// parent reference, and sub-issue summary counts for the issues identified by their node IDs.
// It powers the issue_read `get` relationship signals without adding extra round-trips.
type issueReadEnrichmentQuery struct {
Nodes []struct {
Issue struct {
ID githubv4.ID
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
Parent *struct {
Number githubv4.Int
Title githubv4.String
State githubv4.String
URL githubv4.String
Author struct {
Login githubv4.String
}
Repository struct {
NameWithOwner githubv4.String
}
}
SubIssuesSummary struct {
Total githubv4.Int
Completed githubv4.Int
PercentCompleted githubv4.Int
}
} `graphql:"... on Issue"`
} `graphql:"nodes(ids: $ids)"`
}

// issueReadParent is the parent reference plus the metadata needed to make a lockdown
// safe-content decision about whether the (possibly cross-repo) parent title may be exposed.
type issueReadParent struct {
Ref MinimalIssueRef
AuthorLogin string
}

// issueReadEnrichment is the flattened result of the issue_read `get` enrichment query.
type issueReadEnrichment struct {
FieldValues []MinimalFieldValue
Parent *issueReadParent
SubIssuesSummary MinimalSubIssuesSummary
}

// fetchIssueReadEnrichment runs one GraphQL nodes() query for the given issue node ID and returns
// its field values, parent reference, and sub-issue summary counts. The parent title is sanitized
// here because it may originate from a different repository.
func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, nodeID string) (*issueReadEnrichment, error) {
var q issueReadEnrichmentQuery
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": []githubv4.ID{githubv4.ID(nodeID)}}); err != nil {
return nil, err
}

enrichment := &issueReadEnrichment{}
for _, n := range q.Nodes {
idStr, ok := n.Issue.ID.(string)
if !ok || idStr != nodeID {
continue
}

vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
for _, fv := range n.Issue.IssueFieldValues.Nodes {
if m, ok := fragmentToMinimalFieldValue(fv); ok {
vals = append(vals, m)
}
}
enrichment.FieldValues = vals

if p := n.Issue.Parent; p != nil {
enrichment.Parent = &issueReadParent{
Ref: MinimalIssueRef{
Number: int(p.Number),
Title: sanitize.Sanitize(string(p.Title)),
State: string(p.State),
URL: string(p.URL),
Repository: string(p.Repository.NameWithOwner),
},
AuthorLogin: string(p.Author.Login),
}
}

enrichment.SubIssuesSummary = MinimalSubIssuesSummary{
Total: int(n.Issue.SubIssuesSummary.Total),
Completed: int(n.Issue.SubIssuesSummary.Completed),
PercentCompleted: int(n.Issue.SubIssuesSummary.PercentCompleted),
}
break
}
return enrichment, nil
}

// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
// (e.g. IFC labelling).
Expand Down
Loading
Loading