Skip to content

Commit 1799fed

Browse files
authored
Merge branch 'iunia/reapply-issue-field-commits' of https://github.com/iulia-b/github-mcp-server into iunia/reapply-issue-field-commits
2 parents ecb3ef1 + c6ce146 commit 1799fed

5 files changed

Lines changed: 472 additions & 49 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,7 @@ The following sets of tools are available:
855855
- `assignees`: Usernames to assign to this issue (string[], optional)
856856
- `body`: Issue body content (string, optional)
857857
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
858+
- `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)
858859
- `issue_number`: Issue number to update (number, optional)
859860
- `labels`: Labels to apply to this issue (string[], optional)
860861
- `method`: Write operation to perform on a single issue.

pkg/github/__toolsnaps__/issue_write.snap

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@
3030
"type": "number"
3131
},
3232
"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.",
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.",
3434
"items": {
35-
"additionalProperties": false,
3635
"properties": {
3736
"field_name": {
38-
"description": "Name of the custom field (case-insensitive).",
37+
"description": "Issue field name",
3938
"type": "string"
4039
},
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.",
40+
"field_option_name": {
41+
"description": "Single-select option name to resolve and set for the field",
4342
"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."
4446
}
4547
},
4648
"required": [
47-
"field_name",
48-
"value"
49+
"field_name"
4950
],
5051
"type": "object"
5152
},

pkg/github/issues.go

Lines changed: 168 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,36 @@ type CloseIssueInput struct {
3737
// Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
3838
type IssueClosedStateReason string
3939

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

332+
func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) {
333+
issueFieldsRaw, exists := args["issue_fields"]
334+
if !exists {
335+
return nil, nil
336+
}
337+
338+
var inputMaps []map[string]any
339+
switch v := issueFieldsRaw.(type) {
340+
case []any:
341+
for _, item := range v {
342+
itemMap, ok := item.(map[string]any)
343+
if !ok {
344+
return nil, fmt.Errorf("each issue_fields item must be an object")
345+
}
346+
inputMaps = append(inputMaps, itemMap)
347+
}
348+
case []map[string]any:
349+
inputMaps = v
350+
default:
351+
return nil, fmt.Errorf("issue_fields must be an array")
352+
}
353+
354+
issueFields := make([]IssueWriteFieldInput, 0, len(inputMaps))
355+
for _, itemMap := range inputMaps {
356+
fieldName, err := RequiredParam[string](itemMap, "field_name")
357+
if err != nil || strings.TrimSpace(fieldName) == "" {
358+
return nil, fmt.Errorf("field_name is required for each issue_fields item")
359+
}
360+
361+
fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name")
362+
if err != nil {
363+
return nil, err
364+
}
365+
366+
value, hasValue := itemMap["value"]
367+
if hasValue && value == nil {
368+
return nil, fmt.Errorf("value cannot be null for field %q", fieldName)
369+
}
370+
371+
if hasValue && fieldOptionName != "" {
372+
return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName)
373+
}
374+
375+
if !hasValue && fieldOptionName == "" {
376+
return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName)
377+
}
378+
379+
issueFields = append(issueFields, IssueWriteFieldInput{
380+
FieldName: fieldName,
381+
Value: value,
382+
FieldOptionName: fieldOptionName,
383+
})
384+
}
385+
386+
return issueFields, nil
387+
}
388+
389+
func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) {
390+
if len(issueFields) == 0 {
391+
return nil, nil
392+
}
393+
394+
query := issueFieldMetadataQuery{}
395+
vars := map[string]any{
396+
"owner": githubv4.String(owner),
397+
"repo": githubv4.String(repo),
398+
}
399+
if err := gqlClient.Query(ctx, &query, vars); err != nil {
400+
return nil, fmt.Errorf("failed to query issue fields metadata: %w", err)
401+
}
402+
403+
fieldByName := make(map[string]issueFieldMetadataNode, len(query.Repository.IssueFields.Nodes))
404+
for _, field := range query.Repository.IssueFields.Nodes {
405+
fieldByName[strings.ToLower(strings.TrimSpace(string(field.Name)))] = field
406+
}
407+
408+
resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields))
409+
for _, fieldInput := range issueFields {
410+
field, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))]
411+
if !ok {
412+
return nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo)
413+
}
414+
415+
fieldID := int64(field.DatabaseID)
416+
if fieldID == 0 {
417+
return nil, fmt.Errorf("issue field %q is missing databaseId", fieldInput.FieldName)
418+
}
419+
420+
resolvedValue := fieldInput.Value
421+
if fieldInput.FieldOptionName != "" {
422+
if !strings.EqualFold(string(field.DataType), "single_select") {
423+
return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, field.DataType)
424+
}
425+
426+
optionFound := false
427+
for _, option := range field.SingleSelectField.Options {
428+
if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) {
429+
optionID := int64(option.DatabaseID)
430+
if optionID == 0 {
431+
return nil, fmt.Errorf("issue field option %q on field %q is missing databaseId", fieldInput.FieldOptionName, fieldInput.FieldName)
432+
}
433+
resolvedValue = optionID
434+
optionFound = true
435+
break
436+
}
437+
}
438+
439+
if !optionFound {
440+
return nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName)
441+
}
442+
}
443+
444+
resolved = append(resolved, &github.IssueRequestFieldValue{
445+
FieldID: fieldID,
446+
Value: resolvedValue,
447+
})
448+
}
449+
450+
return resolved, nil
451+
}
452+
302453
// IssueFragment represents a fragment of an issue node in the GraphQL API.
303454
type IssueFragment struct {
304455
Number githubv4.Int
@@ -1412,7 +1563,7 @@ func parseRepositoryURL(repoURL string) (string, string, bool) {
14121563
// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query.
14131564
type SearchIssueResult struct {
14141565
*github.Issue
1415-
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
1566+
FieldValues []MinimalFieldValue `json:"field_values,omitempty"`
14161567
}
14171568

14181569
// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the
@@ -1461,7 +1612,7 @@ type searchIssuesNodesQuery struct {
14611612
// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and
14621613
// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and
14631614
// an empty result set short-circuits the round-trip.
1464-
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) {
1615+
func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalFieldValue, error) {
14651616
ids := make([]githubv4.ID, 0, len(issues))
14661617
for _, iss := range issues {
14671618
if iss == nil || iss.NodeID == nil || *iss.NodeID == "" {
@@ -1478,15 +1629,15 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie
14781629
return nil, err
14791630
}
14801631

1481-
result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes))
1632+
result := make(map[string][]MinimalFieldValue, len(q.Nodes))
14821633
for _, n := range q.Nodes {
14831634
idStr, ok := n.Issue.ID.(string)
14841635
if !ok || idStr == "" {
14851636
continue
14861637
}
1487-
vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
1638+
vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
14881639
for _, fv := range n.Issue.IssueFieldValues.Nodes {
1489-
if m, ok := fragmentToMinimalIssueFieldValue(fv); ok {
1640+
if m, ok := fragmentToMinimalFieldValue(fv); ok {
14901641
vals = append(vals, m)
14911642
}
14921643
}
@@ -1524,8 +1675,8 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st
15241675
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil
15251676
}
15261677

1527-
var fieldValuesByID map[string][]MinimalIssueFieldValue
1528-
if deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) && len(result.Issues) > 0 {
1678+
var fieldValuesByID map[string][]MinimalFieldValue
1679+
if len(result.Issues) > 0 {
15291680
gqlClient, err := deps.GetGQLClient(ctx)
15301681
if err != nil {
15311682
return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil
@@ -1657,21 +1808,23 @@ Options are:
16571808
},
16581809
"issue_fields": {
16591810
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.",
1811+
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.",
16611812
Items: &jsonschema.Schema{
1662-
Type: "object",
1663-
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
1813+
Type: "object",
16641814
Properties: map[string]*jsonschema.Schema{
16651815
"field_name": {
16661816
Type: "string",
1667-
Description: "Name of the custom field (case-insensitive).",
1817+
Description: "Issue field name",
16681818
},
16691819
"value": {
1820+
Description: "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead.",
1821+
},
1822+
"field_option_name": {
16701823
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.",
1824+
Description: "Single-select option name to resolve and set for the field",
16721825
},
16731826
},
1674-
Required: []string{"field_name", "value"},
1827+
Required: []string{"field_name"},
16751828
},
16761829
},
16771830
},
@@ -1775,7 +1928,7 @@ Options are:
17751928
return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil
17761929
}
17771930

1778-
issueFieldInputs, err := optionalIssueWriteFields(args)
1931+
issueFields, err := optionalIssueWriteFields(args)
17791932
if err != nil {
17801933
return utils.NewToolResultError(err.Error()), nil, nil
17811934
}
@@ -1790,7 +1943,7 @@ Options are:
17901943
return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil
17911944
}
17921945

1793-
issueFieldValues, err := resolveIssueWriteFieldValues(ctx, gqlClient, owner, repo, issueFieldInputs)
1946+
issueFieldValues, err := resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields)
17941947
if err != nil {
17951948
return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil
17961949
}

0 commit comments

Comments
 (0)