diff --git a/README.md b/README.md index b387b61f1..1b99bb675 100644 --- a/README.md +++ b/README.md @@ -855,6 +855,7 @@ The following sets of tools are available: - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. diff --git a/docs/feature-flags.md b/docs/feature-flags.md index a552e71a0..2d00e6ad9 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -56,6 +56,7 @@ runtime behavior (such as output formatting) won't appear here. - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. @@ -177,6 +178,7 @@ runtime behavior (such as output formatting) won't appear here. - **update_issue_type** - Update Issue Type - **Required OAuth Scopes**: `repo` + - `is_suggestion`: If true, propose the issue type change instead of applying it. Defaults to false, which applies the change to the issue. (boolean, optional) - `issue_number`: The issue number to update (number, required) - `issue_type`: The issue type to set (string, required) - `owner`: Repository owner (username or organization) (string, required) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index c221b8758..c102138df 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -50,6 +50,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index a125864f0..2248dad90 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -29,6 +29,57 @@ "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", "type": "number" }, + "issue_fields": { + "description": "Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'.", + "items": { + "additionalProperties": false, + "oneOf": [ + { + "not": { + "required": [ + "field_option_name" + ] + }, + "required": [ + "value" + ] + }, + { + "not": { + "required": [ + "value" + ] + }, + "required": [ + "field_option_name" + ] + } + ], + "properties": { + "field_name": { + "description": "Issue field name", + "type": "string" + }, + "field_option_name": { + "description": "Option name for single-select fields — validates the option exists in the field definition before setting it.", + "type": "string" + }, + "value": { + "description": "Value to set. For single-select fields, prefer 'field_option_name' to validate the option exists first.", + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "required": [ + "field_name" + ], + "type": "object" + }, + "type": "array" + }, "issue_number": { "description": "Issue number to update", "type": "number" diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index a7b7c429d..1eabbc02f 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -19,6 +20,7 @@ import ( // IssueField represents a repository issue field definition. type IssueField struct { ID string `json:"id"` + DatabaseID int64 `json:"full_database_id,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` DataType string `json:"data_type"` @@ -37,36 +39,42 @@ type IssueSingleSelectFieldOption struct { // issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. // Only the fragment matching __typename is populated; read from the matching fragment. +// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because +// shurcooL/githubv4 does not support interface fragments at the top level of a union. type issueFieldNode struct { TypeName githubv4.String `graphql:"__typename"` IssueFieldText struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldText"` IssueFieldNumber struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldNumber"` IssueFieldDate struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldDate"` IssueFieldSingleSelect struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - Options []struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { ID githubv4.ID Name githubv4.String Description githubv4.String @@ -200,6 +208,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { } f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)), Name: string(node.IssueFieldSingleSelect.Name), Description: string(node.IssueFieldSingleSelect.Description), DataType: string(node.IssueFieldSingleSelect.DataType), @@ -209,6 +218,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldText": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)), Name: string(node.IssueFieldText.Name), Description: string(node.IssueFieldText.Description), DataType: string(node.IssueFieldText.DataType), @@ -217,6 +227,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldNumber": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)), Name: string(node.IssueFieldNumber.Name), Description: string(node.IssueFieldNumber.Description), DataType: string(node.IssueFieldNumber.DataType), @@ -225,6 +236,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldDate": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)), Name: string(node.IssueFieldDate.Name), Description: string(node.IssueFieldDate.Description), DataType: string(node.IssueFieldDate.DataType), @@ -237,3 +249,16 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { } return fields } + +// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64. +// Returns 0 if the string is empty or cannot be parsed. +func parseFullDatabaseID(s string) int64 { + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 238c0455b..2c2b26ee2 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -75,12 +75,13 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldText", - "id": "IFT_1", - "name": "DRI", - "description": "Directly responsible individual", - "dataType": "TEXT", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "42", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", }, }, }, @@ -89,6 +90,7 @@ func Test_ListIssueFields(t *testing.T) { expectedFields: []IssueField{ { ID: "IFT_1", + DatabaseID: 42, Name: "DRI", Description: "Directly responsible individual", DataType: "TEXT", @@ -107,12 +109,13 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldSingleSelect", - "id": "IFSS_1", - "name": "Priority", - "description": "Level of importance", - "dataType": "SINGLE_SELECT", - "visibility": "ALL", + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "fullDatabaseId": "99", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", "options": []any{ map[string]any{ "id": "OPT_1", @@ -133,6 +136,7 @@ func Test_ListIssueFields(t *testing.T) { expectedFields: []IssueField{ { ID: "IFSS_1", + DatabaseID: 99, Name: "Priority", Description: "Level of importance", DataType: "SINGLE_SELECT", @@ -165,18 +169,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldText", - "id": "IFT_1", - "name": "DRI", - "dataType": "TEXT", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "77", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + {ID: "IFT_1", DatabaseID: 77, Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, }, }, { @@ -190,18 +195,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldNumber", - "id": "IFN_1", - "name": "Engineering Staffing", - "dataType": "NUMBER", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "fullDatabaseId": "101", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFN_1", Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + {ID: "IFN_1", DatabaseID: 101, Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, }, }, { @@ -215,18 +221,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldDate", - "id": "IFD_1", - "name": "Target Date", - "dataType": "DATE", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldDate", + "id": "IFD_1", + "fullDatabaseId": "202", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFD_1", Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + {ID: "IFD_1", DatabaseID: 202, Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, }, }, { @@ -284,6 +291,7 @@ func Test_ListIssueFields(t *testing.T) { require.Equal(t, len(tc.expectedFields), len(returnedFields)) for i, expected := range tc.expectedFields { assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.DatabaseID, returnedFields[i].DatabaseID) assert.Equal(t, expected.Name, returnedFields[i].Name) assert.Equal(t, expected.DataType, returnedFields[i].DataType) assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0074bbd58..4cc354029 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -37,6 +37,14 @@ type CloseIssueInput struct { // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type IssueClosedStateReason string +// issueWriteFieldInput is a user-friendly issue field input for issue_write. +// Field IDs and option IDs are resolved internally before calling the REST API. +type issueWriteFieldInput struct { + FieldName string + Value any + FieldOptionName string +} + const ( IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" @@ -105,6 +113,46 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } +// issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's +// fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation. +// shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat +// fullDatabaseId on each concrete type; all four implement IssueFieldCommon. +type issueFieldWriteMetadataNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +type issueFieldWriteMetadataQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldWriteMetadataNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + // IssueFieldRef resolves the name of an issue field across its concrete types. // IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, // so we have to ask for `name` on each member. @@ -153,6 +201,155 @@ type IssueFieldValueFragment struct { } `graphql:"... on IssueFieldTextValue"` } +func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, error) { + issueFieldsRaw, exists := args["issue_fields"] + if !exists { + return nil, nil + } + + var inputMaps []map[string]any + switch v := issueFieldsRaw.(type) { + case []any: + for _, item := range v { + itemMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("each issue_fields item must be an object") + } + inputMaps = append(inputMaps, itemMap) + } + case []map[string]any: + inputMaps = v + default: + return nil, fmt.Errorf("issue_fields must be an array") + } + + issueFields := make([]issueWriteFieldInput, 0, len(inputMaps)) + for _, itemMap := range inputMaps { + fieldName, err := RequiredParam[string](itemMap, "field_name") + if err != nil || strings.TrimSpace(fieldName) == "" { + return nil, fmt.Errorf("field_name is required for each issue_fields item") + } + + fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name") + if err != nil { + return nil, err + } + + value, hasValue := itemMap["value"] + if hasValue && value == nil { + return nil, fmt.Errorf("value cannot be null for field %q", fieldName) + } + + if hasValue && fieldOptionName != "" { + return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName) + } + + if !hasValue && fieldOptionName == "" { + return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName) + } + + issueFields = append(issueFields, issueWriteFieldInput{ + FieldName: fieldName, + Value: value, + FieldOptionName: fieldOptionName, + }) + } + + return issueFields, nil +} + +func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) { + if len(issueFields) == 0 { + return nil, nil + } + + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + var query issueFieldWriteMetadataQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, fmt.Errorf("failed to query issue fields metadata: %w", err) + } + + // Build name → node map, dispatching on concrete type to extract name. + fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes)) + for _, node := range query.Repository.IssueFields.Nodes { + var name string + switch string(node.TypeName) { + case "IssueFieldText": + name = string(node.IssueFieldText.Name) + case "IssueFieldNumber": + name = string(node.IssueFieldNumber.Name) + case "IssueFieldDate": + name = string(node.IssueFieldDate.Name) + case "IssueFieldSingleSelect": + name = string(node.IssueFieldSingleSelect.Name) + default: + continue + } + fieldByName[strings.ToLower(strings.TrimSpace(name))] = node + } + + resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields)) + for _, fieldInput := range issueFields { + node, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] + if !ok { + return nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo) + } + + var fullDatabaseIDStr, dataType string + switch string(node.TypeName) { + case "IssueFieldText": + fullDatabaseIDStr = string(node.IssueFieldText.FullDatabaseID) + dataType = string(node.IssueFieldText.DataType) + case "IssueFieldNumber": + fullDatabaseIDStr = string(node.IssueFieldNumber.FullDatabaseID) + dataType = string(node.IssueFieldNumber.DataType) + case "IssueFieldDate": + fullDatabaseIDStr = string(node.IssueFieldDate.FullDatabaseID) + dataType = string(node.IssueFieldDate.DataType) + case "IssueFieldSingleSelect": + fullDatabaseIDStr = string(node.IssueFieldSingleSelect.FullDatabaseID) + dataType = string(node.IssueFieldSingleSelect.DataType) + } + + fieldID := parseFullDatabaseID(fullDatabaseIDStr) + if fieldID == 0 { + return nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName) + } + + resolvedValue := fieldInput.Value + if fieldInput.FieldOptionName != "" { + if !strings.EqualFold(dataType, "single_select") { + return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType) + } + + optionFound := false + for _, option := range node.IssueFieldSingleSelect.Options { + if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) { + // REST API expects the option name, not the ID + resolvedValue = string(option.Name) + optionFound = true + break + } + } + + if !optionFound { + return nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName) + } + } + + resolved = append(resolved, &github.IssueRequestFieldValue{ + FieldID: fieldID, + Value: resolvedValue, + }) + } + + return resolved, nil +} + // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { Number githubv4.Int @@ -562,6 +759,17 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, minimalIssue := convertToMinimalIssue(issue) + // Enrich with field_values via GraphQL for consistency with list_issues/search_issues + if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { + gqlClient, err := deps.GetGQLClient(ctx) + if err == nil { + if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil { + minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID] + minimalIssue.IssueFieldValues = nil // Clear verbose REST format + } + } + } + return MarshalledTextResult(minimalIssue), nil } @@ -1266,7 +1474,7 @@ func parseRepositoryURL(repoURL string) (string, string, bool) { // SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query. type SearchIssueResult struct { *github.Issue - FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` } // MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the @@ -1315,7 +1523,7 @@ type searchIssuesNodesQuery struct { // fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and // returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and // an empty result set short-circuits the round-trip. -func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) { +func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalFieldValue, error) { ids := make([]githubv4.ID, 0, len(issues)) for _, iss := range issues { if iss == nil || iss.NodeID == nil || *iss.NodeID == "" { @@ -1332,15 +1540,15 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie return nil, err } - result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes)) + result := make(map[string][]MinimalFieldValue, len(q.Nodes)) for _, n := range q.Nodes { idStr, ok := n.Issue.ID.(string) if !ok || idStr == "" { continue } - vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) + vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) for _, fv := range n.Issue.IssueFieldValues.Nodes { - if m, ok := fragmentToMinimalIssueFieldValue(fv); ok { + if m, ok := fragmentToMinimalFieldValue(fv); ok { vals = append(vals, m) } } @@ -1378,8 +1586,8 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } - var fieldValuesByID map[string][]MinimalIssueFieldValue - if deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) && len(result.Issues) > 0 { + var fieldValuesByID map[string][]MinimalFieldValue + if len(result.Issues) > 0 { gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil @@ -1509,6 +1717,39 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + "issue_fields": { + Type: "array", + Description: "Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Issue field name", + }, + "value": { + Types: []string{"string", "number", "boolean"}, + Description: "Value to set. For single-select fields, prefer 'field_option_name' to validate the option exists first.", + }, + "field_option_name": { + Type: "string", + Description: "Option name for single-select fields — validates the option exists in the field definition before setting it.", + }, + }, + Required: []string{"field_name"}, + OneOf: []*jsonschema.Schema{ + { + Required: []string{"value"}, + Not: &jsonschema.Schema{Required: []string{"field_option_name"}}, + }, + { + Required: []string{"field_option_name"}, + Not: &jsonschema.Schema{Required: []string{"value"}}, + }, + }, + }, + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -1610,6 +1851,11 @@ Options are: return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil } + issueFields, err := optionalIssueWriteFields(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -1620,16 +1866,21 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil } + issueFieldValues, err := resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil + } + switch method { case "create": - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) return result, nil, err case "update": issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil @@ -1639,17 +1890,18 @@ Options are: return st } -func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { +func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) { if title == "" { return utils.NewToolResultError("missing required parameter: title"), nil } // Create the issue request issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + IssueFieldValues: issueFieldValues, } if milestoneNum != 0 { @@ -1692,7 +1944,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return utils.NewToolResultText(string(r)), nil } -func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -1721,6 +1973,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Type = github.Ptr(issueType) } + if len(issueFieldValues) > 0 { + issueRequest.IssueFieldValues = issueFieldValues + } + updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 3bac59722..1634b124e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -393,6 +393,90 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { }) } +func Test_GetIssue_FieldValues(t *testing.T) { + // Verify that issue_field_values from the REST API are present in the returned object. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + SingleSelectOption: &github.IssueFieldValueSingleSelectOption{ + ID: 42, + Name: "High", + Color: "red", + }, + }, + { + IssueFieldID: 1002, + NodeID: "FV_node_2", + DataType: "text", + Value: "some text value", + }, + }, + } + + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": false}) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + GQLClient: defaultGQLClient, + RepoAccessCache: cache, + Flags: flags, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + require.Len(t, returnedIssue.IssueFieldValues, 2, "expected two issue field values") + + first := returnedIssue.IssueFieldValues[0] + assert.Equal(t, int64(1001), first.IssueFieldID) + assert.Equal(t, "FV_node_1", first.NodeID) + assert.Equal(t, "single_select", first.DataType) + assert.Equal(t, "High", first.Value) + require.NotNil(t, first.SingleSelectOption) + assert.Equal(t, int64(42), first.SingleSelectOption.ID) + assert.Equal(t, "High", first.SingleSelectOption.Name) + assert.Equal(t, "red", first.SingleSelectOption.Color) + + second := returnedIssue.IssueFieldValues[1] + assert.Equal(t, int64(1002), second.IssueFieldID) + assert.Equal(t, "FV_node_2", second.NodeID) + assert.Equal(t, "text", second.DataType) + assert.Equal(t, "some text value", second.Value) + assert.Nil(t, second.SingleSelectOption) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) @@ -1103,7 +1187,7 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { require.Equal(t, 2, *response.Total) require.Len(t, response.Items, 2) assert.Equal(t, 42, *response.Items[0].Number) - assert.Equal(t, []MinimalIssueFieldValue{ + assert.Equal(t, []MinimalFieldValue{ {Field: "priority", Value: "P1"}, {Field: "estimate", Value: "2.5"}, }, response.Items[0].FieldValues) @@ -1128,6 +1212,7 @@ func Test_CreateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Setup mock issue for success case @@ -1144,12 +1229,13 @@ func Test_CreateIssue(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedIssue *github.Issue - expectedErrMsg string + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedIssue *github.Issue + expectedErrMsg string }{ { name: "successful issue creation with all fields", @@ -1204,6 +1290,77 @@ func Test_CreateIssue(t *testing.T) { State: github.Ptr("open"), }, }, + { + name: "successful issue creation with issue fields reconciled by names", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }, + }, { name: "issue creation fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -1221,13 +1378,32 @@ func Test_CreateIssue(t *testing.T) { expectError: false, expectedErrMsg: "missing required parameter: title", }, + { + name: "issue_fields rejects both value and field_option_name", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Invalid fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "value": "P1", "field_option_name": "P1"}, + }, + }, + expectError: false, + expectedErrMsg: "cannot specify both value and field_option_name", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) - gqlClient := githubv4.NewClient(nil) + gqlHTTPClient := tc.mockedGQLClient + if gqlHTTPClient == nil { + gqlHTTPClient = githubv4mock.NewMockedHTTPClient() + } + gqlClient := githubv4.NewClient(gqlHTTPClient) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -1811,9 +1987,9 @@ func Test_ListIssues(t *testing.T) { // (including float formatting); #789 has no field values. switch issue.Number { case 123: - assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + assert.Equal(t, []MinimalFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) case 456: - assert.Equal(t, []MinimalIssueFieldValue{ + assert.Equal(t, []MinimalFieldValue{ {Field: "due", Value: "2026-06-01"}, {Field: "estimate", Value: "2.5"}, {Field: "notes", Value: "needs triage"}, @@ -2475,6 +2651,7 @@ func Test_UpdateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases @@ -2586,6 +2763,63 @@ func Test_UpdateIssue(t *testing.T) { expectError: false, expectedIssue: mockUpdatedIssue, }, + { + name: "partial update with issue fields reconciled by names", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + "title": "Updated Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{map[string]any{"fullDatabaseId": "9001", "name": "P1"}}, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, { name: "issue not found when updating non-state fields only", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 02309db45..5ad7656f0 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -220,6 +220,31 @@ type MinimalReactions struct { Eyes int `json:"eyes"` } +// MinimalIssueFieldValueSingleSelectOption is the trimmed output type for a single-select option of an issue field value. +type MinimalIssueFieldValueSingleSelectOption struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// MinimalIssueFieldValue is the trimmed output type for a custom field value attached to an issue, +// populated from REST API responses (e.g. get_issue). For GraphQL-sourced field values see MinimalFieldValue. +type MinimalIssueFieldValue struct { + IssueFieldID int64 `json:"issue_field_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + DataType string `json:"data_type,omitempty"` + Value any `json:"value,omitempty"` + SingleSelectOption *MinimalIssueFieldValueSingleSelectOption `json:"single_select_option,omitempty"` +} + +// MinimalFieldValue is the trimmed output type for a custom field value resolved via GraphQL +// (e.g. list_issues, search_issues). Single-value variants populate Value; Values is reserved for multi-select. +type MinimalFieldValue struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` +} + // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. type MinimalIssue struct { Number int `json:"number"` @@ -242,15 +267,8 @@ type MinimalIssue struct { ClosedAt string `json:"closed_at,omitempty"` ClosedBy string `json:"closed_by,omitempty"` IssueType string `json:"issue_type,omitempty"` - FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` -} - -// MinimalIssueFieldValue is the trimmed output type for a custom issue field value. -// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select. -type MinimalIssueFieldValue struct { - Field string `json:"field"` - Value string `json:"value,omitempty"` - Values []string `json:"values,omitempty"` + IssueFieldValues []MinimalIssueFieldValue `json:"issue_field_values,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. @@ -435,6 +453,26 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m.IssueType = issueType.GetName() } + for _, fv := range issue.IssueFieldValues { + if fv == nil { + continue + } + mfv := MinimalIssueFieldValue{ + IssueFieldID: fv.IssueFieldID, + NodeID: fv.NodeID, + DataType: fv.DataType, + Value: fv.Value, + } + if opt := fv.SingleSelectOption; opt != nil { + mfv.SingleSelectOption = &MinimalIssueFieldValueSingleSelectOption{ + ID: opt.ID, + Name: opt.Name, + Color: opt.Color, + } + } + m.IssueFieldValues = append(m.IssueFieldValues, mfv) + } + if r := issue.Reactions; r != nil { m.Reactions = &MinimalReactions{ TotalCount: r.GetTotalCount(), @@ -471,7 +509,7 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { } for _, fv := range fragment.IssueFieldValues.Nodes { - if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok { + if mfv, ok := fragmentToMinimalFieldValue(fv); ok { m.FieldValues = append(m.FieldValues, mfv) } } @@ -479,32 +517,32 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { return m } -// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single +// fragmentToMinimalFieldValue flattens the union value fragment into a single // {field, value} pair. Returns ok=false if the typename is unrecognised. -func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) { +func fragmentToMinimalFieldValue(fv IssueFieldValueFragment) (MinimalFieldValue, bool) { switch fv.TypeName { case "IssueFieldDateValue": - return MinimalIssueFieldValue{ + return MinimalFieldValue{ Field: fv.DateValue.Field.Name(), Value: string(fv.DateValue.Value), }, true case "IssueFieldNumberValue": - return MinimalIssueFieldValue{ + return MinimalFieldValue{ Field: fv.NumberValue.Field.Name(), Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64), }, true case "IssueFieldSingleSelectValue": - return MinimalIssueFieldValue{ + return MinimalFieldValue{ Field: fv.SingleSelectValue.Field.Name(), Value: string(fv.SingleSelectValue.Value), }, true case "IssueFieldTextValue": - return MinimalIssueFieldValue{ + return MinimalFieldValue{ Field: fv.TextValue.Field.Name(), Value: string(fv.TextValue.Value), }, true } - return MinimalIssueFieldValue{}, false + return MinimalFieldValue{}, false } func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse {