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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,9 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat

- **list_label** - List labels from a repository
- **OAuth Challenge Scopes**: `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `owner`: Repository owner (username or organization name) - required for all operations (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name - required for all operations (string, required)

</details>
Expand Down
37 changes: 35 additions & 2 deletions pkg/github/__toolsnaps__/list_label.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@
"readOnlyHint": true,
"title": "List labels from a repository"
},
"description": "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first",
"description": "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.",
"inputSchema": {
"properties": {
"after": {
"description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"owner": {
"description": "Repository owner (username or organization name) - required for all operations",
"type": "string"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"repo": {
"description": "Repository name - required for all operations",
"type": "string"
Expand Down Expand Up @@ -53,14 +63,37 @@
"array"
]
},
"pageInfo": {
"additionalProperties": false,
"properties": {
"endCursor": {
"type": "string"
},
"hasNextPage": {
"type": "boolean"
},
"hasPreviousPage": {
"type": "boolean"
},
"startCursor": {
"type": "string"
}
},
"required": [
"hasNextPage",
"hasPreviousPage"
],
"type": "object"
},
"totalCount": {
"description": "Total labels in the repository.",
"type": "integer"
}
},
"required": [
"labels",
"totalCount"
"totalCount",
"pageInfo"
],
"type": "object"
}
Expand Down
43 changes: 39 additions & 4 deletions pkg/github/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se
return tool
}

// listLabelsDefaultPerPage keeps the first page as large as GraphQL allows, so most repositories need one call.
const listLabelsDefaultPerPage = 100

// ListLabels lists labels from a repository
func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
return typedGovernanceTool(listLabelsLegacy(t), listLabelsOutputSchema(), decodeGovernanceJSON[ListLabelsOutput])
Expand All @@ -142,12 +145,12 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
ToolsetLabels,
mcp.Tool{
Name: "list_label",
Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first"),
Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository, ordered by issue count (descending) so the most-used labels are returned first. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_LABEL_TITLE", "List labels from a repository"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
InputSchema: WithCursorPagination(&jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Expand All @@ -160,7 +163,7 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
},
},
Required: []string{"owner", "repo"},
},
}),
},
scopes.PublicRead(scopes.Repo),
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
Expand All @@ -174,6 +177,19 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
return utils.NewToolResultError(err.Error()), nil, nil
}

perPage, err := OptionalIntParamWithDefault(args, "perPage", listLabelsDefaultPerPage)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
paginationParams, err := CursorPaginationParams{PerPage: perPage, After: after}.ToGraphQLParams()
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

client, err := deps.GetGQLClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
Expand All @@ -189,17 +205,29 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
Description githubv4.String
}
TotalCount githubv4.Int
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
PageInfo struct {
HasNextPage githubv4.Boolean
HasPreviousPage githubv4.Boolean
StartCursor githubv4.String
EndCursor githubv4.String
}
} `graphql:"labels(first: $first, after: $after, orderBy: {field: $orderByField, direction: $orderByDirection})"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}

vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"first": githubv4.Int(*paginationParams.First),
// Order labels by issue count (descending) so the most-used labels are returned first.
"orderByField": labelOrderFieldIssueCount,
"orderByDirection": githubv4.OrderDirectionDesc,
}
if paginationParams.After != nil {
vars["after"] = githubv4.String(*paginationParams.After)
} else {
vars["after"] = (*githubv4.String)(nil)
}

if err := client.Query(ctx, &query, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
Expand All @@ -215,9 +243,16 @@ func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool
}
}

labelsPage := query.Repository.Labels.PageInfo
response := map[string]any{
"labels": labels,
"totalCount": int(query.Repository.Labels.TotalCount),
"pageInfo": MinimalPageInfo{
HasNextPage: bool(labelsPage.HasNextPage),
HasPreviousPage: bool(labelsPage.HasPreviousPage),
StartCursor: string(labelsPage.StartCursor),
EndCursor: string(labelsPage.EndCursor),
},
}

out, err := json.Marshal(response)
Expand Down
148 changes: 103 additions & 45 deletions pkg/github/labels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package github

import (
"context"
"encoding/json"
"net/http"
"testing"

Expand Down Expand Up @@ -156,58 +157,65 @@ func TestListLabels(t *testing.T) {
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
expectedPageInfo MinimalPageInfo
}{
{
name: "successful repository labels listing",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
} `graphql:"labels(first: 100, orderBy: {field: $orderByField, direction: $orderByDirection})"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"orderByField": labelOrderFieldIssueCount,
"orderByDirection": githubv4.OrderDirectionDesc,
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("label-1"),
"name": githubv4.String("bug"),
"color": githubv4.String("d73a4a"),
"description": githubv4.String("Something isn't working"),
},
map[string]any{
"id": githubv4.ID("label-2"),
"name": githubv4.String("enhancement"),
"color": githubv4.String("a2eeef"),
"description": githubv4.String("New feature or request"),
},
},
"totalCount": githubv4.Int(2),
},
},
}),
),
),
expectToolError: false,
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(100, (*githubv4.String)(nil), map[string]any{
"hasNextPage": githubv4.Boolean(false),
"hasPreviousPage": githubv4.Boolean(false),
"startCursor": githubv4.String("c1"),
"endCursor": githubv4.String("c2"),
})),
expectToolError: false,
expectedPageInfo: MinimalPageInfo{StartCursor: "c1", EndCursor: "c2"},
},
{
name: "reports the next page when the repository has more labels",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"perPage": float64(2),
},
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(2, (*githubv4.String)(nil), map[string]any{
"hasNextPage": githubv4.Boolean(true),
"hasPreviousPage": githubv4.Boolean(false),
"startCursor": githubv4.String("c1"),
"endCursor": githubv4.String("c2"),
})),
expectToolError: false,
expectedPageInfo: MinimalPageInfo{HasNextPage: true, StartCursor: "c1", EndCursor: "c2"},
},
{
name: "passes the after cursor to fetch the next page",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"after": "c2",
},
mockedClient: githubv4mock.NewMockedHTTPClient(listLabelsMatcher(100, githubv4.String("c2"), map[string]any{
"hasNextPage": githubv4.Boolean(false),
"hasPreviousPage": githubv4.Boolean(true),
"startCursor": githubv4.String("c3"),
"endCursor": githubv4.String("c4"),
})),
expectToolError: false,
expectedPageInfo: MinimalPageInfo{HasPreviousPage: true, StartCursor: "c3", EndCursor: "c4"},
},
{
name: "rejects a page size above 100",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"perPage": float64(101),
},
mockedClient: githubv4mock.NewMockedHTTPClient(),
expectToolError: true,
expectedToolErrMsg: "perPage value 101 exceeds maximum of 100",
},
}

Expand All @@ -233,11 +241,61 @@ func TestListLabels(t *testing.T) {
}
} else {
assert.False(t, result.IsError)
var response ListLabelsOutput
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Len(t, response.Labels, 2)
assert.Equal(t, 5, response.TotalCount)
assert.Equal(t, tc.expectedPageInfo, response.PageInfo)
}
})
}
}

// listLabelsMatcher matches one list_label page request and answers with two labels and the given page info.
func listLabelsMatcher(first int32, after any, pageInfo map[string]any) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
PageInfo struct {
HasNextPage githubv4.Boolean
HasPreviousPage githubv4.Boolean
StartCursor githubv4.String
EndCursor githubv4.String
}
} `graphql:"labels(first: $first, after: $after, orderBy: {field: $orderByField, direction: $orderByDirection})"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"first": githubv4.Int(first),
"after": after,
"orderByField": labelOrderFieldIssueCount,
"orderByDirection": githubv4.OrderDirectionDesc,
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{"id": githubv4.ID("label-1"), "name": githubv4.String("bug"), "color": githubv4.String("d73a4a"), "description": githubv4.String("Something isn't working")},
map[string]any{"id": githubv4.ID("label-2"), "name": githubv4.String("enhancement"), "color": githubv4.String("a2eeef"), "description": githubv4.String("New feature or request")},
},
"totalCount": githubv4.Int(5),
"pageInfo": pageInfo,
},
},
}),
)
}

func TestWriteLabel(t *testing.T) {
t.Parallel()

Expand Down
5 changes: 3 additions & 2 deletions pkg/github/typed_governance_outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ type MinimalLabel struct {
}

type ListLabelsOutput struct {
Labels []MinimalLabel `json:"labels"`
TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."`
Labels []MinimalLabel `json:"labels"`
TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."`
PageInfo MinimalPageInfo `json:"pageInfo"`
}

type LabelWriteOutput struct {
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/typed_governance_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func assertGovernanceProjection(t *testing.T, tc governanceWireCase, output any)
case "get_label":
assert.JSONEq(t, `{"name":"bug","color":"ff0000","description":"desc"}`, mustMarshalJSON(t, output))
case "list_label":
assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`, mustMarshalJSON(t, output))
assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, mustMarshalJSON(t, output))
case "label_write":
assert.Equal(t, tc.args["method"], object["method"])
assert.Equal(t, tc.text, object["message"])
Expand Down Expand Up @@ -236,7 +236,7 @@ func governanceWireCases(t *testing.T) []governanceWireCase {
tr := translations.NullTranslationHelper
return []governanceWireCase{
{tool: GetLabel(tr), args: map[string]any{"owner": "o", "repo": "r", "name": "bug"}, text: `{"id":"L1","name":"bug","color":"ff0000","description":"desc"}`},
{tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`},
{tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`},
{tool: LabelWrite(tr), args: map[string]any{"method": "create", "owner": "o", "repo": "r", "name": "feature", "color": "abcdef"}, text: "label 'feature' created successfully"},
{tool: LabelWrite(tr), args: map[string]any{"method": "update", "owner": "o", "repo": "r", "name": "bug", "new_name": "defect"}, text: "label 'defect' updated successfully"},
{tool: LabelWrite(tr), args: map[string]any{"method": "delete", "owner": "o", "repo": "r", "name": "bug"}, text: "label 'bug' deleted successfully"},
Expand Down
Loading