Skip to content

Commit fc7a7dc

Browse files
mattdhollowaySamMorrowDrums
authored andcommitted
feat: add granular tool to set issue field values
1 parent b482ac6 commit fc7a7dc

File tree

4 files changed

+431
-0
lines changed

4 files changed

+431
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
{
2+
"annotations": {
3+
"destructiveHint": false,
4+
"openWorldHint": true,
5+
"title": "Set Issue Fields"
6+
},
7+
"description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue.",
8+
"inputSchema": {
9+
"properties": {
10+
"fields": {
11+
"description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.",
12+
"items": {
13+
"properties": {
14+
"date_value": {
15+
"description": "The value to set for a date field (ISO 8601 date string)",
16+
"type": "string"
17+
},
18+
"delete": {
19+
"description": "Set to true to delete this field value",
20+
"type": "boolean"
21+
},
22+
"field_id": {
23+
"description": "The GraphQL node ID of the issue field",
24+
"type": "string"
25+
},
26+
"number_value": {
27+
"description": "The value to set for a number field",
28+
"type": "number"
29+
},
30+
"single_select_option_id": {
31+
"description": "The GraphQL node ID of the option to set for a single select field",
32+
"type": "string"
33+
},
34+
"text_value": {
35+
"description": "The value to set for a text field",
36+
"type": "string"
37+
}
38+
},
39+
"required": [
40+
"field_id"
41+
],
42+
"type": "object"
43+
},
44+
"minItems": 1,
45+
"type": "array"
46+
},
47+
"issue_number": {
48+
"description": "The issue number to update",
49+
"minimum": 1,
50+
"type": "number"
51+
},
52+
"owner": {
53+
"description": "Repository owner (username or organization)",
54+
"type": "string"
55+
},
56+
"repo": {
57+
"description": "Repository name",
58+
"type": "string"
59+
}
60+
},
61+
"required": [
62+
"owner",
63+
"repo",
64+
"issue_number",
65+
"fields"
66+
],
67+
"type": "object"
68+
},
69+
"name": "set_issue_fields"
70+
}

pkg/github/granular_tools_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func TestGranularToolSnaps(t *testing.T) {
3939
GranularAddSubIssue,
4040
GranularRemoveSubIssue,
4141
GranularReprioritizeSubIssue,
42+
GranularSetIssueFields,
4243
GranularUpdatePullRequestTitle,
4344
GranularUpdatePullRequestBody,
4445
GranularUpdatePullRequestState,
@@ -81,6 +82,7 @@ func TestIssuesGranularToolset(t *testing.T) {
8182
"add_sub_issue",
8283
"remove_sub_issue",
8384
"reprioritize_sub_issue",
85+
"set_issue_fields",
8486
}
8587
for _, name := range expected {
8688
assert.Contains(t, toolNames, name)
@@ -774,3 +776,135 @@ func TestGranularUnresolveReviewThread(t *testing.T) {
774776
require.NoError(t, err)
775777
assert.False(t, result.IsError)
776778
}
779+
780+
func TestGranularSetIssueFields(t *testing.T) {
781+
t.Run("successful set with text value", func(t *testing.T) {
782+
matchers := []githubv4mock.Matcher{
783+
// Mock the issue ID query
784+
githubv4mock.NewQueryMatcher(
785+
struct {
786+
Repository struct {
787+
Issue struct {
788+
ID githubv4.ID
789+
} `graphql:"issue(number: $issueNumber)"`
790+
} `graphql:"repository(owner: $owner, name: $repo)"`
791+
}{},
792+
map[string]any{
793+
"owner": githubv4.String("owner"),
794+
"repo": githubv4.String("repo"),
795+
"issueNumber": githubv4.Int(5),
796+
},
797+
githubv4mock.DataResponse(map[string]any{
798+
"repository": map[string]any{
799+
"issue": map[string]any{"id": "ISSUE_123"},
800+
},
801+
}),
802+
),
803+
// Mock the setIssueFieldValue mutation
804+
githubv4mock.NewMutationMatcher(
805+
struct {
806+
SetIssueFieldValue struct {
807+
Issue struct {
808+
ID githubv4.ID
809+
Number githubv4.Int
810+
URL githubv4.String
811+
}
812+
IssueFieldValues []struct {
813+
Field struct {
814+
Name string
815+
} `graphql:"... on IssueFieldDateValue"`
816+
}
817+
} `graphql:"setIssueFieldValue(input: $input)"`
818+
}{},
819+
SetIssueFieldValueInput{
820+
IssueID: githubv4.ID("ISSUE_123"),
821+
IssueFields: []IssueFieldCreateOrUpdateInput{
822+
{
823+
FieldID: githubv4.ID("FIELD_1"),
824+
TextValue: githubv4.NewString(githubv4.String("hello")),
825+
},
826+
},
827+
},
828+
nil,
829+
githubv4mock.DataResponse(map[string]any{
830+
"setIssueFieldValue": map[string]any{
831+
"issue": map[string]any{
832+
"id": "ISSUE_123",
833+
"number": 5,
834+
"url": "https://github.com/owner/repo/issues/5",
835+
},
836+
},
837+
}),
838+
),
839+
}
840+
841+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
842+
deps := BaseDeps{GQLClient: gqlClient}
843+
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
844+
handler := serverTool.Handler(deps)
845+
846+
request := createMCPRequest(map[string]any{
847+
"owner": "owner",
848+
"repo": "repo",
849+
"issue_number": float64(5),
850+
"fields": []any{
851+
map[string]any{"field_id": "FIELD_1", "text_value": "hello"},
852+
},
853+
})
854+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
855+
require.NoError(t, err)
856+
assert.False(t, result.IsError)
857+
})
858+
859+
t.Run("missing required parameter fields", func(t *testing.T) {
860+
deps := BaseDeps{}
861+
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
862+
handler := serverTool.Handler(deps)
863+
864+
request := createMCPRequest(map[string]any{
865+
"owner": "owner",
866+
"repo": "repo",
867+
"issue_number": float64(5),
868+
})
869+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
870+
require.NoError(t, err)
871+
textContent := getTextResult(t, result)
872+
assert.Contains(t, textContent.Text, "missing required parameter: fields")
873+
})
874+
875+
t.Run("empty fields array", func(t *testing.T) {
876+
deps := BaseDeps{}
877+
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
878+
handler := serverTool.Handler(deps)
879+
880+
request := createMCPRequest(map[string]any{
881+
"owner": "owner",
882+
"repo": "repo",
883+
"issue_number": float64(5),
884+
"fields": []any{},
885+
})
886+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
887+
require.NoError(t, err)
888+
textContent := getTextResult(t, result)
889+
assert.Contains(t, textContent.Text, "fields array must not be empty")
890+
})
891+
892+
t.Run("field missing value", func(t *testing.T) {
893+
deps := BaseDeps{}
894+
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
895+
handler := serverTool.Handler(deps)
896+
897+
request := createMCPRequest(map[string]any{
898+
"owner": "owner",
899+
"repo": "repo",
900+
"issue_number": float64(5),
901+
"fields": []any{
902+
map[string]any{"field_id": "FIELD_1"},
903+
},
904+
})
905+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
906+
require.NoError(t, err)
907+
textContent := getTextResult(t, result)
908+
assert.Contains(t, textContent.Text, "each field must have a value")
909+
})
910+
}

0 commit comments

Comments
 (0)