Files
github--github-mcp-server/pkg/github/issue_fields.go
T
2026-07-13 12:37:57 +08:00

274 lines
10 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"`
}
// 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.
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "list_issue_fields",
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), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."),
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
})
return st
}
// 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 "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
}