Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,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 either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically. (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.
Expand Down
23 changes: 23 additions & 0 deletions pkg/github/__toolsnaps__/issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@
"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 either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically.",
"items": {
"properties": {
"field_name": {
"description": "Issue field name",
"type": "string"
},
"field_option_name": {
"description": "Single-select option name to resolve and set for the field",
"type": "string"
},
"value": {
"description": "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead."
}
},
"required": [
"field_name"
],
"type": "object"
},
"type": "array"
},
"issue_number": {
"description": "Issue number to update",
"type": "number"
Expand Down
203 changes: 195 additions & 8 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ 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
}

type issueFieldMetadataOption struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
}

type issueFieldMetadataNode struct {
DatabaseID githubv4.Int `graphql:"databaseId"`
Name githubv4.String
DataType githubv4.String
SingleSelectField struct {
Options []issueFieldMetadataOption `graphql:"options"`
} `graphql:"... on IssueFieldSingleSelect"`
}

type issueFieldMetadataQuery struct {
Repository struct {
IssueFields struct {
Nodes []issueFieldMetadataNode
} `graphql:"issueFields(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
Comment on lines +60 to +66

const (
IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED"
IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE"
Expand Down Expand Up @@ -103,6 +133,127 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
}
}

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
}

query := issueFieldMetadataQuery{}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
}
if err := gqlClient.Query(ctx, &query, vars); err != nil {
return nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
}

fieldByName := make(map[string]issueFieldMetadataNode, len(query.Repository.IssueFields.Nodes))
for _, field := range query.Repository.IssueFields.Nodes {
fieldByName[strings.ToLower(strings.TrimSpace(string(field.Name)))] = field
}

resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields))
for _, fieldInput := range issueFields {
field, 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)
}

fieldID := int64(field.DatabaseID)
if fieldID == 0 {
return nil, fmt.Errorf("issue field %q is missing databaseId", fieldInput.FieldName)
}

resolvedValue := fieldInput.Value
if fieldInput.FieldOptionName != "" {
if !strings.EqualFold(string(field.DataType), "single_select") {
return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, field.DataType)
}

optionFound := false
for _, option := range field.SingleSelectField.Options {
if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) {
optionID := int64(option.DatabaseID)
if optionID == 0 {
return nil, fmt.Errorf("issue field option %q on field %q is missing databaseId", fieldInput.FieldOptionName, fieldInput.FieldName)
}
resolvedValue = optionID
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
Expand Down Expand Up @@ -1171,6 +1322,27 @@ 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 either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically.",
Items: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"field_name": {
Type: "string",
Description: "Issue field name",
},
"value": {
Description: "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead.",
},
"field_option_name": {
Type: "string",
Description: "Single-select option name to resolve and set for the field",
},
},
Required: []string{"field_name"},
},
},
},
Required: []string{"method", "owner", "repo"},
},
Expand Down Expand Up @@ -1272,6 +1444,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
Expand All @@ -1282,16 +1459,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
Expand All @@ -1301,17 +1483,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 {
Expand Down Expand Up @@ -1354,7 +1537,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{}

Expand Down Expand Up @@ -1383,6 +1566,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,
Expand Down
Loading
Loading