diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 58fd904e88..11dc0a055b 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -1746,6 +1746,27 @@ func TestGranularUnresolveReviewThread(t *testing.T) { } func TestGranularSetIssueFields(t *testing.T) { + t.Run("mutation selects only issue identity", func(t *testing.T) { + transport := &sequencedGraphQLTransport{ + t: t, + responses: []func(capturedGraphQLRequest) (int, string){ + func(req capturedGraphQLRequest) (int, string) { + assert.Contains(t, req.Query, "issue{id,url}") + assert.NotContains(t, req.Query, "issueFieldValues") + assert.NotContains(t, req.Query, "number") + return http.StatusOK, `{"data":{"setIssueFieldValue":{"issue":{"id":"ISSUE_123","url":"https://github.com/owner/repo/issues/5"}}}}` + }, + }, + } + _, err := SetIssueFieldValues(context.Background(), githubv4.NewClient(&http.Client{Transport: transport}), SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{{ + FieldID: githubv4.ID("FIELD_1"), TextValue: githubv4.NewString("hello"), + }}, + }) + require.NoError(t, err) + }) + t.Run("successful set with text value", func(t *testing.T) { matchers := []githubv4mock.Matcher{ // Mock the issue ID query @@ -1770,29 +1791,7 @@ func TestGranularSetIssueFields(t *testing.T) { ), // Mock the setIssueFieldValue mutation githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -1806,9 +1805,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), @@ -1945,29 +1943,7 @@ func TestGranularSetIssueFields(t *testing.T) { }), ), githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -1982,9 +1958,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), @@ -2059,29 +2034,7 @@ func TestGranularSetIssueFields(t *testing.T) { }), ), githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -2096,9 +2049,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), @@ -2173,29 +2125,7 @@ func TestGranularSetIssueFields(t *testing.T) { }), ), githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -2210,9 +2140,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), @@ -2264,29 +2193,7 @@ func TestGranularSetIssueFields(t *testing.T) { }), ), githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -2302,9 +2209,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), @@ -2356,29 +2262,7 @@ func TestGranularSetIssueFields(t *testing.T) { }), ), githubv4mock.NewMutationMatcher( - 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)"` - }{}, + setIssueFieldValueMutation{}, SetIssueFieldValueInput{ IssueID: githubv4.ID("ISSUE_123"), IssueFields: []IssueFieldCreateOrUpdateInput{ @@ -2392,9 +2276,8 @@ func TestGranularSetIssueFields(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "setIssueFieldValue": map[string]any{ "issue": map[string]any{ - "id": "ISSUE_123", - "number": 5, - "url": "https://github.com/owner/repo/issues/5", + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", }, }, }), diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index c1eb556c9c..ab52cab59f 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1263,6 +1263,27 @@ type IssueFieldCreateOrUpdateInput struct { Suggest *githubv4.Boolean `json:"suggest,omitempty"` } +type setIssueFieldValueMutation struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + URL githubv4.String + } + } `graphql:"setIssueFieldValue(input: $input)"` +} + +// SetIssueFieldValues updates Issue Field values and returns the updated issue. +func SetIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, input SetIssueFieldValueInput) (MinimalResponse, error) { + var mutation setIssueFieldValueMutation + 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,31 +1507,6 @@ 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, @@ -1519,14 +1515,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv // 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, mutationInput) + 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..857b4d7b37 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1192,23 +1192,7 @@ func getProjectField(ctx context.Context, client *github.Client, owner, ownerTyp } func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { - var resp *github.Response - var projectItem *github.ProjectV2Item - var opts *github.GetProjectItemOptions - var err error - - if len(fields) > 0 { - opts = &github.GetProjectItemOptions{ - Fields: fields, - } - } - - if ownerType == "org" { - projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) - } else { - projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) - } - + projectItem, resp, err := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project item", @@ -1234,8 +1218,29 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType return utils.NewToolResultText(string(r)), nil, nil } +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 + var err error + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + return projectItem, resp, err +} + 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) + updatePayload, issueField, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { var structured *ghErrors.StructuredResolutionError if errors.As(err, &structured) { @@ -1244,6 +1249,44 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultError(err.Error()), nil, nil } + if issueField != nil { + projectItem, resp, fetchErr := fetchProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, nil) + if fetchErr != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project item", resp, fetchErr), nil, nil + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil + } + + issueID, resolveErr := projectItemIssueID(projectItem) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + + response, mutationErr := SetIssueFieldValues(ctx, gqlClient, SetIssueFieldValueInput{ + IssueID: issueID, + IssueFields: []IssueFieldCreateOrUpdateInput{*issueField}, + }) + if mutationErr != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field value", 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 @@ -1277,6 +1320,42 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi return utils.NewToolResultText(string(r)), nil, nil } +func projectItemIssueID(item *github.ProjectV2Item) (githubv4.ID, error) { + if item == nil { + return nil, ghErrors.NewStructuredResolutionError( + "missing_metadata", + "", + "project item metadata is missing", + nil, + ) + } + + contentType := "" + if item.ContentType != nil { + contentType = string(*item.ContentType) + } + if contentType != string(github.ProjectV2ItemContentTypeIssue) { + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_item_type", + contentType, + "attached Issue Fields can only be updated on Issue project items", + nil, + ) + } + + content := item.GetContent() + if content == nil || content.GetIssue() == nil || content.GetIssue().GetNodeID() == "" { + return nil, ghErrors.NewStructuredResolutionError( + "missing_metadata", + contentType, + "project Issue item is missing its Issue node ID", + nil, + ) + } + + return githubv4.ID(content.GetIssue().GetNodeID()), nil +} + func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error @@ -1614,15 +1693,15 @@ 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) { +// buildUpdateProjectItem builds either a standard Project update or an attached Issue Field update. +func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, *IssueFieldCreateOrUpdateInput, error) { if input == nil { - return nil, fmt.Errorf("updated_field must be an object") + return nil, nil, fmt.Errorf("updated_field must be an object") } valueField, hasValue := input["value"] if !hasValue { - return nil, fmt.Errorf("updated_field.value is required") + return nil, nil, fmt.Errorf("updated_field.value is required") } idField, hasID := input["id"] @@ -1630,9 +1709,9 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own switch { case hasID && hasName: - return nil, fmt.Errorf("updated_field must set either id or name, not both") + return nil, nil, fmt.Errorf("updated_field must set either id or name, not both") case !hasID && !hasName: - return nil, fmt.Errorf("updated_field requires either id or name") + return nil, nil, fmt.Errorf("updated_field requires either id or name") } var ( @@ -1644,24 +1723,37 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own var err error fieldID, err = validateAndConvertToInt64(idField) if err != nil { - return nil, fmt.Errorf("updated_field.id: %w", err) + return nil, nil, fmt.Errorf("updated_field.id: %w", err) } } else { fieldName, ok := nameField.(string) if !ok || fieldName == "" { - return nil, fmt.Errorf("updated_field.name must be a non-empty string") + return nil, nil, fmt.Errorf("updated_field.name must be a non-empty string") } if gqlClient == nil { - return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") + return nil, 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, "") if err != nil { - return nil, err + return nil, nil, err + } + if supportsIssueFieldUpdate(resolved.DataType) { + resolved, err = resolveIssueFieldForUpdate(ctx, gqlClient, owner, ownerType, projectNumber, resolved) + if err != nil { + return nil, nil, err + } + if resolved.IsIssueField { + issueField, buildErr := buildIssueFieldUpdate(resolved, valueField) + if buildErr != nil { + return nil, nil, buildErr + } + return nil, issueField, nil + } } parsedID, parseErr := parseInt64(resolved.ID) if parseErr != nil { - return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) + return nil, nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) } fieldID = parsedID } @@ -1681,7 +1773,7 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own } } if !known { - return nil, optErr + return nil, nil, optErr } } } @@ -1694,7 +1786,87 @@ func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, own }}, } - return payload, nil + return payload, nil, nil +} + +func supportsIssueFieldUpdate(dataType string) bool { + switch dataType { + case "TEXT", "NUMBER", "DATE", "SINGLE_SELECT": + return true + default: + return false + } +} + +func buildIssueFieldUpdate(field *ResolvedField, value any) (*IssueFieldCreateOrUpdateInput, error) { + if !supportsIssueFieldUpdate(field.DataType) { + return nil, ghErrors.NewStructuredResolutionError( + "unsupported_field_type", + field.Name, + fmt.Sprintf("attached Issue Field %q has unsupported data type %q", field.Name, field.DataType), + nil, + ) + } + + if field.IssueFieldID == "" { + return nil, ghErrors.NewStructuredResolutionError( + "missing_field_metadata", + field.Name, + fmt.Sprintf("attached Issue Field %q is missing its Issue Field node ID", field.Name), + nil, + ) + } + + input := &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.IssueFieldID)} + if value == nil { + input.Delete = githubv4.NewBoolean(githubv4.Boolean(true)) + return input, nil + } + + switch field.DataType { + case "TEXT": + text, ok := value.(string) + if !ok { + return nil, invalidIssueFieldValue(field, "value must be a string") + } + input.TextValue = githubv4.NewString(githubv4.String(text)) + case "NUMBER": + number, ok := toFloat64(value) + if !ok { + return nil, invalidIssueFieldValue(field, "value must be a number") + } + input.NumberValue = githubv4.NewFloat(githubv4.Float(number)) + case "DATE": + date, ok := value.(string) + if !ok { + return nil, invalidIssueFieldValue(field, "value must be a date string in YYYY-MM-DD format") + } + if _, err := time.Parse(time.DateOnly, date); err != nil { + return nil, invalidIssueFieldValue(field, "value must be a valid date in YYYY-MM-DD format") + } + input.DateValue = githubv4.NewString(githubv4.String(date)) + case "SINGLE_SELECT": + optionName, ok := value.(string) + if !ok || optionName == "" { + return nil, invalidIssueFieldValue(field, "value must be a non-empty option name") + } + optionID, err := resolveSingleSelectOptionByName(field, optionName) + if err != nil { + return nil, err + } + input.SingleSelectOptionID = githubv4.NewID(githubv4.ID(optionID)) + } + + return input, nil +} + +func invalidIssueFieldValue(field *ResolvedField, hint string) error { + return ghErrors.NewStructuredResolutionError( + "invalid_field_value", + field.Name, + fmt.Sprintf("invalid value for attached Issue Field %q: %s", field.Name, hint), + nil, + ) } func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 3643d6eafa..1c9ba9fdcb 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" ) @@ -28,6 +29,9 @@ type ResolvedField struct { Name string DataType string Options []ResolvedFieldOption + + IsIssueField bool + IssueFieldID string } // projectFieldsQueryOrg fetches all fields on an org-owned project (paginated). @@ -216,6 +220,164 @@ func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, return &field, nil } +type projectIssueFieldMetadata struct { + IssueFieldText struct{ ID githubv4.ID } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct{ ID githubv4.ID } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct{ ID githubv4.ID } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + Options []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +type projectIssueFieldMetadataConnection struct { + Nodes []struct { + TypeName githubv4.String `graphql:"__typename"` + ProjectV2Field struct { + DatabaseID githubv4.Int `graphql:"databaseId"` + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + } `graphql:"... on ProjectV2Field"` + ProjectV2SingleSelectField struct { + DatabaseID githubv4.Int `graphql:"databaseId"` + IsIssueField githubv4.Boolean + IssueField projectIssueFieldMetadata + } `graphql:"... on ProjectV2SingleSelectField"` + } + PageInfo PageInfoFragment +} + +type projectIssueFieldMetadataQueryOrg struct { + Organization struct { + ProjectV2 struct { + Fields projectIssueFieldMetadataConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +type projectIssueFieldMetadataQueryUser struct { + User struct { + ProjectV2 struct { + Fields projectIssueFieldMetadataConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +func resolveIssueFieldForUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, resolved *ResolvedField) (*ResolvedField, error) { + field := *resolved + var after *githubv4.String + + for { + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small + "first": githubv4.Int(resolverFieldsPageSize), + "after": (*githubv4.String)(nil), + } + if after != nil { + vars["after"] = after + } + + var conn projectIssueFieldMetadataConnection + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields") + var queryErr error + if ownerType == "org" { + var q projectIssueFieldMetadataQueryOrg + queryErr = gqlClient.Query(ctxWithFeatures, &q, vars) + conn = q.Organization.ProjectV2.Fields + } else { + var q projectIssueFieldMetadataQueryUser + queryErr = gqlClient.Query(ctxWithFeatures, &q, vars) + conn = q.User.ProjectV2.Fields + } + if queryErr != nil { + if isMissingIssueFieldSchemaError(queryErr) { + return &field, nil + } + return nil, fmt.Errorf("failed to query project Issue Field metadata: %w", queryErr) + } + + for _, node := range conn.Nodes { + switch string(node.TypeName) { + case "ProjectV2Field": + if fmt.Sprintf("%d", node.ProjectV2Field.DatabaseID) == field.ID { + enrichIssueField(&field, bool(node.ProjectV2Field.IsIssueField), node.ProjectV2Field.IssueField) + return &field, nil + } + case "ProjectV2SingleSelectField": + if fmt.Sprintf("%d", node.ProjectV2SingleSelectField.DatabaseID) == field.ID { + enrichIssueField(&field, bool(node.ProjectV2SingleSelectField.IsIssueField), node.ProjectV2SingleSelectField.IssueField) + return &field, nil + } + } + } + + if !bool(conn.PageInfo.HasNextPage) { + break + } + end := conn.PageInfo.EndCursor + after = &end + } + + return nil, ghErrors.NewStructuredResolutionError( + "missing_field_metadata", + field.Name, + fmt.Sprintf("resolved field %q is missing update metadata", field.Name), + nil, + ) +} + +func enrichIssueField(field *ResolvedField, isIssueField bool, metadata projectIssueFieldMetadata) { + if !isIssueField { + return + } + field.IsIssueField = true + + switch field.DataType { + case "TEXT": + field.IssueFieldID = graphqlIDString(metadata.IssueFieldText.ID) + case "NUMBER": + field.IssueFieldID = graphqlIDString(metadata.IssueFieldNumber.ID) + case "DATE": + field.IssueFieldID = graphqlIDString(metadata.IssueFieldDate.ID) + case "SINGLE_SELECT": + field.IssueFieldID = graphqlIDString(metadata.IssueFieldSingleSelect.ID) + field.Options = make([]ResolvedFieldOption, 0, len(metadata.IssueFieldSingleSelect.Options)) + for _, option := range metadata.IssueFieldSingleSelect.Options { + field.Options = append(field.Options, ResolvedFieldOption{ + ID: graphqlIDString(option.ID), + Name: string(option.Name), + }) + } + } +} + +func graphqlIDString(id githubv4.ID) string { + if id == nil { + return "" + } + return fmt.Sprintf("%v", id) +} + +func isMissingIssueFieldSchemaError(err error) bool { + switch err.Error() { + case "Field 'isIssueField' doesn't exist on type 'ProjectV2Field'", + "Field 'issueField' doesn't exist on type 'ProjectV2Field'", + "Field 'isIssueField' doesn't exist on type 'ProjectV2SingleSelectField'", + "Field 'issueField' doesn't exist on type 'ProjectV2SingleSelectField'", + "No such type IssueFieldText, so it cannot be a fragment condition", + "No such type IssueFieldNumber, so it cannot be a fragment condition", + "No such type IssueFieldDate, so it cannot be a fragment condition", + "No such type IssueFieldSingleSelect, so it cannot be a fragment condition": + return true + default: + return false + } +} + // resolveSingleSelectOptionByName resolves an option name to its ID on a // SINGLE_SELECT field. Returns a structured error if not found or ambiguous. func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (string, error) { diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index b08e00cac6..8b11690abe 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -139,6 +141,119 @@ func Test_ResolveProjectFieldByName_Success(t *testing.T) { assert.Equal(t, "OPT_b", optionID) } +func Test_ResolveIssueFieldForUpdate(t *testing.T) { + tests := []struct { + name string + resolved ResolvedField + databaseID int + typeName string + issueField map[string]any + wantID string + wantOption ResolvedFieldOption + }{ + {name: "text", resolved: ResolvedField{ID: "101", Name: "Customer", DataType: "TEXT"}, databaseID: 101, typeName: "ProjectV2Field", issueField: map[string]any{"id": "IF_TEXT"}, wantID: "IF_TEXT"}, + { + name: "single select", resolved: ResolvedField{ID: "102", Name: "Impact", DataType: "SINGLE_SELECT"}, + databaseID: 102, typeName: "ProjectV2SingleSelectField", + issueField: map[string]any{"id": "IF_SELECT", "options": []any{map[string]any{"id": "OPT_HIGH", "name": "High"}}}, + wantID: "IF_SELECT", + wantOption: ResolvedFieldOption{ID: "OPT_HIGH", Name: "High"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher(projectIssueFieldMetadataQueryOrg{}, fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(issueFieldMetadataResponse(tt.typeName, tt.databaseID, true, tt.issueField))), + ) + capture := &headerCaptureTransport{inner: mocked.Transport} + gql := githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: capture}}) + + field, err := resolveIssueFieldForUpdate(context.Background(), gql, "octo-org", "org", 7, &tt.resolved) + require.NoError(t, err) + assert.True(t, field.IsIssueField) + assert.Equal(t, tt.wantID, field.IssueFieldID) + if tt.wantOption.ID != "" { + assert.Equal(t, []ResolvedFieldOption{tt.wantOption}, field.Options) + } + assert.Equal(t, "issue_fields", capture.captured.Get(headers.GraphQLFeaturesHeader)) + }) + } +} + +func Test_ResolveIssueFieldForUpdate_ErrorHandling(t *testing.T) { + for _, tt := range []struct { + name, message string + fallback bool + }{ + {name: "missing schema falls back", message: "Field 'isIssueField' doesn't exist on type 'ProjectV2Field'", fallback: true}, + {name: "missing text fragment type falls back", message: "No such type IssueFieldText, so it cannot be a fragment condition", fallback: true}, + {name: "missing number fragment type falls back", message: "No such type IssueFieldNumber, so it cannot be a fragment condition", fallback: true}, + {name: "missing date fragment type falls back", message: "No such type IssueFieldDate, so it cannot be a fragment condition", fallback: true}, + {name: "missing single select fragment type falls back", message: "No such type IssueFieldSingleSelect, so it cannot be a fragment condition", fallback: true}, + {name: "unknown fragment type propagates", message: "No such type IssueFieldMultiSelect, so it cannot be a fragment condition"}, + {name: "unrelated error propagates", message: "Resource not accessible by integration"}, + } { + t.Run(tt.name, func(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher( + projectIssueFieldMetadataQueryOrg{}, fieldsQueryVars("octo-org", 7), githubv4mock.ErrorResponse(tt.message), + )) + resolved := &ResolvedField{ID: "101", Name: "Status", DataType: "SINGLE_SELECT"} + field, err := resolveIssueFieldForUpdate(context.Background(), githubv4.NewClient(mocked), "octo-org", "org", 7, resolved) + if tt.fallback { + require.NoError(t, err) + assert.Equal(t, resolved, field) + } else { + require.ErrorContains(t, err, tt.message) + } + }) + } + + t.Run("supported type missing metadata still fails", func(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher( + projectIssueFieldMetadataQueryOrg{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse(nil)), + )) + resolved := &ResolvedField{ID: "101", Name: "Customer", DataType: "TEXT"} + + _, err := resolveIssueFieldForUpdate(context.Background(), githubv4.NewClient(mocked), "octo-org", "org", 7, resolved) + require.ErrorContains(t, err, "missing_field_metadata") + }) +} + +func Test_ResolveFieldNamesToIDs_QueryRemainsIssueFieldUngated(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + genericFieldNode("PVTF_text", 101, "Customer", "TEXT"), + })), + ), + ) + capture := &headerCaptureTransport{inner: mocked.Transport} + gql := githubv4.NewClient(&http.Client{Transport: &transportpkg.GraphQLFeaturesTransport{Transport: capture}}) + + ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Customer"}) + require.NoError(t, err) + assert.Equal(t, []int64{101}, ids) + assert.Empty(t, capture.captured.Get(headers.GraphQLFeaturesHeader)) +} + +func issueFieldMetadataResponse(typeName string, databaseID any, isIssueField bool, issueField map[string]any) map[string]any { + node := map[string]any{ + "__typename": typeName, + "databaseId": databaseID, + "isIssueField": isIssueField, + } + if issueField != nil { + node["issueField"] = issueField + } + return fieldsResponse([]map[string]any{node}) +} + func Test_ResolveProjectFieldByName_NodeIDsForAllVariants(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -740,6 +855,30 @@ func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) { }), })), ), + // 4. supplemental update metadata confirms this is a standard Project field + githubv4mock.NewQueryMatcher( + projectIssueFieldMetadataQueryOrg{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "fields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "ProjectV2SingleSelectField", + "databaseId": 101, + "isIssueField": false, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), ) gqlClient := githubv4.NewClient(mockedGQL) @@ -764,6 +903,60 @@ func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) { require.False(t, result.IsError, getTextResult(t, result).Text) } +func Test_ProjectsWrite_UpdateProjectItem_ByNameIteration(t *testing.T) { + updatedItem := verbosePullRequestProjectItemFixture() + restCalled := false + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, r *http.Request) { + restCalled = true + var update struct { + Fields []struct { + ID int64 `json:"id"` + Value any `json:"value"` + } `json:"fields"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&update)) + require.Len(t, update.Fields, 1) + assert.Equal(t, int64(222), update.Fields[0].ID) + assert.Equal(t, "ITERATION_1", update.Fields[0].Value) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(updatedItem)) + }, + }) + mockedGQL := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + iterationFieldNode("PVTIF_iteration1", 222, "Sprint"), + })), + ), + ) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedREST), + GQLClient: githubv4.NewClient(mockedGQL), + } + toolDef := ProjectsWrite(translations.NullTranslationHelper) + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "name": "Sprint", + "value": "ITERATION_1", + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.True(t, restCalled) +} + func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 92de4a5d5f..4a3eacaeb3 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -8,7 +8,10 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -1283,6 +1286,166 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { }) } +func Test_ProjectItemReads_FieldNamesIncludeIssueFieldValues(t *testing.T) { + item := issueProjectItemFixture("Issue") + tests := []struct { + name string + tool inventory.ServerTool + method string + restPath string + response any + }{ + {name: "get project item", tool: ProjectsGet(translations.NullTranslationHelper), method: "get_project_item", restPath: GetOrgsProjectsV2ItemsByProjectByItemID, response: item}, + {name: "list project items", tool: ProjectsList(translations.NullTranslationHelper), method: "list_project_items", restPath: GetOrgsProjectsV2ItemsByProject, response: []any{item}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.restPath: mockResponse(t, http.StatusOK, tt.response), + })) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + genericFieldNode("PVTF_customer", 101, "Customer", "TEXT"), + })), + ), + )) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := tt.tool.Handler(deps) + args := map[string]any{"method": tt.method, "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "field_names": []any{"Customer"}} + if tt.method == "get_project_item" { + args["item_id"] = float64(1001) + } + + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + if tt.method == "list_project_items" { + response = response["items"].([]any)[0].(map[string]any) + } + fields := response["fields"].([]any) + require.Len(t, fields, 1) + assert.Equal(t, "Customer", fields[0].(map[string]any)["name"]) + assert.Equal(t, "Acme", fields[0].(map[string]any)["value"]) + }) + } +} + +func Test_ProjectsWrite_UpdateProjectItem_AttachedIssueFieldDispatch(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{genericFieldNode("PVTF_field", 101, "Customer", "TEXT")})), + ), + githubv4mock.NewQueryMatcher( + projectIssueFieldMetadataQueryOrg{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(issueFieldMetadataResponse( + "ProjectV2Field", 101, true, map[string]any{"id": "IF_TEXT"}, + )), + ), + githubv4mock.NewMutationMatcher( + setIssueFieldValueMutation{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_1"), + IssueFields: []IssueFieldCreateOrUpdateInput{{ + FieldID: githubv4.ID("IF_TEXT"), + TextValue: githubv4.NewString("Acme"), + }}, + }, + nil, + githubv4mock.DataResponse(map[string]any{"setIssueFieldValue": map[string]any{ + "issue": map[string]any{"id": "ISSUE_1", "url": "https://github.com/octo-org/repo/issues/1"}, + }}), + ), + )) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, issueProjectItemFixture("Issue")), + })) + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + tool := ProjectsWrite(translations.NullTranslationHelper) + handler := tool.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", "owner": "octo-org", "owner_type": "org", + "project_number": float64(1), "item_id": float64(1001), + "updated_field": map[string]any{"name": "Customer", "value": "Acme"}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + assert.JSONEq(t, `{"id":"ISSUE_1","url":"https://github.com/octo-org/repo/issues/1"}`, getTextResult(t, result).Text) +} + +func Test_BuildIssueFieldUpdate(t *testing.T) { + selectField := ResolvedField{ + Name: "Impact", DataType: "SINGLE_SELECT", IssueFieldID: "IF_SELECT", + Options: []ResolvedFieldOption{{ID: "OPT_HIGH", Name: "High"}}, + } + tests := []struct { + name string + field ResolvedField + value any + kind string + want *IssueFieldCreateOrUpdateInput + }{ + {name: "text", field: ResolvedField{Name: "Customer", DataType: "TEXT", IssueFieldID: "IF_TEXT"}, value: "Acme", want: &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID("IF_TEXT"), TextValue: githubv4.NewString("Acme")}}, + {name: "number", field: ResolvedField{Name: "Score", DataType: "NUMBER", IssueFieldID: "IF_NUMBER"}, value: float64(42.5), want: &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID("IF_NUMBER"), NumberValue: githubv4.NewFloat(42.5)}}, + {name: "date", field: ResolvedField{Name: "Target", DataType: "DATE", IssueFieldID: "IF_DATE"}, value: "2026-07-27", want: &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID("IF_DATE"), DateValue: githubv4.NewString("2026-07-27")}}, + {name: "single select name", field: selectField, value: "high", want: &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID("IF_SELECT"), SingleSelectOptionID: githubv4.NewID("OPT_HIGH")}}, + {name: "clear", field: ResolvedField{Name: "Customer", DataType: "TEXT", IssueFieldID: "IF_TEXT"}, value: nil, want: &IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID("IF_TEXT"), Delete: githubv4.NewBoolean(true)}}, + {name: "invalid text", field: ResolvedField{Name: "Customer", DataType: "TEXT", IssueFieldID: "IF_TEXT"}, value: 42, kind: "invalid_field_value"}, + {name: "invalid number", field: ResolvedField{Name: "Score", DataType: "NUMBER", IssueFieldID: "IF_NUMBER"}, value: "42", kind: "invalid_field_value"}, + {name: "invalid date", field: ResolvedField{Name: "Target", DataType: "DATE", IssueFieldID: "IF_DATE"}, value: "2026-02-30", kind: "invalid_field_value"}, + {name: "option ID rejected", field: selectField, value: "OPT_HIGH", kind: "option_not_found"}, + {name: "missing metadata", field: ResolvedField{Name: "Customer", DataType: "TEXT"}, value: "Acme", kind: "missing_field_metadata"}, + {name: "unsupported type", field: ResolvedField{Name: "Related", DataType: "MULTI_SELECT", IssueFieldID: "IF_MULTI"}, value: "one", kind: "unsupported_field_type"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildIssueFieldUpdate(&tt.field, tt.value) + if tt.kind == "" { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + return + } + var structured *ghErrors.StructuredResolutionError + require.ErrorAs(t, err, &structured) + assert.Equal(t, tt.kind, structured.Kind) + }) + } +} + +func Test_ProjectItemIssueID_RejectsNonIssueItems(t *testing.T) { + for _, contentType := range []string{"PullRequest", "DraftIssue"} { + t.Run(contentType, func(t *testing.T) { + item := &gogithub.ProjectV2Item{ContentType: gogithub.Ptr(gogithub.ProjectV2ItemContentType(contentType))} + _, err := projectItemIssueID(item) + var structured *ghErrors.StructuredResolutionError + require.ErrorAs(t, err, &structured) + assert.Equal(t, "unsupported_item_type", structured.Kind) + }) + } +} + +func issueProjectItemFixture(contentType string) map[string]any { + return map[string]any{ + "id": 1001, "node_id": "PVTI_1", "content_type": contentType, + "content": map[string]any{"node_id": "ISSUE_1"}, + "fields": []any{map[string]any{"id": 101, "name": "Customer", "data_type": "text", "value": "Acme"}}, + } +} + func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper)