Skip to content

Commit ecb3ef1

Browse files
authored
feat(issue-fields): support issue_fields in issue_write using fullDatabaseId
- Look up field IDs via fullDatabaseId (BigInt) from GQL issueFields query - Accept field values as strings; pass option names directly to REST (REST single-select expects option name string, not numeric option ID) - Add issue_fields parameter to issue_write schema with strict-mode additionalProperties:false - Wire field value resolution into CreateIssue and UpdateIssue
1 parent 4c6465e commit ecb3ef1

2 files changed

Lines changed: 210 additions & 8 deletions

File tree

pkg/github/__toolsnaps__/issue_write.snap

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@
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": "Custom issue field values to set. Each entry specifies a field by name and its value. For single-select fields, value must be the option name (e.g. \"P1\"). For date fields, value must be YYYY-MM-DD.",
34+
"items": {
35+
"additionalProperties": false,
36+
"properties": {
37+
"field_name": {
38+
"description": "Name of the custom field (case-insensitive).",
39+
"type": "string"
40+
},
41+
"value": {
42+
"description": "Value to set. For single-select, the option name. For dates, YYYY-MM-DD. For numbers, the numeric value as a string.",
43+
"type": "string"
44+
}
45+
},
46+
"required": [
47+
"field_name",
48+
"value"
49+
],
50+
"type": "object"
51+
},
52+
"type": "array"
53+
},
3254
"issue_number": {
3355
"description": "Issue number to update",
3456
"type": "number"

pkg/github/issues.go

Lines changed: 188 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,152 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
105105
}
106106
}
107107

108+
// IssueWriteFieldInput is a user-supplied issue field assignment: a field name and a string value.
109+
// The value is forwarded directly to the REST API — for single-select fields it must be the
110+
// option name (e.g. "P1"), not an option ID. Field ID resolution is done internally via GQL.
111+
type IssueWriteFieldInput struct {
112+
FieldName string
113+
Value string
114+
}
115+
116+
// issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's
117+
// fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation.
118+
// shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat
119+
// fullDatabaseId on each concrete type; all four implement IssueFieldCommon.
120+
type issueFieldWriteMetadataNode struct {
121+
TypeName githubv4.String `graphql:"__typename"`
122+
IssueFieldText struct {
123+
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
124+
Name githubv4.String
125+
DataType githubv4.String
126+
} `graphql:"... on IssueFieldText"`
127+
IssueFieldNumber struct {
128+
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
129+
Name githubv4.String
130+
DataType githubv4.String
131+
} `graphql:"... on IssueFieldNumber"`
132+
IssueFieldDate struct {
133+
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
134+
Name githubv4.String
135+
DataType githubv4.String
136+
} `graphql:"... on IssueFieldDate"`
137+
IssueFieldSingleSelect struct {
138+
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
139+
Name githubv4.String
140+
DataType githubv4.String
141+
} `graphql:"... on IssueFieldSingleSelect"`
142+
}
143+
144+
// issueFieldWriteMetadata holds the resolved name, database ID, and data type for a single field.
145+
type issueFieldWriteMetadata struct {
146+
DatabaseID int64
147+
Name string
148+
DataType string
149+
}
150+
151+
type issueFieldWriteMetadataQuery struct {
152+
Repository struct {
153+
IssueFields struct {
154+
Nodes []issueFieldWriteMetadataNode
155+
} `graphql:"issueFields(first: 100)"`
156+
} `graphql:"repository(owner: $owner, name: $repo)"`
157+
}
158+
159+
func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) {
160+
raw, exists := args["issue_fields"]
161+
if !exists {
162+
return nil, nil
163+
}
164+
165+
var inputMaps []map[string]any
166+
switch v := raw.(type) {
167+
case []any:
168+
for _, item := range v {
169+
m, ok := item.(map[string]any)
170+
if !ok {
171+
return nil, fmt.Errorf("each issue_fields item must be an object")
172+
}
173+
inputMaps = append(inputMaps, m)
174+
}
175+
case []map[string]any:
176+
inputMaps = v
177+
default:
178+
return nil, fmt.Errorf("issue_fields must be an array")
179+
}
180+
181+
out := make([]IssueWriteFieldInput, 0, len(inputMaps))
182+
for _, m := range inputMaps {
183+
fieldName, err := RequiredParam[string](m, "field_name")
184+
if err != nil || strings.TrimSpace(fieldName) == "" {
185+
return nil, fmt.Errorf("field_name is required for each issue_fields item")
186+
}
187+
value, err := RequiredParam[string](m, "value")
188+
if err != nil {
189+
return nil, fmt.Errorf("issue_fields item %q: value is required", fieldName)
190+
}
191+
out = append(out, IssueWriteFieldInput{FieldName: fieldName, Value: value})
192+
}
193+
return out, nil
194+
}
195+
196+
func resolveIssueWriteFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, inputs []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) {
197+
if len(inputs) == 0 {
198+
return nil, nil
199+
}
200+
201+
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
202+
var query issueFieldWriteMetadataQuery
203+
vars := map[string]any{
204+
"owner": githubv4.String(owner),
205+
"repo": githubv4.String(repo),
206+
}
207+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
208+
return nil, fmt.Errorf("failed to query issue field metadata: %w", err)
209+
}
210+
211+
// Build name → metadata map from the GQL response.
212+
byName := make(map[string]issueFieldWriteMetadata, len(query.Repository.IssueFields.Nodes))
213+
for _, node := range query.Repository.IssueFields.Nodes {
214+
var name, dataType, fullDBID string
215+
switch string(node.TypeName) {
216+
case "IssueFieldText":
217+
name, dataType, fullDBID = string(node.IssueFieldText.Name), string(node.IssueFieldText.DataType), string(node.IssueFieldText.FullDatabaseID)
218+
case "IssueFieldNumber":
219+
name, dataType, fullDBID = string(node.IssueFieldNumber.Name), string(node.IssueFieldNumber.DataType), string(node.IssueFieldNumber.FullDatabaseID)
220+
case "IssueFieldDate":
221+
name, dataType, fullDBID = string(node.IssueFieldDate.Name), string(node.IssueFieldDate.DataType), string(node.IssueFieldDate.FullDatabaseID)
222+
case "IssueFieldSingleSelect":
223+
name, dataType, fullDBID = string(node.IssueFieldSingleSelect.Name), string(node.IssueFieldSingleSelect.DataType), string(node.IssueFieldSingleSelect.FullDatabaseID)
224+
default:
225+
continue
226+
}
227+
dbID, _ := strconv.ParseInt(fullDBID, 10, 64)
228+
byName[strings.ToLower(strings.TrimSpace(name))] = issueFieldWriteMetadata{
229+
DatabaseID: dbID,
230+
Name: name,
231+
DataType: dataType,
232+
}
233+
}
234+
235+
resolved := make([]*github.IssueRequestFieldValue, 0, len(inputs))
236+
for _, input := range inputs {
237+
meta, ok := byName[strings.ToLower(strings.TrimSpace(input.FieldName))]
238+
if !ok {
239+
return nil, fmt.Errorf("issue field %q was not found in %s/%s", input.FieldName, owner, repo)
240+
}
241+
if meta.DatabaseID == 0 {
242+
return nil, fmt.Errorf("issue field %q is missing fullDatabaseId", input.FieldName)
243+
}
244+
// For single-select the REST API expects the option name as a string value.
245+
// For all other types, pass the value through as-is.
246+
resolved = append(resolved, &github.IssueRequestFieldValue{
247+
FieldID: meta.DatabaseID,
248+
Value: input.Value,
249+
})
250+
}
251+
return resolved, nil
252+
}
253+
108254
// IssueFieldRef resolves the name of an issue field across its concrete types.
109255
// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText,
110256
// so we have to ask for `name` on each member.
@@ -1509,6 +1655,25 @@ Options are:
15091655
Type: "number",
15101656
Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
15111657
},
1658+
"issue_fields": {
1659+
Type: "array",
1660+
Description: "Custom issue field values to set. Each entry specifies a field by name and its value. For single-select fields, value must be the option name (e.g. \"P1\"). For date fields, value must be YYYY-MM-DD.",
1661+
Items: &jsonschema.Schema{
1662+
Type: "object",
1663+
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
1664+
Properties: map[string]*jsonschema.Schema{
1665+
"field_name": {
1666+
Type: "string",
1667+
Description: "Name of the custom field (case-insensitive).",
1668+
},
1669+
"value": {
1670+
Type: "string",
1671+
Description: "Value to set. For single-select, the option name. For dates, YYYY-MM-DD. For numbers, the numeric value as a string.",
1672+
},
1673+
},
1674+
Required: []string{"field_name", "value"},
1675+
},
1676+
},
15121677
},
15131678
Required: []string{"method", "owner", "repo"},
15141679
},
@@ -1610,6 +1775,11 @@ Options are:
16101775
return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil
16111776
}
16121777

1778+
issueFieldInputs, err := optionalIssueWriteFields(args)
1779+
if err != nil {
1780+
return utils.NewToolResultError(err.Error()), nil, nil
1781+
}
1782+
16131783
client, err := deps.GetClient(ctx)
16141784
if err != nil {
16151785
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
@@ -1620,16 +1790,21 @@ Options are:
16201790
return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil
16211791
}
16221792

1793+
issueFieldValues, err := resolveIssueWriteFieldValues(ctx, gqlClient, owner, repo, issueFieldInputs)
1794+
if err != nil {
1795+
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
1796+
}
1797+
16231798
switch method {
16241799
case "create":
1625-
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType)
1800+
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)
16261801
return result, nil, err
16271802
case "update":
16281803
issueNumber, err := RequiredInt(args, "issue_number")
16291804
if err != nil {
16301805
return utils.NewToolResultError(err.Error()), nil, nil
16311806
}
1632-
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf)
1807+
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf)
16331808
return result, nil, err
16341809
default:
16351810
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -1639,17 +1814,18 @@ Options are:
16391814
return st
16401815
}
16411816

1642-
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) {
1817+
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) {
16431818
if title == "" {
16441819
return utils.NewToolResultError("missing required parameter: title"), nil
16451820
}
16461821

16471822
// Create the issue request
16481823
issueRequest := &github.IssueRequest{
1649-
Title: github.Ptr(title),
1650-
Body: github.Ptr(body),
1651-
Assignees: &assignees,
1652-
Labels: &labels,
1824+
Title: github.Ptr(title),
1825+
Body: github.Ptr(body),
1826+
Assignees: &assignees,
1827+
Labels: &labels,
1828+
IssueFieldValues: issueFieldValues,
16531829
}
16541830

16551831
if milestoneNum != 0 {
@@ -1692,7 +1868,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
16921868
return utils.NewToolResultText(string(r)), nil
16931869
}
16941870

1695-
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) {
1871+
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) {
16961872
// Create the issue request with only provided fields
16971873
issueRequest := &github.IssueRequest{}
16981874

@@ -1721,6 +1897,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
17211897
issueRequest.Type = github.Ptr(issueType)
17221898
}
17231899

1900+
if len(issueFieldValues) > 0 {
1901+
issueRequest.IssueFieldValues = issueFieldValues
1902+
}
1903+
17241904
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
17251905
if err != nil {
17261906
return ghErrors.NewGitHubAPIErrorResponse(ctx,

0 commit comments

Comments
 (0)