Skip to content

Commit 2fcbc88

Browse files
authored
add support for fields in issues write
1 parent 56f35f5 commit 2fcbc88

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
@@ -34,6 +34,36 @@ type CloseIssueInput struct {
3434
// Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
3535
type IssueClosedStateReason string
3636

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

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

1329+
issueFields, err := optionalIssueWriteFields(args)
1330+
if err != nil {
1331+
return utils.NewToolResultError(err.Error()), nil, nil
1332+
}
1333+
11571334
client, err := deps.GetClient(ctx)
11581335
if err != nil {
11591336
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
@@ -1164,16 +1341,21 @@ Options are:
11641341
return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil
11651342
}
11661343

1344+
issueFieldValues, err := resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
1345+
if err != nil {
1346+
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
1347+
}
1348+
11671349
switch method {
11681350
case "create":
1169-
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType)
1351+
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)
11701352
return result, nil, err
11711353
case "update":
11721354
issueNumber, err := RequiredInt(args, "issue_number")
11731355
if err != nil {
11741356
return utils.NewToolResultError(err.Error()), nil, nil
11751357
}
1176-
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf)
1358+
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf)
11771359
return result, nil, err
11781360
default:
11791361
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -1183,17 +1365,18 @@ Options are:
11831365
return st
11841366
}
11851367

1186-
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) {
1368+
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) {
11871369
if title == "" {
11881370
return utils.NewToolResultError("missing required parameter: title"), nil
11891371
}
11901372

11911373
// Create the issue request
11921374
issueRequest := &github.IssueRequest{
1193-
Title: github.Ptr(title),
1194-
Body: github.Ptr(body),
1195-
Assignees: &assignees,
1196-
Labels: &labels,
1375+
Title: github.Ptr(title),
1376+
Body: github.Ptr(body),
1377+
Assignees: &assignees,
1378+
Labels: &labels,
1379+
IssueFieldValues: issueFieldValues,
11971380
}
11981381

11991382
if milestoneNum != 0 {
@@ -1236,7 +1419,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
12361419
return utils.NewToolResultText(string(r)), nil
12371420
}
12381421

1239-
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) {
1422+
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) {
12401423
// Create the issue request with only provided fields
12411424
issueRequest := &github.IssueRequest{}
12421425

@@ -1265,6 +1448,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
12651448
issueRequest.Type = github.Ptr(issueType)
12661449
}
12671450

1451+
if len(issueFieldValues) > 0 {
1452+
issueRequest.IssueFieldValues = issueFieldValues
1453+
}
1454+
12681455
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
12691456
if err != nil {
12701457
return ghErrors.NewGitHubAPIErrorResponse(ctx,

0 commit comments

Comments
 (0)