From 587dbabec44b4eebaa7a9ac914ff7e10cb56b650 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Tue, 5 Aug 2025 15:23:10 +0100 Subject: [PATCH 01/13] initial changes --- pkg/github/issues.go | 66 ++++++++++++++++++++++++++++++++------------ pkg/github/tools.go | 2 +- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f718c37cb..2e3a3b22e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -724,7 +724,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t } // ListIssues creates a tool to list and filter repository issues -func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_issues", mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ @@ -818,30 +818,62 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to opts.ListOptions.PerPage = int(perPage) } - client, err := getClient(ctx) + client, err := getGQLClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } - issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list issues: %w", err) + + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"discussionCategories(first: $first)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + //"owner": githubv4.String(params.Owner), + //"repo": githubv4.String(params.Repo), + "first": githubv4.Int(25), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", string(body))), nil + var categories []map[string]string + for _, c := range q.Repository.DiscussionCategories.Nodes { + categories = append(categories, map[string]string{ + "id": fmt.Sprint(c.ID), + "name": string(c.Name), + }) } - r, err := json.Marshal(issues) - if err != nil { - return nil, fmt.Errorf("failed to marshal issues: %w", err) + // Create response with pagination info + response := map[string]interface{}{ + "categories": categories, + "pageInfo": map[string]interface{}{ + "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, + "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, + "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), + "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), + }, + "totalCount": q.Repository.DiscussionCategories.TotalCount, } - return mcp.NewToolResultText(string(r)), nil + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + } + return mcp.NewToolResultText(string(out)), nil } } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7fb1d39c0..4ff733d7e 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -51,7 +51,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG AddReadTools( toolsets.NewServerTool(GetIssue(getClient, t)), toolsets.NewServerTool(SearchIssues(getClient, t)), - toolsets.NewServerTool(ListIssues(getClient, t)), + toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), ). From 18553d5d71f53387f510bd79764ba354c121dceb Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Tue, 5 Aug 2025 16:56:10 +0100 Subject: [PATCH 02/13] Further advances on list_issues tool to use GRPC --- pkg/github/issues.go | 113 ++++++++++++++++++++++++------------------- 1 file changed, 64 insertions(+), 49 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 2e3a3b22e..1a75de6bc 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -741,7 +741,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun ), mcp.WithString("state", mcp.Description("Filter by state"), - mcp.Enum("open", "closed", "all"), + mcp.Enum("OPEN", "CLOSED"), ), mcp.WithArray("labels", mcp.Description("Filter by labels"), @@ -751,13 +751,13 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun }, ), ), - mcp.WithString("sort", - mcp.Description("Sort order"), - mcp.Enum("created", "updated", "comments"), + mcp.WithString("orderBy", + mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), + mcp.Enum("CREATED_AT", "UPDATED_AT"), ), mcp.WithString("direction", - mcp.Description("Sort direction"), - mcp.Enum("asc", "desc"), + mcp.Description("Order direction."), + mcp.Enum("ASC", "DESC"), ), mcp.WithString("since", mcp.Description("Filter by date (ISO 8601 timestamp)"), @@ -774,49 +774,56 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } - opts := &github.IssueListByRepoOptions{} - // Set optional parameters if provided - opts.State, err = OptionalParam[string](request, "state") + state, err := OptionalParam[string](request, "state") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + if state == "" { + state = "OPEN" // Default to OPEN if not provided + } // Get labels - opts.Labels, err = OptionalStringArrayParam(request, "labels") + //labels, err := OptionalStringArrayParam(request, "labels") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - opts.Sort, err = OptionalParam[string](request, "sort") + orderBy, err := OptionalParam[string](request, "orderBy") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + if orderBy == "" { + orderBy = "CREATED_AT" + } - opts.Direction, err = OptionalParam[string](request, "direction") + direction, err := OptionalParam[string](request, "direction") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + if direction == "" { + direction = "DESC" + } since, err := OptionalParam[string](request, "since") if err != nil { return mcp.NewToolResultError(err.Error()), nil } if since != "" { - timestamp, err := parseISOTimestamp(since) + //timestamp, err := parseISOTimestamp(since) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil } - opts.Since = timestamp + //since = timestamp } - if page, ok := request.GetArguments()["page"].(float64); ok { - opts.ListOptions.Page = int(page) - } + //if page, ok := request.GetArguments()["page"].(float64); ok { + //listOptions.Page = int(page) + //} - if perPage, ok := request.GetArguments()["perPage"].(float64); ok { - opts.ListOptions.PerPage = int(perPage) - } + //if perPage, ok := request.GetArguments()["perPage"].(float64); ok { + //.PerPage = int(perPage) + //} client, err := getGQLClient(ctx) if err != nil { @@ -825,53 +832,61 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun var q struct { Repository struct { - DiscussionCategories struct { + Issues struct { Nodes []struct { - ID githubv4.ID - Name githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String + Number githubv4.Int + Title githubv4.String + Body githubv4.String + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + } + } `graphql:"labels(first: 10)"` } - TotalCount int - } `graphql:"discussionCategories(first: $first)"` + } `graphql:"issues(first: $first, states: $states, orderBy: {field: $orderBy, direction: $direction})"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ - //"owner": githubv4.String(params.Owner), - //"repo": githubv4.String(params.Repo), - "first": githubv4.Int(25), + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(100), + "states": []githubv4.IssueState{githubv4.IssueState(state)}, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } - var categories []map[string]string - for _, c := range q.Repository.DiscussionCategories.Nodes { - categories = append(categories, map[string]string{ - "id": fmt.Sprint(c.ID), - "name": string(c.Name), + var issues []map[string]interface{} + for _, issue := range q.Repository.Issues.Nodes { + var labels []string + for _, label := range issue.Labels.Nodes { + labels = append(labels, string(label.Name)) + } + + issues = append(issues, map[string]interface{}{ + "number": int(issue.Number), + "title": string(issue.Title), + "body": string(issue.Body), + "author": string(issue.Author.Login), + "createdAt": issue.CreatedAt.Time, + "labels": labels, }) } - // Create response with pagination info + // Create response with issues response := map[string]interface{}{ - "categories": categories, - "pageInfo": map[string]interface{}{ - "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), - "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), - }, - "totalCount": q.Repository.DiscussionCategories.TotalCount, + "issues": issues, } out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + return nil, fmt.Errorf("failed to marshal issues: %w", err) } return mcp.NewToolResultText(string(out)), nil } From ae6dc68059ae349e2760ba790e11e8f85d20fca2 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Wed, 6 Aug 2025 14:10:55 +0100 Subject: [PATCH 03/13] Updating pagination for Graphql ListIssues --- pkg/github/issues.go | 157 +++++++++++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 44 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1a75de6bc..a10c6e685 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -18,6 +18,34 @@ import ( "github.com/shurcooL/githubv4" ) +var ListIssuesQuery struct { + Repository struct { + Issues struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + } + } `graphql:"labels(first: 10)"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue", @@ -726,7 +754,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t // ListIssues creates a tool to list and filter repository issues func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_issues", - mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")), + mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), ReadOnlyHint: ToBoolPtr(true), @@ -740,6 +768,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun mcp.Description("Repository name"), ), mcp.WithString("state", + mcp.Required(), mcp.Description("Filter by state"), mcp.Enum("OPEN", "CLOSED"), ), @@ -752,17 +781,17 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun ), ), mcp.WithString("orderBy", - mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), + mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), mcp.Enum("CREATED_AT", "UPDATED_AT"), ), mcp.WithString("direction", - mcp.Description("Order direction."), + mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), mcp.Enum("ASC", "DESC"), ), mcp.WithString("since", mcp.Description("Filter by date (ISO 8601 timestamp)"), ), - WithPagination(), + WithCursorPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -775,16 +804,13 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } // Set optional parameters if provided - state, err := OptionalParam[string](request, "state") + state, err := RequiredParam[string](request, "state") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - if state == "" { - state = "OPEN" // Default to OPEN if not provided - } // Get labels - //labels, err := OptionalStringArrayParam(request, "labels") + labels, err := OptionalStringArrayParam(request, "labels") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -793,6 +819,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + //If orderBy is empty, default to CREATED_AT if orderBy == "" { orderBy = "CREATED_AT" } @@ -801,6 +828,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + //If direction is empty, default to DESC if direction == "" { direction = "DESC" } @@ -809,64 +837,99 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + + var sinceTime time.Time if since != "" { - //timestamp, err := parseISOTimestamp(since) + sinceTime, err = parseISOTimestamp(since) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil } - //since = timestamp + } + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + + // Check if someone tried to use page-based pagination instead of cursor-based + if _, pageProvided := request.GetArguments()["page"]; pageProvided { + return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil } - //if page, ok := request.GetArguments()["page"].(float64); ok { - //listOptions.Page = int(page) - //} + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided - //if perPage, ok := request.GetArguments()["perPage"].(float64); ok { - //.PerPage = int(perPage) - //} + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } + + if paginationParams.After == nil { + defaultAfter := string("") + paginationParams.After = &defaultAfter + } + + // Use default of 30 if pagination was not explicitly provided + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } - var q struct { - Repository struct { - Issues struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - Author struct { - Login githubv4.String - } - CreatedAt githubv4.DateTime - Labels struct { - Nodes []struct { - Name githubv4.String - } - } `graphql:"labels(first: 10)"` - } - } `graphql:"issues(first: $first, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), - "first": githubv4.Int(100), "states": []githubv4.IssueState{githubv4.IssueState(state)}, "orderBy": githubv4.IssueOrderField(orderBy), "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &q, vars); err != nil { + + if err := client.Query(ctx, &ListIssuesQuery, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } + //We must filter based on labels after fetching all issues var issues []map[string]interface{} - for _, issue := range q.Repository.Issues.Nodes { - var labels []string + for _, issue := range ListIssuesQuery.Repository.Issues.Nodes { + var issueLabels []string for _, label := range issue.Labels.Nodes { - labels = append(labels, string(label.Name)) + issueLabels = append(issueLabels, string(label.Name)) + } + + // Filter by since date if specified + if !sinceTime.IsZero() && issue.CreatedAt.Time.Before(sinceTime) { + continue // Skip issues created before the since date + } + + // Filter by labels if specified + if len(labels) > 0 { + hasMatchingLabel := false + for _, requestedLabel := range labels { + for _, issueLabel := range issueLabels { + if strings.EqualFold(requestedLabel, issueLabel) { + hasMatchingLabel = true + break + } + } + if hasMatchingLabel { + break + } + } + if !hasMatchingLabel { + continue // Skip this issue as it doesn't match any requested labels + } } issues = append(issues, map[string]interface{}{ @@ -875,15 +938,21 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun "body": string(issue.Body), "author": string(issue.Author.Login), "createdAt": issue.CreatedAt.Time, - "labels": labels, + "labels": issueLabels, }) } // Create response with issues response := map[string]interface{}{ "issues": issues, + "pageInfo": map[string]interface{}{ + "hasNextPage": ListIssuesQuery.Repository.Issues.PageInfo.HasNextPage, + "hasPreviousPage": ListIssuesQuery.Repository.Issues.PageInfo.HasPreviousPage, + "startCursor": string(ListIssuesQuery.Repository.Issues.PageInfo.StartCursor), + "endCursor": string(ListIssuesQuery.Repository.Issues.PageInfo.EndCursor), + }, + "totalCount": ListIssuesQuery.Repository.Issues.TotalCount, } - out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal issues: %w", err) From 653c7f9dedf04d22a05272e108818701e297f4fc Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Thu, 7 Aug 2025 09:58:48 +0100 Subject: [PATCH 04/13] Sorting data structures & returning mapped Issue --- pkg/github/issues.go | 80 ++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index a10c6e685..d226520ce 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -21,20 +21,7 @@ import ( var ListIssuesQuery struct { Repository struct { Issues struct { - Nodes []struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - Author struct { - Login githubv4.String - } - CreatedAt githubv4.DateTime - Labels struct { - Nodes []struct { - Name githubv4.String - } - } `graphql:"labels(first: 10)"` - } + Nodes []IssueFragment `graphql:"nodes"` PageInfo struct { HasNextPage githubv4.Boolean HasPreviousPage githubv4.Boolean @@ -46,6 +33,54 @@ var ListIssuesQuery struct { } `graphql:"repository(owner: $owner, name: $repo)"` } +// NodeFragment represents a fragment of an issue node in the GraphQL API. +type IssueFragment struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + State githubv4.String + DatabaseID int64 + + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + Id githubv4.String + Description githubv4.String + } + } `graphql:"labels(first: 10)"` +} + +func fragmentToIssue(fragment IssueFragment) *github.Issue { + // Convert GraphQL labels to GitHub API labels format + var labels []*github.Label + for _, labelNode := range fragment.Labels.Nodes { + labels = append(labels, &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(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(string(fragment.Body)), + Labels: labels, + } +} + // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue", @@ -865,11 +900,6 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return nil, err } - if paginationParams.After == nil { - defaultAfter := string("") - paginationParams.After = &defaultAfter - } - // Use default of 30 if pagination was not explicitly provided if !paginationExplicit { defaultFirst := int32(DefaultGraphQLPageSize) @@ -901,7 +931,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } //We must filter based on labels after fetching all issues - var issues []map[string]interface{} + var issues []*github.Issue for _, issue := range ListIssuesQuery.Repository.Issues.Nodes { var issueLabels []string for _, label := range issue.Labels.Nodes { @@ -931,15 +961,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun continue // Skip this issue as it doesn't match any requested labels } } - - issues = append(issues, map[string]interface{}{ - "number": int(issue.Number), - "title": string(issue.Title), - "body": string(issue.Body), - "author": string(issue.Author.Login), - "createdAt": issue.CreatedAt.Time, - "labels": issueLabels, - }) + issues = append(issues, fragmentToIssue(issue)) } // Create response with issues From 011b56f5e81e821596baffdeda0b754ab8771723 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Thu, 7 Aug 2025 11:32:25 +0100 Subject: [PATCH 05/13] Adding dynamic label queries --- pkg/github/issues.go | 165 ++++++++++++++++------------ pkg/github/queries.json | 230 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 65 deletions(-) create mode 100644 pkg/github/queries.json diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d226520ce..a1de0c067 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -18,22 +18,7 @@ import ( "github.com/shurcooL/githubv4" ) -var ListIssuesQuery struct { - Repository struct { - Issues struct { - Nodes []IssueFragment `graphql:"nodes"` - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - -// NodeFragment represents a fragment of an issue node in the GraphQL API. +// IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { Number githubv4.Int Title githubv4.String @@ -55,11 +40,57 @@ type IssueFragment struct { } `graphql:"labels(first: 10)"` } +// Common interface for all issue query types +type IssueQueryResult interface { + GetIssueFragment() IssueQueryFragment +} + +type IssueQueryFragment struct { + Nodes []IssueFragment `graphql:"nodes"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int +} + +// ListIssuesQuery is the root query structure for fetching issues with optional label filtering. +type ListIssuesQueryType struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// ListIssuesQueryNoLabels is the query structure for fetching issues without label filtering. +type ListIssuesQueryNoLabelsType struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// Implement the interface for both query types +func (q *ListIssuesQueryType) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func (q *ListIssuesQueryNoLabelsType) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func getIssueQueryType(hasLabels bool) any { + if hasLabels { + return &ListIssuesQueryType{} + } + return &ListIssuesQueryNoLabelsType{} +} + func fragmentToIssue(fragment IssueFragment) *github.Issue { // Convert GraphQL labels to GitHub API labels format - var labels []*github.Label + var foundLabels []*github.Label for _, labelNode := range fragment.Labels.Nodes { - labels = append(labels, &github.Label{ + foundLabels = append(foundLabels, &github.Label{ Name: github.Ptr(string(labelNode.Name)), NodeID: github.Ptr(string(labelNode.Id)), Description: github.Ptr(string(labelNode.Description)), @@ -77,7 +108,7 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { State: github.Ptr(string(fragment.State)), ID: github.Ptr(fragment.DatabaseID), Body: github.Ptr(string(fragment.Body)), - Labels: labels, + Labels: foundLabels, } } @@ -850,6 +881,11 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } + //If labels is empty, default to nil for gql query + if len(labels) == 0 { + labels = nil + } + orderBy, err := OptionalParam[string](request, "orderBy") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -868,18 +904,19 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun direction = "DESC" } - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // since, err := OptionalParam[string](request, "since") + // if err != nil { + // return mcp.NewToolResultError(err.Error()), nil + // } + + // var sinceTime time.Time + // if since != "" { + // sinceTime, err = parseISOTimestamp(since) + // if err != nil { + // return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + // } + // } - var sinceTime time.Time - if since != "" { - sinceTime, err = parseISOTimestamp(since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil - } - } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) if err != nil { @@ -926,54 +963,52 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &ListIssuesQuery, vars); err != nil { + // Choose the appropriate query based on whether labels are specified + hasLabels := len(labels) > 0 + + if hasLabels { + // Use query with labels filtering - convert string labels to githubv4.String slice + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + + issueQuery := getIssueQueryType(hasLabels) + if err := client.Query(ctx, issueQuery, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } - //We must filter based on labels after fetching all issues + // Extract and convert all issue nodes using the common interface var issues []*github.Issue - for _, issue := range ListIssuesQuery.Repository.Issues.Nodes { - var issueLabels []string - for _, label := range issue.Labels.Nodes { - issueLabels = append(issueLabels, string(label.Name)) - } - - // Filter by since date if specified - if !sinceTime.IsZero() && issue.CreatedAt.Time.Before(sinceTime) { - continue // Skip issues created before the since date - } + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + var totalCount int - // Filter by labels if specified - if len(labels) > 0 { - hasMatchingLabel := false - for _, requestedLabel := range labels { - for _, issueLabel := range issueLabels { - if strings.EqualFold(requestedLabel, issueLabel) { - hasMatchingLabel = true - break - } - } - if hasMatchingLabel { - break - } - } - if !hasMatchingLabel { - continue // Skip this issue as it doesn't match any requested labels - } + if queryResult, ok := issueQuery.(IssueQueryResult); ok { + fragment := queryResult.GetIssueFragment() + for _, issue := range fragment.Nodes { + issues = append(issues, fragmentToIssue(issue)) } - issues = append(issues, fragmentToIssue(issue)) + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount } // Create response with issues response := map[string]interface{}{ "issues": issues, "pageInfo": map[string]interface{}{ - "hasNextPage": ListIssuesQuery.Repository.Issues.PageInfo.HasNextPage, - "hasPreviousPage": ListIssuesQuery.Repository.Issues.PageInfo.HasPreviousPage, - "startCursor": string(ListIssuesQuery.Repository.Issues.PageInfo.StartCursor), - "endCursor": string(ListIssuesQuery.Repository.Issues.PageInfo.EndCursor), + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), }, - "totalCount": ListIssuesQuery.Repository.Issues.TotalCount, + "totalCount": totalCount, } out, err := json.Marshal(response) if err != nil { diff --git a/pkg/github/queries.json b/pkg/github/queries.json new file mode 100644 index 000000000..65b3fc6b4 --- /dev/null +++ b/pkg/github/queries.json @@ -0,0 +1,230 @@ +//NEW +{ + "issues": [ + { + "author": "keith-navys-ai", + "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", + "createdAt": "2025-08-03T05:48:02Z", + "labels": [ + "open issue" + ], + "number": 2658, + "title": "Security vulnerability fix - how to update linux base image on container app" + }, + { + "author": "pamelafox", + "body": "This PR is currently failing CI:\nhttps://github.com/Azure-Samples/azure-search-openai-demo/pull/2162\n\nCreate a new PR that accomplishes the same goal as that PR, but passes CI.\n\n## Verification tips\n\nIf you need to create a virtual environment, use the `.venv` folder, like:\n\n```\npython -m venv .venv\n```\n\nWhen you have updated packages, make sure you can install them successfully in the virtual environment.\nTo decide what command to use to install the packages, check the repo's README.md and GitHub Actions workflows in the `.github/workflows` directory.\n\nLeave a comment on the PR indicating what command you used to verify successful installation.", + "createdAt": "2025-07-28T16:47:18Z", + "labels": null, + "number": 2651, + "title": "Dependabot #2162 to upgrade @fluentui/react failed CI" + } + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wNy0yOFQxNzo0NzoxOCswMTowMM7C8g2v", + "hasNextPage": true, + "hasPreviousPage": false, + "startCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wOC0wM1QwNjo0ODowMiswMTowMM7D5you" + }, + "totalCount": 572 +} +[ + { //add as much as possible + "id": 3290111396, // Need to add ID + "number": 2663, + "state": "open", // Add state as optional field + "locked": false, + "title": "Bump azure-monitor-opentelemetry from 1.6.1 to 1.6.13", + "body": "Bumps [azure-monitor-opentelemetry](https://github.com/Azure/azure-sdk-for-python) from 1.6.1 to 1.6.13.\n
\nRelease notes\n

Sourced from azure-monitor-opentelemetry's releases.

\n
\n

azure-monitor-opentelemetry_1.6.13

\n

1.6.13 (2025-07-30)

\n

Features Added

\n
    \n
  • Update to latest OpenTelemetry version after instrumentation breaking change fix. Remove Python 3.8 support.\n(#42247)
  • \n
\n

azure-monitor-opentelemetry_1.6.12

\n

1.6.12 (2025-07-21)

\n

Bugs Fixed

\n
    \n
  • Fix logging formatter breaking change\n(#42122)
  • \n
\n

azure-monitor-opentelemetry_1.6.11

\n

1.6.11 (2025-07-17)

\n

Features Added

\n
    \n
  • Add configuring of logging format and logger name via environment variables\n(#42035)
  • \n
\n
\n
\n
\nCommits\n
    \n
  • a32a5f6 Distro release 1.6.13 (#42257)
  • \n
  • 08a6b28 Update Distro to latest OTel version (#42247)
  • \n
  • 5eba99f Increment package version after release of azure-monitor-querymetrics (#42245)
  • \n
  • 011a618 [Monitor Metrics] Add samples (#42242)
  • \n
  • 0d79413 [AutoRelease] t2-monitor-2025-07-28-70598(can only be merged by SDK owner) (#...
  • \n
  • 5ce5f9a [Monitor] Add TypeSpec-based azure-monitor-querymetrics package (#42174)
  • \n
  • afb07fc Update CHANGELOG.md (#42229)
  • \n
  • 815ddd9 Refactored DJB2 code for Application Insights Sampler (#42210)
  • \n
  • 7a8a3aa Added changes for rate limited sampler (azure-exporter changes) (#41954)
  • \n
  • 779b031 Remove non-existing instrumentor (#42202)
  • \n
  • Additional commits viewable in compare view
  • \n
\n
\n
\n\n\n[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=azure-monitor-opentelemetry&package-manager=pip&previous-version=1.6.1&new-version=1.6.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)\n\nDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.\n\n[//]: # (dependabot-automerge-start)\n[//]: # (dependabot-automerge-end)\n\n---\n\n
\nDependabot commands and options\n
\n\nYou can trigger Dependabot actions by commenting on this PR:\n- `@dependabot rebase` will rebase this PR\n- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it\n- `@dependabot merge` will merge this PR after your CI passes on it\n- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it\n- `@dependabot cancel merge` will cancel a previously requested merge and block automerging\n- `@dependabot reopen` will reopen this PR if it is closed\n- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually\n- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency\n- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)\n\n\n
", + "author_association": "CONTRIBUTOR", + "user": { //Try add user object (?) + "login": "dependabot[bot]", + "id": 49699333, + "node_id": "MDM6Qm90NDk2OTkzMzM=", + "avatar_url": "https://avatars.githubusercontent.com/in/29110?v=4", + "html_url": "https://github.com/apps/dependabot", + "gravatar_id": "", + "type": "Bot", + }, + "labels": [ //id name description + { + "id": 5454473970, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/dependencies", + "name": "dependencies", + "color": "0366d6", + "description": "Pull requests that update a dependency file", + "default": false, + "node_id": "LA_kwDOI7h_Ps8AAAABRRyq8g" + }, + { + "id": 5684189776, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/python", + "name": "python", + "color": "2b67c6", + "description": "Pull requests that update Python code", + "default": false, + "node_id": "LA_kwDOI7h_Ps8AAAABUs3aUA" + } + ], + "comments": 0, + "created_at": "2025-08-04T16:41:44Z", //created - since + "updated_at": "2025-08-04T16:41:45Z", //updated - since + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663", + "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663", + "comments_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/comments", + "events_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/events", + "labels_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/labels{/name}", + "repository_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo", + "pull_request": { // Get PR Info - Number of PRs - Or any info really + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/pulls/2663", + "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663", + "diff_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663.diff", + "patch_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663.patch" + }, + "reactions": { //check if readily available + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "rocket": 0, + "eyes": 0, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/reactions" + }, + "node_id": "PR_kwDOI7h_Ps6iClCX", + "draft": false + }, + { + "id": 3290097713, + "number": 2662, + "state": "open", + "locked": false, + "title": "Bump opentelemetry-instrumentation-urllib from 0.52b1 to 0.57b0", + "body": "Bumps [opentelemetry-instrumentation-urllib](https://github.com/open-telemetry/opentelemetry-python-contrib) from 0.52b1 to 0.57b0.\n
\nChangelog\n

Sourced from opentelemetry-instrumentation-urllib's changelog.

\n
\n

Version 1.36.0/0.57b0 (2025-07-29)

\n

Fixed

\n
    \n
  • opentelemetry-instrumentation: Fix dependency conflict detection when instrumented packages are not installed by moving check back to before instrumentors are loaded. Add "instruments-any" feature for instrumentations that target multiple packages.\n(#3610)
  • \n
  • infra(ci): Fix git pull failures in core contrib test\n(#3357)
  • \n
\n

Added

\n
    \n
  • opentelemetry-instrumentation-psycopg2 Utilize instruments-any functionality.\n(#3610)
  • \n
  • opentelemetry-instrumentation-kafka-python Utilize instruments-any functionality.\n(#3610)
  • \n
  • opentelemetry-instrumentation-system-metrics: Add cpython.gc.collections metrics with collection unit is specified in semconv (3617)
  • \n
\n

Version 1.35.0/0.56b0 (2025-07-11)

\n

Added

\n
    \n
  • opentelemetry-instrumentation-pika Added instrumentation for All SelectConnection adapters\n(#3584)
  • \n
  • opentelemetry-instrumentation-tornado Add support for WebSocketHandler instrumentation\n(#3498)
  • \n
  • opentelemetry-util-http Added support for redacting specific url query string values and url credentials in instrumentations\n(#3508)
  • \n
  • opentelemetry-instrumentation-pymongo aggregate and getMore capture statements support\n(#3601)
  • \n
\n

Fixed

\n
    \n
  • opentelemetry-instrumentation-asgi: fix excluded_urls in instrumentation-asgi\n(#3567)
  • \n
  • opentelemetry-resource-detector-containerid: make it more quiet on platforms without cgroups\n(#3579)
  • \n
\n

Version 1.34.0/0.55b0 (2025-06-04)

\n

Fixed

\n
    \n
  • opentelemetry-instrumentation-system-metrics: fix loading on Google Cloud Run\n(#3533)
  • \n
  • opentelemetry-instrumentation-fastapi: fix wrapping of middlewares\n(#3012)
  • \n
  • opentelemetry-instrumentation-starlette Remove max version constraint on starlette\n(#3456)
  • \n
  • opentelemetry-instrumentation-starlette Fix memory leak and double middleware\n(#3529)
  • \n
  • opentelemetry-instrumentation-urllib3: proper bucket boundaries in stable semconv http duration metrics
  • \n
\n\n
\n

... (truncated)

\n
\n
\nCommits\n\n
\n
\n\n\n[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=opentelemetry-instrumentation-urllib&package-manager=pip&previous-version=0.52b1&new-version=0.57b0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)\n\nDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.\n\n[//]: # (dependabot-automerge-start)\n[//]: # (dependabot-automerge-end)\n\n---\n\n
\nDependabot commands and options\n
\n\nYou can trigger Dependabot actions by commenting on this PR:\n- `@dependabot rebase` will rebase this PR\n- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it\n- `@dependabot merge` will merge this PR after your CI passes on it\n- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it\n- `@dependabot cancel merge` will cancel a previously requested merge and block automerging\n- `@dependabot reopen` will reopen this PR if it is closed\n- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually\n- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency\n- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)\n\n\n
", + "author_association": "CONTRIBUTOR", + "user": { + "login": "dependabot[bot]", + "id": 49699333, + "node_id": "MDM6Qm90NDk2OTkzMzM=", + "avatar_url": "https://avatars.githubusercontent.com/in/29110?v=4", + "html_url": "https://github.com/apps/dependabot", + "gravatar_id": "", + "type": "Bot", + "site_admin": false, + "url": "https://api.github.com/users/dependabot%5Bbot%5D", + "events_url": "https://api.github.com/users/dependabot%5Bbot%5D/events{/privacy}", + "following_url": "https://api.github.com/users/dependabot%5Bbot%5D/following{/other_user}", + "followers_url": "https://api.github.com/users/dependabot%5Bbot%5D/followers", + "gists_url": "https://api.github.com/users/dependabot%5Bbot%5D/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/dependabot%5Bbot%5D/orgs", + "received_events_url": "https://api.github.com/users/dependabot%5Bbot%5D/received_events", + "repos_url": "https://api.github.com/users/dependabot%5Bbot%5D/repos", + "starred_url": "https://api.github.com/users/dependabot%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dependabot%5Bbot%5D/subscriptions" + }, + "labels": [ + { + "id": 5454473970, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/dependencies", + "name": "dependencies", + "color": "0366d6", + "description": "Pull requests that update a dependency file", + "default": false, + "node_id": "LA_kwDOI7h_Ps8AAAABRRyq8g" + }, + { + "id": 5684189776, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/python", + "name": "python", + "color": "2b67c6", + "description": "Pull requests that update Python code", + "default": false, + "node_id": "LA_kwDOI7h_Ps8AAAABUs3aUA" + } + ], + "comments": 0, + "created_at": "2025-08-04T16:35:56Z", + "updated_at": "2025-08-04T16:35:57Z", + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662", + "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662", + "comments_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/comments", + "events_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/events", + "labels_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/labels{/name}", + "repository_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo", + "pull_request": { + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/pulls/2662", + "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662", + "diff_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662.diff", + "patch_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662.patch" + }, + "reactions": { + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "confused": 0, + "heart": 0, + "hooray": 0, + "rocket": 0, + "eyes": 0, + "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/reactions" + }, + "node_id": "PR_kwDOI7h_Ps6iCiDq", + "draft": false + } +] +// NEW NEW +{ + "issues": [ + { + "id": 3286706734, + "number": 2658, + "state": "OPEN", + "title": "Security vulnerability fix - how to update linux base image on container app", + "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", + "user": { + "login": "keith-navys-ai" + }, + "labels": [ + { + "name": "open issue", + "description": "A validated issue that should be tackled. Comment if you'd like it assigned to you.", + "node_id": "LA_kwDOI7h_Ps8AAAABjHNrYw" + } + ], + "created_at": "2025-08-03T05:48:02Z", + "updated_at": "2025-08-04T18:10:33Z" + }, + { + "author": "keith-navys-ai", + "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", + "createdAt": "2025-08-03T05:48:02Z", + "labels": [ + "open issue" + ], + "number": 2658, + "title": "Security vulnerability fix - how to update linux base image on container app" + }, + { + "id": 3270643119, + "number": 2651, + "state": "OPEN", + "title": "Dependabot #2162 to upgrade @fluentui/react failed CI", + "body": "This PR is currently failing CI:\nhttps://github.com/Azure-Samples/azure-search-openai-demo/pull/2162\n\nCreate a new PR that accomplishes the same goal as that PR, but passes CI.\n\n## Verification tips\n\nIf you need to create a virtual environment, use the `.venv` folder, like:\n\n```\npython -m venv .venv\n```\n\nWhen you have updated packages, make sure you can install them successfully in the virtual environment.\nTo decide what command to use to install the packages, check the repo's README.md and GitHub Actions workflows in the `.github/workflows` directory.\n\nLeave a comment on the PR indicating what command you used to verify successful installation.", + "user": { + "login": "pamelafox" + }, + "created_at": "2025-07-28T16:47:18Z", + "updated_at": "2025-07-28T16:47:18Z" + } + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wNy0yOFQxNzo0NzoxOCswMTowMM7C8g2v", + "hasNextPage": true, + "hasPreviousPage": false, + "startCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wOC0wM1QwNjo0ODowMiswMTowMM7D5you" + }, + "totalCount": 572 +} \ No newline at end of file From f90251f44242bc955b4a544b375d302d7f2ccf5f Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Thu, 7 Aug 2025 14:53:46 +0100 Subject: [PATCH 06/13] Adding dynamic state queries for list_issues --- pkg/github/issues.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index a1de0c067..70bc3a753 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -834,7 +834,6 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun mcp.Description("Repository name"), ), mcp.WithString("state", - mcp.Required(), mcp.Description("Filter by state"), mcp.Enum("OPEN", "CLOSED"), ), @@ -870,11 +869,19 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } // Set optional parameters if provided - state, err := RequiredParam[string](request, "state") + state, err := OptionalParam[string](request, "state") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + //If the state has a value, cast into an array of strings + var states []githubv4.IssueState + if state != "" { + states = append(states, githubv4.IssueState(state)) + } else { + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} + } + // Get labels labels, err := OptionalStringArrayParam(request, "labels") if err != nil { @@ -951,7 +958,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), - "states": []githubv4.IssueState{githubv4.IssueState(state)}, + "states": states, "orderBy": githubv4.IssueOrderField(orderBy), "direction": githubv4.OrderDirection(direction), "first": githubv4.Int(*paginationParams.First), From eb4945e1660dd1466877f1a59b00c74b3e9c8b3d Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Thu, 7 Aug 2025 15:23:30 +0100 Subject: [PATCH 07/13] Add optional since filter, to get issues based on last update to issue --- pkg/github/issues.go | 78 +++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 70bc3a753..3be0432ff 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -56,34 +56,60 @@ type IssueQueryFragment struct { TotalCount int } +// ListIssuesQueryNoLabels is the query structure for fetching issues without label filtering. +type ListIssuesQuery struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. -type ListIssuesQueryType struct { +type ListIssuesQueryTypeWithLabels struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` } `graphql:"repository(owner: $owner, name: $repo)"` } -// ListIssuesQueryNoLabels is the query structure for fetching issues without label filtering. -type ListIssuesQueryNoLabelsType struct { +// ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. +type ListIssuesQueryWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. +type ListIssuesQueryTypeWithLabelsWithSince struct { + Repository struct { + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` } `graphql:"repository(owner: $owner, name: $repo)"` } -// Implement the interface for both query types -func (q *ListIssuesQueryType) GetIssueFragment() IssueQueryFragment { +// Implement the interface for all query types +func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } -func (q *ListIssuesQueryNoLabelsType) GetIssueFragment() IssueQueryFragment { +func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } -func getIssueQueryType(hasLabels bool) any { - if hasLabels { - return &ListIssuesQueryType{} +func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { + return q.Repository.Issues +} + +func getIssueQueryType(hasLabels bool, hasSince bool) any { + if hasLabels && hasSince { + return &ListIssuesQueryTypeWithLabelsWithSince{} + } else if hasLabels { + return &ListIssuesQueryTypeWithLabels{} + } else if hasSince { + return &ListIssuesQueryWithSince{} } - return &ListIssuesQueryNoLabelsType{} + return &ListIssuesQuery{} } func fragmentToIssue(fragment IssueFragment) *github.Issue { @@ -911,18 +937,20 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun direction = "DESC" } - // since, err := OptionalParam[string](request, "since") - // if err != nil { - // return mcp.NewToolResultError(err.Error()), nil - // } + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // var sinceTime time.Time - // if since != "" { - // sinceTime, err = parseISOTimestamp(since) - // if err != nil { - // return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil - // } - // } + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + } + hasSince = true + } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) @@ -982,7 +1010,11 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun vars["labels"] = labelStrings } - issueQuery := getIssueQueryType(hasLabels) + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } + + issueQuery := getIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } From d278d45f0580796ac79ce9e57997bf8df2ecc5d2 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 10:56:07 +0100 Subject: [PATCH 08/13] Move ListIssues test to graphql format & removal of temp file --- pkg/github/__toolsnaps__/list_issues.snap | 39 ++- pkg/github/issues.go | 4 +- pkg/github/issues_test.go | 358 +++++++++++++++------- pkg/github/queries.json | 230 -------------- 4 files changed, 260 insertions(+), 371 deletions(-) delete mode 100644 pkg/github/queries.json diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 4fe155f09..dc643291f 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -3,14 +3,18 @@ "title": "List issues", "readOnlyHint": true }, - "description": "List issues in a GitHub repository.", + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", "inputSchema": { "properties": { + "after": { + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" + }, "direction": { - "description": "Sort direction", + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", "enum": [ - "asc", - "desc" + "ASC", + "DESC" ], "type": "string" }, @@ -21,15 +25,18 @@ }, "type": "array" }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT" + ], + "type": "string" + }, "owner": { "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, @@ -44,21 +51,11 @@ "description": "Filter by date (ISO 8601 timestamp)", "type": "string" }, - "sort": { - "description": "Sort order", - "enum": [ - "created", - "updated", - "comments" - ], - "type": "string" - }, "state": { "description": "Filter by state", "enum": [ - "open", - "closed", - "all" + "OPEN", + "CLOSED" ], "type": "string" } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3be0432ff..87c56e392 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -56,14 +56,14 @@ type IssueQueryFragment struct { TotalCount int } -// ListIssuesQueryNoLabels is the query structure for fetching issues without label filtering. +// ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` } `graphql:"repository(owner: $owner, name: $repo)"` } -// ListIssuesQuery is the root query structure for fetching issues with optional label filtering. +// ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2bdb89b06..20452442e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -648,8 +648,8 @@ func Test_CreateIssue(t *testing.T) { func Test_ListIssues(t *testing.T) { // Verify tool definition - mockClient := github.NewClient(nil) - tool, _ := ListIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) + mockClient := githubv4.NewClient(nil) + tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_issues", tool.Name) @@ -658,166 +658,288 @@ func Test_ListIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "state") assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "orderBy") assert.Contains(t, tool.InputSchema.Properties, "direction") assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "after") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - // Setup mock issues for success case - mockIssues := []*github.Issue{ + // Mock issues data + mockIssuesAll := []map[string]any{ + { + "number": 123, + "title": "First Issue", + "body": "This is the first test issue", + "state": "OPEN", + "databaseId": 1001, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "bug", "id": "label1", "description": "Bug label"}, + }, + }, + }, { - Number: github.Ptr(123), - Title: github.Ptr("First Issue"), - Body: github.Ptr("This is the first test issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, + "number": 456, + "title": "Second Issue", + "body": "This is the second test issue", + "state": "OPEN", + "databaseId": 1002, + "createdAt": "2023-02-01T00:00:00Z", + "updatedAt": "2023-02-01T00:00:00Z", + "author": map[string]any{"login": "user2"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, + }, + }, }, + } + + mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} + mockIssuesClosed := []map[string]any{ { - Number: github.Ptr(456), - Title: github.Ptr("Second Issue"), - Body: github.Ptr("This is the second test issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/456"), - Labels: []*github.Label{{Name: github.Ptr("bug")}}, - CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)}, + "number": 789, + "title": "Closed Issue", + "body": "This is a closed issue", + "state": "CLOSED", + "databaseId": 1003, + "createdAt": "2023-03-01T00:00:00Z", + "updatedAt": "2023-03-01T00:00:00Z", + "author": map[string]any{"login": "user3"}, + "labels": map[string]any{ + "nodes": []map[string]any{}, + }, }, } + // Mock responses + mockResponseListAll := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesAll, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesOpen, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + + mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssuesClosed, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 1, + }, + }, + }) + + mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + varsListAll := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOpenOnly := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsClosedOnly := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsWithLabels := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "labels": []interface{}{"bug", "enhancement"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsRepoNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "states": []interface{}{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedIssues []*github.Issue - expectedErrMsg string + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int + verifyOrder func(t *testing.T, issues []*github.Issue) }{ { - name: "list issues with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepo, - mockIssues, - ), - ), - requestArgs: map[string]interface{}{ + name: "list all issues", + reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", }, - expectError: false, - expectedIssues: mockIssues, + expectError: false, + expectedCount: 2, }, { - name: "list issues with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "open", - "labels": "bug,enhancement", - "sort": "created", - "direction": "desc", - "since": "2023-01-01T00:00:00Z", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockIssues), - ), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "state": "open", - "labels": []any{"bug", "enhancement"}, - "sort": "created", - "direction": "desc", - "since": "2023-01-01T00:00:00Z", - "page": float64(1), - "perPage": float64(30), + name: "filter by open state", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "OPEN", }, - expectError: false, - expectedIssues: mockIssues, + expectError: false, + expectedCount: 2, }, { - name: "invalid since parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepo, - mockIssues, - ), - ), - requestArgs: map[string]interface{}{ + name: "filter by closed state", + reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", - "since": "invalid-date", + "state": "CLOSED", }, - expectError: true, - expectedErrMsg: "invalid ISO 8601 timestamp", + expectError: false, + expectedCount: 1, }, { - name: "list issues fails with error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "owner": "nonexistent", - "repo": "repo", + name: "filter by labels", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug", "enhancement"}, }, - expectError: true, - expectedErrMsg: "failed to list issues", + expectError: false, + expectedCount: 2, + }, + { + name: "repository not found error", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", }, } + // Define the actual query strings that match the implementation + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 10){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 10){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListIssues(stubGetClientFn(client), translations.NullTranslationHelper) + var httpClient *http.Client + + switch tc.name { + case "list all issues": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by closed state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by labels": + matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } - // Create call request - request := createMCPRequest(tc.requestArgs) + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - // Call handler - result, err := handler(context.Background(), request) + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text - // Verify results if tc.expectError { - if err != nil { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } else { - // For errors returned as part of the result, not as an error - assert.NotNil(t, result) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, tc.expectedErrMsg) - } + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) return } + 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"` + } + err = json.Unmarshal([]byte(text), &response) require.NoError(t, err) - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) + assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) - // Unmarshal and verify the result - var returnedIssues []*github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssues) - require.NoError(t, err) + // Verify order if verifyOrder function is provided + if tc.verifyOrder != nil { + tc.verifyOrder(t, response.Issues) + } - assert.Len(t, returnedIssues, len(tc.expectedIssues)) - for i, issue := range returnedIssues { - assert.Equal(t, *tc.expectedIssues[i].Number, *issue.Number) - assert.Equal(t, *tc.expectedIssues[i].Title, *issue.Title) - assert.Equal(t, *tc.expectedIssues[i].State, *issue.State) - assert.Equal(t, *tc.expectedIssues[i].HTMLURL, *issue.HTMLURL) + // 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") } }) } diff --git a/pkg/github/queries.json b/pkg/github/queries.json deleted file mode 100644 index 65b3fc6b4..000000000 --- a/pkg/github/queries.json +++ /dev/null @@ -1,230 +0,0 @@ -//NEW -{ - "issues": [ - { - "author": "keith-navys-ai", - "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", - "createdAt": "2025-08-03T05:48:02Z", - "labels": [ - "open issue" - ], - "number": 2658, - "title": "Security vulnerability fix - how to update linux base image on container app" - }, - { - "author": "pamelafox", - "body": "This PR is currently failing CI:\nhttps://github.com/Azure-Samples/azure-search-openai-demo/pull/2162\n\nCreate a new PR that accomplishes the same goal as that PR, but passes CI.\n\n## Verification tips\n\nIf you need to create a virtual environment, use the `.venv` folder, like:\n\n```\npython -m venv .venv\n```\n\nWhen you have updated packages, make sure you can install them successfully in the virtual environment.\nTo decide what command to use to install the packages, check the repo's README.md and GitHub Actions workflows in the `.github/workflows` directory.\n\nLeave a comment on the PR indicating what command you used to verify successful installation.", - "createdAt": "2025-07-28T16:47:18Z", - "labels": null, - "number": 2651, - "title": "Dependabot #2162 to upgrade @fluentui/react failed CI" - } - ], - "pageInfo": { - "endCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wNy0yOFQxNzo0NzoxOCswMTowMM7C8g2v", - "hasNextPage": true, - "hasPreviousPage": false, - "startCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wOC0wM1QwNjo0ODowMiswMTowMM7D5you" - }, - "totalCount": 572 -} -[ - { //add as much as possible - "id": 3290111396, // Need to add ID - "number": 2663, - "state": "open", // Add state as optional field - "locked": false, - "title": "Bump azure-monitor-opentelemetry from 1.6.1 to 1.6.13", - "body": "Bumps [azure-monitor-opentelemetry](https://github.com/Azure/azure-sdk-for-python) from 1.6.1 to 1.6.13.\n
\nRelease notes\n

Sourced from azure-monitor-opentelemetry's releases.

\n
\n

azure-monitor-opentelemetry_1.6.13

\n

1.6.13 (2025-07-30)

\n

Features Added

\n
    \n
  • Update to latest OpenTelemetry version after instrumentation breaking change fix. Remove Python 3.8 support.\n(#42247)
  • \n
\n

azure-monitor-opentelemetry_1.6.12

\n

1.6.12 (2025-07-21)

\n

Bugs Fixed

\n
    \n
  • Fix logging formatter breaking change\n(#42122)
  • \n
\n

azure-monitor-opentelemetry_1.6.11

\n

1.6.11 (2025-07-17)

\n

Features Added

\n
    \n
  • Add configuring of logging format and logger name via environment variables\n(#42035)
  • \n
\n
\n
\n
\nCommits\n
    \n
  • a32a5f6 Distro release 1.6.13 (#42257)
  • \n
  • 08a6b28 Update Distro to latest OTel version (#42247)
  • \n
  • 5eba99f Increment package version after release of azure-monitor-querymetrics (#42245)
  • \n
  • 011a618 [Monitor Metrics] Add samples (#42242)
  • \n
  • 0d79413 [AutoRelease] t2-monitor-2025-07-28-70598(can only be merged by SDK owner) (#...
  • \n
  • 5ce5f9a [Monitor] Add TypeSpec-based azure-monitor-querymetrics package (#42174)
  • \n
  • afb07fc Update CHANGELOG.md (#42229)
  • \n
  • 815ddd9 Refactored DJB2 code for Application Insights Sampler (#42210)
  • \n
  • 7a8a3aa Added changes for rate limited sampler (azure-exporter changes) (#41954)
  • \n
  • 779b031 Remove non-existing instrumentor (#42202)
  • \n
  • Additional commits viewable in compare view
  • \n
\n
\n
\n\n\n[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=azure-monitor-opentelemetry&package-manager=pip&previous-version=1.6.1&new-version=1.6.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)\n\nDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.\n\n[//]: # (dependabot-automerge-start)\n[//]: # (dependabot-automerge-end)\n\n---\n\n
\nDependabot commands and options\n
\n\nYou can trigger Dependabot actions by commenting on this PR:\n- `@dependabot rebase` will rebase this PR\n- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it\n- `@dependabot merge` will merge this PR after your CI passes on it\n- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it\n- `@dependabot cancel merge` will cancel a previously requested merge and block automerging\n- `@dependabot reopen` will reopen this PR if it is closed\n- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually\n- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency\n- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)\n\n\n
", - "author_association": "CONTRIBUTOR", - "user": { //Try add user object (?) - "login": "dependabot[bot]", - "id": 49699333, - "node_id": "MDM6Qm90NDk2OTkzMzM=", - "avatar_url": "https://avatars.githubusercontent.com/in/29110?v=4", - "html_url": "https://github.com/apps/dependabot", - "gravatar_id": "", - "type": "Bot", - }, - "labels": [ //id name description - { - "id": 5454473970, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/dependencies", - "name": "dependencies", - "color": "0366d6", - "description": "Pull requests that update a dependency file", - "default": false, - "node_id": "LA_kwDOI7h_Ps8AAAABRRyq8g" - }, - { - "id": 5684189776, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/python", - "name": "python", - "color": "2b67c6", - "description": "Pull requests that update Python code", - "default": false, - "node_id": "LA_kwDOI7h_Ps8AAAABUs3aUA" - } - ], - "comments": 0, - "created_at": "2025-08-04T16:41:44Z", //created - since - "updated_at": "2025-08-04T16:41:45Z", //updated - since - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663", - "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663", - "comments_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/comments", - "events_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/events", - "labels_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/labels{/name}", - "repository_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo", - "pull_request": { // Get PR Info - Number of PRs - Or any info really - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/pulls/2663", - "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663", - "diff_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663.diff", - "patch_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2663.patch" - }, - "reactions": { //check if readily available - "total_count": 0, - "+1": 0, - "-1": 0, - "laugh": 0, - "confused": 0, - "heart": 0, - "hooray": 0, - "rocket": 0, - "eyes": 0, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2663/reactions" - }, - "node_id": "PR_kwDOI7h_Ps6iClCX", - "draft": false - }, - { - "id": 3290097713, - "number": 2662, - "state": "open", - "locked": false, - "title": "Bump opentelemetry-instrumentation-urllib from 0.52b1 to 0.57b0", - "body": "Bumps [opentelemetry-instrumentation-urllib](https://github.com/open-telemetry/opentelemetry-python-contrib) from 0.52b1 to 0.57b0.\n
\nChangelog\n

Sourced from opentelemetry-instrumentation-urllib's changelog.

\n
\n

Version 1.36.0/0.57b0 (2025-07-29)

\n

Fixed

\n
    \n
  • opentelemetry-instrumentation: Fix dependency conflict detection when instrumented packages are not installed by moving check back to before instrumentors are loaded. Add "instruments-any" feature for instrumentations that target multiple packages.\n(#3610)
  • \n
  • infra(ci): Fix git pull failures in core contrib test\n(#3357)
  • \n
\n

Added

\n
    \n
  • opentelemetry-instrumentation-psycopg2 Utilize instruments-any functionality.\n(#3610)
  • \n
  • opentelemetry-instrumentation-kafka-python Utilize instruments-any functionality.\n(#3610)
  • \n
  • opentelemetry-instrumentation-system-metrics: Add cpython.gc.collections metrics with collection unit is specified in semconv (3617)
  • \n
\n

Version 1.35.0/0.56b0 (2025-07-11)

\n

Added

\n
    \n
  • opentelemetry-instrumentation-pika Added instrumentation for All SelectConnection adapters\n(#3584)
  • \n
  • opentelemetry-instrumentation-tornado Add support for WebSocketHandler instrumentation\n(#3498)
  • \n
  • opentelemetry-util-http Added support for redacting specific url query string values and url credentials in instrumentations\n(#3508)
  • \n
  • opentelemetry-instrumentation-pymongo aggregate and getMore capture statements support\n(#3601)
  • \n
\n

Fixed

\n
    \n
  • opentelemetry-instrumentation-asgi: fix excluded_urls in instrumentation-asgi\n(#3567)
  • \n
  • opentelemetry-resource-detector-containerid: make it more quiet on platforms without cgroups\n(#3579)
  • \n
\n

Version 1.34.0/0.55b0 (2025-06-04)

\n

Fixed

\n
    \n
  • opentelemetry-instrumentation-system-metrics: fix loading on Google Cloud Run\n(#3533)
  • \n
  • opentelemetry-instrumentation-fastapi: fix wrapping of middlewares\n(#3012)
  • \n
  • opentelemetry-instrumentation-starlette Remove max version constraint on starlette\n(#3456)
  • \n
  • opentelemetry-instrumentation-starlette Fix memory leak and double middleware\n(#3529)
  • \n
  • opentelemetry-instrumentation-urllib3: proper bucket boundaries in stable semconv http duration metrics
  • \n
\n\n
\n

... (truncated)

\n
\n
\nCommits\n\n
\n
\n\n\n[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=opentelemetry-instrumentation-urllib&package-manager=pip&previous-version=0.52b1&new-version=0.57b0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)\n\nDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.\n\n[//]: # (dependabot-automerge-start)\n[//]: # (dependabot-automerge-end)\n\n---\n\n
\nDependabot commands and options\n
\n\nYou can trigger Dependabot actions by commenting on this PR:\n- `@dependabot rebase` will rebase this PR\n- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it\n- `@dependabot merge` will merge this PR after your CI passes on it\n- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it\n- `@dependabot cancel merge` will cancel a previously requested merge and block automerging\n- `@dependabot reopen` will reopen this PR if it is closed\n- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually\n- `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency\n- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)\n\n\n
", - "author_association": "CONTRIBUTOR", - "user": { - "login": "dependabot[bot]", - "id": 49699333, - "node_id": "MDM6Qm90NDk2OTkzMzM=", - "avatar_url": "https://avatars.githubusercontent.com/in/29110?v=4", - "html_url": "https://github.com/apps/dependabot", - "gravatar_id": "", - "type": "Bot", - "site_admin": false, - "url": "https://api.github.com/users/dependabot%5Bbot%5D", - "events_url": "https://api.github.com/users/dependabot%5Bbot%5D/events{/privacy}", - "following_url": "https://api.github.com/users/dependabot%5Bbot%5D/following{/other_user}", - "followers_url": "https://api.github.com/users/dependabot%5Bbot%5D/followers", - "gists_url": "https://api.github.com/users/dependabot%5Bbot%5D/gists{/gist_id}", - "organizations_url": "https://api.github.com/users/dependabot%5Bbot%5D/orgs", - "received_events_url": "https://api.github.com/users/dependabot%5Bbot%5D/received_events", - "repos_url": "https://api.github.com/users/dependabot%5Bbot%5D/repos", - "starred_url": "https://api.github.com/users/dependabot%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/dependabot%5Bbot%5D/subscriptions" - }, - "labels": [ - { - "id": 5454473970, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/dependencies", - "name": "dependencies", - "color": "0366d6", - "description": "Pull requests that update a dependency file", - "default": false, - "node_id": "LA_kwDOI7h_Ps8AAAABRRyq8g" - }, - { - "id": 5684189776, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/labels/python", - "name": "python", - "color": "2b67c6", - "description": "Pull requests that update Python code", - "default": false, - "node_id": "LA_kwDOI7h_Ps8AAAABUs3aUA" - } - ], - "comments": 0, - "created_at": "2025-08-04T16:35:56Z", - "updated_at": "2025-08-04T16:35:57Z", - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662", - "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662", - "comments_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/comments", - "events_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/events", - "labels_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/labels{/name}", - "repository_url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo", - "pull_request": { - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/pulls/2662", - "html_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662", - "diff_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662.diff", - "patch_url": "https://github.com/Azure-Samples/azure-search-openai-demo/pull/2662.patch" - }, - "reactions": { - "total_count": 0, - "+1": 0, - "-1": 0, - "laugh": 0, - "confused": 0, - "heart": 0, - "hooray": 0, - "rocket": 0, - "eyes": 0, - "url": "https://api.github.com/repos/Azure-Samples/azure-search-openai-demo/issues/2662/reactions" - }, - "node_id": "PR_kwDOI7h_Ps6iCiDq", - "draft": false - } -] -// NEW NEW -{ - "issues": [ - { - "id": 3286706734, - "number": 2658, - "state": "OPEN", - "title": "Security vulnerability fix - how to update linux base image on container app", - "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", - "user": { - "login": "keith-navys-ai" - }, - "labels": [ - { - "name": "open issue", - "description": "A validated issue that should be tackled. Comment if you'd like it assigned to you.", - "node_id": "LA_kwDOI7h_Ps8AAAABjHNrYw" - } - ], - "created_at": "2025-08-03T05:48:02Z", - "updated_at": "2025-08-04T18:10:33Z" - }, - { - "author": "keith-navys-ai", - "body": "Greetings - \n\nHow can I upgrade the linux base image to address security vulnerabilities with libxml2 and setuptools? \n\nI don't see a main Dockerfile, just the two for backend/frontend. \n\n\"Image\"\n\n", - "createdAt": "2025-08-03T05:48:02Z", - "labels": [ - "open issue" - ], - "number": 2658, - "title": "Security vulnerability fix - how to update linux base image on container app" - }, - { - "id": 3270643119, - "number": 2651, - "state": "OPEN", - "title": "Dependabot #2162 to upgrade @fluentui/react failed CI", - "body": "This PR is currently failing CI:\nhttps://github.com/Azure-Samples/azure-search-openai-demo/pull/2162\n\nCreate a new PR that accomplishes the same goal as that PR, but passes CI.\n\n## Verification tips\n\nIf you need to create a virtual environment, use the `.venv` folder, like:\n\n```\npython -m venv .venv\n```\n\nWhen you have updated packages, make sure you can install them successfully in the virtual environment.\nTo decide what command to use to install the packages, check the repo's README.md and GitHub Actions workflows in the `.github/workflows` directory.\n\nLeave a comment on the PR indicating what command you used to verify successful installation.", - "user": { - "login": "pamelafox" - }, - "created_at": "2025-07-28T16:47:18Z", - "updated_at": "2025-07-28T16:47:18Z" - } - ], - "pageInfo": { - "endCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wNy0yOFQxNzo0NzoxOCswMTowMM7C8g2v", - "hasNextPage": true, - "hasPreviousPage": false, - "startCursor": "Y3Vyc29yOnYyOpK5MjAyNS0wOC0wM1QwNjo0ODowMiswMTowMM7D5you" - }, - "totalCount": 572 -} \ No newline at end of file From 0f155f911b81ca1e310071b5c3c8e90447a01699 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 12:16:44 +0100 Subject: [PATCH 09/13] Update documentation and fix linter issues --- README.md | 6 +++--- pkg/github/discussions_test.go | 2 +- pkg/github/issues.go | 22 ++++++++++++---------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b40974e20..6a9ae51f8 100644 --- a/README.md +++ b/README.md @@ -539,14 +539,14 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **list_issues** - List issues - - `direction`: Sort direction (string, optional) + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `sort`: Sort order (string, optional) - `state`: Filter by state (string, optional) - **list_sub_issues** - List sub-issues diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 9458dfce0..d1dcf064a 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -484,7 +484,7 @@ func Test_GetDiscussion(t *testing.T) { assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}" vars := map[string]interface{}{ "owner": "owner", diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 87c56e392..6d5f4e376 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -34,7 +34,7 @@ type IssueFragment struct { Labels struct { Nodes []struct { Name githubv4.String - Id githubv4.String + ID githubv4.String Description githubv4.String } } `graphql:"labels(first: 10)"` @@ -102,14 +102,16 @@ func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFr } func getIssueQueryType(hasLabels bool, hasSince bool) any { - if hasLabels && hasSince { + switch { + case hasLabels && hasSince: return &ListIssuesQueryTypeWithLabelsWithSince{} - } else if hasLabels { + case hasLabels: return &ListIssuesQueryTypeWithLabels{} - } else if hasSince { + case hasSince: return &ListIssuesQueryWithSince{} + default: + return &ListIssuesQuery{} } - return &ListIssuesQuery{} } func fragmentToIssue(fragment IssueFragment) *github.Issue { @@ -118,7 +120,7 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { for _, labelNode := range fragment.Labels.Nodes { foundLabels = append(foundLabels, &github.Label{ Name: github.Ptr(string(labelNode.Name)), - NodeID: github.Ptr(string(labelNode.Id)), + NodeID: github.Ptr(string(labelNode.ID)), Description: github.Ptr(string(labelNode.Description)), }) } @@ -900,7 +902,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } - //If the state has a value, cast into an array of strings + // If the state has a value, cast into an array of strings var states []githubv4.IssueState if state != "" { states = append(states, githubv4.IssueState(state)) @@ -914,7 +916,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } - //If labels is empty, default to nil for gql query + // If labels is empty, default to nil for gql query if len(labels) == 0 { labels = nil } @@ -923,7 +925,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - //If orderBy is empty, default to CREATED_AT + // If orderBy is empty, default to CREATED_AT if orderBy == "" { orderBy = "CREATED_AT" } @@ -932,7 +934,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - //If direction is empty, default to DESC + // If direction is empty, default to DESC if direction == "" { direction = "DESC" } From 8c5acd524ca07c8131b2383607c4f9e3f2e5cab0 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 12:44:38 +0100 Subject: [PATCH 10/13] Removal of redundant code, and increase limit on label return --- pkg/github/issues.go | 24 +++++++++++------------- pkg/github/issues_test.go | 4 ++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6d5f4e376..f22dfe11a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -37,7 +37,7 @@ type IssueFragment struct { ID githubv4.String Description githubv4.String } - } `graphql:"labels(first: 10)"` + } `graphql:"labels(first: 100)"` } // Common interface for all issue query types @@ -916,24 +916,21 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } - // If labels is empty, default to nil for gql query - if len(labels) == 0 { - labels = nil - } - orderBy, err := OptionalParam[string](request, "orderBy") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // If orderBy is empty, default to CREATED_AT - if orderBy == "" { - orderBy = "CREATED_AT" - } direction, err := OptionalParam[string](request, "direction") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + + // These variables are required for the GraphQL query to be set by default + // If orderBy is empty, default to CREATED_AT + if orderBy == "" { + orderBy = "CREATED_AT" + } // If direction is empty, default to DESC if direction == "" { direction = "DESC" @@ -944,6 +941,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(err.Error()), nil } + // There are two optional parameters: since and labels. var sinceTime time.Time var hasSince bool if since != "" { @@ -953,6 +951,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } hasSince = true } + hasLabels := len(labels) > 0 // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) @@ -997,12 +996,11 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if paginationParams.After != nil { vars["after"] = githubv4.String(*paginationParams.After) } else { + // Used within query, therefore must be set to nil and provided as $after vars["after"] = (*githubv4.String)(nil) } - // Choose the appropriate query based on whether labels are specified - hasLabels := len(labels) > 0 - + // Ensure optional parameters are set if hasLabels { // Use query with labels filtering - convert string labels to githubv4.String slice labelStrings := make([]githubv4.String, len(labels)) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 20452442e..c68f8db01 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -875,8 +875,8 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 10){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 10){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From e4178f299467371c126668c99891c199bcffc729 Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 12:58:46 +0100 Subject: [PATCH 11/13] Fixing context for status to allow for better interactions --- pkg/github/issues.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f22dfe11a..acaf0d54f 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -862,7 +862,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun mcp.Description("Repository name"), ), mcp.WithString("state", - mcp.Description("Filter by state"), + mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), mcp.Enum("OPEN", "CLOSED"), ), mcp.WithArray("labels", From 1e8d464721a78372879300db3c443abd7504b14a Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 13:12:41 +0100 Subject: [PATCH 12/13] Update tool snaps with tool description --- pkg/github/__toolsnaps__/list_issues.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index dc643291f..f63da9c85 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -52,7 +52,7 @@ "type": "string" }, "state": { - "description": "Filter by state", + "description": "Filter by state, by default both open and closed issues are returned when not provided", "enum": [ "OPEN", "CLOSED" From 9c37e7d44b8ca170522cea97cbc89463595c351b Mon Sep 17 00:00:00 2001 From: Matt Babbage Date: Fri, 8 Aug 2025 13:24:08 +0100 Subject: [PATCH 13/13] Update docs for final changes to context --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a9ae51f8..1c90a2940 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,7 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `state`: Filter by state (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - **list_sub_issues** - List sub-issues - `issue_number`: Issue number (number, required) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy