Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,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 details of a specific issue, including hierarchy relationship signals: has_parent and has_children (booleans, always present), plus a compact parent reference (parent) and sub-issue counts (sub_issues_summary) when those relationships exist. Hierarchy is READ here (and via get_parent / get_sub_issues); there is no writable parent field. To change an issue's parent, use sub_issue_write (add with replace_parent). An issue with no parent returns has_parent:false and omits parent.
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 @@ -934,6 +934,7 @@ 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.
This tool WRITES hierarchy; the parent relationship is read back via issue_read (get returns has_parent / has_children / parent / sub_issues_summary) or issue_read get_parent. There is no writable parent field: to move an issue under a new parent, call 'add' with replace_parent=true.
(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)
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 details of a specific issue, including hierarchy relationship signals: has_parent and has_children (booleans, always present), plus a compact parent reference (parent) and sub-issue counts (sub_issues_summary) when those relationships exist. Hierarchy is READ here (and via get_parent / get_sub_issues); there is no writable parent field. To change an issue's parent, use sub_issue_write (add with replace_parent). An issue with no parent returns has_parent:false and omits parent.\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.\nThis tool WRITES hierarchy; the parent relationship is read back via issue_read (get returns has_parent / has_children / parent / sub_issues_summary) or issue_read get_parent. There is no writable parent field: to move an issue under a new parent, call 'add' with replace_parent=true.\n\t\t\t\t",
"type": "string"
},
"owner": {
Expand Down
152 changes: 148 additions & 4 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 @@ -614,7 +615,7 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
Type: "string",
Description: `The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
1. get - Get details of a specific issue, including hierarchy relationship signals: has_parent and has_children (booleans, always present), plus a compact parent reference (parent) and sub-issue counts (sub_issues_summary) when those relationships exist. Hierarchy is READ here (and via get_parent / get_sub_issues); there is no writable parent field. To change an issue's parent, use sub_issue_write (add with replace_parent). An issue with no parent returns has_parent:false and omits parent.
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 @@ -762,20 +763,70 @@ 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
}

// lockdownRedactedTitle replaces a related issue's title when lockdown mode is enabled and the
// related content cannot be verified as safe. Numeric/structural fields stay intact.
const lockdownRedactedTitle = "[redacted - not from a trusted source]"

// 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
// title is redacted unless the parent content can be verified as safe; counts and booleans are pure
// numbers 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

if parent := enrichment.Parent; parent != nil {
ref := parent.Ref
if lockdownMode && !isSafeParentContent(ctx, cache, parent) {
ref.Title = lockdownRedactedTitle
}
minimalIssue.Parent = &ref
minimalIssue.HasParent = true
}

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

// isSafeParentContent reports whether the parent issue's title 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 redaction.
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 @@ -1211,6 +1262,7 @@ 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.
This tool WRITES hierarchy; the parent relationship is read back via issue_read (get returns has_parent / has_children / parent / sub_issues_summary) or issue_read get_parent. There is no writable parent field: to move an issue under a new parent, call 'add' with replace_parent=true.
`,
},
"owner": {
Expand Down Expand Up @@ -1649,6 +1701,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