diff --git a/README.md b/README.md index 1f89dafd06..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 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. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d7c5d25eab..494075e249 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.", "oneOf": [ { "additionalProperties": false, diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index c1eb556c9c..06c9d72295 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1263,6 +1263,46 @@ 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, + } + 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 +1526,13 @@ 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(ctxWithFeatures, 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..9a585d5421 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" @@ -551,7 +552,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.", OneOf: []*jsonschema.Schema{ variant([]string{"id", "value"}, map[string]*jsonschema.Schema{ "id": { @@ -1191,7 +1192,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 +1209,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 +1241,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 +1250,45 @@ 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 + } + 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 + } + 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 { @@ -1614,8 +1652,122 @@ 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 +} + +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 { return nil, fmt.Errorf("updated_field must be an object") } @@ -1646,6 +1798,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 == "" { @@ -1655,7 +1814,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 } @@ -1666,24 +1825,21 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own fieldID = parsedID } - // 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 resolved.IsIssueField { + issueField, buildErr := buildIssueFieldUpdate(resolved, valueField) + if buildErr != nil { + return nil, buildErr + } + return &resolvedProjectItemUpdate{IssueField: issueField}, nil + } + + 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 } } @@ -1694,7 +1850,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own }}, } - return payload, nil + return &resolvedProjectItemUpdate{Project: payload}, 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..8ced513e15 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) { @@ -122,9 +125,18 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie 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) + 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 @@ -137,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) @@ -149,7 +162,17 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie continue } - nodeID, fullDatabaseID, lookupErr := resolveItemReference(p, itemIDLookups, issueLookups) + 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 @@ -167,15 +190,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) @@ -243,6 +269,37 @@ func resolveItemReference(p parsedBatchItem, itemIDLookups map[int64]itemLookupR } } +func resolveIssueFieldItemReference( + p parsedBatchItem, + itemIDLookups map[int64]itemLookupResult, + issueLookups map[issueRefKey]itemLookupResult, + nodeIDLookups map[string]itemLookupResult, +) (nodeID string, fullDatabaseID int64, issueNodeID string, err error) { + var lookup itemLookupResult + switch p.refKind { + case batchRefNodeID: + lookup = nodeIDLookups[p.nodeID] + case batchRefItemID: + lookup = itemIDLookups[p.itemID] + case batchRefIssue: + 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 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; // GraphQL response errors do not because populated aliases still confirm writes. func executeBatchWrites(ctx context.Context, operation batchWriteOperation, items []resolvedBatchItem, results []batchItemResult) { @@ -257,13 +314,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), @@ -273,19 +336,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, "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, }, } @@ -536,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 } @@ -606,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 @@ -658,12 +722,134 @@ func toFloat64(raw any) (float64, bool) { type itemLookupResult struct { nodeID string fullDatabaseID int64 + issueNodeID string 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) 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 { @@ -721,6 +907,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() @@ -784,10 +978,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..6115802cca 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,28 @@ 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_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..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" @@ -92,12 +94,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 { @@ -277,7 +279,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 +325,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 +378,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 +495,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 +563,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 +649,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 +707,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 +772,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 +868,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 +921,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 +1146,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 +1208,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 +1227,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), @@ -1259,7 +1277,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) @@ -1541,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_resolver.go b/pkg/github/projects_resolver.go index 3643d6eafa..5b8a1861d7 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). @@ -48,51 +70,127 @@ 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 +} + +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 []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" { @@ -109,35 +207,75 @@ 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)}) + for _, node := range conn.Nodes { + if field, ok := resolvedProjectField(node); ok { + all = append(all, field) + } + } + + 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 := projectFieldsQueryVariables(owner, projectNumber, 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 _, 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, - }) - 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), - }) + } 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) { @@ -150,15 +288,61 @@ func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner return all, nil } -// resolveProjectFieldByName resolves a field by display name. Returns a -// structured error on not-found, ambiguous, or wrong-data-type (when -// expectedDataType is set) so the agent can self-correct. -func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { +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 + } + } + 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 string, metadata projectIssueFieldMetadata) string { + var id githubv4.ID + switch strings.ToUpper(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) +} + +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 } @@ -216,6 +400,26 @@ 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 := listAllProjectFieldsForUpdate(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), + ) +} + // 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) { @@ -266,6 +470,15 @@ func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (s } } +func resolveSingleSelectOptionByNameOrID(field *ResolvedField, value string) (string, error) { + for _, option := range field.Options { + if option.ID == value { + return value, nil + } + } + return resolveSingleSelectOptionByName(field, value) +} + // 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. @@ -279,10 +492,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 @@ -297,6 +511,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)"` @@ -309,18 +524,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 } } @@ -331,18 +549,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 b08e00cac6..e191e0c94c 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -3,51 +3,21 @@ package github import ( "context" "encoding/json" + "errors" "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/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. -// 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)"` -} +type projectFieldsTestQuery = projectFieldsQueryOrg +type projectFieldsWithIssueFieldsTestQuery = projectFieldsWithIssueFieldsQueryOrg func fieldsQueryVars(owner string, projectNumber int) map[string]any { return map[string]any{ @@ -92,6 +62,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{ @@ -110,10 +96,40 @@ func fieldsResponse(nodes []map[string]any) map[string]any { } } -func Test_ResolveProjectFieldByName_Success(t *testing.T) { +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_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{ @@ -126,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) @@ -139,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{ @@ -166,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) @@ -175,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), @@ -187,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 @@ -197,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), @@ -210,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 @@ -220,6 +236,96 @@ 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("No such type IssueFieldText, so it can't be a fragment condition"), + ), + 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.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( + 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", @@ -260,6 +366,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 @@ -277,6 +384,7 @@ type resolveItemByIssueQuery struct { type resolveItemByIssuePageQuery struct { Repository struct { Issue struct { + ID githubv4.ID ProjectItems struct { Nodes []struct { ID githubv4.ID @@ -626,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{ @@ -641,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) @@ -732,7 +840,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{ @@ -769,7 +877,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), @@ -803,3 +911,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 92de4a5d5f..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" @@ -1235,6 +1239,15 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsWithIssueFieldsTestQuery{}, + 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{ @@ -1645,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"`) + }) + } +}