Files
github--github-mcp-server/pkg/github/issue_fields.go
Copilot a589920d1a
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Merge main; remove multi-select feature flag
Resolves conflicts from merging origin/main into the multi-select branch.
main added the `fields_param` feature which also split `list_issues` into
flag-gated variants, colliding with this branch's
`remote_mcp_issue_fields_multiselect` split.

Rather than gate `list_issues` on two independent flags (which the inventory's
single FeatureFlagEnable can't express for the "both on" case), the multi-select
feature flag is removed entirely — multi-select is now unconditional, matching
the already-unconditional read paths:

- issue_write / list_issue_fields collapse to their single multi-select-aware
  variant; the legacy siblings and issues_legacy_multiselect.go are deleted
- list_issues adopts main's fields_param split (ListIssues / LegacyListIssues)
  with the multi-select `values` slot always advertised
- optionalIssueWriteFields / resolveIssueRequestFieldValues / parseRawFieldFilters
  drop the now-constant multiSelectEnabled bool and their dead single-select-only
  branches
- FeatureFlagIssueFieldsMultiSelect const, allowlist and insiders entries removed
- toolsnaps + generated docs regenerated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-13 13:28:02 +01:00

320 lines
12 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"strconv"
ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// IssueField represents a repository issue field definition.
type IssueField struct {
ID string `json:"id"`
DatabaseID int64 `json:"full_database_id,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DataType string `json:"data_type"`
Visibility string `json:"visibility"`
Options []IssueSingleSelectFieldOption `json:"options,omitempty"`
}
// IssueSingleSelectFieldOption represents an option for a single_select issue field.
type IssueSingleSelectFieldOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color"`
Priority *int `json:"priority,omitempty"`
}
// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union.
// Only the fragment matching __typename is populated; read from the matching fragment.
// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because
// shurcooL/githubv4 does not support interface fragments at the top level of a union.
type issueFieldNode struct {
TypeName githubv4.String `graphql:"__typename"`
IssueFieldText struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Name githubv4.String
Description githubv4.String
DataType githubv4.String
Visibility githubv4.String
} `graphql:"... on IssueFieldText"`
IssueFieldNumber struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Name githubv4.String
Description githubv4.String
DataType githubv4.String
Visibility githubv4.String
} `graphql:"... on IssueFieldNumber"`
IssueFieldDate struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Name githubv4.String
Description githubv4.String
DataType githubv4.String
Visibility githubv4.String
} `graphql:"... on IssueFieldDate"`
IssueFieldSingleSelect struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Name githubv4.String
Description githubv4.String
DataType githubv4.String
Visibility githubv4.String
Options []struct {
ID githubv4.ID
Name githubv4.String
Description githubv4.String
Color githubv4.String
Priority *int
}
} `graphql:"... on IssueFieldSingleSelect"`
IssueFieldMultiSelect struct {
ID githubv4.ID
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
Name githubv4.String
Description githubv4.String
DataType githubv4.String
Visibility githubv4.String
Options []struct {
ID githubv4.ID
Name githubv4.String
Description githubv4.String
Color githubv4.String
Priority *int
}
} `graphql:"... on IssueFieldMultiSelect"`
}
// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository.
type issueFieldsRepoQuery struct {
Repository struct {
IssueFields struct {
Nodes []issueFieldNode
} `graphql:"issueFields(first: 100)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization.
type issueFieldsOrgQuery struct {
Organization struct {
IssueFields struct {
Nodes []issueFieldNode
} `graphql:"issueFields(first: 100)"`
} `graphql:"organization(login: $login)"`
}
// ListIssueFields creates a tool to list issue field definitions for a repository or organization.
//
// Note: the underlying GraphQL fragment unconditionally includes the
// multi_select arm, so the OUTPUT will surface multi-select fields if the org
// has them (matching what the dotcom UI shows).
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
description := t(
"TOOL_LIST_ISSUE_FIELDS_DESCRIPTION",
"List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select, multi_select), and for single_select and multi_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.",
)
return buildListIssueFields(t, description)
}
func buildListIssueFields(t translations.TranslationHelperFunc, description string) inventory.ServerTool {
return NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "list_issue_fields",
Description: description,
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "The account owner of the repository or organization. The name is not case sensitive.",
},
"repo": {
Type: "string",
Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.",
},
},
Required: []string{"owner"},
},
},
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := OptionalParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
fields, err := fetchIssueFields(ctx, gqlClient, owner, repo)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
}
r, err := json.Marshal(fields)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
}
result := utils.NewToolResultText(string(r))
// Issue field definitions are repo/org structural metadata
// (trusted). When scoped to a specific repo, confidentiality
// follows that repo's visibility; for an org-level lookup (no
// repo) it is conservatively treated as private.
if repo == "" {
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true))
} else {
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
}
return result, nil, nil
})
}
// fetchIssueFields returns the issue field definitions for the given owner.
// If repo is provided, fields are scoped to that repository (inherited from its
// organization); otherwise fields are returned directly from the organization.
func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) {
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
if repo != "" {
var query issueFieldsRepoQuery
vars := map[string]any{
"owner": githubv4.String(owner),
"name": githubv4.String(repo),
}
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
return nil, err
}
return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil
}
var query issueFieldsOrgQuery
vars := map[string]any{
"login": githubv4.String(owner),
}
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
return nil, err
}
return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil
}
// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values.
// Read from the fragment matching __typename; the other fragments are zero-valued.
func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField {
fields := make([]IssueField, 0, len(nodes))
for _, node := range nodes {
var f IssueField
switch string(node.TypeName) {
case "IssueFieldSingleSelect":
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options))
for _, o := range node.IssueFieldSingleSelect.Options {
opts = append(opts, IssueSingleSelectFieldOption{
ID: fmt.Sprintf("%v", o.ID),
Name: string(o.Name),
Description: string(o.Description),
Color: string(o.Color),
Priority: o.Priority,
})
}
f = IssueField{
ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID),
DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)),
Name: string(node.IssueFieldSingleSelect.Name),
Description: string(node.IssueFieldSingleSelect.Description),
DataType: string(node.IssueFieldSingleSelect.DataType),
Visibility: string(node.IssueFieldSingleSelect.Visibility),
Options: opts,
}
case "IssueFieldMultiSelect":
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldMultiSelect.Options))
for _, o := range node.IssueFieldMultiSelect.Options {
opts = append(opts, IssueSingleSelectFieldOption{
ID: fmt.Sprintf("%v", o.ID),
Name: string(o.Name),
Description: string(o.Description),
Color: string(o.Color),
Priority: o.Priority,
})
}
f = IssueField{
ID: fmt.Sprintf("%v", node.IssueFieldMultiSelect.ID),
DatabaseID: parseFullDatabaseID(string(node.IssueFieldMultiSelect.FullDatabaseID)),
Name: string(node.IssueFieldMultiSelect.Name),
Description: string(node.IssueFieldMultiSelect.Description),
DataType: string(node.IssueFieldMultiSelect.DataType),
Visibility: string(node.IssueFieldMultiSelect.Visibility),
Options: opts,
}
case "IssueFieldText":
f = IssueField{
ID: fmt.Sprintf("%v", node.IssueFieldText.ID),
DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)),
Name: string(node.IssueFieldText.Name),
Description: string(node.IssueFieldText.Description),
DataType: string(node.IssueFieldText.DataType),
Visibility: string(node.IssueFieldText.Visibility),
}
case "IssueFieldNumber":
f = IssueField{
ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID),
DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)),
Name: string(node.IssueFieldNumber.Name),
Description: string(node.IssueFieldNumber.Description),
DataType: string(node.IssueFieldNumber.DataType),
Visibility: string(node.IssueFieldNumber.Visibility),
}
case "IssueFieldDate":
f = IssueField{
ID: fmt.Sprintf("%v", node.IssueFieldDate.ID),
DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)),
Name: string(node.IssueFieldDate.Name),
Description: string(node.IssueFieldDate.Description),
DataType: string(node.IssueFieldDate.DataType),
Visibility: string(node.IssueFieldDate.Visibility),
}
default:
continue
}
fields = append(fields, f)
}
return fields
}
// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64.
// Returns 0 if the string is empty or cannot be parsed.
func parseFullDatabaseID(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}