Skip to content

Commit 562f865

Browse files
authored
add support for fields in issues write
1 parent 754c64c commit 562f865

3 files changed

Lines changed: 365 additions & 9 deletions

File tree

pkg/github/__toolsnaps__/issue_write.snap

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@
2929
"description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
3030
"type": "number"
3131
},
32+
"issue_fields": {
33+
"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.",
34+
"items": {
35+
"properties": {
36+
"field_name": {
37+
"description": "Issue field name",
38+
"type": "string"
39+
},
40+
"field_option_name": {
41+
"description": "Single-select option name to resolve and set for the field",
42+
"type": "string"
43+
},
44+
"value": {
45+
"description": "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead."
46+
}
47+
},
48+
"required": [
49+
"field_name"
50+
],
51+
"type": "object"
52+
},
53+
"type": "array"
54+
},
3255
"issue_number": {
3356
"description": "Issue number to update",
3457
"type": "number"

pkg/github/issues.go

Lines changed: 195 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,36 @@ type CloseIssueInput struct {
3535
// Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
3636
type IssueClosedStateReason string
3737

38+
// IssueWriteFieldInput is a user-friendly issue field input for issue_write.
39+
// Field IDs and option IDs are resolved internally before calling the REST API.
40+
type IssueWriteFieldInput struct {
41+
FieldName string
42+
Value any
43+
FieldOptionName string
44+
}
45+
46+
type issueFieldMetadataOption struct {
47+
DatabaseID githubv4.Int `graphql:"databaseId"`
48+
Name githubv4.String
49+
}
50+
51+
type issueFieldMetadataNode struct {
52+
DatabaseID githubv4.Int `graphql:"databaseId"`
53+
Name githubv4.String
54+
DataType githubv4.String
55+
SingleSelectField struct {
56+
Options []issueFieldMetadataOption `graphql:"options"`
57+
} `graphql:"... on IssueFieldSingleSelect"`
58+
}
59+
60+
type issueFieldMetadataQuery struct {
61+
Repository struct {
62+
IssueFields struct {
63+
Nodes []issueFieldMetadataNode
64+
} `graphql:"issueFields(first: 100)"`
65+
} `graphql:"repository(owner: $owner, name: $repo)"`
66+
}
67+
3868
const (
3969
IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED"
4070
IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE"
@@ -103,6 +133,127 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
103133
}
104134
}
105135

136+
func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) {
137+
issueFieldsRaw, exists := args["issue_fields"]
138+
if !exists {
139+
return nil, nil
140+
}
141+
142+
var inputMaps []map[string]any
143+
switch v := issueFieldsRaw.(type) {
144+
case []any:
145+
for _, item := range v {
146+
itemMap, ok := item.(map[string]any)
147+
if !ok {
148+
return nil, fmt.Errorf("each issue_fields item must be an object")
149+
}
150+
inputMaps = append(inputMaps, itemMap)
151+
}
152+
case []map[string]any:
153+
inputMaps = v
154+
default:
155+
return nil, fmt.Errorf("issue_fields must be an array")
156+
}
157+
158+
issueFields := make([]IssueWriteFieldInput, 0, len(inputMaps))
159+
for _, itemMap := range inputMaps {
160+
fieldName, err := RequiredParam[string](itemMap, "field_name")
161+
if err != nil || strings.TrimSpace(fieldName) == "" {
162+
return nil, fmt.Errorf("field_name is required for each issue_fields item")
163+
}
164+
165+
fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name")
166+
if err != nil {
167+
return nil, err
168+
}
169+
170+
value, hasValue := itemMap["value"]
171+
if hasValue && value == nil {
172+
return nil, fmt.Errorf("value cannot be null for field %q", fieldName)
173+
}
174+
175+
if hasValue && fieldOptionName != "" {
176+
return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName)
177+
}
178+
179+
if !hasValue && fieldOptionName == "" {
180+
return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName)
181+
}
182+
183+
issueFields = append(issueFields, IssueWriteFieldInput{
184+
FieldName: fieldName,
185+
Value: value,
186+
FieldOptionName: fieldOptionName,
187+
})
188+
}
189+
190+
return issueFields, nil
191+
}
192+
193+
func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) {
194+
if len(issueFields) == 0 {
195+
return nil, nil
196+
}
197+
198+
query := issueFieldMetadataQuery{}
199+
vars := map[string]any{
200+
"owner": githubv4.String(owner),
201+
"repo": githubv4.String(repo),
202+
}
203+
if err := gqlClient.Query(ctx, &query, vars); err != nil {
204+
return nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
205+
}
206+
207+
fieldByName := make(map[string]issueFieldMetadataNode, len(query.Repository.IssueFields.Nodes))
208+
for _, field := range query.Repository.IssueFields.Nodes {
209+
fieldByName[strings.ToLower(strings.TrimSpace(string(field.Name)))] = field
210+
}
211+
212+
resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields))
213+
for _, fieldInput := range issueFields {
214+
field, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))]
215+
if !ok {
216+
return nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo)
217+
}
218+
219+
fieldID := int64(field.DatabaseID)
220+
if fieldID == 0 {
221+
return nil, fmt.Errorf("issue field %q is missing databaseId", fieldInput.FieldName)
222+
}
223+
224+
resolvedValue := fieldInput.Value
225+
if fieldInput.FieldOptionName != "" {
226+
if !strings.EqualFold(string(field.DataType), "single_select") {
227+
return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, field.DataType)
228+
}
229+
230+
optionFound := false
231+
for _, option := range field.SingleSelectField.Options {
232+
if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) {
233+
optionID := int64(option.DatabaseID)
234+
if optionID == 0 {
235+
return nil, fmt.Errorf("issue field option %q on field %q is missing databaseId", fieldInput.FieldOptionName, fieldInput.FieldName)
236+
}
237+
resolvedValue = optionID
238+
optionFound = true
239+
break
240+
}
241+
}
242+
243+
if !optionFound {
244+
return nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName)
245+
}
246+
}
247+
248+
resolved = append(resolved, &github.IssueRequestFieldValue{
249+
FieldID: fieldID,
250+
Value: resolvedValue,
251+
})
252+
}
253+
254+
return resolved, nil
255+
}
256+
106257
// IssueFragment represents a fragment of an issue node in the GraphQL API.
107258
type IssueFragment struct {
108259
Number githubv4.Int
@@ -1171,6 +1322,27 @@ Options are:
11711322
Type: "number",
11721323
Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
11731324
},
1325+
"issue_fields": {
1326+
Type: "array",
1327+
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.",
1328+
Items: &jsonschema.Schema{
1329+
Type: "object",
1330+
Properties: map[string]*jsonschema.Schema{
1331+
"field_name": {
1332+
Type: "string",
1333+
Description: "Issue field name",
1334+
},
1335+
"value": {
1336+
Description: "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead.",
1337+
},
1338+
"field_option_name": {
1339+
Type: "string",
1340+
Description: "Single-select option name to resolve and set for the field",
1341+
},
1342+
},
1343+
Required: []string{"field_name"},
1344+
},
1345+
},
11741346
},
11751347
Required: []string{"method", "owner", "repo"},
11761348
},
@@ -1272,6 +1444,11 @@ Options are:
12721444
return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil
12731445
}
12741446

1447+
issueFields, err := optionalIssueWriteFields(args)
1448+
if err != nil {
1449+
return utils.NewToolResultError(err.Error()), nil, nil
1450+
}
1451+
12751452
client, err := deps.GetClient(ctx)
12761453
if err != nil {
12771454
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
@@ -1282,16 +1459,21 @@ Options are:
12821459
return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil
12831460
}
12841461

1462+
issueFieldValues, err := resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
1463+
if err != nil {
1464+
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
1465+
}
1466+
12851467
switch method {
12861468
case "create":
1287-
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType)
1469+
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)
12881470
return result, nil, err
12891471
case "update":
12901472
issueNumber, err := RequiredInt(args, "issue_number")
12911473
if err != nil {
12921474
return utils.NewToolResultError(err.Error()), nil, nil
12931475
}
1294-
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf)
1476+
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf)
12951477
return result, nil, err
12961478
default:
12971479
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -1301,17 +1483,18 @@ Options are:
13011483
return st
13021484
}
13031485

1304-
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) {
1486+
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) {
13051487
if title == "" {
13061488
return utils.NewToolResultError("missing required parameter: title"), nil
13071489
}
13081490

13091491
// Create the issue request
13101492
issueRequest := &github.IssueRequest{
1311-
Title: github.Ptr(title),
1312-
Body: github.Ptr(body),
1313-
Assignees: &assignees,
1314-
Labels: &labels,
1493+
Title: github.Ptr(title),
1494+
Body: github.Ptr(body),
1495+
Assignees: &assignees,
1496+
Labels: &labels,
1497+
IssueFieldValues: issueFieldValues,
13151498
}
13161499

13171500
if milestoneNum != 0 {
@@ -1354,7 +1537,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
13541537
return utils.NewToolResultText(string(r)), nil
13551538
}
13561539

1357-
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) {
1540+
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) {
13581541
// Create the issue request with only provided fields
13591542
issueRequest := &github.IssueRequest{}
13601543

@@ -1383,6 +1566,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
13831566
issueRequest.Type = github.Ptr(issueType)
13841567
}
13851568

1569+
if len(issueFieldValues) > 0 {
1570+
issueRequest.IssueFieldValues = issueFieldValues
1571+
}
1572+
13861573
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
13871574
if err != nil {
13881575
return ghErrors.NewGitHubAPIErrorResponse(ctx,

0 commit comments

Comments
 (0)