@@ -18,6 +18,25 @@ import (
1818 "github.com/shurcooL/githubv4"
1919)
2020
21+ // Custom GraphQL structs for closing issues as duplicate
22+ // These extend the githubv4 library which is missing this functionality
23+
24+ // Local definition matching GitHub's GraphQL schema for closing issues
25+ type CloseIssueInput struct {
26+ IssueID githubv4.ID `json:"issueId"`
27+ ClientMutationID * githubv4.String `json:"clientMutationId,omitempty"`
28+ StateReason * IssueClosedStateReason `json:"stateReason,omitempty"`
29+ DuplicateIssueID * githubv4.ID `json:"duplicateIssueId,omitempty"`
30+ }
31+
32+ type IssueClosedStateReason string
33+
34+ const (
35+ IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED"
36+ IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE"
37+ IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED"
38+ )
39+
2140// IssueFragment represents a fragment of an issue node in the GraphQL API.
2241type IssueFragment struct {
2342 Number githubv4.Int
@@ -1099,7 +1118,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
10991118}
11001119
11011120// UpdateIssue creates a tool to update an existing issue in a GitHub repository.
1102- func UpdateIssue (getClient GetClientFn , t translations.TranslationHelperFunc ) (tool mcp.Tool , handler server.ToolHandlerFunc ) {
1121+ func UpdateIssue (getClient GetClientFn , getGQLClient GetGQLClientFn , t translations.TranslationHelperFunc ) (tool mcp.Tool , handler server.ToolHandlerFunc ) {
11031122 return mcp .NewTool ("update_issue" ,
11041123 mcp .WithDescription (t ("TOOL_UPDATE_ISSUE_DESCRIPTION" , "Update an existing issue in a GitHub repository." )),
11051124 mcp .WithToolAnnotation (mcp.ToolAnnotation {
@@ -1129,9 +1148,12 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11291148 mcp .Enum ("open" , "closed" ),
11301149 ),
11311150 mcp .WithString ("state_reason" ,
1132- mcp .Description ("Reason for the state change, ignored unless state is changed." ),
1151+ mcp .Description ("Reason for the state change. Ignored unless state is changed." ),
11331152 mcp .Enum ("completed" , "not_planned" , "duplicate" , "reopened" ),
11341153 ),
1154+ mcp .WithNumber ("duplicate_of" ,
1155+ mcp .Description ("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." ),
1156+ ),
11351157 mcp .WithArray ("labels" ,
11361158 mcp .Description ("New labels" ),
11371159 mcp .Items (
@@ -1171,6 +1193,8 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11711193
11721194 // Create the issue request with only provided fields
11731195 issueRequest := & github.IssueRequest {}
1196+ restUpdateNeeded := false
1197+ gqlUpdateNeeded := false
11741198
11751199 // Set optional parameters if provided
11761200 title , err := OptionalParam [string ](request , "title" )
@@ -1179,6 +1203,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11791203 }
11801204 if title != "" {
11811205 issueRequest .Title = github .Ptr (title )
1206+ restUpdateNeeded = true
11821207 }
11831208
11841209 body , err := OptionalParam [string ](request , "body" )
@@ -1187,22 +1212,37 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11871212 }
11881213 if body != "" {
11891214 issueRequest .Body = github .Ptr (body )
1215+ restUpdateNeeded = true
11901216 }
11911217
1218+ // Handle state and state_reason parameters
11921219 state , err := OptionalParam [string ](request , "state" )
11931220 if err != nil {
11941221 return mcp .NewToolResultError (err .Error ()), nil
11951222 }
1196- if state != "" {
1197- issueRequest .State = github .Ptr (state )
1198- }
11991223
12001224 stateReason , err := OptionalParam [string ](request , "state_reason" )
12011225 if err != nil {
12021226 return mcp .NewToolResultError (err .Error ()), nil
12031227 }
1204- if stateReason != "" {
1205- issueRequest .StateReason = github .Ptr (stateReason )
1228+
1229+ // If closing as duplicate, use GraphQL API, otherwise use REST API for state changes
1230+ if stateReason == "duplicate" {
1231+ gqlUpdateNeeded = true
1232+ } else {
1233+ if state != "" {
1234+ issueRequest .State = github .Ptr (state )
1235+ restUpdateNeeded = true
1236+ }
1237+ if stateReason != "" {
1238+ issueRequest .StateReason = github .Ptr (stateReason )
1239+ restUpdateNeeded = true
1240+ }
1241+ }
1242+
1243+ duplicateOf , err := OptionalIntParam (request , "duplicate_of" )
1244+ if err != nil {
1245+ return mcp .NewToolResultError (err .Error ()), nil
12061246 }
12071247
12081248 // Get labels
@@ -1212,6 +1252,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12121252 }
12131253 if len (labels ) > 0 {
12141254 issueRequest .Labels = & labels
1255+ restUpdateNeeded = true
12151256 }
12161257
12171258 // Get assignees
@@ -1221,6 +1262,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12211262 }
12221263 if len (assignees ) > 0 {
12231264 issueRequest .Assignees = & assignees
1265+ restUpdateNeeded = true
12241266 }
12251267
12261268 milestone , err := OptionalIntParam (request , "milestone" )
@@ -1230,6 +1272,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12301272 if milestone != 0 {
12311273 milestoneNum := milestone
12321274 issueRequest .Milestone = & milestoneNum
1275+ restUpdateNeeded = true
12331276 }
12341277
12351278 // Get issue type
@@ -1239,34 +1282,117 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12391282 }
12401283 if issueType != "" {
12411284 issueRequest .Type = github .Ptr (issueType )
1285+ restUpdateNeeded = true
12421286 }
12431287
1244- client , err := getClient (ctx )
1245- if err != nil {
1246- return nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
1288+ if ! restUpdateNeeded && ! gqlUpdateNeeded {
1289+ return mcp .NewToolResultError ("No update parameters provided." ), nil
12471290 }
1248- updatedIssue , resp , err := client .Issues .Edit (ctx , owner , repo , issueNumber , issueRequest )
1249- if err != nil {
1250- return nil , fmt .Errorf ("failed to update issue: %w" , err )
1291+
1292+ // Handle REST API updates (title, body, state, labels, assignees, milestone, type, state_reason except "duplicate")
1293+ if restUpdateNeeded {
1294+ client , err := getClient (ctx )
1295+ if err != nil {
1296+ return nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
1297+ }
1298+
1299+ _ , resp , err := client .Issues .Edit (ctx , owner , repo , issueNumber , issueRequest )
1300+ if err != nil {
1301+ return ghErrors .NewGitHubAPIErrorResponse (ctx ,
1302+ "failed to update issue" ,
1303+ resp ,
1304+ err ,
1305+ ), nil
1306+ }
1307+ defer func () { _ = resp .Body .Close () }()
1308+
1309+ if resp .StatusCode != http .StatusOK {
1310+ body , err := io .ReadAll (resp .Body )
1311+ if err != nil {
1312+ return nil , fmt .Errorf ("failed to read response body: %w" , err )
1313+ }
1314+ return mcp .NewToolResultError (fmt .Sprintf ("failed to update issue: %s" , string (body ))), nil
1315+ }
12511316 }
1252- defer func () { _ = resp .Body .Close () }()
12531317
1254- if resp .StatusCode != http .StatusOK {
1255- body , err := io .ReadAll (resp .Body )
1318+ // Handle GraphQL API updates (state_reason = "duplicate")
1319+ if gqlUpdateNeeded {
1320+ if duplicateOf == 0 {
1321+ return mcp .NewToolResultError ("duplicate_of must be provided when state_reason is 'duplicate'" ), nil
1322+ }
1323+
1324+ gqlClient , err := getGQLClient (ctx )
12561325 if err != nil {
1257- return nil , fmt .Errorf ("failed to read response body: %w" , err )
1326+ return nil , fmt .Errorf ("failed to get GraphQL client: %w" , err )
1327+ }
1328+
1329+ var issueQuery struct {
1330+ Repository struct {
1331+ Issue struct {
1332+ ID githubv4.ID
1333+ } `graphql:"issue(number: $issueNumber)"`
1334+ DuplicateIssue struct {
1335+ ID githubv4.ID
1336+ } `graphql:"duplicateIssue: issue(number: $duplicateNumber)"`
1337+ } `graphql:"repository(owner: $owner, name: $repo)"`
1338+ }
1339+
1340+ err = gqlClient .Query (ctx , & issueQuery , map [string ]interface {}{
1341+ "owner" : githubv4 .String (owner ),
1342+ "repo" : githubv4 .String (repo ),
1343+ "issueNumber" : githubv4 .Int (issueNumber ), // #nosec G115 - issue numbers are always small positive integers
1344+ "duplicateNumber" : githubv4 .Int (duplicateOf ), // #nosec G115 - issue numbers are always small positive integers
1345+ })
1346+ if err != nil {
1347+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to find issues" , err ), nil
1348+ }
1349+
1350+ var mutation struct {
1351+ CloseIssue struct {
1352+ Issue struct {
1353+ ID githubv4.ID
1354+ Number githubv4.Int
1355+ URL githubv4.String
1356+ State githubv4.String
1357+ }
1358+ } `graphql:"closeIssue(input: $input)"`
1359+ }
1360+
1361+ duplicateStateReason := IssueClosedStateReasonDuplicate
1362+ err = gqlClient .Mutate (ctx , & mutation , CloseIssueInput {
1363+ IssueID : issueQuery .Repository .Issue .ID ,
1364+ StateReason : & duplicateStateReason ,
1365+ DuplicateIssueID : & issueQuery .Repository .DuplicateIssue .ID ,
1366+ }, nil )
1367+ if err != nil {
1368+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to close issue as duplicate" , err ), nil
12581369 }
1259- return mcp .NewToolResultError (fmt .Sprintf ("failed to update issue: %s" , string (body ))), nil
12601370 }
12611371
1372+ // Get the final state of the issue to return
1373+ client , err := getClient (ctx )
1374+ if err != nil {
1375+ return nil , err
1376+ }
1377+
1378+ finalIssue , resp , err := client .Issues .Get (ctx , owner , repo , issueNumber )
1379+ if err != nil {
1380+ return ghErrors .NewGitHubAPIErrorResponse (ctx , "Failed to get issue" , resp , err ), nil
1381+ }
1382+ defer func () {
1383+ if resp != nil && resp .Body != nil {
1384+ _ = resp .Body .Close ()
1385+ }
1386+ }()
1387+
12621388 // Return minimal response with just essential information
12631389 minimalResponse := MinimalResponse {
1264- URL : updatedIssue .GetHTMLURL (),
1390+ URL : finalIssue .GetHTMLURL (),
12651391 }
12661392
12671393 r , err := json .Marshal (minimalResponse )
12681394 if err != nil {
1269- return nil , fmt .Errorf ( "failed to marshal response: %w " , err )
1395+ return mcp . NewToolResultError ( fmt .Sprintf ( "Failed to marshal response: %v " , err )), nil
12701396 }
12711397
12721398 return mcp .NewToolResultText (string (r )), nil
0 commit comments