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
64 changes: 4 additions & 60 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,33 +201,6 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any {
}
}

func fragmentToIssue(fragment IssueFragment) *github.Issue {
// Convert GraphQL labels to GitHub API labels format
var foundLabels []*github.Label
for _, labelNode := range fragment.Labels.Nodes {
foundLabels = append(foundLabels, &github.Label{
Name: github.Ptr(string(labelNode.Name)),
NodeID: github.Ptr(string(labelNode.ID)),
Description: github.Ptr(string(labelNode.Description)),
})
}

return &github.Issue{
Number: github.Ptr(int(fragment.Number)),
Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))),
CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
User: &github.User{
Login: github.Ptr(string(fragment.Author.Login)),
},
State: github.Ptr(string(fragment.State)),
ID: github.Ptr(fragment.DatabaseID),
Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))),
Labels: foundLabels,
Comments: github.Ptr(int(fragment.Comments.TotalCount)),
}
}

// IssueRead creates a tool to get details of a specific issue in a GitHub repository.
func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Expand Down Expand Up @@ -1584,41 +1557,12 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
), nil, nil
}

// Extract and convert all issue nodes using the common interface
var issues []*github.Issue
var pageInfo struct {
HasNextPage githubv4.Boolean
HasPreviousPage githubv4.Boolean
StartCursor githubv4.String
EndCursor githubv4.String
}
var totalCount int

var resp MinimalIssuesResponse
if queryResult, ok := issueQuery.(IssueQueryResult); ok {
fragment := queryResult.GetIssueFragment()
for _, issue := range fragment.Nodes {
issues = append(issues, fragmentToIssue(issue))
}
pageInfo = fragment.PageInfo
totalCount = fragment.TotalCount
}

// Create response with issues
response := map[string]any{
"issues": issues,
"pageInfo": map[string]any{
"hasNextPage": pageInfo.HasNextPage,
"hasPreviousPage": pageInfo.HasPreviousPage,
"startCursor": string(pageInfo.StartCursor),
"endCursor": string(pageInfo.EndCursor),
},
"totalCount": totalCount,
resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment())
}
out, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal issues: %w", err)
}
return utils.NewToolResultText(string(out)), nil, nil

return MarshalledTextResult(resp), nil, nil
})
}

Expand Down
36 changes: 18 additions & 18 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,6 @@ func Test_ListIssues(t *testing.T) {
expectError bool
errContains string
expectedCount int
verifyOrder func(t *testing.T, issues []*github.Issue)
}{
{
name: "list all issues",
Expand Down Expand Up @@ -1296,31 +1295,32 @@ func Test_ListIssues(t *testing.T) {
require.NoError(t, err)

// Parse the structured response with pagination info
var response struct {
Issues []*github.Issue `json:"issues"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor string `json:"startCursor"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
var response MinimalIssuesResponse
err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)

assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues))

// Verify order if verifyOrder function is provided
if tc.verifyOrder != nil {
tc.verifyOrder(t, response.Issues)
}
// Verify pagination metadata
assert.Equal(t, tc.expectedCount, response.TotalCount)
assert.False(t, response.PageInfo.HasNextPage)
assert.False(t, response.PageInfo.HasPreviousPage)

// Verify that returned issues have expected structure
for _, issue := range response.Issues {
assert.NotNil(t, issue.Number, "Issue should have number")
assert.NotNil(t, issue.Title, "Issue should have title")
assert.NotNil(t, issue.State, "Issue should have state")
assert.NotZero(t, issue.Number, "Issue should have number")
assert.NotEmpty(t, issue.Title, "Issue should have title")
assert.NotEmpty(t, issue.State, "Issue should have state")
assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at")
assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at")
assert.NotNil(t, issue.User, "Issue should have user")
assert.NotEmpty(t, issue.User.Login, "Issue user should have login")
assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)")

// Labels should be flattened to name strings
for _, label := range issue.Labels {
assert.NotEmpty(t, label, "Label should be a non-empty string")
}
}
})
}
Expand Down
62 changes: 55 additions & 7 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"time"

"github.com/google/go-github/v82/github"

"github.com/github/github-mcp-server/pkg/sanitize"
)

// MinimalUser is the output type for user and organization search results.
Expand Down Expand Up @@ -176,7 +178,7 @@ type MinimalIssue struct {
StateReason string `json:"state_reason,omitempty"`
Draft bool `json:"draft,omitempty"`
Locked bool `json:"locked,omitempty"`
HTMLURL string `json:"html_url"`
HTMLURL string `json:"html_url,omitempty"`
User *MinimalUser `json:"user,omitempty"`
AuthorAssociation string `json:"author_association,omitempty"`
Labels []string `json:"labels,omitempty"`
Expand All @@ -191,6 +193,13 @@ type MinimalIssue struct {
IssueType string `json:"issue_type,omitempty"`
}

// MinimalIssuesResponse is the trimmed output for a paginated list of issues.
type MinimalIssuesResponse struct {
Issues []MinimalIssue `json:"issues"`
TotalCount int `json:"totalCount"`
PageInfo MinimalPageInfo `json:"pageInfo"`
}

// MinimalIssueComment is the trimmed output type for issue comment objects to reduce verbosity.
type MinimalIssueComment struct {
ID int64 `json:"id"`
Expand Down Expand Up @@ -376,6 +385,45 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue {
return m
}

func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue {
m := MinimalIssue{
Number: int(fragment.Number),
Title: sanitize.Sanitize(string(fragment.Title)),
Body: sanitize.Sanitize(string(fragment.Body)),
State: string(fragment.State),
Comments: int(fragment.Comments.TotalCount),
CreatedAt: fragment.CreatedAt.Format(time.RFC3339),
UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339),
User: &MinimalUser{
Login: string(fragment.Author.Login),
},
}

for _, label := range fragment.Labels.Nodes {
m.Labels = append(m.Labels, string(label.Name))
}

return m
}

func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse {
minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes))
for _, issue := range fragment.Nodes {
minimalIssues = append(minimalIssues, fragmentToMinimalIssue(issue))
}

return MinimalIssuesResponse{
Issues: minimalIssues,
TotalCount: fragment.TotalCount,
PageInfo: MinimalPageInfo{
HasNextPage: bool(fragment.PageInfo.HasNextPage),
HasPreviousPage: bool(fragment.PageInfo.HasPreviousPage),
StartCursor: string(fragment.PageInfo.StartCursor),
EndCursor: string(fragment.PageInfo.EndCursor),
},
}
}

func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment {
m := MinimalIssueComment{
ID: comment.GetID(),
Expand Down Expand Up @@ -650,10 +698,10 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool)

// MinimalPageInfo contains pagination cursor information.
type MinimalPageInfo struct {
HasNextPage bool `json:"has_next_page"`
HasPreviousPage bool `json:"has_previous_page"`
StartCursor string `json:"start_cursor,omitempty"`
EndCursor string `json:"end_cursor,omitempty"`
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor string `json:"startCursor,omitempty"`
EndCursor string `json:"endCursor,omitempty"`
}

// MinimalReviewComment is the trimmed output type for PR review comment objects.
Expand All @@ -679,8 +727,8 @@ type MinimalReviewThread struct {
// MinimalReviewThreadsResponse is the trimmed output for a paginated list of PR review threads.
type MinimalReviewThreadsResponse struct {
ReviewThreads []MinimalReviewThread `json:"review_threads"`
TotalCount int `json:"total_count"`
PageInfo MinimalPageInfo `json:"page_info"`
TotalCount int `json:"totalCount"`
PageInfo MinimalPageInfo `json:"pageInfo"`
}

func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile {
Expand Down