From 965fd8afcce5ea62a952664050c416bf4d3fb348 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 10:44:59 -0400 Subject: [PATCH 01/10] Add Project Issue Field updates Resolve attached Issue Fields through project field names and dispatch singular updates through the Issue Field mutation while keeping batch writes explicit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 2 +- pkg/github/granular_tools_test.go | 2 +- pkg/github/issues_granular.go | 82 ++-- pkg/github/projects.go | 173 ++++++- pkg/github/projects_batch.go | 6 + pkg/github/projects_issue_fields_test.go | 468 +++++++++++++++++++ pkg/github/projects_resolver.go | 113 +++-- pkg/github/projects_resolver_test.go | 22 +- 9 files changed, 786 insertions(+), 84 deletions(-) create mode 100644 pkg/github/projects_issue_fields_test.go diff --git a/README.md b/README.md index 1f89dafd06..e58bb64ddd 100644 --- a/README.md +++ b/README.md @@ -1133,7 +1133,7 @@ The following sets of tools are available: - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `title`: The project title. Required for 'create_project' method. (string, optional) - - `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID. (object, optional) + - `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d7c5d25eab..8cad1abaa1 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -186,7 +186,7 @@ "type": "string" }, "updated_field": { - "description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.", + "description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.", "oneOf": [ { "additionalProperties": false, diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 58fd904e88..67c18ab59a 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -2428,7 +2428,7 @@ func TestGranularSetIssueFields(t *testing.T) { require.False(t, result.IsError, getTextResult(t, result).Text) // The last request captured is the mutation; the preceding issue ID // query does not require the feature flag. - assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) + assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index c1eb556c9c..8cbf1cf196 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1263,6 +1263,47 @@ type IssueFieldCreateOrUpdateInput struct { Suggest *githubv4.Boolean `json:"suggest,omitempty"` } +type setIssueFieldValueMutation struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` +} + +// SetIssueFieldValues applies typed Issue Field values to an issue node. +func SetIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, issueID githubv4.ID, issueFields []IssueFieldCreateOrUpdateInput) (MinimalResponse, error) { + var mutation setIssueFieldValueMutation + input := SetIssueFieldValueInput{ + IssueID: issueID, + IssueFields: issueFields, + } + ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") + if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil { + return MinimalResponse{}, err + } + return MinimalResponse{ + ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID), + URL: string(mutation.SetIssueFieldValue.Issue.URL), + }, nil +} + // GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL. func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( @@ -1486,47 +1527,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil } - // Execute the setIssueFieldValue mutation - var mutation struct { - SetIssueFieldValue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - IssueFieldValues []struct { - TextValue struct { - Value string - } `graphql:"... on IssueFieldTextValue"` - SingleSelectValue struct { - Name string - } `graphql:"... on IssueFieldSingleSelectValue"` - DateValue struct { - Value string - } `graphql:"... on IssueFieldDateValue"` - NumberValue struct { - Value float64 - } `graphql:"... on IssueFieldNumberValue"` - } - } `graphql:"setIssueFieldValue(input: $input)"` - } - - mutationInput := SetIssueFieldValueInput{ - IssueID: issueID, - IssueFields: issueFields, - } - - // The rationale and suggest input fields on IssueFieldCreateOrUpdateInput - // are gated behind the update_issue_suggestions GraphQL feature flag. - ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") - if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil { + response, err := SetIssueFieldValues(ctx, gqlClient, issueID, issueFields) + if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil } - r, err := json.Marshal(MinimalResponse{ - ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID), - URL: string(mutation.SetIssueFieldValue.Issue.URL), - }) + r, err := json.Marshal(response) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 514964be93..06ef176e16 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -551,7 +551,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "object", - Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.", + Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.", OneOf: []*jsonschema.Schema{ variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { @@ -1191,7 +1191,7 @@ func getProjectField(ctx context.Context, client *github.Client, owner, ownerTyp return utils.NewToolResultText(string(r)), nil, nil } -func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { +func fetchProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*github.ProjectV2Item, *github.Response, error) { var resp *github.Response var projectItem *github.ProjectV2Item var opts *github.GetProjectItemOptions @@ -1208,6 +1208,11 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType } else { projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) } + return projectItem, resp, err +} + +func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { + projectItem, resp, err := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1235,7 +1240,7 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType } func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { - updatePayload, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) + update, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { var structured *ghErrors.StructuredResolutionError if errors.As(err, &structured) { @@ -1244,13 +1249,44 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultError(err.Error()), nil, nil } + if update.IssueField != nil { + projectItem, resp, fetchErr := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, nil) + if fetchErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to inspect project item content", resp, fetchErr), nil, nil + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if resp == nil || resp.StatusCode != http.StatusOK { + return utils.NewToolResultError("failed to inspect project item content"), nil, nil + } + + issueID, resolveErr := projectItemIssueNodeID(projectItem) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + response, mutationErr := SetIssueFieldValues(ctx, gqlClient, issueID, []IssueFieldCreateOrUpdateInput{*update.IssueField}) + if mutationErr != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to update Issue Field", mutationErr), nil, nil + } + r, marshalErr := json.Marshal(response) + if marshalErr != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", marshalErr) + } + return utils.NewToolResultText(string(r)), nil, nil + } + var resp *github.Response var updatedItem *github.ProjectV2Item if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, update.Project) } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, update.Project) } if err != nil { @@ -1277,6 +1313,36 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultText(string(r)), nil, nil } +func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) { + if item == nil || item.ContentType == nil { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "", + "the project item response did not identify its content type; Issue Fields can only be updated on Issue items", + nil, + ) + } + + contentType := string(*item.ContentType) + if contentType != "Issue" { + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + contentType, + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "Issue", + "the project item response did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) + } + return githubv4.ID(item.Content.Issue.GetNodeID()), nil +} + func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error @@ -1614,8 +1680,13 @@ func validateAndConvertToInt64(value any) (int64, error) { } } -// buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. -func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { +type resolvedProjectItemUpdate struct { + Project *github.UpdateProjectItemOptions + IssueField *IssueFieldCreateOrUpdateInput +} + +// buildUpdateProjectItem resolves the target field and builds the matching Project or Issue Field write. +func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*resolvedProjectItemUpdate, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } @@ -1659,6 +1730,13 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own if err != nil { return nil, err } + if resolved.IsIssueField { + issueField, buildErr := buildIssueFieldUpdate(resolved, valueField) + if buildErr != nil { + return nil, buildErr + } + return &resolvedProjectItemUpdate{IssueField: issueField}, nil + } parsedID, parseErr := parseInt64(resolved.ID) if parseErr != nil { return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) @@ -1694,7 +1772,86 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own }}, } - return payload, nil + return &resolvedProjectItemUpdate{Project: payload}, nil +} + +func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { + if field == nil || field.IssueFieldNodeID == "" { + name := "" + if field != nil { + name = field.Name + } + return nil, ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + name, + "the attached Project field did not include the underlying Issue Field node ID; refresh field metadata and retry", + nil, + ) + } + + input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)} + if raw == nil { + deleteValue := githubv4.Boolean(true) + input.Delete = &deleteValue + return input, nil + } + + invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) { + return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil) + } + + switch field.DataType { + case "TEXT": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name)) + } + input.TextValue = githubv4.NewString(githubv4.String(value)) + case "NUMBER": + value, ok := toFloat64(raw) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name)) + } + number := githubv4.Float(value) + input.NumberValue = &number + case "DATE": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name)) + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value)) + } + input.DateValue = githubv4.NewString(githubv4.String(value)) + case "SINGLE_SELECT": + value, ok := raw.(string) + if !ok || value == "" { + return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) + } + optionID, err := resolveSingleSelectOptionByName(field, value) + if err != nil { + for _, option := range field.Options { + if option.ID == value { + optionID = value + err = nil + break + } + } + if err != nil { + return nil, err + } + } + id := githubv4.ID(optionID) + input.SingleSelectOptionID = &id + default: + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_field_type", + field.Name, + fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), + nil, + ) + } + return input, nil } func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 28493a4bf2..94a770f8e6 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -119,6 +119,12 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie if fieldErr != nil { return batchTopLevelError(fieldErr), nil, nil } + if field.IsIssueField { + return utils.NewToolResultError(fmt.Sprintf( + "field %q is an attached Issue Field; update_project_items does not support Issue Fields because they are values on Issue content. Use singular update_project_item for each Issue item", + field.Name, + )), nil, nil + } kind := batchMutationUpdate var value githubv4.ProjectV2FieldValue diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go new file mode 100644 index 0000000000..287787dd2d --- /dev/null +++ b/pkg/github/projects_issue_fields_test.go @@ -0,0 +1,468 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func attachedIssueFieldNode(projectNodeID string, projectDatabaseID int, issueFieldNodeID, name, dataType string, options []map[string]any) map[string]any { + node := map[string]any{ + "id": projectNodeID, + "databaseId": projectDatabaseID, + "name": name, + "dataType": dataType, + "isIssueField": true, + "issueField": map[string]any{ + "id": issueFieldNodeID, + }, + } + if dataType == "SINGLE_SELECT" { + node["options"] = []any{} + node["issueField"].(map[string]any)["options"] = options + } + return node +} + +func issueProjectItemFixture(fields []map[string]any) map[string]any { + return map[string]any{ + "id": 1001, + "node_id": "PVTI_1", + "content_type": "Issue", + "content": map[string]any{ + "id": 2002, + "node_id": "I_123", + "number": 5, + "title": "Track customer", + "state": "open", + "html_url": "https://github.com/octo-org/roadmap/issues/5", + "repository_url": "https://api.github.com/repos/octo-org/roadmap", + }, + "fields": fields, + } +} + +func issueFieldMutationResponse() map[string]any { + return map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "I_123", + "number": 5, + "url": "https://github.com/octo-org/roadmap/issues/5", + }, + "issueFieldValues": []any{}, + }, + } +} + +func Test_ResolveProjectFieldByName_AttachedIssueFields(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + attachedIssueFieldNode("PVTSSF_risk", 702, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ + {"id": "IFO_low", "name": "Low"}, + {"id": "IFO_high", "name": "High"}, + }), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + customer, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "customer", "") + require.NoError(t, err) + assert.Equal(t, "701", customer.ID) + assert.Equal(t, "PVTF_customer", customer.NodeID) + assert.True(t, customer.IsIssueField) + assert.Equal(t, "IF_customer", customer.IssueFieldNodeID) + + risk, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "RISK", "") + require.NoError(t, err) + assert.Equal(t, "702", risk.ID) + assert.Equal(t, "IF_risk", risk.IssueFieldNodeID) + assert.Equal(t, []ResolvedFieldOption{ + {ID: "IFO_low", Name: "Low"}, + {ID: "IFO_high", Name: "High"}, + }, risk.Options) +} + +func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing.T) { + fieldValue := map[string]any{ + "id": 701, + "issue_field_id": 9001, + "name": "Customer", + "data_type": "text", + "value": "Acme", + } + + tests := []struct { + name string + tool func(translations.TranslationHelperFunc) inventory.ServerTool + method string + route string + body any + }{ + { + name: "get", + tool: ProjectsGet, + method: projectsMethodGetProjectItem, + route: GetOrgsProjectsV2ItemsByProjectByItemID, + body: issueProjectItemFixture([]map[string]any{fieldValue}), + }, + { + name: "list", + tool: ProjectsList, + method: projectsMethodListProjectItems, + route: GetOrgsProjectsV2ItemsByProject, + body: []map[string]any{issueProjectItemFixture([]map[string]any{fieldValue})}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + })), + ), + )) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "701", r.URL.Query().Get("fields")) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(tt.body)) + }, + }) + serverTool := tt.tool(translations.NullTranslationHelper) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + handler := serverTool.Handler(deps) + args := map[string]any{ + "method": tt.method, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_names": []any{"customer"}, + } + if tt.method == projectsMethodGetProjectItem { + args["item_id"] = float64(1001) + } + + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + item := response + if tt.method == projectsMethodListProjectItems { + items := response["items"].([]any) + item = items[0].(map[string]any) + } + fields := item["fields"].([]any) + require.Len(t, fields, 1) + assert.Equal(t, map[string]any{ + "id": float64(701), + "name": "Customer", + "data_type": "text", + "value": "Acme", + }, fields[0]) + }) + } +} + +func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) { + number := githubv4.Float(3.5) + optionID := githubv4.ID("IFO_high") + deleteValue := githubv4.Boolean(true) + + tests := []struct { + name string + fieldNode map[string]any + fieldName string + value any + want IssueFieldCreateOrUpdateInput + }{ + { + name: "text", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + fieldName: "cUsToMeR", + value: "Acme", + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_customer"), + TextValue: githubv4.NewString(githubv4.String("Acme")), + }, + }, + { + name: "number", + fieldNode: attachedIssueFieldNode("PVTF_score", 702, "IF_score", "Score", "NUMBER", nil), + fieldName: "Score", + value: 3.5, + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_score"), + NumberValue: &number, + }, + }, + { + name: "date", + fieldNode: attachedIssueFieldNode("PVTF_due", 703, "IF_due", "Due", "DATE", nil), + fieldName: "Due", + value: "2026-08-01", + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_due"), + DateValue: githubv4.NewString(githubv4.String("2026-08-01")), + }, + }, + { + name: "single select resolves option case insensitively", + fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ + {"id": "IFO_low", "name": "Low"}, + {"id": "IFO_high", "name": "High"}, + }), + fieldName: "rIsK", + value: "hIgH", + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_risk"), + SingleSelectOptionID: &optionID, + }, + }, + { + name: "clear", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + fieldName: "Customer", + value: nil, + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_customer"), + Delete: &deleteValue, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), + ), + githubv4mock.NewMutationMatcher( + setIssueFieldValueMutation{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("I_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{tt.want}, + }, + nil, + githubv4mock.DataResponse(issueFieldMutationResponse()), + ), + )) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), + }) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "name": tt.fieldName, + "value": tt.value, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.JSONEq(t, `{"id":"I_123","url":"https://github.com/octo-org/roadmap/issues/5"}`, getTextResult(t, result).Text) + }) + } +} + +func Test_ProjectsWrite_UpdateProjectItem_RejectsIssueFieldForNonIssueItems(t *testing.T) { + tests := []struct { + name string + contentType string + content map[string]any + }{ + { + name: "pull request", + contentType: "PullRequest", + content: map[string]any{ + "id": 2002, "node_id": "PR_123", "number": 5, "title": "PR", + }, + }, + { + name: "draft issue", + contentType: "DraftIssue", + content: map[string]any{ + "id": 2003, "node_id": "DI_123", "title": "Draft", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + })), + ), + )) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, map[string]any{ + "id": 1001, + "node_id": "PVTI_1", + "content_type": tt.contentType, + "content": tt.content, + }), + }) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, "unsupported_item_type", response["error"]) + assert.Equal(t, tt.contentType, response["name"]) + assert.Contains(t, response["hint"], "only be updated on Issue project items") + }) + } +} + +func Test_ProjectsWrite_UpdateProjectItem_IssueFieldErrorsAreStructured(t *testing.T) { + tests := []struct { + name string + fieldNode map[string]any + value any + wantKind string + }{ + { + name: "wrong value type", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + value: 42.0, + wantKind: "invalid_field_value", + }, + { + name: "missing underlying metadata", + fieldNode: map[string]any{ + "id": "PVTF_customer", "databaseId": 701, "name": "Customer", "dataType": "TEXT", "isIssueField": true, + }, + value: "Acme", + wantKind: "issue_field_metadata_unavailable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), + ), + )) + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})), + GQLClient: gql, + } + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{"name": "Customer", "value": tt.value}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, tt.wantKind, response["error"]) + assert.NotEmpty(t, response["hint"]) + }) + } +} + +func Test_ProjectsWrite_UpdateProjectItem_StandardFieldNameStillUsesProjectsREST(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{{"id": "OPT_doing", "name": "Doing"}}), + })), + ), + )) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, verbosePullRequestProjectItemFixture()), + }) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{"name": "status", "value": "doing"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) +} + +func Test_UpdateProjectItemsBatch_RejectsAttachedIssueFields(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + })), + ), + )) + + result, _, err := updateProjectItemsBatch(context.Background(), nil, gql, "octo-org", "org", 1, map[string]any{ + "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, + "items": []any{map[string]any{"node_id": "PVTI_1"}}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, "update_project_items does not support Issue Fields") + assert.Contains(t, text, "Use singular update_project_item") +} diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 3643d6eafa..1e379dcee1 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/shurcooL/githubv4" ) @@ -23,11 +24,32 @@ type ResolvedFieldOption struct { // ResolvedField contains a project's numeric database ID, GraphQL node ID, and // type-specific options. type ResolvedField struct { - ID string - NodeID string - Name string - DataType string - Options []ResolvedFieldOption + ID string + NodeID string + Name string + DataType string + Options []ResolvedFieldOption + IsIssueField bool + IssueFieldNodeID string +} + +type projectIssueFieldMetadata struct { + Text struct { + ID githubv4.ID + } `graphql:"... on IssueFieldText"` + Number struct { + ID githubv4.ID + } `graphql:"... on IssueFieldNumber"` + Date struct { + ID githubv4.ID + } `graphql:"... on IssueFieldDate"` + SingleSelect struct { + ID githubv4.ID + Options []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"... on IssueFieldSingleSelect"` } // projectFieldsQueryOrg fetches all fields on an org-owned project (paginated). @@ -53,10 +75,12 @@ type projectFieldsQueryUser struct { type projectFieldsConnection struct { Nodes []struct { ProjectV2Field struct { - ID githubv4.ID - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata } `graphql:"... on ProjectV2Field"` ProjectV2IterationField struct { ID githubv4.ID @@ -65,11 +89,13 @@ type projectFieldsConnection struct { 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.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + Options []struct { ID githubv4.String Name githubv4.String } @@ -82,6 +108,7 @@ type projectFieldsConnection struct { func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { all := []ResolvedField{} var after *githubv4.String + ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") for { vars := map[string]any{ @@ -112,16 +139,28 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner 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)}) + field := n.ProjectV2SingleSelectField + opts := make([]ResolvedFieldOption, 0, len(field.Options)) + issueFieldNodeID := "" + if field.IsIssueField { + opts = make([]ResolvedFieldOption, 0, len(field.IssueField.SingleSelect.Options)) + issueFieldNodeID = issueFieldNodeIDForType(field.DataType, field.IssueField) + for _, o := range field.IssueField.SingleSelect.Options { + opts = append(opts, ResolvedFieldOption{ID: fmt.Sprintf("%v", o.ID), Name: string(o.Name)}) + } + } else { + for _, o := range field.Options { + opts = append(opts, ResolvedFieldOption{ID: string(o.ID), Name: string(o.Name)}) + } } all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), - NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), - Name: string(n.ProjectV2SingleSelectField.Name), - DataType: string(n.ProjectV2SingleSelectField.DataType), - Options: opts, + ID: fmt.Sprintf("%d", field.DatabaseID), + NodeID: fmt.Sprintf("%v", field.ID), + Name: string(field.Name), + DataType: string(field.DataType), + Options: opts, + IsIssueField: bool(field.IsIssueField), + IssueFieldNodeID: issueFieldNodeID, }) case n.ProjectV2IterationField.ID != nil: all = append(all, ResolvedField{ @@ -131,13 +170,17 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner DataType: string(n.ProjectV2IterationField.DataType), }) case n.ProjectV2Field.ID != nil: + field := n.ProjectV2Field all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), - NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), - Name: string(n.ProjectV2Field.Name), - DataType: string(n.ProjectV2Field.DataType), + ID: fmt.Sprintf("%d", field.DatabaseID), + NodeID: fmt.Sprintf("%v", field.ID), + Name: string(field.Name), + DataType: string(field.DataType), + IsIssueField: bool(field.IsIssueField), + IssueFieldNodeID: issueFieldNodeIDForType(field.DataType, field.IssueField), }) } + } if !bool(conn.PageInfo.HasNextPage) { @@ -150,6 +193,24 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner return all, nil } +func issueFieldNodeIDForType(dataType githubv4.String, metadata projectIssueFieldMetadata) string { + var id githubv4.ID + switch strings.ToUpper(string(dataType)) { + case "TEXT": + id = metadata.Text.ID + case "NUMBER": + id = metadata.Number.ID + case "DATE": + id = metadata.Date.ID + case "SINGLE_SELECT": + id = metadata.SingleSelect.ID + } + if id == nil { + return "" + } + return fmt.Sprintf("%v", id) +} + // 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. diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index b08e00cac6..db8fe4e0be 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -21,10 +21,12 @@ type projectFieldsTestQuery struct { Fields struct { Nodes []struct { ProjectV2Field struct { - ID githubv4.ID - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata } `graphql:"... on ProjectV2Field"` ProjectV2IterationField struct { ID githubv4.ID @@ -33,11 +35,13 @@ type projectFieldsTestQuery struct { 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.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + Options []struct { ID githubv4.String Name githubv4.String } From 338f40f8fe54483ac3035147e00a0b9599384972 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 10:51:29 -0400 Subject: [PATCH 02/10] Clarify updated field schema guidance Separate method, identifier, and typed value rules so clients can choose the correct updated_field variant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 10 +++++----- pkg/github/projects.go | 17 +++++++---------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e58bb64ddd..d7043ac9d3 100644 --- a/README.md +++ b/README.md @@ -1133,7 +1133,7 @@ The following sets of tools are available: - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `title`: The project title. Required for 'create_project' method. (string, optional) - - `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT. (object, optional) + - `updated_field`: The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 8cad1abaa1..1696cdecea 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -186,17 +186,17 @@ "type": "string" }, "updated_field": { - "description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.", + "description": "The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", "oneOf": [ { "additionalProperties": false, "properties": { "id": { - "description": "The numeric project field ID.", + "description": "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.", "type": "integer" }, "value": { - "description": "The value to apply. Any JSON value is accepted; use null to clear the field." + "description": "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID." } }, "required": [ @@ -209,11 +209,11 @@ "additionalProperties": false, "properties": { "name": { - "description": "The project field name. Matching is case-insensitive.", + "description": "The case-insensitive field name. Supports standard Project fields. Singular 'update_project_item' also supports attached Issue Fields; 'update_project_items' does not.", "type": "string" }, "value": { - "description": "The value to apply. Any JSON value is accepted; use null to clear the field." + "description": "The new value, or null to clear the field. Use a string for TEXT, a finite number for NUMBER, a YYYY-MM-DD string for DATE, and an option name (case-insensitive) or option ID for SINGLE_SELECT." } }, "required": [ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 06ef176e16..efda4eba0b 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -536,11 +536,8 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } func projectUpdatedFieldSchema() *jsonschema.Schema { - value := &jsonschema.Schema{ - Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", - } - variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { - properties["value"] = value + variant := func(required []string, properties map[string]*jsonschema.Schema, valueDescription string) *jsonschema.Schema { + properties["value"] = &jsonschema.Schema{Description: valueDescription} return &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -551,20 +548,20 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "object", - Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item', the name form supports attached Issue Fields on Issue items and accepts SINGLE_SELECT option names case-insensitively. Attached Issue Fields are not supported by 'update_project_items'; use singular 'update_project_item'. The ID form addresses standard Project fields and expects an option ID for SINGLE_SELECT.", + Description: "The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", OneOf: []*jsonschema.Schema{ variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { Type: "integer", - Description: "The numeric project field ID.", + Description: "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.", }, - }), + }, "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID."), variant([]string{"name", "value"}, map[string]*jsonschema.Schema{ "name": { Type: "string", - Description: "The project field name. Matching is case-insensitive.", + Description: "The case-insensitive field name. Supports standard Project fields. Singular 'update_project_item' also supports attached Issue Fields; 'update_project_items' does not.", }, - }), + }, "The new value, or null to clear the field. Use a string for TEXT, a finite number for NUMBER, a YYYY-MM-DD string for DATE, and an option name (case-insensitive) or option ID for SINGLE_SELECT."), }, } } From 944fdb478de2a15e88e37f284974b108cbb8caa2 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 10:58:48 -0400 Subject: [PATCH 03/10] Support Issue Field updates by ID Resolve numeric Project field IDs before singular updates so attached Issue Fields use the same name-or-ID targeting contract as standard fields. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/__toolsnaps__/projects_write.snap | 2 +- pkg/github/projects.go | 61 ++++++++++++++------ pkg/github/projects_batch.go | 31 +--------- pkg/github/projects_issue_fields_test.go | 45 +++++++++++---- pkg/github/projects_resolver.go | 32 ++++++++++ pkg/github/projects_test.go | 9 +++ 6 files changed, 120 insertions(+), 60 deletions(-) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 1696cdecea..27d983de5a 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -192,7 +192,7 @@ "additionalProperties": false, "properties": { "id": { - "description": "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.", + "description": "The numeric Project field ID.", "type": "integer" }, "value": { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index efda4eba0b..58fc8077e0 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -553,7 +553,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { Type: "integer", - Description: "The numeric ID of a standard Project field. Attached Issue Fields must be updated by name with singular 'update_project_item'.", + Description: "The numeric Project field ID.", }, }, "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID."), variant([]string{"name", "value"}, map[string]*jsonschema.Schema{ @@ -1704,8 +1704,9 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own } var ( - fieldID int64 - resolved *ResolvedField + fieldID int64 + resolved *ResolvedField + resolvedByName bool ) if hasID { @@ -1714,6 +1715,13 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own if err != nil { return nil, fmt.Errorf("updated_field.id: %w", err) } + if gqlClient == nil { + return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.id") + } + resolved, err = resolveProjectFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, fieldID) + if err != nil { + return nil, err + } } else { fieldName, ok := nameField.(string) if !ok || fieldName == "" { @@ -1727,13 +1735,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own if err != nil { return nil, err } - if resolved.IsIssueField { - issueField, buildErr := buildIssueFieldUpdate(resolved, valueField) - if buildErr != nil { - return nil, buildErr - } - return &resolvedProjectItemUpdate{IssueField: issueField}, nil - } + resolvedByName = true parsedID, parseErr := parseInt64(resolved.ID) if parseErr != nil { return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) @@ -1741,8 +1743,16 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own fieldID = parsedID } + if resolved.IsIssueField { + issueField, buildErr := buildIssueFieldUpdate(resolved, valueField, resolvedByName) + if buildErr != nil { + return nil, buildErr + } + return &resolvedProjectItemUpdate{IssueField: issueField}, nil + } + // SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID. - if resolved != nil && resolved.DataType == "SINGLE_SELECT" { + if resolvedByName && resolved.DataType == "SINGLE_SELECT" { if str, ok := valueField.(string); ok && str != "" { if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil { valueField = optID @@ -1772,7 +1782,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return &resolvedProjectItemUpdate{Project: payload}, nil } -func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { +func buildIssueFieldUpdate(field *ResolvedField, raw any, resolveOptionName bool) (*IssueFieldCreateOrUpdateInput, error) { if field == nil || field.IssueFieldNodeID == "" { name := "" if field != nil { @@ -1825,17 +1835,32 @@ func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUp if !ok || value == "" { return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) } - optionID, err := resolveSingleSelectOptionByName(field, value) - if err != nil { + optionID := value + if resolveOptionName { + var err error + optionID, err = resolveSingleSelectOptionByName(field, value) + if err != nil { + for _, option := range field.Options { + if option.ID == value { + optionID = value + err = nil + break + } + } + if err != nil { + return nil, err + } + } + } else { + known := false for _, option := range field.Options { if option.ID == value { - optionID = value - err = nil + known = true break } } - if err != nil { - return nil, err + if !known { + return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; when updated_field.id is used, value must be an option ID", field.Name)) } } id := githubv4.ID(optionID) diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 94a770f8e6..16e5bbcdfa 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -544,36 +544,7 @@ func resolveBatchProjectField(ctx context.Context, gqlClient *githubv4.Client, o if spec.name != "" { return resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "") } - - fields, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) - if err != nil { - return nil, err - } - - id := fmt.Sprintf("%d", spec.id) - for _, field := range fields { - if field.ID == id { - return &field, nil - } - } - return nil, ghErrors.NewStructuredResolutionError( - "field_not_found", - id, - fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber), - projectFieldCandidates(fields), - ) -} - -func projectFieldCandidates(fields []ResolvedField) []any { - candidates := make([]any, 0, len(fields)) - for _, field := range fields { - candidates = append(candidates, map[string]any{ - "id": field.ID, - "name": field.Name, - "data_type": field.DataType, - }) - } - return candidates + return resolveProjectFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, spec.id) } func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) { diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index 287787dd2d..1efed22058 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "maps" "net/http" "testing" @@ -192,14 +193,24 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) tests := []struct { name string fieldNode map[string]any - fieldName string + fieldRef map[string]any value any want IssueFieldCreateOrUpdateInput }{ { name: "text", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldName: "cUsToMeR", + fieldRef: map[string]any{"name": "cUsToMeR"}, + value: "Acme", + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_customer"), + TextValue: githubv4.NewString(githubv4.String("Acme")), + }, + }, + { + name: "text by project field ID", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + fieldRef: map[string]any{"id": float64(701)}, value: "Acme", want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_customer"), @@ -209,7 +220,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) { name: "number", fieldNode: attachedIssueFieldNode("PVTF_score", 702, "IF_score", "Score", "NUMBER", nil), - fieldName: "Score", + fieldRef: map[string]any{"name": "Score"}, value: 3.5, want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_score"), @@ -219,7 +230,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) { name: "date", fieldNode: attachedIssueFieldNode("PVTF_due", 703, "IF_due", "Due", "DATE", nil), - fieldName: "Due", + fieldRef: map[string]any{"name": "Due"}, value: "2026-08-01", want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_due"), @@ -232,8 +243,21 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, }), - fieldName: "rIsK", - value: "hIgH", + fieldRef: map[string]any{"name": "rIsK"}, + value: "hIgH", + want: IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID("IF_risk"), + SingleSelectOptionID: &optionID, + }, + }, + { + name: "single select by project field ID", + fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ + {"id": "IFO_low", "name": "Low"}, + {"id": "IFO_high", "name": "High"}, + }), + fieldRef: map[string]any{"id": float64(704)}, + value: "IFO_high", want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_risk"), SingleSelectOptionID: &optionID, @@ -242,7 +266,7 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) { name: "clear", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldName: "Customer", + fieldRef: map[string]any{"name": "Customer"}, value: nil, want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_customer"), @@ -275,16 +299,15 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} serverTool := ProjectsWrite(translations.NullTranslationHelper) handler := serverTool.Handler(deps) + updatedField := map[string]any{"value": tt.value} + maps.Copy(updatedField, tt.fieldRef) request := createMCPRequest(map[string]any{ "method": projectsMethodUpdateProjectItem, "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), - "updated_field": map[string]any{ - "name": tt.fieldName, - "value": tt.value, - }, + "updated_field": updatedField, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 1e379dcee1..525edb8bfb 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -277,6 +277,38 @@ func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, return &field, nil } +func resolveProjectFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) { + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + id := strconv.FormatInt(fieldID, 10) + for _, field := range all { + if field.ID == id { + return &field, nil + } + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + id, + fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber), + projectFieldCandidates(all), + ) +} + +func projectFieldCandidates(fields []ResolvedField) []any { + candidates := make([]any, 0, len(fields)) + for _, field := range fields { + candidates = append(candidates, map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + }) + } + return candidates +} + // 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) { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 92de4a5d5f..557739865b 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1235,6 +1235,15 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + genericFieldNode("PVTF_notes", 101, "Notes", "TEXT"), + })), + ), + )), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ From d54df0524c76b8161210bc92ce6440e185d5b4a4 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 11:22:53 -0400 Subject: [PATCH 04/10] Support batched Issue Field updates Resolve each Project item to its underlying Issue and alias setIssueFieldValue so update_project_items supports attached Issue Fields with partial outcomes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 4 +- pkg/github/projects.go | 4 +- pkg/github/projects_batch.go | 274 ++++++++++++++++--- pkg/github/projects_batch_mutation.go | 54 +++- pkg/github/projects_batch_mutation_test.go | 80 +++++- pkg/github/projects_batch_test.go | 6 +- pkg/github/projects_issue_fields_test.go | 262 ++++++++++++++++-- pkg/github/projects_resolver.go | 20 +- pkg/github/projects_resolver_test.go | 2 + 10 files changed, 615 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index d7043ac9d3..fceca21319 100644 --- a/README.md +++ b/README.md @@ -1133,7 +1133,7 @@ The following sets of tools are available: - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `title`: The project title. Required for 'create_project' method. (string, optional) - - `updated_field`: The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field. (object, optional) + - `updated_field`: The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 27d983de5a..655e496f85 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -186,7 +186,7 @@ "type": "string" }, "updated_field": { - "description": "The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", + "description": "The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", "oneOf": [ { "additionalProperties": false, @@ -209,7 +209,7 @@ "additionalProperties": false, "properties": { "name": { - "description": "The case-insensitive field name. Supports standard Project fields. Singular 'update_project_item' also supports attached Issue Fields; 'update_project_items' does not.", + "description": "The case-insensitive Project or attached Issue Field name.", "type": "string" }, "value": { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 58fc8077e0..7749b91079 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -548,7 +548,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "object", - Description: "The field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", + Description: "The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", OneOf: []*jsonschema.Schema{ variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { @@ -559,7 +559,7 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { variant([]string{"name", "value"}, map[string]*jsonschema.Schema{ "name": { Type: "string", - Description: "The case-insensitive field name. Supports standard Project fields. Singular 'update_project_item' also supports attached Issue Fields; 'update_project_items' does not.", + Description: "The case-insensitive Project or attached Issue Field name.", }, }, "The new value, or null to clear the field. Use a string for TEXT, a finite number for NUMBER, a YYYY-MM-DD string for DATE, and an option name (case-insensitive) or option ID for SINGLE_SELECT."), }, diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 16e5bbcdfa..4ce554f8d0 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -9,6 +9,7 @@ import ( "sync" "time" + 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/utils" "github.com/google/go-github/v89/github" @@ -53,14 +54,16 @@ type resolvedBatchItem struct { ref map[string]any nodeID string fullDatabaseID int64 + issueNodeID string } type batchWriteOperation struct { - gqlClient *githubv4.Client - kind batchMutationKind - projectID githubv4.ID - fieldID githubv4.ID - value githubv4.ProjectV2FieldValue + gqlClient *githubv4.Client + kind batchMutationKind + projectID githubv4.ID + fieldID githubv4.ID + value githubv4.ProjectV2FieldValue + issueField IssueFieldCreateOrUpdateInput } func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -119,18 +122,21 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie if fieldErr != nil { return batchTopLevelError(fieldErr), nil, nil } - if field.IsIssueField { - return utils.NewToolResultError(fmt.Sprintf( - "field %q is an attached Issue Field; update_project_items does not support Issue Fields because they are values on Issue content. Use singular update_project_item for each Issue item", - field.Name, - )), nil, nil - } kind := batchMutationUpdate var value githubv4.ProjectV2FieldValue - if fieldSpec.value == nil { + var issueField IssueFieldCreateOrUpdateInput + switch { + case field.IsIssueField: + kind = batchMutationSetIssueField + resolvedIssueField, buildErr := buildIssueFieldUpdate(field, fieldSpec.value, fieldSpec.name != "") + if buildErr != nil { + return batchTopLevelError(buildErr), nil, nil + } + issueField = *resolvedIssueField + case fieldSpec.value == nil: kind = batchMutationClear - } else { + default: value, fieldErr = convertProjectFieldValue(field, fieldSpec.value) if fieldErr != nil { return batchTopLevelError(fieldErr), nil, nil @@ -143,9 +149,10 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie numericIDs = append(numericIDs, p.itemID) } } - itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs) + itemIDLookups := resolveItemNodeIDsByNumericID(ctx, client, owner, ownerType, projectNumber, numericIDs, field.IsIssueField) issueLookups := resolveIssueRefs(ctx, gqlClient, projectID, parsed) + nodeIDLookups := resolveItemIssuesByNodeID(ctx, gqlClient, projectID, parsed, field.IsIssueField) var work []resolvedBatchItem seenTargets := make(map[string]int) @@ -155,7 +162,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - nodeID, fullDatabaseID, lookupErr := resolveItemReference(p, itemIDLookups, issueLookups) + nodeID, fullDatabaseID, issueNodeID, lookupErr := resolveItemReference(p, itemIDLookups, issueLookups, nodeIDLookups, field.IsIssueField) if lookupErr != nil { results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(lookupErr)} continue @@ -173,15 +180,18 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie } seenTargets[nodeID] = i - work = append(work, resolvedBatchItem{index: i, ref: p.ref, nodeID: nodeID, fullDatabaseID: fullDatabaseID}) + work = append(work, resolvedBatchItem{ + index: i, ref: p.ref, nodeID: nodeID, fullDatabaseID: fullDatabaseID, issueNodeID: issueNodeID, + }) } executeBatchWrites(ctx, batchWriteOperation{ - gqlClient: gqlClient, - kind: kind, - projectID: projectID, - fieldID: githubv4.ID(field.NodeID), - value: value, + gqlClient: gqlClient, + kind: kind, + projectID: projectID, + fieldID: githubv4.ID(field.NodeID), + value: value, + issueField: issueField, }, work, results) return newUpdateProjectItemsResult(results) @@ -227,26 +237,40 @@ func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult return result, nil, nil } -func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult) (nodeID string, fullDatabaseID int64, err error) { +func resolveItemReference( + p parsedBatchItem, + itemIDLookups map[int64]itemLookupResult, + issueLookups map[issueRefKey]itemLookupResult, + nodeIDLookups map[string]itemLookupResult, + requireIssue bool, +) (nodeID string, fullDatabaseID int64, issueNodeID string, err error) { + var lookup itemLookupResult switch p.refKind { case batchRefNodeID: - return p.nodeID, 0, nil - case batchRefItemID: - lookup := itemIDLookups[p.itemID] - if lookup.err != nil { - return "", 0, lookup.err + if !requireIssue { + return p.nodeID, 0, "", nil } - return lookup.nodeID, p.itemID, nil + lookup = nodeIDLookups[p.nodeID] + case batchRefItemID: + lookup = itemIDLookups[p.itemID] case batchRefIssue: key := issueRefKey{owner: p.issueOwner, repo: p.issueRepo, number: p.issueNumber} - lookup := issueLookups[key] - if lookup.err != nil { - return "", 0, lookup.err - } - return lookup.nodeID, lookup.fullDatabaseID, nil + lookup = issueLookups[key] default: - return "", 0, fmt.Errorf("internal error: unrecognised item reference kind") + return "", 0, "", fmt.Errorf("internal error: unrecognised item reference kind") + } + if lookup.err != nil { + return "", 0, "", lookup.err } + if requireIssue && lookup.issueNodeID == "" { + return "", 0, "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + lookup.nodeID, + "the project item did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) + } + return lookup.nodeID, lookup.fullDatabaseID, lookup.issueNodeID, nil } // Transport, cancellation, or incomplete-data ambiguity stops later chunks; @@ -263,13 +287,19 @@ func executeBatchWrites(ctx context.Context, operation batchWriteOperation, item inputs := make([]githubv4.Input, len(chunk)) for i, item := range chunk { - if operation.kind == batchMutationClear { + switch operation.kind { + case batchMutationClear: inputs[i] = githubv4.ClearProjectV2ItemFieldValueInput{ ProjectID: operation.projectID, ItemID: githubv4.ID(item.nodeID), FieldID: operation.fieldID, } - } else { + case batchMutationSetIssueField: + inputs[i] = SetIssueFieldValueInput{ + IssueID: githubv4.ID(item.issueNodeID), + IssueFields: []IssueFieldCreateOrUpdateInput{operation.issueField}, + } + default: inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{ ProjectID: operation.projectID, ItemID: githubv4.ID(item.nodeID), @@ -279,19 +309,31 @@ func executeBatchWrites(ctx context.Context, operation batchWriteOperation, item } } - outcomes, mutateErr := executeAliasedMutation(ctx, operation.gqlClient, operation.kind, inputs) + mutationCtx := ctx + if operation.kind == batchMutationSetIssueField { + mutationCtx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") + } + outcomes, mutateErr := executeAliasedMutation(mutationCtx, operation.gqlClient, operation.kind, inputs) populated := 0 for i, oc := range outcomes { if oc.Populated { populated++ + nodeID := oc.NodeID + fullDatabaseID := oc.FullDatabaseID + if operation.kind == batchMutationSetIssueField { + nodeID = chunk[i].nodeID + if chunk[i].fullDatabaseID != 0 { + fullDatabaseID = fmt.Sprintf("%d", chunk[i].fullDatabaseID) + } + } results[chunk[i].index] = batchItemResult{ Index: chunk[i].index, Status: batchItemSucceeded, Ref: chunk[i].ref, Item: &batchItemIdentity{ - NodeID: oc.NodeID, - FullDatabaseID: oc.FullDatabaseID, + NodeID: nodeID, + FullDatabaseID: fullDatabaseID, ItemID: chunk[i].fullDatabaseID, }, } @@ -635,12 +677,13 @@ func toFloat64(raw any) (float64, bool) { type itemLookupResult struct { nodeID string fullDatabaseID int64 + issueNodeID string err error } // Numeric lookups are deduplicated and concurrency-bounded; individual failures // remain isolated while cancellation stops pending work. -func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, ids []int64) map[int64]itemLookupResult { +func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, ids []int64, requireIssue bool) map[int64]itemLookupResult { seen := make(map[int64]struct{}, len(ids)) var unique []int64 for _, id := range ids { @@ -683,11 +726,15 @@ func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, o } var item *github.ProjectV2Item + var resp *github.Response var err error if ownerType == "org" { - item, _, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, id, nil) + item, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, id, nil) } else { - item, _, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, id, nil) + item, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, id, nil) + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() } var res itemLookupResult @@ -698,6 +745,14 @@ func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, o res = itemLookupResult{err: fmt.Errorf("project item %d: response did not include a node id", id)} default: res = itemLookupResult{nodeID: *item.NodeID, fullDatabaseID: id} + if requireIssue { + issueID, issueErr := projectItemIssueNodeID(item) + if issueErr != nil { + res.err = issueErr + } else { + res.issueNodeID = fmt.Sprintf("%v", issueID) + } + } } mu.Lock() @@ -715,6 +770,137 @@ type issueRefKey struct { number int } +type batchProjectItemIssueContent struct { + TypeName githubv4.String `graphql:"__typename"` + Issue struct { + ID githubv4.ID + } `graphql:"... on Issue"` + PullRequest struct { + ID githubv4.ID + } `graphql:"... on PullRequest"` + DraftIssue struct { + ID githubv4.ID + } `graphql:"... on DraftIssue"` +} + +type batchProjectItemIssueNode struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + Content batchProjectItemIssueContent +} + +type batchProjectItemsByNodeIDQuery struct { + Nodes []struct { + ProjectV2Item batchProjectItemIssueNode `graphql:"... on ProjectV2Item"` + } `graphql:"nodes(ids: $ids)"` +} + +func resolveItemIssuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem, requireIssue bool) map[string]itemLookupResult { + if !requireIssue { + return nil + } + + seen := make(map[string]struct{}, len(items)) + var ids []githubv4.ID + for _, item := range items { + if item.err != nil || item.refKind != batchRefNodeID { + continue + } + if _, exists := seen[item.nodeID]; exists { + continue + } + seen[item.nodeID] = struct{}{} + ids = append(ids, githubv4.ID(item.nodeID)) + } + if len(ids) == 0 { + return nil + } + + var query batchProjectItemsByNodeIDQuery + if err := gqlClient.Query(ctx, &query, map[string]any{"ids": ids}); err != nil { + results := make(map[string]itemLookupResult, len(ids)) + for _, id := range ids { + results[fmt.Sprintf("%v", id)] = itemLookupResult{err: fmt.Errorf("failed to inspect project item content: %w", err)} + } + return results + } + + results := make(map[string]itemLookupResult, len(ids)) + for _, node := range query.Nodes { + item := node.ProjectV2Item + nodeID := fmt.Sprintf("%v", item.ID) + if item.ID == nil { + continue + } + if item.Project.ID != projectID { + results[nodeID] = itemLookupResult{err: ghErrors.NewStructuredResolutionError( + "item_not_in_project", + nodeID, + "the project item does not belong to the named project", + nil, + )} + continue + } + + issueNodeID, err := batchProjectItemIssueNodeID(item) + fullDatabaseID := int64(0) + if item.FullDatabaseID != "" { + var parseErr error + fullDatabaseID, parseErr = parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + err = fmt.Errorf("project item %s has invalid full database ID %q: %w", nodeID, item.FullDatabaseID, parseErr) + } + } + results[nodeID] = itemLookupResult{ + nodeID: nodeID, + fullDatabaseID: fullDatabaseID, + issueNodeID: issueNodeID, + err: err, + } + } + + for _, id := range ids { + nodeID := fmt.Sprintf("%v", id) + if _, exists := results[nodeID]; !exists { + results[nodeID] = itemLookupResult{err: fmt.Errorf("project item %s was not found", nodeID)} + } + } + return results +} + +func batchProjectItemIssueNodeID(item batchProjectItemIssueNode) (string, error) { + switch item.Content.TypeName { + case "Issue": + if item.Content.Issue.ID == nil { + break + } + return fmt.Sprintf("%v", item.Content.Issue.ID), nil + case "PullRequest": + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + "PullRequest", + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + case "DraftIssue": + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + "DraftIssue", + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + fmt.Sprintf("%v", item.ID), + "the project item did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) +} + func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult { seen := make(map[issueRefKey]struct{}, len(items)) var unique []issueRefKey @@ -761,10 +947,10 @@ func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID return } - nodeID, itemID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, key.owner, key.repo, key.number) + nodeID, itemID, issueNodeID, err := resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, key.owner, key.repo, key.number) mu.Lock() - out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, err: err} + out[key] = itemLookupResult{nodeID: nodeID, fullDatabaseID: itemID, issueNodeID: issueNodeID, err: err} mu.Unlock() }(key) } diff --git a/pkg/github/projects_batch_mutation.go b/pkg/github/projects_batch_mutation.go index 0c478aabf9..4dd5d8663a 100644 --- a/pkg/github/projects_batch_mutation.go +++ b/pkg/github/projects_batch_mutation.go @@ -17,13 +17,18 @@ type batchMutationKind int const ( batchMutationUpdate batchMutationKind = iota batchMutationClear + batchMutationSetIssueField ) func (k batchMutationKind) fieldName() string { - if k == batchMutationClear { + switch k { + case batchMutationClear: return "clearProjectV2ItemFieldValue" + case batchMutationSetIssueField: + return "setIssueFieldValue" + default: + return "updateProjectV2ItemFieldValue" } - return "updateProjectV2ItemFieldValue" } type projectV2ItemMutationResult struct { @@ -33,6 +38,19 @@ type projectV2ItemMutationResult struct { } `graphql:"projectV2Item"` } +type issueFieldMutationResult struct { + Issue struct { + ID string + } `graphql:"issue"` +} + +func (k batchMutationKind) resultType() reflect.Type { + if k == batchMutationSetIssueField { + return reflect.TypeFor[issueFieldMutationResult]() + } + return reflect.TypeFor[projectV2ItemMutationResult]() +} + type reflectedMutationTypeKey struct { kind batchMutationKind size int @@ -51,7 +69,7 @@ func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type { return cached.(reflect.Type) } - resultType := reflect.TypeFor[projectV2ItemMutationResult]() + resultType := kind.resultType() fields := make([]reflect.StructField, size) for i := range size { varName := "input" @@ -71,8 +89,8 @@ func buildAliasedMutationType(kind batchMutationKind, size int) reflect.Type { } type mutationAliasOutcome struct { - // Populated confirms this alias returned a project item, even when the - // response also contains GraphQL errors. + // Populated confirms this alias returned its target, even when the response + // also contains GraphQL errors. Populated bool NodeID string FullDatabaseID string @@ -105,14 +123,24 @@ func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kin outcomes := make([]mutationAliasOutcome, len(inputs)) elem := mutationPtr.Elem() for i := range inputs { - result, ok := elem.Field(i).Interface().(projectV2ItemMutationResult) - if !ok || result.ProjectV2Item.ID == "" { - continue - } - outcomes[i] = mutationAliasOutcome{ - Populated: true, - NodeID: result.ProjectV2Item.ID, - FullDatabaseID: result.ProjectV2Item.FullDatabaseID, + switch result := elem.Field(i).Interface().(type) { + case projectV2ItemMutationResult: + if result.ProjectV2Item.ID == "" { + continue + } + outcomes[i] = mutationAliasOutcome{ + Populated: true, + NodeID: result.ProjectV2Item.ID, + FullDatabaseID: result.ProjectV2Item.FullDatabaseID, + } + case issueFieldMutationResult: + if result.Issue.ID == "" { + continue + } + outcomes[i] = mutationAliasOutcome{ + Populated: true, + NodeID: result.Issue.ID, + } } } return outcomes, mutateErr diff --git a/pkg/github/projects_batch_mutation_test.go b/pkg/github/projects_batch_mutation_test.go index 749776862d..1b56cc40a1 100644 --- a/pkg/github/projects_batch_mutation_test.go +++ b/pkg/github/projects_batch_mutation_test.go @@ -20,6 +20,7 @@ import ( type capturedGraphQLRequest struct { Query string Variables map[string]any + Headers http.Header } // sequencedGraphQLTransport is a minimal fake http.RoundTripper for exercising @@ -45,7 +46,7 @@ func (s *sequencedGraphQLTransport) RoundTrip(req *http.Request) (*http.Response if err := json.Unmarshal(raw, &parsed); err != nil { return nil, err } - captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables} + captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables, Headers: req.Header.Clone()} s.calls = append(s.calls, captured) idx := len(s.calls) - 1 @@ -88,6 +89,19 @@ func mutationDataResponse(t *testing.T, ids map[int]struct{ NodeID, FullDatabase return string(body) } +func issueFieldMutationDataResponse(t *testing.T, ids map[int]string) string { + t.Helper() + data := make(map[string]any, len(ids)) + for i, id := range ids { + data[fmt.Sprintf("item%d", i)] = map[string]any{ + "issue": map[string]any{"id": id}, + } + } + body, err := json.Marshal(map[string]any{"data": data}) + require.NoError(t, err) + return string(body) +} + func mutationErrorResponse(t *testing.T, data map[string]any, message string) string { t.Helper() payload := map[string]any{ @@ -118,6 +132,20 @@ func inputsOfSize(n int) []githubv4.Input { return inputs } +func issueFieldInputsOfSize(n int) []githubv4.Input { + inputs := make([]githubv4.Input, n) + for i := range n { + inputs[i] = SetIssueFieldValueInput{ + IssueID: githubv4.ID(fmt.Sprintf("I_issue%d", i)), + IssueFields: []IssueFieldCreateOrUpdateInput{{ + FieldID: githubv4.ID("IF_field"), + TextValue: githubv4.NewString("v"), + }}, + } + } + return inputs +} + func Test_BuildAliasedMutationType_FieldNamesAndTags(t *testing.T) { for _, size := range []int{1, 2, 20} { t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) { @@ -154,6 +182,13 @@ func Test_BuildAliasedMutationType_ClearKindUsesClearMutation(t *testing.T) { assert.Equal(t, "item1: clearProjectV2ItemFieldValue(input: $input1)", tag1) } +func Test_BuildAliasedMutationType_IssueFieldKindUsesSetIssueFieldValue(t *testing.T) { + typ := buildAliasedMutationType(batchMutationSetIssueField, 2) + assert.Equal(t, reflect.TypeFor[issueFieldMutationResult](), typ.Field(0).Type) + assert.Equal(t, "item0: setIssueFieldValue(input: $input)", typ.Field(0).Tag.Get("graphql")) + assert.Equal(t, "item1: setIssueFieldValue(input: $input1)", typ.Field(1).Tag.Get("graphql")) +} + func Test_BuildAliasedMutationType_CachedByKindAndSize(t *testing.T) { a := buildAliasedMutationType(batchMutationUpdate, 3) b := buildAliasedMutationType(batchMutationUpdate, 3) @@ -209,6 +244,7 @@ func Test_ExecuteAliasedMutation_TwoAliases_FirstInputWorkaround(t *testing.T) { }, }, } + gqlClient := newTestGQLClient(transport) outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(2)) @@ -218,6 +254,48 @@ func Test_ExecuteAliasedMutation_TwoAliases_FirstInputWorkaround(t *testing.T) { assert.True(t, outcomes[1].Populated) } +func Test_ExecuteAliasedMutation_IssueFieldAliases(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "setIssueFieldValue") + assert.NotContains(t, req.Query, "updateProjectV2ItemFieldValue") + return http.StatusOK, issueFieldMutationDataResponse(t, map[int]string{ + 0: "I_issue0", + 1: "I_issue1", + }) + }, + }, + } + + outcomes, err := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationSetIssueField, issueFieldInputsOfSize(2)) + require.NoError(t, err) + require.Len(t, outcomes, 2) + assert.Equal(t, mutationAliasOutcome{Populated: true, NodeID: "I_issue0"}, outcomes[0]) + assert.Equal(t, mutationAliasOutcome{Populated: true, NodeID: "I_issue1"}, outcomes[1]) +} + +func Test_ExecuteAliasedMutation_IssueFieldPartialData(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(capturedGraphQLRequest) (int, string) { + data := map[string]any{ + "item0": map[string]any{"issue": map[string]any{"id": "I_issue0"}}, + } + return http.StatusOK, mutationErrorResponse(t, data, "item1 failed") + }, + }, + } + + outcomes, err := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationSetIssueField, issueFieldInputsOfSize(2)) + require.Error(t, err) + assert.True(t, isGraphQLResponseError(err)) + assert.Equal(t, mutationAliasOutcome{Populated: true, NodeID: "I_issue0"}, outcomes[0]) + assert.Equal(t, mutationAliasOutcome{}, outcomes[1]) +} + func Test_ExecuteAliasedMutation_PreservesPartialDataWithGraphQLErrors(t *testing.T) { transport := &sequencedGraphQLTransport{ t: t, diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 985cd5bfc7..45f38d66ae 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -92,12 +92,12 @@ func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, e } if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") { - m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}) + m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables, Headers: req.Header.Clone()}) req.Body = io.NopCloser(strings.NewReader(string(raw))) return m.queries.RoundTrip(req) } - captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables} + captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables, Headers: req.Header.Clone()} idx := len(m.mutationCalls) m.mutationCalls = append(m.mutationCalls, captured) if m.mutationRespond == nil { @@ -1259,7 +1259,7 @@ func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing }, })) - resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001}) + resolved := resolveItemNodeIDsByNumericID(t.Context(), client, "octocat", tt.ownerType, 1, []int64{1001, 1001}, false) require.NoError(t, resolved[1001].err) assert.Equal(t, "PVTI_item1001", resolved[1001].nodeID) diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index 1efed22058..75777c4ea3 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -3,11 +3,14 @@ package github import ( "context" "encoding/json" + "fmt" "maps" "net/http" "testing" "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/shurcooL/githubv4" @@ -64,6 +67,52 @@ func issueFieldMutationResponse() map[string]any { } } +func issueProjectItemMatcher(issueNumber int, issueNodeID, itemNodeID string, itemID int) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-org"), + "issueRepo": githubv4.String("roadmap"), + "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": issueNodeID, + "projectItems": map[string]any{ + "nodes": []any{map[string]any{ + "id": itemNodeID, + "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + }}, + "pageInfo": map[string]any{"hasNextPage": false}, + }, + }, + }, + }), + ) +} + +func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID string) githubv4mock.Matcher { + matcher := githubv4mock.NewQueryMatcher( + batchProjectItemsByNodeIDQuery{}, + map[string]any{"ids": []githubv4.ID{githubv4.ID(itemNodeID)}}, + githubv4mock.DataResponse(map[string]any{ + "nodes": []any{map[string]any{ + "id": itemNodeID, + "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + "content": map[string]any{ + "__typename": "Issue", + "id": issueNodeID, + }, + }}, + }), + ) + matcher.Variables["ids"] = []any{itemNodeID} + return matcher +} + func Test_ResolveProjectFieldByName_AttachedIssueFields(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -467,25 +516,198 @@ func Test_ProjectsWrite_UpdateProjectItem_StandardFieldNameStillUsesProjectsREST require.False(t, result.IsError, getTextResult(t, result).Text) } -func Test_UpdateProjectItemsBatch_RejectsAttachedIssueFields(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - projectIDMatcher("octo-org", 1, "PVT_project1"), - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - })), - ), - )) +func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { + tests := []struct { + name string + fieldNode map[string]any + fieldRef map[string]any + value any + item map[string]any + extraMatchers []githubv4mock.Matcher + restHandlers map[string]http.HandlerFunc + issueNodeID string + itemNodeID string + itemID int + expectedFieldID string + expectedValueKey string + expectedValue any + }{ + { + name: "issue reference", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + fieldRef: map[string]any{"name": "Customer"}, + value: "Acme", + item: map[string]any{"item_owner": "octo-org", "item_repo": "roadmap", "issue_number": float64(5)}, + extraMatchers: []githubv4mock.Matcher{issueProjectItemMatcher(5, "I_5", "PVTI_5", 1005)}, + restHandlers: map[string]http.HandlerFunc{}, + issueNodeID: "I_5", + itemNodeID: "PVTI_5", + itemID: 1005, + expectedFieldID: "IF_customer", + expectedValueKey: "textValue", + expectedValue: "Acme", + }, + { + name: "numeric item and field IDs clear", + fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + fieldRef: map[string]any{"id": float64(701)}, + value: nil, + item: map[string]any{"item_id": float64(1001)}, + restHandlers: map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), + }, + issueNodeID: "I_123", + itemNodeID: "PVTI_1", + itemID: 1001, + expectedFieldID: "IF_customer", + expectedValueKey: "delete", + expectedValue: true, + }, + { + name: "project item node ID and single-select option name", + fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ + {"id": "IFO_low", "name": "Low"}, + {"id": "IFO_high", "name": "High"}, + }), + fieldRef: map[string]any{"name": "Risk"}, + value: "high", + item: map[string]any{"node_id": "PVTI_1"}, + extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, + restHandlers: map[string]http.HandlerFunc{}, + issueNodeID: "I_123", + itemNodeID: "PVTI_1", + itemID: 1001, + expectedFieldID: "IF_risk", + expectedValueKey: "singleSelectOptionId", + expectedValue: "IFO_high", + }, + } - result, _, err := updateProjectItemsBatch(context.Background(), nil, gql, "octo-org", "org", 1, map[string]any{ - "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, - "items": []any{map[string]any{"node_id": "PVTI_1"}}, - }) - require.NoError(t, err) - require.True(t, result.IsError) - text := getTextResult(t, result).Text - assert.Contains(t, text, "update_project_items does not support Issue Fields") - assert.Contains(t, text, "Use singular update_project_item") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), + ), + } + matchers = append(matchers, tt.extraMatchers...) + queryTransport := githubv4mock.NewMockedHTTPClient(matchers...) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "setIssueFieldValue") + assert.NotContains(t, req.Query, "updateProjectV2ItemFieldValue") + assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + input := req.Variables["input"].(map[string]any) + assert.Equal(t, tt.issueNodeID, input["issueId"]) + issueFields := input["issueFields"].([]any) + require.Len(t, issueFields, 1) + field := issueFields[0].(map[string]any) + assert.Equal(t, tt.expectedFieldID, field["fieldId"]) + assert.Equal(t, tt.expectedValue, field[tt.expectedValueKey]) + return http.StatusOK, issueFieldMutationDataResponse(t, map[int]string{0: tt.issueNodeID}) + }, + } + updatedField := map[string]any{"value": tt.value} + maps.Copy(updatedField, tt.fieldRef) + + result, _, err := updateProjectItemsBatch( + t.Context(), + mustNewGHClient(t, MockHTTPClientWithHandlers(tt.restHandlers)), + githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }), + "octo-org", + "org", + 1, + map[string]any{ + "updated_field": updatedField, + "items": []any{tt.item}, + }, + ) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, float64(1), response["succeeded"]) + item := response["results"].([]any)[0].(map[string]any)["item"].(map[string]any) + assert.Equal(t, tt.itemNodeID, item["node_id"]) + assert.Equal(t, fmt.Sprintf("%d", tt.itemID), item["full_database_id"]) + }) + } +} + +func Test_UpdateProjectItemsBatch_IssueFieldRejectsNonIssueContent(t *testing.T) { + tests := []struct { + name string + contentType string + content map[string]any + }{ + { + name: "pull request", + contentType: "PullRequest", + content: map[string]any{"id": 2002, "node_id": "PR_123", "number": 5, "title": "PR"}, + }, + { + name: "draft issue", + contentType: "DraftIssue", + content: map[string]any{"id": 2003, "node_id": "DI_123", "title": "Draft"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, + queries: queryTransport.Transport, + mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { + t.Fatal("non-Issue content must fail before mutation") + return http.StatusInternalServerError, "" + }, + } + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, map[string]any{ + "id": 1001, "node_id": "PVTI_1", "content_type": tt.contentType, "content": tt.content, + }), + }) + + result, _, err := updateProjectItemsBatch( + t.Context(), + mustNewGHClient(t, rest), + newTestGQLClient(transport), + "octo-org", + "org", + 1, + map[string]any{ + "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, + "items": []any{map[string]any{"item_id": float64(1001)}}, + }, + ) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Empty(t, transport.mutationCalls) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + entry := response["results"].([]any)[0].(map[string]any) + itemError := entry["error"].(map[string]any) + assert.Equal(t, "unsupported_item_type", itemError["code"]) + assert.Contains(t, itemError["hint"], "only be updated on Issue project items") + }) + } } diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 525edb8bfb..7acd187920 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -372,10 +372,11 @@ func resolveProjectItemByIssueNumber(ctx context.Context, gqlClient *githubv4.Cl if err != nil { return "", 0, err } - return resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, issueOwner, issueRepo, issueNumber) + nodeID, itemID, _, err = resolveProjectItemByIssueNumberWithProjectID(ctx, gqlClient, projectID, issueOwner, issueRepo, issueNumber) + return nodeID, itemID, err } -func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, err error) { +func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, issueOwner, issueRepo string, issueNumber int) (nodeID string, itemID int64, issueNodeID string, err error) { type projectItemsConnection struct { Nodes []struct { ID githubv4.ID @@ -390,6 +391,7 @@ func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient var firstPageQuery struct { Repository struct { Issue struct { + ID githubv4.ID ProjectItems projectItemsConnection `graphql:"projectItems(first: 50, includeArchived: true)"` } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` @@ -402,18 +404,21 @@ func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient } if err := gqlClient.Query(ctx, &firstPageQuery, vars); err != nil { - return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, "", fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } + if firstPageQuery.Repository.Issue.ID != nil { + issueNodeID = fmt.Sprintf("%v", firstPageQuery.Repository.Issue.ID) + } projectItems := firstPageQuery.Repository.Issue.ProjectItems for { for _, item := range projectItems.Nodes { if item.Project.ID == projectID { parsedItemID, 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 "", 0, "", fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) } - return fmt.Sprintf("%v", item.ID), parsedItemID, nil + return fmt.Sprintf("%v", item.ID), parsedItemID, issueNodeID, nil } } @@ -424,18 +429,19 @@ func resolveProjectItemByIssueNumberWithProjectID(ctx context.Context, gqlClient var nextPageQuery struct { Repository struct { Issue struct { + ID githubv4.ID ProjectItems projectItemsConnection `graphql:"projectItems(first: 50, after: $after, includeArchived: true)"` } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` } vars["after"] = projectItems.PageInfo.EndCursor if err := gqlClient.Query(ctx, &nextPageQuery, vars); err != nil { - return "", 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + return "", 0, "", fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) } projectItems = nextPageQuery.Repository.Issue.ProjectItems } - return "", 0, ghErrors.NewStructuredResolutionError( + 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", diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index db8fe4e0be..3f0f1574ad 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -264,6 +264,7 @@ func Test_ResolveSingleSelectOptionByName_WrongFieldType(t *testing.T) { type resolveItemByIssueQuery struct { Repository struct { Issue struct { + ID githubv4.ID ProjectItems struct { Nodes []struct { ID githubv4.ID @@ -281,6 +282,7 @@ type resolveItemByIssueQuery struct { type resolveItemByIssuePageQuery struct { Repository struct { Issue struct { + ID githubv4.ID ProjectItems struct { Nodes []struct { ID githubv4.ID From 54f1d0bc302af7662ed06e25673f31168d043453 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 12:06:01 -0400 Subject: [PATCH 05/10] Resolve single-select options consistently Accept option names or IDs regardless of how the field is targeted, and keep the updated_field schema guidance concise. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/__toolsnaps__/projects_write.snap | 4 +- pkg/github/projects.go | 70 +++++--------------- pkg/github/projects_batch.go | 19 ++---- pkg/github/projects_issue_fields_test.go | 49 ++++++++++++-- pkg/github/projects_resolver.go | 13 ++++ 5 files changed, 81 insertions(+), 74 deletions(-) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 655e496f85..350cb08980 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -196,7 +196,7 @@ "type": "integer" }, "value": { - "description": "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID." + "description": "The field value." } }, "required": [ @@ -213,7 +213,7 @@ "type": "string" }, "value": { - "description": "The new value, or null to clear the field. Use a string for TEXT, a finite number for NUMBER, a YYYY-MM-DD string for DATE, and an option name (case-insensitive) or option ID for SINGLE_SELECT." + "description": "The field value." } }, "required": [ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 7749b91079..070619f1e2 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -536,8 +536,9 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } func projectUpdatedFieldSchema() *jsonschema.Schema { - variant := func(required []string, properties map[string]*jsonschema.Schema, valueDescription string) *jsonschema.Schema { - properties["value"] = &jsonschema.Schema{Description: valueDescription} + value := &jsonschema.Schema{Description: "The field value."} + variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { + properties["value"] = value return &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -555,13 +556,13 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { Type: "integer", Description: "The numeric Project field ID.", }, - }, "The new value, or null to clear the field. For SINGLE_SELECT, use the option ID."), + }), variant([]string{"name", "value"}, map[string]*jsonschema.Schema{ "name": { Type: "string", Description: "The case-insensitive Project or attached Issue Field name.", }, - }, "The new value, or null to clear the field. Use a string for TEXT, a finite number for NUMBER, a YYYY-MM-DD string for DATE, and an option name (case-insensitive) or option ID for SINGLE_SELECT."), + }), }, } } @@ -1704,9 +1705,8 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own } var ( - fieldID int64 - resolved *ResolvedField - resolvedByName bool + fieldID int64 + resolved *ResolvedField ) if hasID { @@ -1735,7 +1735,6 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own if err != nil { return nil, err } - resolvedByName = true parsedID, parseErr := parseInt64(resolved.ID) if parseErr != nil { return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) @@ -1744,31 +1743,20 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own } if resolved.IsIssueField { - issueField, buildErr := buildIssueFieldUpdate(resolved, valueField, resolvedByName) + issueField, buildErr := buildIssueFieldUpdate(resolved, valueField) if buildErr != nil { return nil, buildErr } return &resolvedProjectItemUpdate{IssueField: issueField}, nil } - // SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID. - if resolvedByName && resolved.DataType == "SINGLE_SELECT" { + if resolved.DataType == "SINGLE_SELECT" { if str, ok := valueField.(string); ok && str != "" { - if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil { - valueField = optID - } else { - // Fall back: if the string is already a known option ID, accept it. - known := false - for _, opt := range resolved.Options { - if opt.ID == str { - known = true - break - } - } - if !known { - return nil, optErr - } + optionID, optionErr := resolveSingleSelectOptionByNameOrID(resolved, str) + if optionErr != nil { + return nil, optionErr } + valueField = optionID } } @@ -1782,7 +1770,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return &resolvedProjectItemUpdate{Project: payload}, nil } -func buildIssueFieldUpdate(field *ResolvedField, raw any, resolveOptionName bool) (*IssueFieldCreateOrUpdateInput, error) { +func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { if field == nil || field.IssueFieldNodeID == "" { name := "" if field != nil { @@ -1835,33 +1823,9 @@ func buildIssueFieldUpdate(field *ResolvedField, raw any, resolveOptionName bool if !ok || value == "" { return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) } - optionID := value - if resolveOptionName { - var err error - optionID, err = resolveSingleSelectOptionByName(field, value) - if err != nil { - for _, option := range field.Options { - if option.ID == value { - optionID = value - err = nil - break - } - } - if err != nil { - return nil, err - } - } - } else { - known := false - for _, option := range field.Options { - if option.ID == value { - known = true - break - } - } - if !known { - return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; when updated_field.id is used, value must be an option ID", field.Name)) - } + optionID, err := resolveSingleSelectOptionByNameOrID(field, value) + if err != nil { + return nil, err } id := githubv4.ID(optionID) input.SingleSelectOptionID = &id diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 4ce554f8d0..1dd9504261 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -129,7 +129,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie switch { case field.IsIssueField: kind = batchMutationSetIssueField - resolvedIssueField, buildErr := buildIssueFieldUpdate(field, fieldSpec.value, fieldSpec.name != "") + resolvedIssueField, buildErr := buildIssueFieldUpdate(field, fieldSpec.value) if buildErr != nil { return batchTopLevelError(buildErr), nil, nil } @@ -625,20 +625,9 @@ func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2 if !ok || s == "" { return zero, fmt.Errorf("field %q is SINGLE_SELECT; value must be a non-empty string (option name or ID)", field.Name) } - optID := s - if resolvedID, optErr := resolveSingleSelectOptionByName(field, s); optErr == nil { - optID = resolvedID - } else { - known := false - for _, opt := range field.Options { - if opt.ID == s { - known = true - break - } - } - if !known { - return zero, optErr - } + optID, err := resolveSingleSelectOptionByNameOrID(field, s) + if err != nil { + return zero, err } v := githubv4.String(optID) return githubv4.ProjectV2FieldValue{SingleSelectOptionID: &v}, nil diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index 75777c4ea3..9655c11180 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -300,13 +300,13 @@ func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) }, }, { - name: "single select by project field ID", + name: "single select option name by project field ID", fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, }), fieldRef: map[string]any{"id": float64(704)}, - value: "IFO_high", + value: "hIgH", want: IssueFieldCreateOrUpdateInput{ FieldID: githubv4.ID("IF_risk"), SingleSelectOptionID: &optionID, @@ -516,6 +516,47 @@ func Test_ProjectsWrite_UpdateProjectItem_StandardFieldNameStillUsesProjectsREST require.False(t, result.IsError, getTextResult(t, result).Text) } +func Test_ProjectsWrite_UpdateProjectItem_StandardFieldIDResolvesOptionName(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{{"id": "OPT_doing", "name": "Doing"}}), + })), + ), + )) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, r *http.Request) { + var body struct { + Fields []struct { + ID int64 `json:"id"` + Value string `json:"value"` + } `json:"fields"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Len(t, body.Fields, 1) + require.Equal(t, int64(101), body.Fields[0].ID) + require.Equal(t, "OPT_doing", body.Fields[0].Value) + mockResponse(t, http.StatusOK, verbosePullRequestProjectItemFixture())(w, r) + }, + }) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{"id": float64(101), "value": "doing"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) +} + func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { tests := []struct { name string @@ -564,12 +605,12 @@ func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { expectedValue: true, }, { - name: "project item node ID and single-select option name", + name: "project item node ID and single-select option name by field ID", fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, }), - fieldRef: map[string]any{"name": "Risk"}, + fieldRef: map[string]any{"id": float64(704)}, value: "high", item: map[string]any{"node_id": "PVTI_1"}, extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 7acd187920..45a1b5a208 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -359,6 +359,19 @@ func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (s } } +func resolveSingleSelectOptionByNameOrID(field *ResolvedField, value string) (string, error) { + optionID, err := resolveSingleSelectOptionByName(field, value) + if err == nil { + return optionID, nil + } + for _, option := range field.Options { + if option.ID == value { + return value, nil + } + } + return "", err +} + // 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. From 27d07c52342711144db996f804f122de3449b5bd Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 12:46:50 -0400 Subject: [PATCH 06/10] Narrow Issue Field changes Restore unrelated schema and standard batch behavior, isolate Issue Field helpers, and consolidate acceptance coverage without changing supported writes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- README.md | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 10 +- pkg/github/granular_tools_test.go | 2 +- pkg/github/issues_granular.go | 4 +- pkg/github/projects.go | 114 +-- pkg/github/projects_batch.go | 232 ++---- pkg/github/projects_batch_mutation_test.go | 20 - pkg/github/projects_batch_test.go | 2 +- pkg/github/projects_issue_fields.go | 232 ++++++ pkg/github/projects_issue_fields_test.go | 770 +++++-------------- pkg/github/projects_resolver.go | 60 +- 11 files changed, 539 insertions(+), 909 deletions(-) create mode 100644 pkg/github/projects_issue_fields.go diff --git a/README.md b/README.md index fceca21319..7b6dd7a39c 100644 --- a/README.md +++ b/README.md @@ -1133,7 +1133,7 @@ The following sets of tools are available: - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `title`: The project title. Required for 'create_project' method. (string, optional) - - `updated_field`: The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field. (object, optional) + - `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 350cb08980..494075e249 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -186,17 +186,17 @@ "type": "string" }, "updated_field": { - "description": "The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", + "description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch.", "oneOf": [ { "additionalProperties": false, "properties": { "id": { - "description": "The numeric Project field ID.", + "description": "The numeric project field ID.", "type": "integer" }, "value": { - "description": "The field value." + "description": "The value to apply. Any JSON value is accepted; use null to clear the field." } }, "required": [ @@ -209,11 +209,11 @@ "additionalProperties": false, "properties": { "name": { - "description": "The case-insensitive Project or attached Issue Field name.", + "description": "The project field name. Matching is case-insensitive.", "type": "string" }, "value": { - "description": "The field value." + "description": "The value to apply. Any JSON value is accepted; use null to clear the field." } }, "required": [ diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 67c18ab59a..58fd904e88 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -2428,7 +2428,7 @@ func TestGranularSetIssueFields(t *testing.T) { require.False(t, result.IsError, getTextResult(t, result).Text) // The last request captured is the mutation; the preceding issue ID // query does not require the feature flag. - assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) + assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 8cbf1cf196..06c9d72295 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1294,7 +1294,6 @@ func SetIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, issueI IssueID: issueID, IssueFields: issueFields, } - ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil { return MinimalResponse{}, err } @@ -1527,7 +1526,8 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil } - response, err := SetIssueFieldValues(ctx, gqlClient, issueID, issueFields) + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") + response, err := SetIssueFieldValues(ctxWithFeatures, gqlClient, issueID, issueFields) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 070619f1e2..88c2950751 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + 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" @@ -536,7 +537,9 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } func projectUpdatedFieldSchema() *jsonschema.Schema { - value := &jsonschema.Schema{Description: "The field value."} + value := &jsonschema.Schema{ + Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", + } variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { properties["value"] = value return &jsonschema.Schema{ @@ -549,18 +552,18 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "object", - Description: "The Project or attached Issue Field to update and its new value. Required for 'update_project_item' and 'update_project_items'. For 'update_project_items', one top-level field/value applies to every item. Set value to null to clear the field.", + Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch.", OneOf: []*jsonschema.Schema{ variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { Type: "integer", - Description: "The numeric Project field ID.", + Description: "The numeric project field ID.", }, }), variant([]string{"name", "value"}, map[string]*jsonschema.Schema{ "name": { Type: "string", - Description: "The case-insensitive Project or attached Issue Field name.", + Description: "The project field name. Matching is case-insensitive.", }, }), }, @@ -1267,7 +1270,8 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi } return utils.NewToolResultError(resolveErr.Error()), nil, nil } - response, mutationErr := SetIssueFieldValues(ctx, gqlClient, issueID, []IssueFieldCreateOrUpdateInput{*update.IssueField}) + mutationCtx := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") + response, mutationErr := SetIssueFieldValues(mutationCtx, gqlClient, issueID, []IssueFieldCreateOrUpdateInput{*update.IssueField}) if mutationErr != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to update Issue Field", mutationErr), nil, nil } @@ -1311,36 +1315,6 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultText(string(r)), nil, nil } -func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) { - if item == nil || item.ContentType == nil { - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - "", - "the project item response did not identify its content type; Issue Fields can only be updated on Issue items", - nil, - ) - } - - contentType := string(*item.ContentType) - if contentType != "Issue" { - return "", ghErrors.NewStructuredResolutionError( - "unsupported_item_type", - contentType, - "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", - nil, - ) - } - if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" { - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - "Issue", - "the project item response did not include the underlying Issue node ID needed to update the Issue Field", - nil, - ) - } - return githubv4.ID(item.Content.Issue.GetNodeID()), nil -} - func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error @@ -1770,76 +1744,6 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return &resolvedProjectItemUpdate{Project: payload}, nil } -func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { - if field == nil || field.IssueFieldNodeID == "" { - name := "" - if field != nil { - name = field.Name - } - return nil, ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - name, - "the attached Project field did not include the underlying Issue Field node ID; refresh field metadata and retry", - nil, - ) - } - - input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)} - if raw == nil { - deleteValue := githubv4.Boolean(true) - input.Delete = &deleteValue - return input, nil - } - - invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) { - return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil) - } - - switch field.DataType { - case "TEXT": - value, ok := raw.(string) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name)) - } - input.TextValue = githubv4.NewString(githubv4.String(value)) - case "NUMBER": - value, ok := toFloat64(raw) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name)) - } - number := githubv4.Float(value) - input.NumberValue = &number - case "DATE": - value, ok := raw.(string) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name)) - } - if _, err := time.Parse("2006-01-02", value); err != nil { - return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value)) - } - input.DateValue = githubv4.NewString(githubv4.String(value)) - case "SINGLE_SELECT": - value, ok := raw.(string) - if !ok || value == "" { - return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) - } - optionID, err := resolveSingleSelectOptionByNameOrID(field, value) - if err != nil { - return nil, err - } - id := githubv4.ID(optionID) - input.SingleSelectOptionID = &id - default: - return nil, ghErrors.NewStructuredResolutionError( - "unsupported_field_type", - field.Name, - fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), - nil, - ) - } - return input, nil -} - func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 1dd9504261..2da9e4f9ae 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -162,7 +162,17 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - nodeID, fullDatabaseID, issueNodeID, lookupErr := resolveItemReference(p, itemIDLookups, issueLookups, nodeIDLookups, field.IsIssueField) + var ( + nodeID string + fullDatabaseID int64 + issueNodeID string + lookupErr error + ) + if field.IsIssueField { + nodeID, fullDatabaseID, issueNodeID, lookupErr = resolveIssueFieldItemReference(p, itemIDLookups, issueLookups, nodeIDLookups) + } else { + nodeID, fullDatabaseID, lookupErr = resolveItemReference(p, itemIDLookups, issueLookups) + } if lookupErr != nil { results[i] = batchItemResult{Index: i, Status: batchItemFailed, Ref: p.ref, Error: batchErrorFromResolution(lookupErr)} continue @@ -237,32 +247,49 @@ func newUpdateProjectItemsResult(results []batchItemResult) (*mcp.CallToolResult return result, nil, nil } -func resolveItemReference( +func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult) (nodeID string, fullDatabaseID int64, err error) { + switch p.refKind { + case batchRefNodeID: + return p.nodeID, 0, nil + case batchRefItemID: + lookup := itemIDLookups[p.itemID] + if lookup.err != nil { + return "", 0, lookup.err + } + return lookup.nodeID, p.itemID, nil + case batchRefIssue: + key := issueRefKey{owner: p.issueOwner, repo: p.issueRepo, number: p.issueNumber} + lookup := issueLookups[key] + if lookup.err != nil { + return "", 0, lookup.err + } + return lookup.nodeID, lookup.fullDatabaseID, nil + default: + return "", 0, fmt.Errorf("internal error: unrecognised item reference kind") + } +} + +func resolveIssueFieldItemReference( p parsedBatchItem, itemIDLookups map[int64]itemLookupResult, issueLookups map[issueRefKey]itemLookupResult, nodeIDLookups map[string]itemLookupResult, - requireIssue bool, ) (nodeID string, fullDatabaseID int64, issueNodeID string, err error) { var lookup itemLookupResult switch p.refKind { case batchRefNodeID: - if !requireIssue { - return p.nodeID, 0, "", nil - } lookup = nodeIDLookups[p.nodeID] case batchRefItemID: lookup = itemIDLookups[p.itemID] case batchRefIssue: - key := issueRefKey{owner: p.issueOwner, repo: p.issueRepo, number: p.issueNumber} - lookup = issueLookups[key] + lookup = issueLookups[issueRefKey{owner: p.issueOwner, repo: p.issueRepo, number: p.issueNumber}] default: return "", 0, "", fmt.Errorf("internal error: unrecognised item reference kind") } if lookup.err != nil { return "", 0, "", lookup.err } - if requireIssue && lookup.issueNodeID == "" { + if lookup.issueNodeID == "" { return "", 0, "", ghErrors.NewStructuredResolutionError( "issue_field_metadata_unavailable", lookup.nodeID, @@ -586,7 +613,36 @@ func resolveBatchProjectField(ctx context.Context, gqlClient *githubv4.Client, o if spec.name != "" { return resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "") } - return resolveProjectFieldByID(ctx, gqlClient, owner, ownerType, projectNumber, spec.id) + + fields, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + id := fmt.Sprintf("%d", spec.id) + for _, field := range fields { + if field.ID == id { + return &field, nil + } + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + id, + fmt.Sprintf("no project field with id %s on project %s#%d; see candidates for available fields", id, owner, projectNumber), + projectFieldCandidates(fields), + ) +} + +func projectFieldCandidates(fields []ResolvedField) []any { + candidates := make([]any, 0, len(fields)) + for _, field := range fields { + candidates = append(candidates, map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + }) + } + return candidates } func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2FieldValue, error) { @@ -625,9 +681,20 @@ func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2 if !ok || s == "" { return zero, fmt.Errorf("field %q is SINGLE_SELECT; value must be a non-empty string (option name or ID)", field.Name) } - optID, err := resolveSingleSelectOptionByNameOrID(field, s) - if err != nil { - return zero, err + optID := s + if resolvedID, optErr := resolveSingleSelectOptionByName(field, s); optErr == nil { + optID = resolvedID + } else { + known := false + for _, opt := range field.Options { + if opt.ID == s { + known = true + break + } + } + if !known { + return zero, optErr + } } v := githubv4.String(optID) return githubv4.ProjectV2FieldValue{SingleSelectOptionID: &v}, nil @@ -715,15 +782,11 @@ func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, o } var item *github.ProjectV2Item - var resp *github.Response var err error if ownerType == "org" { - item, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, id, nil) + item, _, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, id, nil) } else { - item, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, id, nil) - } - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() + item, _, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, id, nil) } var res itemLookupResult @@ -759,137 +822,6 @@ type issueRefKey struct { number int } -type batchProjectItemIssueContent struct { - TypeName githubv4.String `graphql:"__typename"` - Issue struct { - ID githubv4.ID - } `graphql:"... on Issue"` - PullRequest struct { - ID githubv4.ID - } `graphql:"... on PullRequest"` - DraftIssue struct { - ID githubv4.ID - } `graphql:"... on DraftIssue"` -} - -type batchProjectItemIssueNode struct { - ID githubv4.ID - FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` - Project struct { - ID githubv4.ID - } - Content batchProjectItemIssueContent -} - -type batchProjectItemsByNodeIDQuery struct { - Nodes []struct { - ProjectV2Item batchProjectItemIssueNode `graphql:"... on ProjectV2Item"` - } `graphql:"nodes(ids: $ids)"` -} - -func resolveItemIssuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem, requireIssue bool) map[string]itemLookupResult { - if !requireIssue { - return nil - } - - seen := make(map[string]struct{}, len(items)) - var ids []githubv4.ID - for _, item := range items { - if item.err != nil || item.refKind != batchRefNodeID { - continue - } - if _, exists := seen[item.nodeID]; exists { - continue - } - seen[item.nodeID] = struct{}{} - ids = append(ids, githubv4.ID(item.nodeID)) - } - if len(ids) == 0 { - return nil - } - - var query batchProjectItemsByNodeIDQuery - if err := gqlClient.Query(ctx, &query, map[string]any{"ids": ids}); err != nil { - results := make(map[string]itemLookupResult, len(ids)) - for _, id := range ids { - results[fmt.Sprintf("%v", id)] = itemLookupResult{err: fmt.Errorf("failed to inspect project item content: %w", err)} - } - return results - } - - results := make(map[string]itemLookupResult, len(ids)) - for _, node := range query.Nodes { - item := node.ProjectV2Item - nodeID := fmt.Sprintf("%v", item.ID) - if item.ID == nil { - continue - } - if item.Project.ID != projectID { - results[nodeID] = itemLookupResult{err: ghErrors.NewStructuredResolutionError( - "item_not_in_project", - nodeID, - "the project item does not belong to the named project", - nil, - )} - continue - } - - issueNodeID, err := batchProjectItemIssueNodeID(item) - fullDatabaseID := int64(0) - if item.FullDatabaseID != "" { - var parseErr error - fullDatabaseID, parseErr = parseInt64(string(item.FullDatabaseID)) - if parseErr != nil { - err = fmt.Errorf("project item %s has invalid full database ID %q: %w", nodeID, item.FullDatabaseID, parseErr) - } - } - results[nodeID] = itemLookupResult{ - nodeID: nodeID, - fullDatabaseID: fullDatabaseID, - issueNodeID: issueNodeID, - err: err, - } - } - - for _, id := range ids { - nodeID := fmt.Sprintf("%v", id) - if _, exists := results[nodeID]; !exists { - results[nodeID] = itemLookupResult{err: fmt.Errorf("project item %s was not found", nodeID)} - } - } - return results -} - -func batchProjectItemIssueNodeID(item batchProjectItemIssueNode) (string, error) { - switch item.Content.TypeName { - case "Issue": - if item.Content.Issue.ID == nil { - break - } - return fmt.Sprintf("%v", item.Content.Issue.ID), nil - case "PullRequest": - return "", ghErrors.NewStructuredResolutionError( - "unsupported_item_type", - "PullRequest", - "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", - nil, - ) - case "DraftIssue": - return "", ghErrors.NewStructuredResolutionError( - "unsupported_item_type", - "DraftIssue", - "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", - nil, - ) - } - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - fmt.Sprintf("%v", item.ID), - "the project item did not include the underlying Issue node ID needed to update the Issue Field", - nil, - ) -} - func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem) map[issueRefKey]itemLookupResult { seen := make(map[issueRefKey]struct{}, len(items)) var unique []issueRefKey diff --git a/pkg/github/projects_batch_mutation_test.go b/pkg/github/projects_batch_mutation_test.go index 1b56cc40a1..6115802cca 100644 --- a/pkg/github/projects_batch_mutation_test.go +++ b/pkg/github/projects_batch_mutation_test.go @@ -276,26 +276,6 @@ func Test_ExecuteAliasedMutation_IssueFieldAliases(t *testing.T) { assert.Equal(t, mutationAliasOutcome{Populated: true, NodeID: "I_issue1"}, outcomes[1]) } -func Test_ExecuteAliasedMutation_IssueFieldPartialData(t *testing.T) { - transport := &sequencedGraphQLTransport{ - t: t, - responses: []func(capturedGraphQLRequest) (int, string){ - func(capturedGraphQLRequest) (int, string) { - data := map[string]any{ - "item0": map[string]any{"issue": map[string]any{"id": "I_issue0"}}, - } - return http.StatusOK, mutationErrorResponse(t, data, "item1 failed") - }, - }, - } - - outcomes, err := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationSetIssueField, issueFieldInputsOfSize(2)) - require.Error(t, err) - assert.True(t, isGraphQLResponseError(err)) - assert.Equal(t, mutationAliasOutcome{Populated: true, NodeID: "I_issue0"}, outcomes[0]) - assert.Equal(t, mutationAliasOutcome{}, outcomes[1]) -} - func Test_ExecuteAliasedMutation_PreservesPartialDataWithGraphQLErrors(t *testing.T) { transport := &sequencedGraphQLTransport{ t: t, diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 45f38d66ae..cf645206d4 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -92,7 +92,7 @@ func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, e } if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") { - m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables, Headers: req.Header.Clone()}) + m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}) req.Body = io.NopCloser(strings.NewReader(string(raw))) return m.queries.RoundTrip(req) } diff --git a/pkg/github/projects_issue_fields.go b/pkg/github/projects_issue_fields.go new file mode 100644 index 0000000000..9c6f399650 --- /dev/null +++ b/pkg/github/projects_issue_fields.go @@ -0,0 +1,232 @@ +package github + +import ( + "context" + "fmt" + "time" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/google/go-github/v89/github" + "github.com/shurcooL/githubv4" +) + +func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { + if field == nil || field.IssueFieldNodeID == "" { + name := "" + if field != nil { + name = field.Name + } + return nil, ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + name, + "the attached Project field did not include the underlying Issue Field node ID; refresh field metadata and retry", + nil, + ) + } + + input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)} + if raw == nil { + deleteValue := githubv4.Boolean(true) + input.Delete = &deleteValue + return input, nil + } + + invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) { + return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil) + } + + switch field.DataType { + case "TEXT": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name)) + } + input.TextValue = githubv4.NewString(githubv4.String(value)) + case "NUMBER": + value, ok := toFloat64(raw) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name)) + } + number := githubv4.Float(value) + input.NumberValue = &number + case "DATE": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name)) + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value)) + } + input.DateValue = githubv4.NewString(githubv4.String(value)) + case "SINGLE_SELECT": + value, ok := raw.(string) + if !ok || value == "" { + return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) + } + optionID, err := resolveSingleSelectOptionByNameOrID(field, value) + if err != nil { + return nil, err + } + id := githubv4.ID(optionID) + input.SingleSelectOptionID = &id + default: + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_field_type", + field.Name, + fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), + nil, + ) + } + return input, nil +} + +func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) { + if item == nil || item.ContentType == nil { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "", + "the project item response did not identify its content type; Issue Fields can only be updated on Issue items", + nil, + ) + } + + contentType := string(*item.ContentType) + if contentType != "Issue" { + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + contentType, + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "Issue", + "the project item response did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) + } + return githubv4.ID(item.Content.Issue.GetNodeID()), nil +} + +type batchProjectItemIssueNode struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + Content struct { + TypeName githubv4.String `graphql:"__typename"` + Issue struct { + ID githubv4.ID + } `graphql:"... on Issue"` + PullRequest struct { + ID githubv4.ID + } `graphql:"... on PullRequest"` + DraftIssue struct { + ID githubv4.ID + } `graphql:"... on DraftIssue"` + } +} + +type batchProjectItemsByNodeIDQuery struct { + Nodes []struct { + ProjectV2Item batchProjectItemIssueNode `graphql:"... on ProjectV2Item"` + } `graphql:"nodes(ids: $ids)"` +} + +func resolveItemIssuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem, requireIssue bool) map[string]itemLookupResult { + if !requireIssue { + return nil + } + + seen := make(map[string]struct{}, len(items)) + var ids []githubv4.ID + for _, item := range items { + if item.err != nil || item.refKind != batchRefNodeID { + continue + } + if _, exists := seen[item.nodeID]; exists { + continue + } + seen[item.nodeID] = struct{}{} + ids = append(ids, githubv4.ID(item.nodeID)) + } + if len(ids) == 0 { + return nil + } + + var query batchProjectItemsByNodeIDQuery + if err := gqlClient.Query(ctx, &query, map[string]any{"ids": ids}); err != nil { + results := make(map[string]itemLookupResult, len(ids)) + for _, id := range ids { + results[fmt.Sprintf("%v", id)] = itemLookupResult{err: fmt.Errorf("failed to inspect project item content: %w", err)} + } + return results + } + + results := make(map[string]itemLookupResult, len(ids)) + for _, node := range query.Nodes { + item := node.ProjectV2Item + nodeID := fmt.Sprintf("%v", item.ID) + if item.ID == nil { + continue + } + if item.Project.ID != projectID { + results[nodeID] = itemLookupResult{err: ghErrors.NewStructuredResolutionError( + "item_not_in_project", + nodeID, + "the project item does not belong to the named project", + nil, + )} + continue + } + + issueNodeID, err := batchProjectItemIssueNodeID(item) + fullDatabaseID := int64(0) + if item.FullDatabaseID != "" { + var parseErr error + fullDatabaseID, parseErr = parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + err = fmt.Errorf("project item %s has invalid full database ID %q: %w", nodeID, item.FullDatabaseID, parseErr) + } + } + results[nodeID] = itemLookupResult{ + nodeID: nodeID, + fullDatabaseID: fullDatabaseID, + issueNodeID: issueNodeID, + err: err, + } + } + + for _, id := range ids { + nodeID := fmt.Sprintf("%v", id) + if _, exists := results[nodeID]; !exists { + results[nodeID] = itemLookupResult{err: fmt.Errorf("project item %s was not found", nodeID)} + } + } + return results +} + +func batchProjectItemIssueNodeID(item batchProjectItemIssueNode) (string, error) { + switch item.Content.TypeName { + case "Issue": + if item.Content.Issue.ID != nil { + return fmt.Sprintf("%v", item.Content.Issue.ID), nil + } + case "PullRequest", "DraftIssue": + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + string(item.Content.TypeName), + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + fmt.Sprintf("%v", item.ID), + "the project item did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) +} diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index 9655c11180..cdf41554b9 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -1,10 +1,8 @@ package github import ( - "context" "encoding/json" "fmt" - "maps" "net/http" "testing" @@ -13,6 +11,7 @@ import ( transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,9 +24,7 @@ func attachedIssueFieldNode(projectNodeID string, projectDatabaseID int, issueFi "name": name, "dataType": dataType, "isIssueField": true, - "issueField": map[string]any{ - "id": issueFieldNodeID, - }, + "issueField": map[string]any{"id": issueFieldNodeID}, } if dataType == "SINGLE_SELECT" { node["options"] = []any{} @@ -42,53 +39,30 @@ func issueProjectItemFixture(fields []map[string]any) map[string]any { "node_id": "PVTI_1", "content_type": "Issue", "content": map[string]any{ - "id": 2002, - "node_id": "I_123", - "number": 5, - "title": "Track customer", - "state": "open", - "html_url": "https://github.com/octo-org/roadmap/issues/5", - "repository_url": "https://api.github.com/repos/octo-org/roadmap", + "id": 2002, "node_id": "I_123", "number": 5, "title": "Track customer", }, "fields": fields, } } -func issueFieldMutationResponse() map[string]any { - return map[string]any{ - "setIssueFieldValue": map[string]any{ - "issue": map[string]any{ - "id": "I_123", - "number": 5, - "url": "https://github.com/octo-org/roadmap/issues/5", - }, - "issueFieldValues": []any{}, - }, - } -} - -func issueProjectItemMatcher(issueNumber int, issueNodeID, itemNodeID string, itemID int) githubv4mock.Matcher { +func issueProjectItemMatcher(issueNodeID, itemNodeID string, itemID int) githubv4mock.Matcher { return githubv4mock.NewQueryMatcher( resolveItemByIssueQuery{}, map[string]any{ - "issueOwner": githubv4.String("octo-org"), - "issueRepo": githubv4.String("roadmap"), - "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec + "issueOwner": githubv4.String("octo-org"), "issueRepo": githubv4.String("roadmap"), + "issueNumber": githubv4.Int(5), }, githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": issueNodeID, - "projectItems": map[string]any{ - "nodes": []any{map[string]any{ - "id": itemNodeID, - "fullDatabaseId": fmt.Sprintf("%d", itemID), - "project": map[string]any{"id": "PVT_project1"}, - }}, - "pageInfo": map[string]any{"hasNextPage": false}, - }, + "repository": map[string]any{"issue": map[string]any{ + "id": issueNodeID, + "projectItems": map[string]any{ + "nodes": []any{map[string]any{ + "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + }}, + "pageInfo": map[string]any{"hasNextPage": false}, }, - }, + }}, }), ) } @@ -99,13 +73,9 @@ func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID map[string]any{"ids": []githubv4.ID{githubv4.ID(itemNodeID)}}, githubv4mock.DataResponse(map[string]any{ "nodes": []any{map[string]any{ - "id": itemNodeID, - "fullDatabaseId": fmt.Sprintf("%d", itemID), - "project": map[string]any{"id": "PVT_project1"}, - "content": map[string]any{ - "__typename": "Issue", - "id": issueNodeID, - }, + "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + "content": map[string]any{"__typename": "Issue", "id": issueNodeID}, }}, }), ) @@ -113,279 +83,138 @@ func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID return matcher } -func Test_ResolveProjectFieldByName_AttachedIssueFields(t *testing.T) { - mocked := githubv4mock.NewMockedHTTPClient( +func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( projectFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), attachedIssueFieldNode("PVTSSF_risk", 702, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ - {"id": "IFO_low", "name": "Low"}, - {"id": "IFO_high", "name": "High"}, + {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, }), })), ), - ) - gql := githubv4.NewClient(mocked) - - customer, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "customer", "") - require.NoError(t, err) - assert.Equal(t, "701", customer.ID) - assert.Equal(t, "PVTF_customer", customer.NodeID) - assert.True(t, customer.IsIssueField) - assert.Equal(t, "IF_customer", customer.IssueFieldNodeID) + )) - risk, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "RISK", "") + field, err := resolveProjectFieldByID(t.Context(), gql, "octo-org", "org", 7, 702) require.NoError(t, err) - assert.Equal(t, "702", risk.ID) - assert.Equal(t, "IF_risk", risk.IssueFieldNodeID) + assert.Equal(t, "702", field.ID) + assert.Equal(t, "PVTSSF_risk", field.NodeID) + assert.Equal(t, "IF_risk", field.IssueFieldNodeID) + assert.True(t, field.IsIssueField) assert.Equal(t, []ResolvedFieldOption{ - {ID: "IFO_low", Name: "Low"}, - {ID: "IFO_high", Name: "High"}, - }, risk.Options) + {ID: "IFO_low", Name: "Low"}, {ID: "IFO_high", Name: "High"}, + }, field.Options) } -func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing.T) { - fieldValue := map[string]any{ - "id": 701, - "issue_field_id": 9001, - "name": "Customer", - "data_type": "text", - "value": "Acme", - } - +func Test_BuildIssueFieldUpdate(t *testing.T) { + number := githubv4.Float(3.5) + optionID := githubv4.ID("IFO_high") + deleteValue := githubv4.Boolean(true) tests := []struct { - name string - tool func(translations.TranslationHelperFunc) inventory.ServerTool - method string - route string - body any + name string + dataType string + options []ResolvedFieldOption + value any + want IssueFieldCreateOrUpdateInput }{ - { - name: "get", - tool: ProjectsGet, - method: projectsMethodGetProjectItem, - route: GetOrgsProjectsV2ItemsByProjectByItemID, - body: issueProjectItemFixture([]map[string]any{fieldValue}), - }, - { - name: "list", - tool: ProjectsList, - method: projectsMethodListProjectItems, - route: GetOrgsProjectsV2ItemsByProject, - body: []map[string]any{issueProjectItemFixture([]map[string]any{fieldValue})}, - }, + {"text", "TEXT", nil, "Acme", IssueFieldCreateOrUpdateInput{TextValue: githubv4.NewString(githubv4.String("Acme"))}}, + {"number", "NUMBER", nil, 3.5, IssueFieldCreateOrUpdateInput{NumberValue: &number}}, + {"date", "DATE", nil, "2026-08-01", IssueFieldCreateOrUpdateInput{DateValue: githubv4.NewString(githubv4.String("2026-08-01"))}}, + {"single select name", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_high", Name: "High"}}, "high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, + {"single select ID", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_other", Name: "IFO_high"}, {ID: "IFO_high", Name: "High"}}, "IFO_high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, + {"clear", "TEXT", nil, nil, IssueFieldCreateOrUpdateInput{Delete: &deleteValue}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - })), - ), - )) - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - tt.route: func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "701", r.URL.Query().Get("fields")) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(tt.body)) - }, - }) - serverTool := tt.tool(translations.NullTranslationHelper) - deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - handler := serverTool.Handler(deps) - args := map[string]any{ - "method": tt.method, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "field_names": []any{"customer"}, - } - if tt.method == projectsMethodGetProjectItem { - args["item_id"] = float64(1001) + field := &ResolvedField{ + Name: "Customer", DataType: tt.dataType, Options: tt.options, IsIssueField: true, IssueFieldNodeID: "IF_customer", } - - request := createMCPRequest(args) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + got, err := buildIssueFieldUpdate(field, tt.value) require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - item := response - if tt.method == projectsMethodListProjectItems { - items := response["items"].([]any) - item = items[0].(map[string]any) - } - fields := item["fields"].([]any) - require.Len(t, fields, 1) - assert.Equal(t, map[string]any{ - "id": float64(701), - "name": "Customer", - "data_type": "text", - "value": "Acme", - }, fields[0]) + tt.want.FieldID = githubv4.ID("IF_customer") + assert.Equal(t, tt.want, *got) }) } } -func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldTypes(t *testing.T) { - number := githubv4.Float(3.5) - optionID := githubv4.ID("IFO_high") - deleteValue := githubv4.Boolean(true) - +func Test_BuildIssueFieldUpdate_StructuredErrors(t *testing.T) { tests := []struct { - name string - fieldNode map[string]any - fieldRef map[string]any - value any - want IssueFieldCreateOrUpdateInput + name string + field *ResolvedField + value any + code string }{ - { - name: "text", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldRef: map[string]any{"name": "cUsToMeR"}, - value: "Acme", - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_customer"), - TextValue: githubv4.NewString(githubv4.String("Acme")), - }, - }, - { - name: "text by project field ID", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldRef: map[string]any{"id": float64(701)}, - value: "Acme", - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_customer"), - TextValue: githubv4.NewString(githubv4.String("Acme")), - }, - }, - { - name: "number", - fieldNode: attachedIssueFieldNode("PVTF_score", 702, "IF_score", "Score", "NUMBER", nil), - fieldRef: map[string]any{"name": "Score"}, - value: 3.5, - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_score"), - NumberValue: &number, - }, - }, - { - name: "date", - fieldNode: attachedIssueFieldNode("PVTF_due", 703, "IF_due", "Due", "DATE", nil), - fieldRef: map[string]any{"name": "Due"}, - value: "2026-08-01", - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_due"), - DateValue: githubv4.NewString(githubv4.String("2026-08-01")), - }, - }, - { - name: "single select resolves option case insensitively", - fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ - {"id": "IFO_low", "name": "Low"}, - {"id": "IFO_high", "name": "High"}, - }), - fieldRef: map[string]any{"name": "rIsK"}, - value: "hIgH", - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_risk"), - SingleSelectOptionID: &optionID, - }, - }, - { - name: "single select option name by project field ID", - fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ - {"id": "IFO_low", "name": "Low"}, - {"id": "IFO_high", "name": "High"}, - }), - fieldRef: map[string]any{"id": float64(704)}, - value: "hIgH", - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_risk"), - SingleSelectOptionID: &optionID, - }, - }, - { - name: "clear", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldRef: map[string]any{"name": "Customer"}, - value: nil, - want: IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID("IF_customer"), - Delete: &deleteValue, - }, - }, + {"missing metadata", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true}, "Acme", "issue_field_metadata_unavailable"}, + {"wrong value type", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true, IssueFieldNodeID: "IF_customer"}, 42.0, "invalid_field_value"}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), - ), - githubv4mock.NewMutationMatcher( - setIssueFieldValueMutation{}, - SetIssueFieldValueInput{ - IssueID: githubv4.ID("I_123"), - IssueFields: []IssueFieldCreateOrUpdateInput{tt.want}, - }, - nil, - githubv4mock.DataResponse(issueFieldMutationResponse()), - ), - )) - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), - }) - deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - updatedField := map[string]any{"value": tt.value} - maps.Copy(updatedField, tt.fieldRef) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(1001), - "updated_field": updatedField, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - assert.JSONEq(t, `{"id":"I_123","url":"https://github.com/octo-org/roadmap/issues/5"}`, getTextResult(t, result).Text) + _, err := buildIssueFieldUpdate(tt.field, tt.value) + require.Error(t, err) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) + assert.Equal(t, tt.code, response["error"]) }) } } -func Test_ProjectsWrite_UpdateProjectItem_RejectsIssueFieldForNonIssueItems(t *testing.T) { +func Test_UpdateProjectItem_AttachedIssueField(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, queries: queryTransport.Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "setIssueFieldValue") + assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + input := req.Variables["input"].(map[string]any) + assert.Equal(t, "I_123", input["issueId"]) + assert.Equal(t, "IFO_high", input["issueFields"].([]any)[0].(map[string]any)["singleSelectOptionId"]) + return http.StatusOK, `{"data":{"setIssueFieldValue":{"issue":{"id":"I_123"}}}}` + }, + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), + })), + GQLClient: githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }), + } + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), "item_id": float64(1001), + "updated_field": map[string]any{"id": float64(704), "value": "high"}, + }) + result, err := handler(ContextWithDeps(t.Context(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.Len(t, transport.mutationCalls, 1) +} + +func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing.T) { + fieldValue := map[string]any{ + "id": 701, "issue_field_id": 9001, "name": "Customer", "data_type": "text", "value": "Acme", + } tests := []struct { - name string - contentType string - content map[string]any + name string + tool func(translations.TranslationHelperFunc) inventory.ServerTool + method string + route string + body any }{ - { - name: "pull request", - contentType: "PullRequest", - content: map[string]any{ - "id": 2002, "node_id": "PR_123", "number": 5, "title": "PR", - }, - }, - { - name: "draft issue", - contentType: "DraftIssue", - content: map[string]any{ - "id": 2003, "node_id": "DI_123", "title": "Draft", - }, - }, + {"get", ProjectsGet, projectsMethodGetProjectItem, GetOrgsProjectsV2ItemsByProjectByItemID, issueProjectItemFixture([]map[string]any{fieldValue})}, + {"list", ProjectsList, projectsMethodListProjectItems, GetOrgsProjectsV2ItemsByProject, []map[string]any{issueProjectItemFixture([]map[string]any{fieldValue})}}, } for _, tt := range tests { @@ -400,227 +229,92 @@ func Test_ProjectsWrite_UpdateProjectItem_RejectsIssueFieldForNonIssueItems(t *t ), )) rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, map[string]any{ - "id": 1001, - "node_id": "PVTI_1", - "content_type": tt.contentType, - "content": tt.content, - }), + tt.route: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "701", r.URL.Query().Get("fields")) + require.NoError(t, json.NewEncoder(w).Encode(tt.body)) + }, }) deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(1001), - "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + args := map[string]any{ + "method": tt.method, "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), "field_names": []any{"customer"}, + } + if tt.method == projectsMethodGetProjectItem { + args["item_id"] = float64(1001) + } + request := createMCPRequest(args) + serverTool := tt.tool(translations.NullTranslationHelper) + result, err := serverTool.Handler(deps)(ContextWithDeps(t.Context(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, "unsupported_item_type", response["error"]) - assert.Equal(t, tt.contentType, response["name"]) - assert.Contains(t, response["hint"], "only be updated on Issue project items") + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.Contains(t, getTextResult(t, result).Text, `"name":"Customer"`) + assert.Contains(t, getTextResult(t, result).Text, `"value":"Acme"`) }) } } -func Test_ProjectsWrite_UpdateProjectItem_IssueFieldErrorsAreStructured(t *testing.T) { - tests := []struct { - name string - fieldNode map[string]any - value any - wantKind string - }{ - { - name: "wrong value type", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - value: 42.0, - wantKind: "invalid_field_value", - }, - { - name: "missing underlying metadata", - fieldNode: map[string]any{ - "id": "PVTF_customer", "databaseId": 701, "name": "Customer", "dataType": "TEXT", "isIssueField": true, - }, - value: "Acme", - wantKind: "issue_field_metadata_unavailable", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), - ), - )) - deps := BaseDeps{ - Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})), - GQLClient: gql, - } - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(1001), - "updated_field": map[string]any{"name": "Customer", "value": tt.value}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) +func Test_IssueFieldItemTypeValidation(t *testing.T) { + for _, contentType := range []string{"PullRequest", "DraftIssue"} { + t.Run(contentType, func(t *testing.T) { + item := issueProjectItemFixture(nil) + item["content_type"] = contentType + item["content"] = map[string]any{"node_id": "CONTENT_1"} + raw, err := json.Marshal(item) require.NoError(t, err) - require.True(t, result.IsError) - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, tt.wantKind, response["error"]) - assert.NotEmpty(t, response["hint"]) + var projectItem github.ProjectV2Item + require.NoError(t, json.Unmarshal(raw, &projectItem)) + + _, err = projectItemIssueNodeID(&projectItem) + require.Error(t, err) + assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) + + node := batchProjectItemIssueNode{ID: githubv4.ID("PVTI_1")} + node.Content.TypeName = githubv4.String(contentType) + _, err = batchProjectItemIssueNodeID(node) + require.Error(t, err) + assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) }) } } -func Test_ProjectsWrite_UpdateProjectItem_StandardFieldNameStillUsesProjectsREST(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{{"id": "OPT_doing", "name": "Doing"}}), - })), - ), - )) - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, verbosePullRequestProjectItemFixture()), - }) - deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(1001), - "updated_field": map[string]any{"name": "status", "value": "doing"}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) -} - -func Test_ProjectsWrite_UpdateProjectItem_StandardFieldIDResolvesOptionName(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{{"id": "OPT_doing", "name": "Doing"}}), - })), - ), - )) - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, r *http.Request) { - var body struct { - Fields []struct { - ID int64 `json:"id"` - Value string `json:"value"` - } `json:"fields"` - } - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Len(t, body.Fields, 1) - require.Equal(t, int64(101), body.Fields[0].ID) - require.Equal(t, "OPT_doing", body.Fields[0].Value) - mockResponse(t, http.StatusOK, verbosePullRequestProjectItemFixture())(w, r) - }, - }) - deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(1001), - "updated_field": map[string]any{"id": float64(101), "value": "doing"}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) -} - func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { tests := []struct { - name string - fieldNode map[string]any - fieldRef map[string]any - value any - item map[string]any - extraMatchers []githubv4mock.Matcher - restHandlers map[string]http.HandlerFunc - issueNodeID string - itemNodeID string - itemID int - expectedFieldID string - expectedValueKey string - expectedValue any + name string + fieldNode map[string]any + updatedField map[string]any + item map[string]any + extraMatchers []githubv4mock.Matcher + restHandlers map[string]http.HandlerFunc + issueNodeID string + itemNodeID string + itemID int + valueKey string + value any }{ { - name: "issue reference", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldRef: map[string]any{"name": "Customer"}, - value: "Acme", - item: map[string]any{"item_owner": "octo-org", "item_repo": "roadmap", "issue_number": float64(5)}, - extraMatchers: []githubv4mock.Matcher{issueProjectItemMatcher(5, "I_5", "PVTI_5", 1005)}, - restHandlers: map[string]http.HandlerFunc{}, - issueNodeID: "I_5", - itemNodeID: "PVTI_5", - itemID: 1005, - expectedFieldID: "IF_customer", - expectedValueKey: "textValue", - expectedValue: "Acme", + name: "issue reference", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + updatedField: map[string]any{"name": "Customer", "value": "Acme"}, + item: map[string]any{"item_owner": "octo-org", "item_repo": "roadmap", "issue_number": float64(5)}, + extraMatchers: []githubv4mock.Matcher{issueProjectItemMatcher("I_5", "PVTI_5", 1005)}, + restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_5", itemNodeID: "PVTI_5", itemID: 1005, + valueKey: "textValue", value: "Acme", }, { - name: "numeric item and field IDs clear", - fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - fieldRef: map[string]any{"id": float64(701)}, - value: nil, - item: map[string]any{"item_id": float64(1001)}, + name: "numeric IDs clear", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + updatedField: map[string]any{"id": float64(701), "value": nil}, + item: map[string]any{"item_id": float64(1001)}, restHandlers: map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), }, - issueNodeID: "I_123", - itemNodeID: "PVTI_1", - itemID: 1001, - expectedFieldID: "IF_customer", - expectedValueKey: "delete", - expectedValue: true, + issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, valueKey: "delete", value: true, }, { - name: "project item node ID and single-select option name by field ID", - fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ - {"id": "IFO_low", "name": "Low"}, - {"id": "IFO_high", "name": "High"}, - }), - fieldRef: map[string]any{"id": float64(704)}, - value: "high", - item: map[string]any{"node_id": "PVTI_1"}, - extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, - restHandlers: map[string]http.HandlerFunc{}, - issueNodeID: "I_123", - itemNodeID: "PVTI_1", - itemID: 1001, - expectedFieldID: "IF_risk", - expectedValueKey: "singleSelectOptionId", - expectedValue: "IFO_high", + name: "node ID and option name", fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), + updatedField: map[string]any{"id": float64(704), "value": "high"}, + item: map[string]any{"node_id": "PVTI_1"}, + extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, + restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, + valueKey: "singleSelectOptionId", value: "IFO_high", }, } @@ -629,126 +323,34 @@ func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { matchers := []githubv4mock.Matcher{ projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), + projectFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), ), } matchers = append(matchers, tt.extraMatchers...) - queryTransport := githubv4mock.NewMockedHTTPClient(matchers...) transport := &mutationAwareTransport{ - t: t, - queries: queryTransport.Transport, + t: t, queries: githubv4mock.NewMockedHTTPClient(matchers...).Transport, mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { assert.Contains(t, req.Query, "setIssueFieldValue") - assert.NotContains(t, req.Query, "updateProjectV2ItemFieldValue") assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) input := req.Variables["input"].(map[string]any) assert.Equal(t, tt.issueNodeID, input["issueId"]) - issueFields := input["issueFields"].([]any) - require.Len(t, issueFields, 1) - field := issueFields[0].(map[string]any) - assert.Equal(t, tt.expectedFieldID, field["fieldId"]) - assert.Equal(t, tt.expectedValue, field[tt.expectedValueKey]) + field := input["issueFields"].([]any)[0].(map[string]any) + assert.Equal(t, tt.value, field[tt.valueKey]) return http.StatusOK, issueFieldMutationDataResponse(t, map[int]string{0: tt.issueNodeID}) }, } - updatedField := map[string]any{"value": tt.value} - maps.Copy(updatedField, tt.fieldRef) - result, _, err := updateProjectItemsBatch( t.Context(), mustNewGHClient(t, MockHTTPClientWithHandlers(tt.restHandlers)), - githubv4.NewClient(&http.Client{ - Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, - }), - "octo-org", - "org", - 1, - map[string]any{ - "updated_field": updatedField, - "items": []any{tt.item}, - }, + githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}}), + "octo-org", "org", 1, + map[string]any{"updated_field": tt.updatedField, "items": []any{tt.item}}, ) require.NoError(t, err) require.False(t, result.IsError, getTextResult(t, result).Text) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, float64(1), response["succeeded"]) - item := response["results"].([]any)[0].(map[string]any)["item"].(map[string]any) - assert.Equal(t, tt.itemNodeID, item["node_id"]) - assert.Equal(t, fmt.Sprintf("%d", tt.itemID), item["full_database_id"]) - }) - } -} - -func Test_UpdateProjectItemsBatch_IssueFieldRejectsNonIssueContent(t *testing.T) { - tests := []struct { - name string - contentType string - content map[string]any - }{ - { - name: "pull request", - contentType: "PullRequest", - content: map[string]any{"id": 2002, "node_id": "PR_123", "number": 5, "title": "PR"}, - }, - { - name: "draft issue", - contentType: "DraftIssue", - content: map[string]any{"id": 2003, "node_id": "DI_123", "title": "Draft"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - queryTransport := githubv4mock.NewMockedHTTPClient( - projectIDMatcher("octo-org", 1, "PVT_project1"), - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - })), - ), - ) - transport := &mutationAwareTransport{ - t: t, - queries: queryTransport.Transport, - mutationRespond: func(_ int, _ capturedGraphQLRequest) (int, string) { - t.Fatal("non-Issue content must fail before mutation") - return http.StatusInternalServerError, "" - }, - } - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, map[string]any{ - "id": 1001, "node_id": "PVTI_1", "content_type": tt.contentType, "content": tt.content, - }), - }) - - result, _, err := updateProjectItemsBatch( - t.Context(), - mustNewGHClient(t, rest), - newTestGQLClient(transport), - "octo-org", - "org", - 1, - map[string]any{ - "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, - "items": []any{map[string]any{"item_id": float64(1001)}}, - }, - ) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Empty(t, transport.mutationCalls) - - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - entry := response["results"].([]any)[0].(map[string]any) - itemError := entry["error"].(map[string]any) - assert.Equal(t, "unsupported_item_type", itemError["code"]) - assert.Contains(t, itemError["hint"], "only be updated on Issue project items") + assert.Contains(t, getTextResult(t, result).Text, tt.itemNodeID) + assert.Contains(t, getTextResult(t, result).Text, fmt.Sprintf(`"item_id":%d`, tt.itemID)) }) } } diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 45a1b5a208..75df00eef4 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -139,27 +139,25 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner for _, n := range conn.Nodes { switch { case n.ProjectV2SingleSelectField.ID != nil: - field := n.ProjectV2SingleSelectField - opts := make([]ResolvedFieldOption, 0, len(field.Options)) + 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)}) + } issueFieldNodeID := "" - if field.IsIssueField { - opts = make([]ResolvedFieldOption, 0, len(field.IssueField.SingleSelect.Options)) - issueFieldNodeID = issueFieldNodeIDForType(field.DataType, field.IssueField) - for _, o := range field.IssueField.SingleSelect.Options { + if n.ProjectV2SingleSelectField.IsIssueField { + opts = opts[:0] + issueFieldNodeID = issueFieldNodeIDForType(n.ProjectV2SingleSelectField.DataType, n.ProjectV2SingleSelectField.IssueField) + for _, o := range n.ProjectV2SingleSelectField.IssueField.SingleSelect.Options { opts = append(opts, ResolvedFieldOption{ID: fmt.Sprintf("%v", o.ID), Name: string(o.Name)}) } - } else { - for _, o := range field.Options { - opts = append(opts, ResolvedFieldOption{ID: string(o.ID), Name: string(o.Name)}) - } } all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", field.DatabaseID), - NodeID: fmt.Sprintf("%v", field.ID), - Name: string(field.Name), - DataType: string(field.DataType), + ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), + Name: string(n.ProjectV2SingleSelectField.Name), + DataType: string(n.ProjectV2SingleSelectField.DataType), Options: opts, - IsIssueField: bool(field.IsIssueField), + IsIssueField: bool(n.ProjectV2SingleSelectField.IsIssueField), IssueFieldNodeID: issueFieldNodeID, }) case n.ProjectV2IterationField.ID != nil: @@ -170,17 +168,15 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner DataType: string(n.ProjectV2IterationField.DataType), }) case n.ProjectV2Field.ID != nil: - field := n.ProjectV2Field all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", field.DatabaseID), - NodeID: fmt.Sprintf("%v", field.ID), - Name: string(field.Name), - DataType: string(field.DataType), - IsIssueField: bool(field.IsIssueField), - IssueFieldNodeID: issueFieldNodeIDForType(field.DataType, field.IssueField), + ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), + NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), + Name: string(n.ProjectV2Field.Name), + DataType: string(n.ProjectV2Field.DataType), + IsIssueField: bool(n.ProjectV2Field.IsIssueField), + IssueFieldNodeID: issueFieldNodeIDForType(n.ProjectV2Field.DataType, n.ProjectV2Field.IssueField), }) } - } if !bool(conn.PageInfo.HasNextPage) { @@ -297,18 +293,6 @@ func resolveProjectFieldByID(ctx context.Context, gqlClient *githubv4.Client, ow ) } -func projectFieldCandidates(fields []ResolvedField) []any { - candidates := make([]any, 0, len(fields)) - for _, field := range fields { - candidates = append(candidates, map[string]any{ - "id": field.ID, - "name": field.Name, - "data_type": field.DataType, - }) - } - return candidates -} - // 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) { @@ -360,16 +344,12 @@ func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (s } func resolveSingleSelectOptionByNameOrID(field *ResolvedField, value string) (string, error) { - optionID, err := resolveSingleSelectOptionByName(field, value) - if err == nil { - return optionID, nil - } for _, option := range field.Options { if option.ID == value { return value, nil } } - return "", err + return resolveSingleSelectOptionByName(field, value) } // resolveProjectItemIDByIssueNumber resolves a (project, issue) pair to the From ef62fe3a9460849dc1ae21e778c805dbe38a0d05 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 16:21:12 -0400 Subject: [PATCH 07/10] Correct Issue Field feature headers Use issue_fields only for Project attachment metadata and update_issue_suggestions only for setIssueFieldValue mutations, matching live schema behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/projects.go | 2 +- pkg/github/projects_batch.go | 2 +- pkg/github/projects_batch_test.go | 2 +- pkg/github/projects_issue_fields_test.go | 14 ++++++++++---- pkg/github/projects_resolver.go | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 88c2950751..df7cb057f7 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1270,7 +1270,7 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi } return utils.NewToolResultError(resolveErr.Error()), nil, nil } - mutationCtx := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") + mutationCtx := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") response, mutationErr := SetIssueFieldValues(mutationCtx, gqlClient, issueID, []IssueFieldCreateOrUpdateInput{*update.IssueField}) if mutationErr != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to update Issue Field", mutationErr), nil, nil diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 2da9e4f9ae..c7341d94e0 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -338,7 +338,7 @@ func executeBatchWrites(ctx context.Context, operation batchWriteOperation, item mutationCtx := ctx if operation.kind == batchMutationSetIssueField { - mutationCtx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields", "update_issue_suggestions") + mutationCtx = ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") } outcomes, mutateErr := executeAliasedMutation(mutationCtx, operation.gqlClient, operation.kind, inputs) diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index cf645206d4..45f38d66ae 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -92,7 +92,7 @@ func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, e } if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") { - m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}) + m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables, Headers: req.Header.Clone()}) req.Body = io.NopCloser(strings.NewReader(string(raw))) return m.queries.RoundTrip(req) } diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index cdf41554b9..3f8c9d6621 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -84,7 +84,7 @@ func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID } func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + queryTransport := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( projectFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), @@ -94,10 +94,16 @@ func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { }), })), ), - )) + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) field, err := resolveProjectFieldByID(t.Context(), gql, "octo-org", "org", 7, 702) require.NoError(t, err) + require.Len(t, transport.queryCalls, 1) + assert.Equal(t, "issue_fields", transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) assert.Equal(t, "702", field.ID) assert.Equal(t, "PVTSSF_risk", field.NodeID) assert.Equal(t, "IF_risk", field.IssueFieldNodeID) @@ -174,7 +180,7 @@ func Test_UpdateProjectItem_AttachedIssueField(t *testing.T) { t: t, queries: queryTransport.Transport, mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { assert.Contains(t, req.Query, "setIssueFieldValue") - assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) input := req.Variables["input"].(map[string]any) assert.Equal(t, "I_123", input["issueId"]) assert.Equal(t, "IFO_high", input["issueFields"].([]any)[0].(map[string]any)["singleSelectOptionId"]) @@ -332,7 +338,7 @@ func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { t: t, queries: githubv4mock.NewMockedHTTPClient(matchers...).Transport, mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { assert.Contains(t, req.Query, "setIssueFieldValue") - assert.Equal(t, "issue_fields, repo_issue_fields, update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) input := req.Variables["input"].(map[string]any) assert.Equal(t, tt.issueNodeID, input["issueId"]) field := input["issueFields"].([]any)[0].(map[string]any) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 75df00eef4..b27da52077 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -108,7 +108,7 @@ type projectFieldsConnection struct { func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { all := []ResolvedField{} var after *githubv4.String - ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields") for { vars := map[string]any{ From 9f4af24d6f0a3b843e7f4188cf4e4c695515aa38 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 16:44:40 -0400 Subject: [PATCH 08/10] Preserve standard project field resolution Keep read and general field resolution independent of Issue Field schema features, and narrowly fall back for standard writes on hosts without the attachment bridge. Correct Issue Field error classification and align batch single-select option precedence with singular updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/projects.go | 2 +- pkg/github/projects_batch.go | 21 +-- pkg/github/projects_batch_test.go | 40 ++++-- pkg/github/projects_issue_fields.go | 37 ++++-- pkg/github/projects_issue_fields_test.go | 21 ++- pkg/github/projects_resolver.go | 158 ++++++++++++++++++++++- pkg/github/projects_resolver_test.go | 110 +++++++++++++--- pkg/github/projects_test.go | 2 +- 8 files changed, 322 insertions(+), 69 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index df7cb057f7..015d3a7164 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1705,7 +1705,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") } var err error - resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") + resolved, err = resolveProjectFieldForUpdateByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") if err != nil { return nil, err } diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index c7341d94e0..45509d2b88 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -611,10 +611,10 @@ func parseBatchFieldSpec(raw any) (batchFieldSpec, error) { func resolveBatchProjectField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, spec batchFieldSpec) (*ResolvedField, error) { if spec.name != "" { - return resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "") + return resolveProjectFieldForUpdateByName(ctx, gqlClient, owner, ownerType, projectNumber, spec.name, "") } - fields, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + fields, err := listAllProjectFieldsForUpdate(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return nil, err } @@ -681,20 +681,9 @@ func convertProjectFieldValue(field *ResolvedField, raw any) (githubv4.ProjectV2 if !ok || s == "" { return zero, fmt.Errorf("field %q is SINGLE_SELECT; value must be a non-empty string (option name or ID)", field.Name) } - optID := s - if resolvedID, optErr := resolveSingleSelectOptionByName(field, s); optErr == nil { - optID = resolvedID - } else { - known := false - for _, opt := range field.Options { - if opt.ID == s { - known = true - break - } - } - if !known { - return zero, optErr - } + optID, err := resolveSingleSelectOptionByNameOrID(field, s) + if err != nil { + return zero, err } v := githubv4.String(optID) return githubv4.ProjectV2FieldValue{SingleSelectOptionID: &v}, nil diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 45f38d66ae..a35ca49901 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -277,7 +277,7 @@ func Test_UpdateProjectItemsBatch_InvalidSharedValueIsTopLevelError(t *testing.T queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{ @@ -323,7 +323,7 @@ func Test_ProjectsWrite_UpdateProjectItems_NodeIDBypassesRESTLookup(t *testing.T queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -376,7 +376,7 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -493,7 +493,7 @@ func Test_ProjectsWrite_UpdateProjectItems_IssueRefPaginationIsDeduplicated(t *t }), ), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -561,7 +561,7 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -647,7 +647,7 @@ func chunkSizeTestRun(t *testing.T, toolDef inventory.ServerTool, itemCount int) queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -705,7 +705,7 @@ func Test_ProjectsWrite_UpdateProjectItems_SharedNullClearsAllItemsInOrder(t *te queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -770,7 +770,7 @@ func Test_ProjectsWrite_UpdateProjectItems_TransportFailureAbortsLaterChunks(t * queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -866,7 +866,7 @@ func Test_ProjectsWrite_UpdateProjectItems_MixedOutcomeKeepsIsErrorFalse(t *test queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -919,7 +919,7 @@ func Test_ProjectsWrite_UpdateProjectItems_EnterpriseClientWiring(t *testing.T) queryTransport := githubv4mock.NewMockedHTTPClient( projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ fieldNode("PVTF_notes", 101, "Notes", "TEXT"), @@ -1144,6 +1144,22 @@ func Test_ConvertProjectFieldValue_SingleSelect_ByOptionID(t *testing.T) { assert.Equal(t, "OPT_1", string(*v.SingleSelectOptionID)) } +func Test_ConvertProjectFieldValue_SingleSelect_IDPrecedesName(t *testing.T) { + field := &ResolvedField{ + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{ + {ID: "OPT_other", Name: "OPT_target"}, + {ID: "OPT_target", Name: "Target"}, + }, + } + + v, err := convertProjectFieldValue(field, "OPT_target") + require.NoError(t, err) + require.NotNil(t, v.SingleSelectOptionID) + assert.Equal(t, "OPT_target", string(*v.SingleSelectOptionID)) +} + func Test_ConvertProjectFieldValue_SingleSelect_Unknown(t *testing.T) { field := &ResolvedField{ Name: "Status", @@ -1190,7 +1206,7 @@ func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTF_status", 101, "Status", nil), @@ -1209,7 +1225,7 @@ func Test_ResolveBatchProjectField_ByIDAndName(t *testing.T) { func Test_ResolveBatchProjectField_AmbiguousName(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_status1", 101, "Status", nil), diff --git a/pkg/github/projects_issue_fields.go b/pkg/github/projects_issue_fields.go index 9c6f399650..9d21df2581 100644 --- a/pkg/github/projects_issue_fields.go +++ b/pkg/github/projects_issue_fields.go @@ -11,15 +11,31 @@ import ( ) func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { - if field == nil || field.IssueFieldNodeID == "" { - name := "" - if field != nil { - name = field.Name - } + if field == nil { + return nil, ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "", + "the attached Project field metadata is unavailable", + nil, + ) + } + + switch field.DataType { + case "TEXT", "NUMBER", "DATE", "SINGLE_SELECT": + default: + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_field_type", + field.Name, + fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), + nil, + ) + } + + if field.IssueFieldNodeID == "" { return nil, ghErrors.NewStructuredResolutionError( "issue_field_metadata_unavailable", - name, - "the attached Project field did not include the underlying Issue Field node ID; refresh field metadata and retry", + field.Name, + "the attached Project field did not include the underlying Issue Field node ID", nil, ) } @@ -69,13 +85,6 @@ func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUp } id := githubv4.ID(optionID) input.SingleSelectOptionID = &id - default: - return nil, ghErrors.NewStructuredResolutionError( - "unsupported_field_type", - field.Name, - fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), - nil, - ) } return input, nil } diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go index 3f8c9d6621..2de54b9963 100644 --- a/pkg/github/projects_issue_fields_test.go +++ b/pkg/github/projects_issue_fields_test.go @@ -86,7 +86,7 @@ func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { queryTransport := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ attachedIssueFieldNode("PVTSSF_risk", 702, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ @@ -154,6 +154,7 @@ func Test_BuildIssueFieldUpdate_StructuredErrors(t *testing.T) { }{ {"missing metadata", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true}, "Acme", "issue_field_metadata_unavailable"}, {"wrong value type", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true, IssueFieldNodeID: "IF_customer"}, 42.0, "invalid_field_value"}, + {"unsupported type clear", &ResolvedField{Name: "Reviewers", DataType: "USER", IsIssueField: true}, nil, "unsupported_field_type"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -169,7 +170,7 @@ func Test_BuildIssueFieldUpdate_StructuredErrors(t *testing.T) { func Test_UpdateProjectItem_AttachedIssueField(t *testing.T) { queryTransport := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), @@ -225,15 +226,19 @@ func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing. for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + queryTransport := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( projectFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + genericFieldNode("PVTF_customer", 701, "Customer", "TEXT"), })), ), - )) + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ tt.route: func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "701", r.URL.Query().Get("fields")) @@ -255,6 +260,10 @@ func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing. require.False(t, result.IsError, getTextResult(t, result).Text) assert.Contains(t, getTextResult(t, result).Text, `"name":"Customer"`) assert.Contains(t, getTextResult(t, result).Text, `"value":"Acme"`) + require.Len(t, transport.queryCalls, 1) + assert.Empty(t, transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.NotContains(t, transport.queryCalls[0].Query, "isIssueField") + assert.NotContains(t, transport.queryCalls[0].Query, "issueField") }) } } @@ -329,7 +338,7 @@ func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { matchers := []githubv4mock.Matcher{ projectIDMatcher("octo-org", 1, "PVT_project1"), githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), ), } diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index b27da52077..f8bebd3e2c 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -73,6 +73,50 @@ type projectFieldsQueryUser struct { // 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 +} + +type projectFieldsWithIssueFieldsQueryOrg struct { + Organization struct { + ProjectV2 struct { + Fields projectFieldsWithIssueFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +type projectFieldsWithIssueFieldsQueryUser struct { + User struct { + ProjectV2 struct { + Fields projectFieldsWithIssueFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +type projectFieldsWithIssueFieldsConnection struct { Nodes []struct { ProjectV2Field struct { ID githubv4.ID @@ -108,7 +152,6 @@ type projectFieldsConnection struct { func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { all := []ResolvedField{} var after *githubv4.String - ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields") for { vars := map[string]any{ @@ -136,6 +179,86 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner 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), + NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), + 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), + NodeID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), + 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), + NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), + 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 +} + +func listAllProjectFieldsForUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { + fields, err := listAllProjectFieldsWithIssueFieldMetadata(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil && issueFieldSchemaUnavailable(err) { + return listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + } + return fields, err +} + +func listAllProjectFieldsWithIssueFieldMetadata(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { + all := []ResolvedField{} + var after *githubv4.String + ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields") + + 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 projectFieldsWithIssueFieldsConnection + if ownerType == "org" { + var q projectFieldsWithIssueFieldsQueryOrg + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields with Issue Field metadata: %w", err) + } + conn = q.Organization.ProjectV2.Fields + } else { + var q projectFieldsWithIssueFieldsQueryUser + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields with Issue Field metadata: %w", err) + } + conn = q.User.ProjectV2.Fields + } + for _, n := range conn.Nodes { switch { case n.ProjectV2SingleSelectField.ID != nil: @@ -189,6 +312,25 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner return all, nil } +func issueFieldSchemaUnavailable(err error) bool { + message := err.Error() + for _, selection := range []struct { + fieldName string + typeName string + }{ + {"isIssueField", "ProjectV2Field"}, + {"issueField", "ProjectV2Field"}, + {"isIssueField", "ProjectV2SingleSelectField"}, + {"issueField", "ProjectV2SingleSelectField"}, + } { + if strings.Contains(message, fmt.Sprintf("Field '%s' doesn't exist on type '%s'", selection.fieldName, selection.typeName)) || + strings.Contains(message, fmt.Sprintf(`Cannot query field "%s" on type "%s"`, selection.fieldName, selection.typeName)) { + return true + } + } + return false +} + func issueFieldNodeIDForType(dataType githubv4.String, metadata projectIssueFieldMetadata) string { var id githubv4.ID switch strings.ToUpper(string(dataType)) { @@ -211,11 +353,21 @@ func issueFieldNodeIDForType(dataType githubv4.String, metadata projectIssueFiel // 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) { + return resolveProjectFieldByNameWithLister(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, expectedDataType, listAllProjectFields) +} + +func resolveProjectFieldForUpdateByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { + return resolveProjectFieldByNameWithLister(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, expectedDataType, listAllProjectFieldsForUpdate) +} + +type projectFieldLister func(context.Context, *githubv4.Client, string, string, int) ([]ResolvedField, error) + +func resolveProjectFieldByNameWithLister(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string, listFields projectFieldLister) (*ResolvedField, error) { if fieldName == "" { return nil, fmt.Errorf("field name must not be empty") } - all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + all, err := listFields(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return nil, err } @@ -274,7 +426,7 @@ func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, } func resolveProjectFieldByID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldID int64) (*ResolvedField, error) { - all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + all, err := listAllProjectFieldsForUpdate(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return nil, err } diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 3f0f1574ad..2c97417724 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -7,13 +7,15 @@ import ( "testing" "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// projectFieldsQueryMatcher is the GraphQL shape we use for fields(first:100) resolution. +// projectFieldsTestQuery is the ungated GraphQL shape used by read and general field resolution. // Keep this in sync with projectFieldsConnection in projects_resolver.go. type projectFieldsTestQuery struct { Organization struct { @@ -21,12 +23,10 @@ type projectFieldsTestQuery struct { Fields struct { Nodes []struct { ProjectV2Field struct { - ID githubv4.ID - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String - IsIssueField githubv4.Boolean - IssueField projectIssueFieldMetadata + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String } `graphql:"... on ProjectV2Field"` ProjectV2IterationField struct { ID githubv4.ID @@ -35,13 +35,11 @@ type projectFieldsTestQuery struct { DataType githubv4.String } `graphql:"... on ProjectV2IterationField"` ProjectV2SingleSelectField struct { - ID githubv4.ID - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String - IsIssueField githubv4.Boolean - IssueField projectIssueFieldMetadata - Options []struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { ID githubv4.String Name githubv4.String } @@ -53,6 +51,15 @@ type projectFieldsTestQuery struct { } `graphql:"organization(login: $owner)"` } +// projectFieldsWithIssueFieldsTestQuery includes the issue_fields-gated attachment bridge. +type projectFieldsWithIssueFieldsTestQuery struct { + Organization struct { + ProjectV2 struct { + Fields projectFieldsWithIssueFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + func fieldsQueryVars(owner string, projectNumber int) map[string]any { return map[string]any{ "owner": githubv4.String(owner), @@ -224,6 +231,54 @@ func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing. assert.Len(t, candidates, 2) } +func Test_ResolveProjectFieldForUpdateByName_IssueFieldSchemaUnavailable(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.ErrorResponse("Field 'isIssueField' doesn't exist on type 'ProjectV2Field'"), + ), + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + genericFieldNode("PVTF_notes", 333, "Notes", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) + + field, err := resolveProjectFieldForUpdateByName(t.Context(), gql, "octo-org", "org", 7, "Notes", "") + require.NoError(t, err) + assert.Equal(t, "333", field.ID) + require.Len(t, transport.queryCalls, 2) + assert.Equal(t, "issue_fields", transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.Empty(t, transport.queryCalls[1].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.NotContains(t, transport.queryCalls[1].Query, "isIssueField") + assert.NotContains(t, transport.queryCalls[1].Query, "issueField") +} + +func Test_ResolveProjectFieldForUpdateByName_DoesNotHideGraphQLErrors(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.ErrorResponse("Something went wrong while resolving project fields"), + ), + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) + + _, err := resolveProjectFieldForUpdateByName(t.Context(), gql, "octo-org", "org", 7, "Notes", "") + require.ErrorContains(t, err, "Something went wrong") + assert.Len(t, transport.queryCalls, 1) +} + func Test_ResolveSingleSelectOptionByName_NotFound(t *testing.T) { field := &ResolvedField{ ID: "12345", @@ -738,7 +793,7 @@ func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) { ), // 3. fields(first:100) for name resolution githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ @@ -775,7 +830,7 @@ func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testin mockedGQL := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", nil), @@ -809,3 +864,26 @@ func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testin assert.Equal(t, "field_not_found", msg["error"]) assert.Equal(t, "Doesnt Exist", msg["name"]) } + +func Test_BuildUpdateProjectItem_SingleSelectIDPrecedesName(t *testing.T) { + gql := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_status", 101, "Status", []map[string]any{ + {"id": "OPT_other", "name": "OPT_target"}, + {"id": "OPT_target", "name": "Target"}, + }), + })), + ), + )) + + update, err := buildUpdateProjectItem(t.Context(), gql, "octo-org", "org", 1, map[string]any{ + "name": "Status", "value": "OPT_target", + }) + require.NoError(t, err) + require.NotNil(t, update.Project) + require.Len(t, update.Project.Fields, 1) + assert.Equal(t, "OPT_target", update.Project.Fields[0].Value) +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 557739865b..79d6398cc2 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1237,7 +1237,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { Client: client, GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ genericFieldNode("PVTF_notes", 101, "Notes", "TEXT"), From cb5f66012ad89969e52a3b4a1b08a029176c99c6 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 16:55:44 -0400 Subject: [PATCH 09/10] Integrate Project Issue Field helpers Keep Issue Field value building and singular validation in projects.go, batch resolution in projects_batch.go, and the corresponding coverage in the existing project test files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/projects.go | 109 +++++++ pkg/github/projects_batch.go | 121 ++++++++ pkg/github/projects_batch_test.go | 130 ++++++++ pkg/github/projects_issue_fields.go | 241 --------------- pkg/github/projects_issue_fields_test.go | 371 ----------------------- pkg/github/projects_resolver_test.go | 46 +++ pkg/github/projects_test.go | 189 ++++++++++++ 7 files changed, 595 insertions(+), 612 deletions(-) delete mode 100644 pkg/github/projects_issue_fields.go delete mode 100644 pkg/github/projects_issue_fields_test.go diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 015d3a7164..9a585d5421 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1657,6 +1657,115 @@ type resolvedProjectItemUpdate struct { IssueField *IssueFieldCreateOrUpdateInput } +func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { + if field == nil { + return nil, ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "", + "the attached Project field metadata is unavailable", + nil, + ) + } + + switch field.DataType { + case "TEXT", "NUMBER", "DATE", "SINGLE_SELECT": + default: + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_field_type", + field.Name, + fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), + nil, + ) + } + + if field.IssueFieldNodeID == "" { + return nil, ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + field.Name, + "the attached Project field did not include the underlying Issue Field node ID", + nil, + ) + } + + input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)} + if raw == nil { + deleteValue := githubv4.Boolean(true) + input.Delete = &deleteValue + return input, nil + } + + invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) { + return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil) + } + + switch field.DataType { + case "TEXT": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name)) + } + input.TextValue = githubv4.NewString(githubv4.String(value)) + case "NUMBER": + value, ok := toFloat64(raw) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name)) + } + number := githubv4.Float(value) + input.NumberValue = &number + case "DATE": + value, ok := raw.(string) + if !ok { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name)) + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value)) + } + input.DateValue = githubv4.NewString(githubv4.String(value)) + case "SINGLE_SELECT": + value, ok := raw.(string) + if !ok || value == "" { + return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) + } + optionID, err := resolveSingleSelectOptionByNameOrID(field, value) + if err != nil { + return nil, err + } + id := githubv4.ID(optionID) + input.SingleSelectOptionID = &id + } + return input, nil +} + +func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) { + if item == nil || item.ContentType == nil { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "", + "the project item response did not identify its content type; Issue Fields can only be updated on Issue items", + nil, + ) + } + + contentType := string(*item.ContentType) + if contentType != "Issue" { + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + contentType, + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" { + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + "Issue", + "the project item response did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) + } + return githubv4.ID(item.Content.Issue.GetNodeID()), nil +} + // buildUpdateProjectItem resolves the target field and builds the matching Project or Issue Field write. func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*resolvedProjectItemUpdate, error) { if input == nil { diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 45509d2b88..8ced513e15 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -726,6 +726,127 @@ type itemLookupResult struct { err error } +type batchProjectItemIssueNode struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + Content struct { + TypeName githubv4.String `graphql:"__typename"` + Issue struct { + ID githubv4.ID + } `graphql:"... on Issue"` + PullRequest struct { + ID githubv4.ID + } `graphql:"... on PullRequest"` + DraftIssue struct { + ID githubv4.ID + } `graphql:"... on DraftIssue"` + } +} + +type batchProjectItemsByNodeIDQuery struct { + Nodes []struct { + ProjectV2Item batchProjectItemIssueNode `graphql:"... on ProjectV2Item"` + } `graphql:"nodes(ids: $ids)"` +} + +func resolveItemIssuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem, requireIssue bool) map[string]itemLookupResult { + if !requireIssue { + return nil + } + + seen := make(map[string]struct{}, len(items)) + var ids []githubv4.ID + for _, item := range items { + if item.err != nil || item.refKind != batchRefNodeID { + continue + } + if _, exists := seen[item.nodeID]; exists { + continue + } + seen[item.nodeID] = struct{}{} + ids = append(ids, githubv4.ID(item.nodeID)) + } + if len(ids) == 0 { + return nil + } + + var query batchProjectItemsByNodeIDQuery + if err := gqlClient.Query(ctx, &query, map[string]any{"ids": ids}); err != nil { + results := make(map[string]itemLookupResult, len(ids)) + for _, id := range ids { + results[fmt.Sprintf("%v", id)] = itemLookupResult{err: fmt.Errorf("failed to inspect project item content: %w", err)} + } + return results + } + + results := make(map[string]itemLookupResult, len(ids)) + for _, node := range query.Nodes { + item := node.ProjectV2Item + nodeID := fmt.Sprintf("%v", item.ID) + if item.ID == nil { + continue + } + if item.Project.ID != projectID { + results[nodeID] = itemLookupResult{err: ghErrors.NewStructuredResolutionError( + "item_not_in_project", + nodeID, + "the project item does not belong to the named project", + nil, + )} + continue + } + + issueNodeID, err := batchProjectItemIssueNodeID(item) + fullDatabaseID := int64(0) + if item.FullDatabaseID != "" { + var parseErr error + fullDatabaseID, parseErr = parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + err = fmt.Errorf("project item %s has invalid full database ID %q: %w", nodeID, item.FullDatabaseID, parseErr) + } + } + results[nodeID] = itemLookupResult{ + nodeID: nodeID, + fullDatabaseID: fullDatabaseID, + issueNodeID: issueNodeID, + err: err, + } + } + + for _, id := range ids { + nodeID := fmt.Sprintf("%v", id) + if _, exists := results[nodeID]; !exists { + results[nodeID] = itemLookupResult{err: fmt.Errorf("project item %s was not found", nodeID)} + } + } + return results +} + +func batchProjectItemIssueNodeID(item batchProjectItemIssueNode) (string, error) { + switch item.Content.TypeName { + case "Issue": + if item.Content.Issue.ID != nil { + return fmt.Sprintf("%v", item.Content.Issue.ID), nil + } + case "PullRequest", "DraftIssue": + return "", ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + string(item.Content.TypeName), + "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", + nil, + ) + } + return "", ghErrors.NewStructuredResolutionError( + "issue_field_metadata_unavailable", + fmt.Sprintf("%v", item.ID), + "the project item did not include the underlying Issue node ID needed to update the Issue Field", + nil, + ) +} + // Numeric lookups are deduplicated and concurrency-bounded; individual failures // remain isolated while cancellation stops pending work. func resolveItemNodeIDsByNumericID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, ids []int64, requireIssue bool) map[int64]itemLookupResult { diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index a35ca49901..b6087e19bd 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -16,6 +16,8 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/shurcooL/githubv4" @@ -1557,3 +1559,131 @@ func batchItemsOfSize(n int) ([]resolvedBatchItem, []batchItemResult) { } return items, make([]batchItemResult, n) } + +func issueProjectItemMatcher(issueNodeID, itemNodeID string, itemID int) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-org"), "issueRepo": githubv4.String("roadmap"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"issue": map[string]any{ + "id": issueNodeID, + "projectItems": map[string]any{ + "nodes": []any{map[string]any{ + "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + }}, + "pageInfo": map[string]any{"hasNextPage": false}, + }, + }}, + }), + ) +} + +func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID string) githubv4mock.Matcher { + matcher := githubv4mock.NewQueryMatcher( + batchProjectItemsByNodeIDQuery{}, + map[string]any{"ids": []githubv4.ID{githubv4.ID(itemNodeID)}}, + githubv4mock.DataResponse(map[string]any{ + "nodes": []any{map[string]any{ + "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), + "project": map[string]any{"id": "PVT_project1"}, + "content": map[string]any{"__typename": "Issue", "id": issueNodeID}, + }}, + }), + ) + matcher.Variables["ids"] = []any{itemNodeID} + return matcher +} + +func Test_BatchProjectItemIssueNodeID_RejectsUnsupportedTypes(t *testing.T) { + for _, contentType := range []string{"PullRequest", "DraftIssue"} { + t.Run(contentType, func(t *testing.T) { + node := batchProjectItemIssueNode{ID: githubv4.ID("PVTI_1")} + node.Content.TypeName = githubv4.String(contentType) + _, err := batchProjectItemIssueNodeID(node) + require.Error(t, err) + assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) + }) + } +} + +func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { + tests := []struct { + name string + fieldNode map[string]any + updatedField map[string]any + item map[string]any + extraMatchers []githubv4mock.Matcher + restHandlers map[string]http.HandlerFunc + issueNodeID string + itemNodeID string + itemID int + valueKey string + value any + }{ + { + name: "issue reference", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + updatedField: map[string]any{"name": "Customer", "value": "Acme"}, + item: map[string]any{"item_owner": "octo-org", "item_repo": "roadmap", "issue_number": float64(5)}, + extraMatchers: []githubv4mock.Matcher{issueProjectItemMatcher("I_5", "PVTI_5", 1005)}, + restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_5", itemNodeID: "PVTI_5", itemID: 1005, + valueKey: "textValue", value: "Acme", + }, + { + name: "numeric IDs clear", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), + updatedField: map[string]any{"id": float64(701), "value": nil}, + item: map[string]any{"item_id": float64(1001)}, + restHandlers: map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), + }, + issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, valueKey: "delete", value: true, + }, + { + name: "node ID and option name", fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), + updatedField: map[string]any{"id": float64(704), "value": "high"}, + item: map[string]any{"node_id": "PVTI_1"}, + extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, + restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, + valueKey: "singleSelectOptionId", value: "IFO_high", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + projectIDMatcher("octo-org", 1, "PVT_project1"), + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), + ), + } + matchers = append(matchers, tt.extraMatchers...) + transport := &mutationAwareTransport{ + t: t, queries: githubv4mock.NewMockedHTTPClient(matchers...).Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "setIssueFieldValue") + assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + input := req.Variables["input"].(map[string]any) + assert.Equal(t, tt.issueNodeID, input["issueId"]) + field := input["issueFields"].([]any)[0].(map[string]any) + assert.Equal(t, tt.value, field[tt.valueKey]) + return http.StatusOK, issueFieldMutationDataResponse(t, map[int]string{0: tt.issueNodeID}) + }, + } + result, _, err := updateProjectItemsBatch( + t.Context(), + mustNewGHClient(t, MockHTTPClientWithHandlers(tt.restHandlers)), + githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}}), + "octo-org", "org", 1, + map[string]any{"updated_field": tt.updatedField, "items": []any{tt.item}}, + ) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.Contains(t, getTextResult(t, result).Text, tt.itemNodeID) + assert.Contains(t, getTextResult(t, result).Text, fmt.Sprintf(`"item_id":%d`, tt.itemID)) + }) + } +} diff --git a/pkg/github/projects_issue_fields.go b/pkg/github/projects_issue_fields.go deleted file mode 100644 index 9d21df2581..0000000000 --- a/pkg/github/projects_issue_fields.go +++ /dev/null @@ -1,241 +0,0 @@ -package github - -import ( - "context" - "fmt" - "time" - - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/google/go-github/v89/github" - "github.com/shurcooL/githubv4" -) - -func buildIssueFieldUpdate(field *ResolvedField, raw any) (*IssueFieldCreateOrUpdateInput, error) { - if field == nil { - return nil, ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - "", - "the attached Project field metadata is unavailable", - nil, - ) - } - - switch field.DataType { - case "TEXT", "NUMBER", "DATE", "SINGLE_SELECT": - default: - return nil, ghErrors.NewStructuredResolutionError( - "unsupported_field_type", - field.Name, - fmt.Sprintf("Issue Field %q has unsupported data type %q; supported types are TEXT, NUMBER, DATE, and SINGLE_SELECT", field.Name, field.DataType), - nil, - ) - } - - if field.IssueFieldNodeID == "" { - return nil, ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - field.Name, - "the attached Project field did not include the underlying Issue Field node ID", - nil, - ) - } - - input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldNodeID)} - if raw == nil { - deleteValue := githubv4.Boolean(true) - input.Delete = &deleteValue - return input, nil - } - - invalidValue := func(hint string) (*IssueFieldCreateOrUpdateInput, error) { - return nil, ghErrors.NewStructuredResolutionError("invalid_field_value", field.Name, hint, nil) - } - - switch field.DataType { - case "TEXT": - value, ok := raw.(string) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is TEXT; value must be a string or null to clear it", field.Name)) - } - input.TextValue = githubv4.NewString(githubv4.String(value)) - case "NUMBER": - value, ok := toFloat64(raw) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is NUMBER; value must be a finite number or null to clear it", field.Name)) - } - number := githubv4.Float(value) - input.NumberValue = &number - case "DATE": - value, ok := raw.(string) - if !ok { - return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value must be a YYYY-MM-DD string or null to clear it", field.Name)) - } - if _, err := time.Parse("2006-01-02", value); err != nil { - return invalidValue(fmt.Sprintf("Issue Field %q is DATE; value %q must use YYYY-MM-DD format", field.Name, value)) - } - input.DateValue = githubv4.NewString(githubv4.String(value)) - case "SINGLE_SELECT": - value, ok := raw.(string) - if !ok || value == "" { - return invalidValue(fmt.Sprintf("Issue Field %q is SINGLE_SELECT; value must be a non-empty option name or ID, or null to clear it", field.Name)) - } - optionID, err := resolveSingleSelectOptionByNameOrID(field, value) - if err != nil { - return nil, err - } - id := githubv4.ID(optionID) - input.SingleSelectOptionID = &id - } - return input, nil -} - -func projectItemIssueNodeID(item *github.ProjectV2Item) (githubv4.ID, error) { - if item == nil || item.ContentType == nil { - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - "", - "the project item response did not identify its content type; Issue Fields can only be updated on Issue items", - nil, - ) - } - - contentType := string(*item.ContentType) - if contentType != "Issue" { - return "", ghErrors.NewStructuredResolutionError( - "unsupported_item_type", - contentType, - "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", - nil, - ) - } - if item.Content == nil || item.Content.Issue == nil || item.Content.Issue.GetNodeID() == "" { - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - "Issue", - "the project item response did not include the underlying Issue node ID needed to update the Issue Field", - nil, - ) - } - return githubv4.ID(item.Content.Issue.GetNodeID()), nil -} - -type batchProjectItemIssueNode struct { - ID githubv4.ID - FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` - Project struct { - ID githubv4.ID - } - Content struct { - TypeName githubv4.String `graphql:"__typename"` - Issue struct { - ID githubv4.ID - } `graphql:"... on Issue"` - PullRequest struct { - ID githubv4.ID - } `graphql:"... on PullRequest"` - DraftIssue struct { - ID githubv4.ID - } `graphql:"... on DraftIssue"` - } -} - -type batchProjectItemsByNodeIDQuery struct { - Nodes []struct { - ProjectV2Item batchProjectItemIssueNode `graphql:"... on ProjectV2Item"` - } `graphql:"nodes(ids: $ids)"` -} - -func resolveItemIssuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, projectID githubv4.ID, items []parsedBatchItem, requireIssue bool) map[string]itemLookupResult { - if !requireIssue { - return nil - } - - seen := make(map[string]struct{}, len(items)) - var ids []githubv4.ID - for _, item := range items { - if item.err != nil || item.refKind != batchRefNodeID { - continue - } - if _, exists := seen[item.nodeID]; exists { - continue - } - seen[item.nodeID] = struct{}{} - ids = append(ids, githubv4.ID(item.nodeID)) - } - if len(ids) == 0 { - return nil - } - - var query batchProjectItemsByNodeIDQuery - if err := gqlClient.Query(ctx, &query, map[string]any{"ids": ids}); err != nil { - results := make(map[string]itemLookupResult, len(ids)) - for _, id := range ids { - results[fmt.Sprintf("%v", id)] = itemLookupResult{err: fmt.Errorf("failed to inspect project item content: %w", err)} - } - return results - } - - results := make(map[string]itemLookupResult, len(ids)) - for _, node := range query.Nodes { - item := node.ProjectV2Item - nodeID := fmt.Sprintf("%v", item.ID) - if item.ID == nil { - continue - } - if item.Project.ID != projectID { - results[nodeID] = itemLookupResult{err: ghErrors.NewStructuredResolutionError( - "item_not_in_project", - nodeID, - "the project item does not belong to the named project", - nil, - )} - continue - } - - issueNodeID, err := batchProjectItemIssueNodeID(item) - fullDatabaseID := int64(0) - if item.FullDatabaseID != "" { - var parseErr error - fullDatabaseID, parseErr = parseInt64(string(item.FullDatabaseID)) - if parseErr != nil { - err = fmt.Errorf("project item %s has invalid full database ID %q: %w", nodeID, item.FullDatabaseID, parseErr) - } - } - results[nodeID] = itemLookupResult{ - nodeID: nodeID, - fullDatabaseID: fullDatabaseID, - issueNodeID: issueNodeID, - err: err, - } - } - - for _, id := range ids { - nodeID := fmt.Sprintf("%v", id) - if _, exists := results[nodeID]; !exists { - results[nodeID] = itemLookupResult{err: fmt.Errorf("project item %s was not found", nodeID)} - } - } - return results -} - -func batchProjectItemIssueNodeID(item batchProjectItemIssueNode) (string, error) { - switch item.Content.TypeName { - case "Issue": - if item.Content.Issue.ID != nil { - return fmt.Sprintf("%v", item.Content.Issue.ID), nil - } - case "PullRequest", "DraftIssue": - return "", ghErrors.NewStructuredResolutionError( - "unsupported_item_type", - string(item.Content.TypeName), - "Issue Fields can only be updated on Issue project items, not pull requests or draft issues", - nil, - ) - } - return "", ghErrors.NewStructuredResolutionError( - "issue_field_metadata_unavailable", - fmt.Sprintf("%v", item.ID), - "the project item did not include the underlying Issue node ID needed to update the Issue Field", - nil, - ) -} diff --git a/pkg/github/projects_issue_fields_test.go b/pkg/github/projects_issue_fields_test.go deleted file mode 100644 index 2de54b9963..0000000000 --- a/pkg/github/projects_issue_fields_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package github - -import ( - "encoding/json" - "fmt" - "net/http" - "testing" - - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/http/headers" - transportpkg "github.com/github/github-mcp-server/pkg/http/transport" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" - "github.com/shurcooL/githubv4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func attachedIssueFieldNode(projectNodeID string, projectDatabaseID int, issueFieldNodeID, name, dataType string, options []map[string]any) map[string]any { - node := map[string]any{ - "id": projectNodeID, - "databaseId": projectDatabaseID, - "name": name, - "dataType": dataType, - "isIssueField": true, - "issueField": map[string]any{"id": issueFieldNodeID}, - } - if dataType == "SINGLE_SELECT" { - node["options"] = []any{} - node["issueField"].(map[string]any)["options"] = options - } - return node -} - -func issueProjectItemFixture(fields []map[string]any) map[string]any { - return map[string]any{ - "id": 1001, - "node_id": "PVTI_1", - "content_type": "Issue", - "content": map[string]any{ - "id": 2002, "node_id": "I_123", "number": 5, "title": "Track customer", - }, - "fields": fields, - } -} - -func issueProjectItemMatcher(issueNodeID, itemNodeID string, itemID int) githubv4mock.Matcher { - return githubv4mock.NewQueryMatcher( - resolveItemByIssueQuery{}, - map[string]any{ - "issueOwner": githubv4.String("octo-org"), "issueRepo": githubv4.String("roadmap"), - "issueNumber": githubv4.Int(5), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{"issue": map[string]any{ - "id": issueNodeID, - "projectItems": map[string]any{ - "nodes": []any{map[string]any{ - "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), - "project": map[string]any{"id": "PVT_project1"}, - }}, - "pageInfo": map[string]any{"hasNextPage": false}, - }, - }}, - }), - ) -} - -func projectItemIssueByNodeIDMatcher(itemNodeID string, itemID int, issueNodeID string) githubv4mock.Matcher { - matcher := githubv4mock.NewQueryMatcher( - batchProjectItemsByNodeIDQuery{}, - map[string]any{"ids": []githubv4.ID{githubv4.ID(itemNodeID)}}, - githubv4mock.DataResponse(map[string]any{ - "nodes": []any{map[string]any{ - "id": itemNodeID, "fullDatabaseId": fmt.Sprintf("%d", itemID), - "project": map[string]any{"id": "PVT_project1"}, - "content": map[string]any{"__typename": "Issue", "id": issueNodeID}, - }}, - }), - ) - matcher.Variables["ids"] = []any{itemNodeID} - return matcher -} - -func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { - queryTransport := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsWithIssueFieldsTestQuery{}, - fieldsQueryVars("octo-org", 7), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTSSF_risk", 702, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ - {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, - }), - })), - ), - ) - transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} - gql := githubv4.NewClient(&http.Client{ - Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, - }) - - field, err := resolveProjectFieldByID(t.Context(), gql, "octo-org", "org", 7, 702) - require.NoError(t, err) - require.Len(t, transport.queryCalls, 1) - assert.Equal(t, "issue_fields", transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) - assert.Equal(t, "702", field.ID) - assert.Equal(t, "PVTSSF_risk", field.NodeID) - assert.Equal(t, "IF_risk", field.IssueFieldNodeID) - assert.True(t, field.IsIssueField) - assert.Equal(t, []ResolvedFieldOption{ - {ID: "IFO_low", Name: "Low"}, {ID: "IFO_high", Name: "High"}, - }, field.Options) -} - -func Test_BuildIssueFieldUpdate(t *testing.T) { - number := githubv4.Float(3.5) - optionID := githubv4.ID("IFO_high") - deleteValue := githubv4.Boolean(true) - tests := []struct { - name string - dataType string - options []ResolvedFieldOption - value any - want IssueFieldCreateOrUpdateInput - }{ - {"text", "TEXT", nil, "Acme", IssueFieldCreateOrUpdateInput{TextValue: githubv4.NewString(githubv4.String("Acme"))}}, - {"number", "NUMBER", nil, 3.5, IssueFieldCreateOrUpdateInput{NumberValue: &number}}, - {"date", "DATE", nil, "2026-08-01", IssueFieldCreateOrUpdateInput{DateValue: githubv4.NewString(githubv4.String("2026-08-01"))}}, - {"single select name", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_high", Name: "High"}}, "high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, - {"single select ID", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_other", Name: "IFO_high"}, {ID: "IFO_high", Name: "High"}}, "IFO_high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, - {"clear", "TEXT", nil, nil, IssueFieldCreateOrUpdateInput{Delete: &deleteValue}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - field := &ResolvedField{ - Name: "Customer", DataType: tt.dataType, Options: tt.options, IsIssueField: true, IssueFieldNodeID: "IF_customer", - } - got, err := buildIssueFieldUpdate(field, tt.value) - require.NoError(t, err) - tt.want.FieldID = githubv4.ID("IF_customer") - assert.Equal(t, tt.want, *got) - }) - } -} - -func Test_BuildIssueFieldUpdate_StructuredErrors(t *testing.T) { - tests := []struct { - name string - field *ResolvedField - value any - code string - }{ - {"missing metadata", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true}, "Acme", "issue_field_metadata_unavailable"}, - {"wrong value type", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true, IssueFieldNodeID: "IF_customer"}, 42.0, "invalid_field_value"}, - {"unsupported type clear", &ResolvedField{Name: "Reviewers", DataType: "USER", IsIssueField: true}, nil, "unsupported_field_type"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := buildIssueFieldUpdate(tt.field, tt.value) - require.Error(t, err) - var response map[string]any - require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) - assert.Equal(t, tt.code, response["error"]) - }) - } -} - -func Test_UpdateProjectItem_AttachedIssueField(t *testing.T) { - queryTransport := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsWithIssueFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), - })), - ), - ) - transport := &mutationAwareTransport{ - t: t, queries: queryTransport.Transport, - mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { - assert.Contains(t, req.Query, "setIssueFieldValue") - assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) - input := req.Variables["input"].(map[string]any) - assert.Equal(t, "I_123", input["issueId"]) - assert.Equal(t, "IFO_high", input["issueFields"].([]any)[0].(map[string]any)["singleSelectOptionId"]) - return http.StatusOK, `{"data":{"setIssueFieldValue":{"issue":{"id":"I_123"}}}}` - }, - } - deps := BaseDeps{ - Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), - })), - GQLClient: githubv4.NewClient(&http.Client{ - Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, - }), - } - serverTool := ProjectsWrite(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": projectsMethodUpdateProjectItem, "owner": "octo-org", "owner_type": "org", - "project_number": float64(1), "item_id": float64(1001), - "updated_field": map[string]any{"id": float64(704), "value": "high"}, - }) - result, err := handler(ContextWithDeps(t.Context(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - assert.Len(t, transport.mutationCalls, 1) -} - -func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing.T) { - fieldValue := map[string]any{ - "id": 701, "issue_field_id": 9001, "name": "Customer", "data_type": "text", "value": "Acme", - } - tests := []struct { - name string - tool func(translations.TranslationHelperFunc) inventory.ServerTool - method string - route string - body any - }{ - {"get", ProjectsGet, projectsMethodGetProjectItem, GetOrgsProjectsV2ItemsByProjectByItemID, issueProjectItemFixture([]map[string]any{fieldValue})}, - {"list", ProjectsList, projectsMethodListProjectItems, GetOrgsProjectsV2ItemsByProject, []map[string]any{issueProjectItemFixture([]map[string]any{fieldValue})}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - queryTransport := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, - fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{ - genericFieldNode("PVTF_customer", 701, "Customer", "TEXT"), - })), - ), - ) - transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} - gql := githubv4.NewClient(&http.Client{ - Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, - }) - rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - tt.route: func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "701", r.URL.Query().Get("fields")) - require.NoError(t, json.NewEncoder(w).Encode(tt.body)) - }, - }) - deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} - args := map[string]any{ - "method": tt.method, "owner": "octo-org", "owner_type": "org", - "project_number": float64(1), "field_names": []any{"customer"}, - } - if tt.method == projectsMethodGetProjectItem { - args["item_id"] = float64(1001) - } - request := createMCPRequest(args) - serverTool := tt.tool(translations.NullTranslationHelper) - result, err := serverTool.Handler(deps)(ContextWithDeps(t.Context(), deps), &request) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - assert.Contains(t, getTextResult(t, result).Text, `"name":"Customer"`) - assert.Contains(t, getTextResult(t, result).Text, `"value":"Acme"`) - require.Len(t, transport.queryCalls, 1) - assert.Empty(t, transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) - assert.NotContains(t, transport.queryCalls[0].Query, "isIssueField") - assert.NotContains(t, transport.queryCalls[0].Query, "issueField") - }) - } -} - -func Test_IssueFieldItemTypeValidation(t *testing.T) { - for _, contentType := range []string{"PullRequest", "DraftIssue"} { - t.Run(contentType, func(t *testing.T) { - item := issueProjectItemFixture(nil) - item["content_type"] = contentType - item["content"] = map[string]any{"node_id": "CONTENT_1"} - raw, err := json.Marshal(item) - require.NoError(t, err) - var projectItem github.ProjectV2Item - require.NoError(t, json.Unmarshal(raw, &projectItem)) - - _, err = projectItemIssueNodeID(&projectItem) - require.Error(t, err) - assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) - - node := batchProjectItemIssueNode{ID: githubv4.ID("PVTI_1")} - node.Content.TypeName = githubv4.String(contentType) - _, err = batchProjectItemIssueNodeID(node) - require.Error(t, err) - assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) - }) - } -} - -func Test_UpdateProjectItemsBatch_AttachedIssueFields(t *testing.T) { - tests := []struct { - name string - fieldNode map[string]any - updatedField map[string]any - item map[string]any - extraMatchers []githubv4mock.Matcher - restHandlers map[string]http.HandlerFunc - issueNodeID string - itemNodeID string - itemID int - valueKey string - value any - }{ - { - name: "issue reference", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - updatedField: map[string]any{"name": "Customer", "value": "Acme"}, - item: map[string]any{"item_owner": "octo-org", "item_repo": "roadmap", "issue_number": float64(5)}, - extraMatchers: []githubv4mock.Matcher{issueProjectItemMatcher("I_5", "PVTI_5", 1005)}, - restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_5", itemNodeID: "PVTI_5", itemID: 1005, - valueKey: "textValue", value: "Acme", - }, - { - name: "numeric IDs clear", fieldNode: attachedIssueFieldNode("PVTF_customer", 701, "IF_customer", "Customer", "TEXT", nil), - updatedField: map[string]any{"id": float64(701), "value": nil}, - item: map[string]any{"item_id": float64(1001)}, - restHandlers: map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), - }, - issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, valueKey: "delete", value: true, - }, - { - name: "node ID and option name", fieldNode: attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), - updatedField: map[string]any{"id": float64(704), "value": "high"}, - item: map[string]any{"node_id": "PVTI_1"}, - extraMatchers: []githubv4mock.Matcher{projectItemIssueByNodeIDMatcher("PVTI_1", 1001, "I_123")}, - restHandlers: map[string]http.HandlerFunc{}, issueNodeID: "I_123", itemNodeID: "PVTI_1", itemID: 1001, - valueKey: "singleSelectOptionId", value: "IFO_high", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - matchers := []githubv4mock.Matcher{ - projectIDMatcher("octo-org", 1, "PVT_project1"), - githubv4mock.NewQueryMatcher( - projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 1), - githubv4mock.DataResponse(fieldsResponse([]map[string]any{tt.fieldNode})), - ), - } - matchers = append(matchers, tt.extraMatchers...) - transport := &mutationAwareTransport{ - t: t, queries: githubv4mock.NewMockedHTTPClient(matchers...).Transport, - mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { - assert.Contains(t, req.Query, "setIssueFieldValue") - assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) - input := req.Variables["input"].(map[string]any) - assert.Equal(t, tt.issueNodeID, input["issueId"]) - field := input["issueFields"].([]any)[0].(map[string]any) - assert.Equal(t, tt.value, field[tt.valueKey]) - return http.StatusOK, issueFieldMutationDataResponse(t, map[int]string{0: tt.issueNodeID}) - }, - } - result, _, err := updateProjectItemsBatch( - t.Context(), - mustNewGHClient(t, MockHTTPClientWithHandlers(tt.restHandlers)), - githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}}), - "octo-org", "org", 1, - map[string]any{"updated_field": tt.updatedField, "items": []any{tt.item}}, - ) - require.NoError(t, err) - require.False(t, result.IsError, getTextResult(t, result).Text) - assert.Contains(t, getTextResult(t, result).Text, tt.itemNodeID) - assert.Contains(t, getTextResult(t, result).Text, fmt.Sprintf(`"item_id":%d`, tt.itemID)) - }) - } -} diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 2c97417724..835549fc8b 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -103,6 +103,22 @@ func genericFieldNode(nodeID string, databaseID int, name, dataType string) map[ } } +func attachedIssueFieldNode(projectNodeID string, projectDatabaseID int, issueFieldNodeID, name, dataType string, options []map[string]any) map[string]any { + node := map[string]any{ + "id": projectNodeID, + "databaseId": projectDatabaseID, + "name": name, + "dataType": dataType, + "isIssueField": true, + "issueField": map[string]any{"id": issueFieldNodeID}, + } + if dataType == "SINGLE_SELECT" { + node["options"] = []any{} + node["issueField"].(map[string]any)["options"] = options + } + return node +} + func fieldsResponse(nodes []map[string]any) map[string]any { return map[string]any{ "organization": map[string]any{ @@ -121,6 +137,36 @@ func fieldsResponse(nodes []map[string]any) map[string]any { } } +func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTSSF_risk", 702, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{ + {"id": "IFO_low", "name": "Low"}, {"id": "IFO_high", "name": "High"}, + }), + })), + ), + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) + + field, err := resolveProjectFieldByID(t.Context(), gql, "octo-org", "org", 7, 702) + require.NoError(t, err) + require.Len(t, transport.queryCalls, 1) + assert.Equal(t, "issue_fields", transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.Equal(t, "702", field.ID) + assert.Equal(t, "PVTSSF_risk", field.NodeID) + assert.Equal(t, "IF_risk", field.IssueFieldNodeID) + assert.True(t, field.IsIssueField) + assert.Equal(t, []ResolvedFieldOption{ + {ID: "IFO_low", Name: "Low"}, {ID: "IFO_high", Name: "High"}, + }, field.Options) +} + func Test_ResolveProjectFieldByName_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 79d6398cc2..0bec958cb3 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -8,7 +8,11 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -1654,3 +1658,188 @@ func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { assert.Equal(t, "AT_RISK", response["status"]) }) } + +func issueProjectItemFixture(fields []map[string]any) map[string]any { + return map[string]any{ + "id": 1001, + "node_id": "PVTI_1", + "content_type": "Issue", + "content": map[string]any{ + "id": 2002, "node_id": "I_123", "number": 5, "title": "Track customer", + }, + "fields": fields, + } +} + +func Test_BuildIssueFieldUpdate(t *testing.T) { + number := githubv4.Float(3.5) + optionID := githubv4.ID("IFO_high") + deleteValue := githubv4.Boolean(true) + tests := []struct { + name string + dataType string + options []ResolvedFieldOption + value any + want IssueFieldCreateOrUpdateInput + }{ + {"text", "TEXT", nil, "Acme", IssueFieldCreateOrUpdateInput{TextValue: githubv4.NewString(githubv4.String("Acme"))}}, + {"number", "NUMBER", nil, 3.5, IssueFieldCreateOrUpdateInput{NumberValue: &number}}, + {"date", "DATE", nil, "2026-08-01", IssueFieldCreateOrUpdateInput{DateValue: githubv4.NewString(githubv4.String("2026-08-01"))}}, + {"single select name", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_high", Name: "High"}}, "high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, + {"single select ID", "SINGLE_SELECT", []ResolvedFieldOption{{ID: "IFO_other", Name: "IFO_high"}, {ID: "IFO_high", Name: "High"}}, "IFO_high", IssueFieldCreateOrUpdateInput{SingleSelectOptionID: &optionID}}, + {"clear", "TEXT", nil, nil, IssueFieldCreateOrUpdateInput{Delete: &deleteValue}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + field := &ResolvedField{ + Name: "Customer", DataType: tt.dataType, Options: tt.options, IsIssueField: true, IssueFieldNodeID: "IF_customer", + } + got, err := buildIssueFieldUpdate(field, tt.value) + require.NoError(t, err) + tt.want.FieldID = githubv4.ID("IF_customer") + assert.Equal(t, tt.want, *got) + }) + } +} + +func Test_BuildIssueFieldUpdate_StructuredErrors(t *testing.T) { + tests := []struct { + name string + field *ResolvedField + value any + code string + }{ + {"missing metadata", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true}, "Acme", "issue_field_metadata_unavailable"}, + {"wrong value type", &ResolvedField{Name: "Customer", DataType: "TEXT", IsIssueField: true, IssueFieldNodeID: "IF_customer"}, 42.0, "invalid_field_value"}, + {"unsupported type clear", &ResolvedField{Name: "Reviewers", DataType: "USER", IsIssueField: true}, nil, "unsupported_field_type"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildIssueFieldUpdate(tt.field, tt.value) + require.Error(t, err) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &response)) + assert.Equal(t, tt.code, response["error"]) + }) + } +} + +func Test_UpdateProjectItem_AttachedIssueField(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + attachedIssueFieldNode("PVTSSF_risk", 704, "IF_risk", "Risk", "SINGLE_SELECT", []map[string]any{{"id": "IFO_high", "name": "High"}}), + })), + ), + ) + transport := &mutationAwareTransport{ + t: t, queries: queryTransport.Transport, + mutationRespond: func(_ int, req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "setIssueFieldValue") + assert.Equal(t, "update_issue_suggestions", req.Headers.Get(headers.GraphQLFeaturesHeader)) + input := req.Variables["input"].(map[string]any) + assert.Equal(t, "I_123", input["issueId"]) + assert.Equal(t, "IFO_high", input["issueFields"].([]any)[0].(map[string]any)["singleSelectOptionId"]) + return http.StatusOK, `{"data":{"setIssueFieldValue":{"issue":{"id":"I_123"}}}}` + }, + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture(nil)), + })), + GQLClient: githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }), + } + serverTool := ProjectsWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": projectsMethodUpdateProjectItem, "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), "item_id": float64(1001), + "updated_field": map[string]any{"id": float64(704), "value": "high"}, + }) + result, err := handler(ContextWithDeps(t.Context(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.Len(t, transport.mutationCalls, 1) +} + +func Test_ProjectItemReads_FieldNamesIncludeAttachedIssueFieldValues(t *testing.T) { + fieldValue := map[string]any{ + "id": 701, "issue_field_id": 9001, "name": "Customer", "data_type": "text", "value": "Acme", + } + tests := []struct { + name string + tool func(translations.TranslationHelperFunc) inventory.ServerTool + method string + route string + body any + }{ + {"get", ProjectsGet, projectsMethodGetProjectItem, GetOrgsProjectsV2ItemsByProjectByItemID, issueProjectItemFixture([]map[string]any{fieldValue})}, + {"list", ProjectsList, projectsMethodListProjectItems, GetOrgsProjectsV2ItemsByProject, []map[string]any{issueProjectItemFixture([]map[string]any{fieldValue})}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + queryTransport := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + genericFieldNode("PVTF_customer", 701, "Customer", "TEXT"), + })), + ), + ) + transport := &mutationAwareTransport{t: t, queries: queryTransport.Transport} + gql := githubv4.NewClient(&http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: transport}, + }) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "701", r.URL.Query().Get("fields")) + require.NoError(t, json.NewEncoder(w).Encode(tt.body)) + }, + }) + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: gql} + args := map[string]any{ + "method": tt.method, "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), "field_names": []any{"customer"}, + } + if tt.method == projectsMethodGetProjectItem { + args["item_id"] = float64(1001) + } + request := createMCPRequest(args) + serverTool := tt.tool(translations.NullTranslationHelper) + result, err := serverTool.Handler(deps)(ContextWithDeps(t.Context(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.Contains(t, getTextResult(t, result).Text, `"name":"Customer"`) + assert.Contains(t, getTextResult(t, result).Text, `"value":"Acme"`) + require.Len(t, transport.queryCalls, 1) + assert.Empty(t, transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.NotContains(t, transport.queryCalls[0].Query, "isIssueField") + assert.NotContains(t, transport.queryCalls[0].Query, "issueField") + }) + } +} + +func Test_ProjectItemIssueNodeID_RejectsUnsupportedTypes(t *testing.T) { + for _, contentType := range []string{"PullRequest", "DraftIssue"} { + t.Run(contentType, func(t *testing.T) { + item := issueProjectItemFixture(nil) + item["content_type"] = contentType + item["content"] = map[string]any{"node_id": "CONTENT_1"} + raw, err := json.Marshal(item) + require.NoError(t, err) + var projectItem github.ProjectV2Item + require.NoError(t, json.Unmarshal(raw, &projectItem)) + + _, err = projectItemIssueNodeID(&projectItem) + require.Error(t, err) + assert.Contains(t, err.Error(), `"error":"unsupported_item_type"`) + }) + } +} From ec8320e78d2931de42eef518ff71fcf61dc3eb68 Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Fri, 24 Jul 2026 17:51:21 -0400 Subject: [PATCH 10/10] Refine Project field resolution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d574277-3735-4356-a753-8354aa6b537c --- pkg/github/projects_resolver.go | 271 ++++++++++++--------------- pkg/github/projects_resolver_test.go | 121 ++++++------ 2 files changed, 184 insertions(+), 208 deletions(-) diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index f8bebd3e2c..5b8a1861d7 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -70,33 +70,36 @@ type projectFieldsQueryUser struct { } `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 projectV2FieldSelection struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String +} + +type projectV2SingleSelectOptionSelection struct { + ID githubv4.String + Name githubv4.String +} + +type projectV2SingleSelectFieldSelection struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + Options []projectV2SingleSelectOptionSelection +} + +// projectFieldsNode selects `id` to discriminate the union variant and +// `databaseId` for the numeric ID REST needs. +type projectFieldsNode struct { + ProjectV2Field projectV2FieldSelection `graphql:"... on ProjectV2Field"` + ProjectV2IterationField projectV2FieldSelection `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField projectV2SingleSelectFieldSelection `graphql:"... on ProjectV2SingleSelectField"` +} + 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"` - } + Nodes []projectFieldsNode PageInfo PageInfoFragment } @@ -117,52 +120,77 @@ type projectFieldsWithIssueFieldsQueryUser struct { } type projectFieldsWithIssueFieldsConnection struct { - Nodes []struct { - ProjectV2Field struct { - ID githubv4.ID - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String - IsIssueField githubv4.Boolean - IssueField projectIssueFieldMetadata - } `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 - IsIssueField githubv4.Boolean - IssueField projectIssueFieldMetadata - Options []struct { - ID githubv4.String - Name githubv4.String - } - } `graphql:"... on ProjectV2SingleSelectField"` - } + Nodes []projectFieldsWithIssueFieldsNode PageInfo PageInfoFragment } +type projectFieldsWithIssueFieldsNode struct { + ProjectV2Field struct { + projectV2FieldSelection + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + } `graphql:"... on ProjectV2Field"` + ProjectV2IterationField projectV2FieldSelection `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField struct { + projectV2SingleSelectFieldSelection + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + } `graphql:"... on ProjectV2SingleSelectField"` +} + +func projectFieldsQueryVariables(owner string, projectNumber int, after *githubv4.String) map[string]any { + 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 + } + return vars +} + +func resolvedProjectField(node projectFieldsNode) (ResolvedField, bool) { + switch { + case node.ProjectV2SingleSelectField.ID != nil: + options := make([]ResolvedFieldOption, 0, len(node.ProjectV2SingleSelectField.Options)) + for _, option := range node.ProjectV2SingleSelectField.Options { + options = append(options, ResolvedFieldOption{ID: string(option.ID), Name: string(option.Name)}) + } + return ResolvedField{ + ID: fmt.Sprintf("%d", node.ProjectV2SingleSelectField.DatabaseID), + NodeID: fmt.Sprintf("%v", node.ProjectV2SingleSelectField.ID), + Name: string(node.ProjectV2SingleSelectField.Name), + DataType: string(node.ProjectV2SingleSelectField.DataType), + Options: options, + }, true + case node.ProjectV2IterationField.ID != nil: + return ResolvedField{ + ID: fmt.Sprintf("%d", node.ProjectV2IterationField.DatabaseID), + NodeID: fmt.Sprintf("%v", node.ProjectV2IterationField.ID), + Name: string(node.ProjectV2IterationField.Name), + DataType: string(node.ProjectV2IterationField.DataType), + }, true + case node.ProjectV2Field.ID != nil: + return ResolvedField{ + ID: fmt.Sprintf("%d", node.ProjectV2Field.DatabaseID), + NodeID: fmt.Sprintf("%v", node.ProjectV2Field.ID), + Name: string(node.ProjectV2Field.Name), + DataType: string(node.ProjectV2Field.DataType), + }, true + default: + return ResolvedField{}, false + } +} + // 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 - } + vars := projectFieldsQueryVariables(owner, projectNumber, after) var conn projectFieldsConnection if ownerType == "org" { @@ -179,34 +207,9 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner 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), - NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), - 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), - NodeID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), - 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), - NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), - Name: string(n.ProjectV2Field.Name), - DataType: string(n.ProjectV2Field.DataType), - }) + for _, node := range conn.Nodes { + if field, ok := resolvedProjectField(node); ok { + all = append(all, field) } } @@ -234,15 +237,7 @@ func listAllProjectFieldsWithIssueFieldMetadata(ctx context.Context, gqlClient * ctx = ghcontext.WithGraphQLFeatures(ctx, "issue_fields") 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 - } + vars := projectFieldsQueryVariables(owner, projectNumber, after) var conn projectFieldsWithIssueFieldsConnection if ownerType == "org" { @@ -259,47 +254,28 @@ func listAllProjectFieldsWithIssueFieldMetadata(ctx context.Context, gqlClient * 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)}) - } - issueFieldNodeID := "" - if n.ProjectV2SingleSelectField.IsIssueField { - opts = opts[:0] - issueFieldNodeID = issueFieldNodeIDForType(n.ProjectV2SingleSelectField.DataType, n.ProjectV2SingleSelectField.IssueField) - for _, o := range n.ProjectV2SingleSelectField.IssueField.SingleSelect.Options { - opts = append(opts, ResolvedFieldOption{ID: fmt.Sprintf("%v", o.ID), Name: string(o.Name)}) - } + for _, node := range conn.Nodes { + field, ok := resolvedProjectField(projectFieldsNode{ + ProjectV2Field: node.ProjectV2Field.projectV2FieldSelection, + ProjectV2IterationField: node.ProjectV2IterationField, + ProjectV2SingleSelectField: node.ProjectV2SingleSelectField.projectV2SingleSelectFieldSelection, + }) + if !ok { + continue + } + + if field.DataType == "SINGLE_SELECT" && bool(node.ProjectV2SingleSelectField.IsIssueField) { + field.IsIssueField = true + field.IssueFieldNodeID = issueFieldNodeIDForType(field.DataType, node.ProjectV2SingleSelectField.IssueField) + field.Options = field.Options[:0] + for _, option := range node.ProjectV2SingleSelectField.IssueField.SingleSelect.Options { + field.Options = append(field.Options, ResolvedFieldOption{ID: fmt.Sprintf("%v", option.ID), Name: string(option.Name)}) } - all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), - NodeID: fmt.Sprintf("%v", n.ProjectV2SingleSelectField.ID), - Name: string(n.ProjectV2SingleSelectField.Name), - DataType: string(n.ProjectV2SingleSelectField.DataType), - Options: opts, - IsIssueField: bool(n.ProjectV2SingleSelectField.IsIssueField), - IssueFieldNodeID: issueFieldNodeID, - }) - case n.ProjectV2IterationField.ID != nil: - all = append(all, ResolvedField{ - ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID), - NodeID: fmt.Sprintf("%v", n.ProjectV2IterationField.ID), - 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), - NodeID: fmt.Sprintf("%v", n.ProjectV2Field.ID), - Name: string(n.ProjectV2Field.Name), - DataType: string(n.ProjectV2Field.DataType), - IsIssueField: bool(n.ProjectV2Field.IsIssueField), - IssueFieldNodeID: issueFieldNodeIDForType(n.ProjectV2Field.DataType, n.ProjectV2Field.IssueField), - }) + } else if bool(node.ProjectV2Field.IsIssueField) { + field.IsIssueField = true + field.IssueFieldNodeID = issueFieldNodeIDForType(field.DataType, node.ProjectV2Field.IssueField) } + all = append(all, field) } if !bool(conn.PageInfo.HasNextPage) { @@ -328,12 +304,18 @@ func issueFieldSchemaUnavailable(err error) bool { return true } } + for _, typeName := range []string{"IssueFieldText", "IssueFieldNumber", "IssueFieldDate", "IssueFieldSingleSelect"} { + if strings.Contains(message, fmt.Sprintf("No such type %s, so it can't be a fragment condition", typeName)) || + strings.Contains(message, fmt.Sprintf(`Unknown type "%s".`, typeName)) { + return true + } + } return false } -func issueFieldNodeIDForType(dataType githubv4.String, metadata projectIssueFieldMetadata) string { +func issueFieldNodeIDForType(dataType string, metadata projectIssueFieldMetadata) string { var id githubv4.ID - switch strings.ToUpper(string(dataType)) { + switch strings.ToUpper(dataType) { case "TEXT": id = metadata.Text.ID case "NUMBER": @@ -349,13 +331,6 @@ func issueFieldNodeIDForType(dataType githubv4.String, metadata projectIssueFiel return fmt.Sprintf("%v", id) } -// 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) { - return resolveProjectFieldByNameWithLister(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, expectedDataType, listAllProjectFields) -} - func resolveProjectFieldForUpdateByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { return resolveProjectFieldByNameWithLister(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, expectedDataType, listAllProjectFieldsForUpdate) } diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 835549fc8b..e191e0c94c 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "net/http" "testing" @@ -15,50 +16,8 @@ import ( "github.com/stretchr/testify/require" ) -// projectFieldsTestQuery is the ungated GraphQL shape used by read and general field resolution. -// Keep this in sync with projectFieldsConnection in projects_resolver.go. -type projectFieldsTestQuery struct { - Organization struct { - ProjectV2 struct { - Fields 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 - } `graphql:"fields(first: $first, after: $after)"` - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` -} - -// projectFieldsWithIssueFieldsTestQuery includes the issue_fields-gated attachment bridge. -type projectFieldsWithIssueFieldsTestQuery struct { - Organization struct { - ProjectV2 struct { - Fields projectFieldsWithIssueFieldsConnection `graphql:"fields(first: $first, after: $after)"` - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` -} +type projectFieldsTestQuery = projectFieldsQueryOrg +type projectFieldsWithIssueFieldsTestQuery = projectFieldsWithIssueFieldsQueryOrg func fieldsQueryVars(owner string, projectNumber int) map[string]any { return map[string]any{ @@ -167,10 +126,10 @@ func Test_ResolveProjectFieldByID_AttachedIssueFieldMetadata(t *testing.T) { }, field.Options) } -func Test_ResolveProjectFieldByName_Success(t *testing.T) { +func Test_ResolveProjectFieldForUpdateByName_Success(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{ @@ -183,7 +142,7 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { ) gql := githubv4.NewClient(mocked) - field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT") + field, err := resolveProjectFieldForUpdateByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT") require.NoError(t, err) require.NotNil(t, field) assert.Equal(t, "12345", field.ID) @@ -196,10 +155,10 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { assert.Equal(t, "OPT_b", optionID) } -func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { +func Test_ResolveProjectFieldForUpdateByName_NodeIDsForAllVariants(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_single1", 111, "Status", []map[string]any{ @@ -223,7 +182,7 @@ func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { } for _, v := range variants { t.Run(v.fieldName, func(t *testing.T) { - field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, v.fieldName, v.expectedType) + field, err := resolveProjectFieldForUpdateByName(context.Background(), gql, "octo-org", "org", 7, v.fieldName, v.expectedType) require.NoError(t, err) require.NotNil(t, field) assert.Equal(t, v.wantNodeID, field.NodeID) @@ -232,10 +191,10 @@ func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { } } -func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) { +func Test_ResolveProjectFieldForUpdateByName_NotFound_ReturnsStructuredError(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil), @@ -244,7 +203,7 @@ func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T ) gql := githubv4.NewClient(mocked) - _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "") + _, err := resolveProjectFieldForUpdateByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "") require.Error(t, err) var msg map[string]any @@ -254,10 +213,10 @@ func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T assert.NotEmpty(t, msg["candidates"]) } -func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing.T) { +func Test_ResolveProjectFieldForUpdateByName_Ambiguous_ReturnsStructuredError(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil), @@ -267,7 +226,7 @@ func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing. ) gql := githubv4.NewClient(mocked) - _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "") + _, err := resolveProjectFieldForUpdateByName(context.Background(), gql, "octo-org", "org", 7, "Status", "") require.Error(t, err) var msg map[string]any @@ -282,7 +241,7 @@ func Test_ResolveProjectFieldForUpdateByName_IssueFieldSchemaUnavailable(t *test githubv4mock.NewQueryMatcher( projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), - githubv4mock.ErrorResponse("Field 'isIssueField' doesn't exist on type 'ProjectV2Field'"), + githubv4mock.ErrorResponse("No such type IssueFieldText, so it can't be a fragment condition"), ), githubv4mock.NewQueryMatcher( projectFieldsTestQuery{}, @@ -303,10 +262,52 @@ func Test_ResolveProjectFieldForUpdateByName_IssueFieldSchemaUnavailable(t *test require.Len(t, transport.queryCalls, 2) assert.Equal(t, "issue_fields", transport.queryCalls[0].Headers.Get(headers.GraphQLFeaturesHeader)) assert.Empty(t, transport.queryCalls[1].Headers.Get(headers.GraphQLFeaturesHeader)) + assert.Contains(t, transport.queryCalls[1].Query, "... on ProjectV2Field{id,databaseId,name,dataType}") assert.NotContains(t, transport.queryCalls[1].Query, "isIssueField") assert.NotContains(t, transport.queryCalls[1].Query, "issueField") } +func Test_IssueFieldSchemaUnavailable(t *testing.T) { + var recognized []string + for _, selection := range []struct { + fieldName string + typeName string + }{ + {"isIssueField", "ProjectV2Field"}, + {"issueField", "ProjectV2Field"}, + {"isIssueField", "ProjectV2SingleSelectField"}, + {"issueField", "ProjectV2SingleSelectField"}, + } { + recognized = append(recognized, + "Field '"+selection.fieldName+"' doesn't exist on type '"+selection.typeName+"'", + `Cannot query field "`+selection.fieldName+`" on type "`+selection.typeName+`"`, + ) + } + for _, typeName := range []string{"IssueFieldText", "IssueFieldNumber", "IssueFieldDate", "IssueFieldSingleSelect"} { + recognized = append(recognized, + "No such type "+typeName+", so it can't be a fragment condition", + `Unknown type "`+typeName+`".`, + ) + } + + for _, message := range recognized { + t.Run(message, func(t *testing.T) { + assert.True(t, issueFieldSchemaUnavailable(errors.New(message))) + }) + } + + for _, message := range []string{ + "Something went wrong while resolving project fields", + "authentication required for IssueFieldText", + `Unknown type "UnrelatedType".`, + "connection reset by peer", + } { + t.Run("reject "+message, func(t *testing.T) { + assert.False(t, issueFieldSchemaUnavailable(errors.New(message))) + }) + } +} + func Test_ResolveProjectFieldForUpdateByName_DoesNotHideGraphQLErrors(t *testing.T) { queryTransport := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -733,10 +734,10 @@ func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { // Field and single-select option name matching is case-insensitive so agents passing lowercase // names like "status" or "in progress" resolve to "Status" and "In Progress" respectively. -func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) { +func Test_ResolveProjectFieldForUpdateByName_CaseInsensitive(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - projectFieldsTestQuery{}, + projectFieldsWithIssueFieldsTestQuery{}, fieldsQueryVars("octo-org", 7), githubv4mock.DataResponse(fieldsResponse([]map[string]any{ statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{ @@ -748,7 +749,7 @@ func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) { ) gql := githubv4.NewClient(mocked) - field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "status", "") + field, err := resolveProjectFieldForUpdateByName(context.Background(), gql, "octo-org", "org", 7, "status", "") require.NoError(t, err) require.NotNil(t, field) assert.Equal(t, "12345", field.ID)