package github import ( "context" "fmt" "strconv" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/shurcooL/githubv4" ) // resolverFieldsPageSize is the GraphQL ProjectV2 max page size; covers most // projects in a single round-trip. const resolverFieldsPageSize = 100 // ResolvedFieldOption is one option on a SINGLE_SELECT project field. type ResolvedFieldOption struct { ID string Name string } // ResolvedField is a project field resolved by name; Options is only set when // DataType == "SINGLE_SELECT". type ResolvedField struct { ID string Name string DataType string Options []ResolvedFieldOption } // projectFieldsQueryOrg fetches all fields on an org-owned project (paginated). type projectFieldsQueryOrg struct { Organization struct { ProjectV2 struct { Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` } // projectFieldsQueryUser fetches all fields on a user-owned project (paginated). type projectFieldsQueryUser struct { User struct { ProjectV2 struct { Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` } // projectFieldsConnection is a paginated list of project fields. We select `id` // to discriminate the union variant and `databaseId` for the numeric ID REST needs. type projectFieldsConnection struct { Nodes []struct { ProjectV2Field struct { ID githubv4.ID DatabaseID githubv4.Int `graphql:"databaseId"` Name githubv4.String DataType githubv4.String } `graphql:"... on ProjectV2Field"` ProjectV2IterationField struct { ID githubv4.ID DatabaseID githubv4.Int `graphql:"databaseId"` Name githubv4.String DataType githubv4.String } `graphql:"... on ProjectV2IterationField"` ProjectV2SingleSelectField struct { ID githubv4.ID DatabaseID githubv4.Int `graphql:"databaseId"` Name githubv4.String DataType githubv4.String Options []struct { ID githubv4.String Name githubv4.String } } `graphql:"... on ProjectV2SingleSelectField"` } PageInfo PageInfoFragment } // listAllProjectFields fetches every field on a project, paginating as needed. func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { all := []ResolvedField{} var after *githubv4.String for { vars := map[string]any{ "owner": githubv4.String(owner), "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small "first": githubv4.Int(resolverFieldsPageSize), "after": (*githubv4.String)(nil), } if after != nil { vars["after"] = after } var conn projectFieldsConnection if ownerType == "org" { var q projectFieldsQueryOrg if err := gqlClient.Query(ctx, &q, vars); err != nil { return nil, fmt.Errorf("failed to list project fields: %w", err) } conn = q.Organization.ProjectV2.Fields } else { var q projectFieldsQueryUser if err := gqlClient.Query(ctx, &q, vars); err != nil { return nil, fmt.Errorf("failed to list project fields: %w", err) } conn = q.User.ProjectV2.Fields } for _, n := range conn.Nodes { switch { case n.ProjectV2SingleSelectField.ID != nil: opts := make([]ResolvedFieldOption, 0, len(n.ProjectV2SingleSelectField.Options)) for _, o := range n.ProjectV2SingleSelectField.Options { opts = append(opts, ResolvedFieldOption{ID: string(o.ID), Name: string(o.Name)}) } all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), Name: string(n.ProjectV2SingleSelectField.Name), DataType: string(n.ProjectV2SingleSelectField.DataType), Options: opts, }) case n.ProjectV2IterationField.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID), Name: string(n.ProjectV2IterationField.Name), DataType: string(n.ProjectV2IterationField.DataType), }) case n.ProjectV2Field.ID != nil: all = append(all, ResolvedField{ ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), Name: string(n.ProjectV2Field.Name), DataType: string(n.ProjectV2Field.DataType), }) } } if !bool(conn.PageInfo.HasNextPage) { break } end := conn.PageInfo.EndCursor after = &end } return all, nil } // resolveProjectFieldByName resolves a field by display name. Returns a // structured error on not-found, ambiguous, or wrong-data-type (when // expectedDataType is set) so the agent can self-correct. func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { if fieldName == "" { return nil, fmt.Errorf("field name must not be empty") } all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return nil, err } var matches []ResolvedField for _, f := range all { if strings.EqualFold(f.Name, fieldName) { matches = append(matches, f) } } if len(matches) == 0 { candidates := make([]any, 0, len(all)) for _, f := range all { candidates = append(candidates, map[string]any{ "name": f.Name, "data_type": f.DataType, }) } return nil, ghErrors.NewStructuredResolutionError( "field_not_found", fieldName, fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), candidates, ) } if len(matches) > 1 { candidates := make([]any, 0, len(matches)) for _, f := range matches { candidates = append(candidates, map[string]any{ "id": f.ID, "data_type": f.DataType, }) } return nil, ghErrors.NewStructuredResolutionError( "field_ambiguous", fieldName, "multiple fields share this name; pass updated_field.id to disambiguate", candidates, ) } field := matches[0] if expectedDataType != "" && field.DataType != expectedDataType { return nil, ghErrors.NewStructuredResolutionError( "wrong_field_type", fieldName, fmt.Sprintf("field %q has data type %q but %q was expected", fieldName, field.DataType, expectedDataType), []any{map[string]any{"id": field.ID, "data_type": field.DataType}}, ) } return &field, nil } // resolveSingleSelectOptionByName resolves an option name to its ID on a // SINGLE_SELECT field. Returns a structured error if not found or ambiguous. func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (string, error) { if field == nil { return "", fmt.Errorf("field must not be nil") } if field.DataType != "SINGLE_SELECT" { return "", ghErrors.NewStructuredResolutionError( "wrong_field_type", field.Name, fmt.Sprintf("cannot resolve option name on non-SINGLE_SELECT field %q (data type %q)", field.Name, field.DataType), nil, ) } var matchIDs []string for _, o := range field.Options { if strings.EqualFold(o.Name, optionName) { matchIDs = append(matchIDs, o.ID) } } switch len(matchIDs) { case 0: candidates := make([]any, 0, len(field.Options)) for _, o := range field.Options { candidates = append(candidates, map[string]any{"name": o.Name}) } return "", ghErrors.NewStructuredResolutionError( "option_not_found", optionName, fmt.Sprintf("no option named %q on field %q; see candidates for available options", optionName, field.Name), candidates, ) case 1: return matchIDs[0], nil default: candidates := make([]any, 0, len(matchIDs)) for _, id := range matchIDs { candidates = append(candidates, map[string]any{"id": id}) } return "", ghErrors.NewStructuredResolutionError( "option_ambiguous", optionName, fmt.Sprintf("multiple options on field %q share the name %q", field.Name, optionName), candidates, ) } } // resolveProjectItemIDByIssueNumber resolves a (project, issue) pair to the // project item's full database ID in one GraphQL hop. Returns a structured // error if the issue is not an item on the project. func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (int64, error) { projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return 0, err } var query struct { Repository struct { Issue struct { ProjectItems struct { Nodes []struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Project struct { ID githubv4.ID } } PageInfo PageInfoFragment } `graphql:"projectItems(first: 50, includeArchived: true)"` } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` } vars := map[string]any{ "issueOwner": githubv4.String(issueOwner), "issueRepo": githubv4.String(issueRepo), "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small } if err := gqlClient.Query(ctx, &query, vars); err != nil { return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } for _, item := range query.Repository.Issue.ProjectItems.Nodes { if item.Project.ID == projectID { itemID, parseErr := parseInt64(string(item.FullDatabaseID)) if parseErr != nil { return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) } return itemID, nil } } return 0, ghErrors.NewStructuredResolutionError( "item_not_in_project", fmt.Sprintf("%s/%s#%d", issueOwner, issueRepo, issueNumber), "the issue exists but is not an item on the named project; add it first via add_project_item", nil, ) } // resolveItemIDFromIssueArgs reads (item_owner, item_repo, issue_number) from args // and resolves them to a project item ID. Returns a single friendly error if any input is missing. func resolveItemIDFromIssueArgs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (int64, error) { issueOwner, ownerErr := RequiredParam[string](args, "item_owner") issueRepo, repoErr := RequiredParam[string](args, "item_repo") issueNumber, numErr := RequiredInt(args, "issue_number") if ownerErr != nil || repoErr != nil || numErr != nil { return 0, fmt.Errorf("update_project_item requires either item_id, or item_owner + item_repo + issue_number to resolve the item by issue") } return resolveProjectItemIDByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) } func parseInt64(s string) (int64, error) { return strconv.ParseInt(s, 10, 64) } // resolveFieldNamesToIDs resolves field names to numeric IDs in one GraphQL // hop. Fails fast with a structured error on any unresolved or ambiguous name. func resolveFieldNamesToIDs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, names []string) ([]int64, error) { if len(names) == 0 { return nil, nil } all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return nil, err } // Build a name -> []ResolvedField map so we can detect duplicates per name. // Matching is case-insensitive to align with the GraphQL API's behaviour. byName := make(map[string][]ResolvedField, len(all)) for _, f := range all { key := strings.ToLower(f.Name) byName[key] = append(byName[key], f) } out := make([]int64, 0, len(names)) for _, name := range names { matches := byName[strings.ToLower(name)] switch len(matches) { case 0: candidates := make([]any, 0, len(all)) for _, f := range all { candidates = append(candidates, map[string]any{"name": f.Name, "data_type": f.DataType}) } return nil, ghErrors.NewStructuredResolutionError( "field_not_found", name, fmt.Sprintf("no project field named %q on project %s#%d", name, owner, projectNumber), candidates, ) case 1: id, parseErr := parseInt64(matches[0].ID) if parseErr != nil { return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via 'fields' instead", name, matches[0].ID) } out = append(out, id) default: candidates := make([]any, 0, len(matches)) for _, f := range matches { candidates = append(candidates, map[string]any{"id": f.ID, "data_type": f.DataType}) } return nil, ghErrors.NewStructuredResolutionError( "field_ambiguous", name, "multiple fields share this name; pass numeric IDs via 'fields' to disambiguate", candidates, ) } } return out, nil }