@@ -18,6 +18,87 @@ import (
1818	"github.com/shurcooL/githubv4" 
1919)
2020
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+ 
21102// IssueFragment represents a fragment of an issue node in the GraphQL API. 
22103type  IssueFragment  struct  {
23104	Number      githubv4.Int 
@@ -1100,7 +1181,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
11001181}
11011182
11021183// 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 ) {
11041185	return  mcp .NewTool ("update_issue" ,
11051186			mcp .WithDescription (t ("TOOL_UPDATE_ISSUE_DESCRIPTION" , "Update an existing issue in a GitHub repository." )),
11061187			mcp .WithToolAnnotation (mcp.ToolAnnotation {
@@ -1125,10 +1206,6 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11251206			mcp .WithString ("body" ,
11261207				mcp .Description ("New description" ),
11271208			),
1128- 			mcp .WithString ("state" ,
1129- 				mcp .Description ("New state" ),
1130- 				mcp .Enum ("open" , "closed" ),
1131- 			),
11321209			mcp .WithArray ("labels" ,
11331210				mcp .Description ("New labels" ),
11341211				mcp .Items (
@@ -1151,6 +1228,17 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11511228			mcp .WithString ("type" ,
11521229				mcp .Description ("New issue type" ),
11531230			),
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+ 			),
11541242		),
11551243		func (ctx  context.Context , request  mcp.CallToolRequest ) (* mcp.CallToolResult , error ) {
11561244			owner , err  :=  RequiredParam [string ](request , "owner" )
@@ -1186,14 +1274,6 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
11861274				issueRequest .Body  =  github .Ptr (body )
11871275			}
11881276
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- 
11971277			// Get labels 
11981278			labels , err  :=  OptionalStringArrayParam (request , "labels" )
11991279			if  err  !=  nil  {
@@ -1230,13 +1310,38 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12301310				issueRequest .Type  =  github .Ptr (issueType )
12311311			}
12321312
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 
12331333			client , err  :=  getClient (ctx )
12341334			if  err  !=  nil  {
12351335				return  nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
12361336			}
1337+ 
12371338			updatedIssue , resp , err  :=  client .Issues .Edit (ctx , owner , repo , issueNumber , issueRequest )
12381339			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 
12401345			}
12411346			defer  func () { _  =  resp .Body .Close () }()
12421347
@@ -1248,6 +1353,75 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
12481353				return  mcp .NewToolResultError (fmt .Sprintf ("failed to update issue: %s" , string (body ))), nil 
12491354			}
12501355
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+ 
12511425			// Return minimal response with just essential information 
12521426			minimalResponse  :=  MinimalResponse {
12531427				ID :  fmt .Sprintf ("%d" , updatedIssue .GetID ()),
0 commit comments