274 lines
10 KiB
Go
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
|
|
}
|