@@ -18,6 +18,87 @@ import (
18
18
"github.com/shurcooL/githubv4"
19
19
)
20
20
21
+ // CloseIssueInput represents the input for closing an issue via the GraphQL API.
22
+ // Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
23
+ type CloseIssueInput struct {
24
+ IssueID githubv4.ID `json:"issueId"`
25
+ ClientMutationID * githubv4.String `json:"clientMutationId,omitempty"`
26
+ StateReason * IssueClosedStateReason `json:"stateReason,omitempty"`
27
+ DuplicateIssueID * githubv4.ID `json:"duplicateIssueId,omitempty"`
28
+ }
29
+
30
+ // IssueClosedStateReason represents the reason an issue was closed.
31
+ // Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
32
+ type IssueClosedStateReason string
33
+
34
+ const (
35
+ IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED"
36
+ IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE"
37
+ IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED"
38
+ )
39
+
40
+ // fetchIssueIDs retrieves issue IDs via the GraphQL API.
41
+ // When duplicateOf is 0, it fetches only the main issue ID.
42
+ // When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query.
43
+ func fetchIssueIDs (ctx context.Context , gqlClient * githubv4.Client , owner , repo string , issueNumber int , duplicateOf int ) (githubv4.ID , githubv4.ID , error ) {
44
+ // Build query variables common to both cases
45
+ vars := map [string ]interface {}{
46
+ "owner" : githubv4 .String (owner ),
47
+ "repo" : githubv4 .String (repo ),
48
+ "issueNumber" : githubv4 .Int (issueNumber ), // #nosec G115 - issue numbers are always small positive integers
49
+ }
50
+
51
+ if duplicateOf == 0 {
52
+ // Only fetch the main issue ID
53
+ var query struct {
54
+ Repository struct {
55
+ Issue struct {
56
+ ID githubv4.ID
57
+ } `graphql:"issue(number: $issueNumber)"`
58
+ } `graphql:"repository(owner: $owner, name: $repo)"`
59
+ }
60
+
61
+ if err := gqlClient .Query (ctx , & query , vars ); err != nil {
62
+ return "" , "" , fmt .Errorf ("failed to get issue ID" )
63
+ }
64
+
65
+ return query .Repository .Issue .ID , "" , nil
66
+ }
67
+
68
+ // Fetch both issue IDs in a single query
69
+ var query struct {
70
+ Repository struct {
71
+ Issue struct {
72
+ ID githubv4.ID
73
+ } `graphql:"issue(number: $issueNumber)"`
74
+ DuplicateIssue struct {
75
+ ID githubv4.ID
76
+ } `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
77
+ } `graphql:"repository(owner: $owner, name: $repo)"`
78
+ }
79
+
80
+ // Add duplicate issue number to variables
81
+ vars ["duplicateOf" ] = githubv4 .Int (duplicateOf ) // #nosec G115 - issue numbers are always small positive integers
82
+
83
+ if err := gqlClient .Query (ctx , & query , vars ); err != nil {
84
+ return "" , "" , fmt .Errorf ("failed to get issue ID" )
85
+ }
86
+
87
+ return query .Repository .Issue .ID , query .Repository .DuplicateIssue .ID , nil
88
+ }
89
+
90
+ // getCloseStateReason converts a string state reason to the appropriate enum value
91
+ func getCloseStateReason (stateReason string ) IssueClosedStateReason {
92
+ switch stateReason {
93
+ case "not_planned" :
94
+ return IssueClosedStateReasonNotPlanned
95
+ case "duplicate" :
96
+ return IssueClosedStateReasonDuplicate
97
+ default : // Default to "completed" for empty or "completed" values
98
+ return IssueClosedStateReasonCompleted
99
+ }
100
+ }
101
+
21
102
// IssueFragment represents a fragment of an issue node in the GraphQL API.
22
103
type IssueFragment struct {
23
104
Number githubv4.Int
@@ -1100,7 +1181,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
1100
1181
}
1101
1182
1102
1183
// UpdateIssue creates a tool to update an existing issue in a GitHub repository.
1103
- func UpdateIssue (getClient GetClientFn , t translations.TranslationHelperFunc ) (tool mcp.Tool , handler server.ToolHandlerFunc ) {
1184
+ func UpdateIssue (getClient GetClientFn , getGQLClient GetGQLClientFn , t translations.TranslationHelperFunc ) (tool mcp.Tool , handler server.ToolHandlerFunc ) {
1104
1185
return mcp .NewTool ("update_issue" ,
1105
1186
mcp .WithDescription (t ("TOOL_UPDATE_ISSUE_DESCRIPTION" , "Update an existing issue in a GitHub repository." )),
1106
1187
mcp .WithToolAnnotation (mcp.ToolAnnotation {
@@ -1125,10 +1206,6 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
1125
1206
mcp .WithString ("body" ,
1126
1207
mcp .Description ("New description" ),
1127
1208
),
1128
- mcp .WithString ("state" ,
1129
- mcp .Description ("New state" ),
1130
- mcp .Enum ("open" , "closed" ),
1131
- ),
1132
1209
mcp .WithArray ("labels" ,
1133
1210
mcp .Description ("New labels" ),
1134
1211
mcp .Items (
@@ -1151,6 +1228,17 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
1151
1228
mcp .WithString ("type" ,
1152
1229
mcp .Description ("New issue type" ),
1153
1230
),
1231
+ mcp .WithString ("state" ,
1232
+ mcp .Description ("New state" ),
1233
+ mcp .Enum ("open" , "closed" ),
1234
+ ),
1235
+ mcp .WithString ("state_reason" ,
1236
+ mcp .Description ("Reason for the state change. Ignored unless state is changed." ),
1237
+ mcp .Enum ("completed" , "not_planned" , "duplicate" ),
1238
+ ),
1239
+ mcp .WithNumber ("duplicate_of" ,
1240
+ mcp .Description ("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." ),
1241
+ ),
1154
1242
),
1155
1243
func (ctx context.Context , request mcp.CallToolRequest ) (* mcp.CallToolResult , error ) {
1156
1244
owner , err := RequiredParam [string ](request , "owner" )
@@ -1186,14 +1274,6 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
1186
1274
issueRequest .Body = github .Ptr (body )
1187
1275
}
1188
1276
1189
- state , err := OptionalParam [string ](request , "state" )
1190
- if err != nil {
1191
- return mcp .NewToolResultError (err .Error ()), nil
1192
- }
1193
- if state != "" {
1194
- issueRequest .State = github .Ptr (state )
1195
- }
1196
-
1197
1277
// Get labels
1198
1278
labels , err := OptionalStringArrayParam (request , "labels" )
1199
1279
if err != nil {
@@ -1230,13 +1310,38 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
1230
1310
issueRequest .Type = github .Ptr (issueType )
1231
1311
}
1232
1312
1313
+ // Handle state, state_reason and duplicateOf parameters
1314
+ state , err := OptionalParam [string ](request , "state" )
1315
+ if err != nil {
1316
+ return mcp .NewToolResultError (err .Error ()), nil
1317
+ }
1318
+
1319
+ stateReason , err := OptionalParam [string ](request , "state_reason" )
1320
+ if err != nil {
1321
+ return mcp .NewToolResultError (err .Error ()), nil
1322
+ }
1323
+
1324
+ duplicateOf , err := OptionalIntParam (request , "duplicate_of" )
1325
+ if err != nil {
1326
+ return mcp .NewToolResultError (err .Error ()), nil
1327
+ }
1328
+ if duplicateOf != 0 && stateReason != "duplicate" {
1329
+ return mcp .NewToolResultError ("duplicate_of can only be used when state_reason is 'duplicate'" ), nil
1330
+ }
1331
+
1332
+ // Use REST API for non-state updates
1233
1333
client , err := getClient (ctx )
1234
1334
if err != nil {
1235
1335
return nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
1236
1336
}
1337
+
1237
1338
updatedIssue , resp , err := client .Issues .Edit (ctx , owner , repo , issueNumber , issueRequest )
1238
1339
if err != nil {
1239
- return nil , fmt .Errorf ("failed to update issue: %w" , err )
1340
+ return ghErrors .NewGitHubAPIErrorResponse (ctx ,
1341
+ "failed to update issue" ,
1342
+ resp ,
1343
+ err ,
1344
+ ), nil
1240
1345
}
1241
1346
defer func () { _ = resp .Body .Close () }()
1242
1347
@@ -1248,6 +1353,75 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
1248
1353
return mcp .NewToolResultError (fmt .Sprintf ("failed to update issue: %s" , string (body ))), nil
1249
1354
}
1250
1355
1356
+ // Use GraphQL API for state updates
1357
+ if state != "" {
1358
+ gqlClient , err := getGQLClient (ctx )
1359
+ if err != nil {
1360
+ return nil , fmt .Errorf ("failed to get GraphQL client: %w" , err )
1361
+ }
1362
+
1363
+ // Mandate specifying duplicateOf when trying to close as duplicate
1364
+ if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 {
1365
+ return mcp .NewToolResultError ("duplicate_of must be provided when state_reason is 'duplicate'" ), nil
1366
+ }
1367
+
1368
+ // Get target issue ID (and duplicate issue ID if needed)
1369
+ issueID , duplicateIssueID , err := fetchIssueIDs (ctx , gqlClient , owner , repo , issueNumber , duplicateOf )
1370
+ if err != nil {
1371
+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to find issues" , err ), nil
1372
+ }
1373
+
1374
+ switch state {
1375
+ case "open" :
1376
+ // Use ReopenIssue mutation for opening
1377
+ var mutation struct {
1378
+ ReopenIssue struct {
1379
+ Issue struct {
1380
+ ID githubv4.ID
1381
+ Number githubv4.Int
1382
+ URL githubv4.String
1383
+ State githubv4.String
1384
+ }
1385
+ } `graphql:"reopenIssue(input: $input)"`
1386
+ }
1387
+
1388
+ err = gqlClient .Mutate (ctx , & mutation , githubv4.ReopenIssueInput {
1389
+ IssueID : issueID ,
1390
+ }, nil )
1391
+ if err != nil {
1392
+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to reopen issue" , err ), nil
1393
+ }
1394
+ case "closed" :
1395
+ // Use CloseIssue mutation for closing
1396
+ var mutation struct {
1397
+ CloseIssue struct {
1398
+ Issue struct {
1399
+ ID githubv4.ID
1400
+ Number githubv4.Int
1401
+ URL githubv4.String
1402
+ State githubv4.String
1403
+ }
1404
+ } `graphql:"closeIssue(input: $input)"`
1405
+ }
1406
+
1407
+ stateReasonValue := getCloseStateReason (stateReason )
1408
+ closeInput := CloseIssueInput {
1409
+ IssueID : issueID ,
1410
+ StateReason : & stateReasonValue ,
1411
+ }
1412
+
1413
+ // Set duplicate issue ID if needed
1414
+ if stateReason == "duplicate" {
1415
+ closeInput .DuplicateIssueID = & duplicateIssueID
1416
+ }
1417
+
1418
+ err = gqlClient .Mutate (ctx , & mutation , closeInput , nil )
1419
+ if err != nil {
1420
+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to close issue" , err ), nil
1421
+ }
1422
+ }
1423
+ }
1424
+
1251
1425
// Return minimal response with just essential information
1252
1426
minimalResponse := MinimalResponse {
1253
1427
ID : fmt .Sprintf ("%d" , updatedIssue .GetID ()),
0 commit comments