@@ -37,6 +37,71 @@ const (
3737 IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED"
3838)
3939
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+ reason := IssueClosedStateReasonNotPlanned
95+ return & reason
96+ case "duplicate" :
97+ reason := IssueClosedStateReasonDuplicate
98+ return & reason
99+ default : // Default to "completed" for empty or "completed" values
100+ reason := IssueClosedStateReasonCompleted
101+ return & reason
102+ }
103+ }
104+
40105// IssueFragment represents a fragment of an issue node in the GraphQL API.
41106type IssueFragment struct {
42107 Number githubv4.Int
@@ -1144,17 +1209,6 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
11441209 mcp .WithString ("body" ,
11451210 mcp .Description ("New description" ),
11461211 ),
1147- mcp .WithString ("state" ,
1148- mcp .Description ("New state" ),
1149- mcp .Enum ("open" , "closed" ),
1150- ),
1151- mcp .WithString ("state_reason" ,
1152- mcp .Description ("Reason for the state change. Ignored unless state is changed." ),
1153- mcp .Enum ("completed" , "not_planned" , "duplicate" , "reopened" ),
1154- ),
1155- mcp .WithNumber ("duplicate_of" ,
1156- mcp .Description ("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." ),
1157- ),
11581212 mcp .WithArray ("labels" ,
11591213 mcp .Description ("New labels" ),
11601214 mcp .Items (
@@ -1177,6 +1231,17 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
11771231 mcp .WithString ("type" ,
11781232 mcp .Description ("New issue type" ),
11791233 ),
1234+ mcp .WithString ("state" ,
1235+ mcp .Description ("New state" ),
1236+ mcp .Enum ("open" , "closed" ),
1237+ ),
1238+ mcp .WithString ("state_reason" ,
1239+ mcp .Description ("Reason for the state change. Ignored unless state is changed." ),
1240+ mcp .Enum ("completed" , "not_planned" , "duplicate" ),
1241+ ),
1242+ mcp .WithNumber ("duplicate_of" ,
1243+ mcp .Description ("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." ),
1244+ ),
11801245 ),
11811246 func (ctx context.Context , request mcp.CallToolRequest ) (* mcp.CallToolResult , error ) {
11821247 owner , err := RequiredParam [string ](request , "owner" )
@@ -1194,8 +1259,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
11941259
11951260 // Create the issue request with only provided fields
11961261 issueRequest := & github.IssueRequest {}
1197- restUpdateNeeded := false
1198- gqlUpdateNeeded := false
1262+ hasNonStateUpdates := false
11991263
12001264 // Set optional parameters if provided
12011265 title , err := OptionalParam [string ](request , "title" )
@@ -1204,7 +1268,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12041268 }
12051269 if title != "" {
12061270 issueRequest .Title = github .Ptr (title )
1207- restUpdateNeeded = true
1271+ hasNonStateUpdates = true
12081272 }
12091273
12101274 body , err := OptionalParam [string ](request , "body" )
@@ -1213,45 +1277,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12131277 }
12141278 if body != "" {
12151279 issueRequest .Body = github .Ptr (body )
1216- restUpdateNeeded = true
1217- }
1218-
1219- // Handle state and state_reason parameters
1220- state , err := OptionalParam [string ](request , "state" )
1221- if err != nil {
1222- return mcp .NewToolResultError (err .Error ()), nil
1223- }
1224-
1225- stateReason , err := OptionalParam [string ](request , "state_reason" )
1226- if err != nil {
1227- return mcp .NewToolResultError (err .Error ()), nil
1228- }
1229-
1230- // Validate state_reason usage
1231- if stateReason != "" && state == "" {
1232- return mcp .NewToolResultError ("state_reason can only be used when state is also provided" ), nil
1233- }
1234- if state == "open" && stateReason != "" && stateReason != "reopened" {
1235- return mcp .NewToolResultError ("when state is 'open', state_reason can only be 'reopened'" ), nil
1236- }
1237- if state == "closed" && stateReason != "" && stateReason != "completed" && stateReason != "not_planned" && stateReason != "duplicate" {
1238- return mcp .NewToolResultError ("when state is 'closed', state_reason can only be 'completed', 'not_planned', or 'duplicate'" ), nil
1239- }
1240-
1241- // Use GraphQL for duplicate closure, REST for everything else
1242- if state == "closed" && stateReason == "duplicate" {
1243- gqlUpdateNeeded = true
1244- } else if state != "" {
1245- issueRequest .State = github .Ptr (state )
1246- if stateReason != "" {
1247- issueRequest .StateReason = github .Ptr (stateReason )
1248- }
1249- restUpdateNeeded = true
1250- }
1251-
1252- duplicateOf , err := OptionalIntParam (request , "duplicate_of" )
1253- if err != nil {
1254- return mcp .NewToolResultError (err .Error ()), nil
1280+ hasNonStateUpdates = true
12551281 }
12561282
12571283 // Get labels
@@ -1261,7 +1287,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12611287 }
12621288 if len (labels ) > 0 {
12631289 issueRequest .Labels = & labels
1264- restUpdateNeeded = true
1290+ hasNonStateUpdates = true
12651291 }
12661292
12671293 // Get assignees
@@ -1271,7 +1297,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12711297 }
12721298 if len (assignees ) > 0 {
12731299 issueRequest .Assignees = & assignees
1274- restUpdateNeeded = true
1300+ hasNonStateUpdates = true
12751301 }
12761302
12771303 milestone , err := OptionalIntParam (request , "milestone" )
@@ -1281,7 +1307,7 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12811307 if milestone != 0 {
12821308 milestoneNum := milestone
12831309 issueRequest .Milestone = & milestoneNum
1284- restUpdateNeeded = true
1310+ hasNonStateUpdates = true
12851311 }
12861312
12871313 // Get issue type
@@ -1291,15 +1317,38 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
12911317 }
12921318 if issueType != "" {
12931319 issueRequest .Type = github .Ptr (issueType )
1294- restUpdateNeeded = true
1320+ hasNonStateUpdates = true
1321+ }
1322+
1323+ // Handle state, state_reason and duplicateOf parameters
1324+ state , err := OptionalParam [string ](request , "state" )
1325+ if err != nil {
1326+ return mcp .NewToolResultError (err .Error ()), nil
12951327 }
12961328
1297- if ! restUpdateNeeded && ! gqlUpdateNeeded {
1329+ stateReason , err := OptionalParam [string ](request , "state_reason" )
1330+ if err != nil {
1331+ return mcp .NewToolResultError (err .Error ()), nil
1332+ }
1333+ if stateReason != "" && state == "" {
1334+ return mcp .NewToolResultError ("state_reason can only be used when state is also provided" ), nil
1335+ }
1336+
1337+ duplicateOf , err := OptionalIntParam (request , "duplicate_of" )
1338+ if err != nil {
1339+ return mcp .NewToolResultError (err .Error ()), nil
1340+ }
1341+ if duplicateOf != 0 && stateReason != "duplicate" {
1342+ return mcp .NewToolResultError ("duplicate_of can only be used when state_reason is 'duplicate'" ), nil
1343+ }
1344+
1345+ // Determine if there were any updates at all
1346+ if ! hasNonStateUpdates && state == "" {
12981347 return mcp .NewToolResultError ("No update parameters provided." ), nil
12991348 }
13001349
1301- // Handle REST API updates (title, body, state, labels, assignees, milestone, type, state_reason except "duplicate")
1302- if restUpdateNeeded {
1350+ // Use REST API for non- state updates
1351+ if hasNonStateUpdates {
13031352 client , err := getClient (ctx )
13041353 if err != nil {
13051354 return nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
@@ -1324,57 +1373,72 @@ func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translati
13241373 }
13251374 }
13261375
1327- // Handle GraphQL API updates (state_reason = "duplicate")
1328- if gqlUpdateNeeded {
1329- if duplicateOf == 0 {
1330- return mcp .NewToolResultError ("duplicate_of must be provided when state_reason is 'duplicate'" ), nil
1331- }
1332-
1376+ // Use GraphQL API for state updates
1377+ if state != "" {
13331378 gqlClient , err := getGQLClient (ctx )
13341379 if err != nil {
13351380 return nil , fmt .Errorf ("failed to get GraphQL client: %w" , err )
13361381 }
13371382
1338- var issueQuery struct {
1339- Repository struct {
1340- Issue struct {
1341- ID githubv4.ID
1342- } `graphql:"issue(number: $issueNumber)"`
1343- DuplicateIssue struct {
1344- ID githubv4.ID
1345- } `graphql:"duplicateIssue: issue(number: $duplicateNumber)"`
1346- } `graphql:"repository(owner: $owner, name: $repo)"`
1383+ // Mandate specifying duplicateOf when trying to close as duplicate
1384+ if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 {
1385+ return mcp .NewToolResultError ("duplicate_of must be provided when state_reason is 'duplicate'" ), nil
13471386 }
13481387
1349- err = gqlClient .Query (ctx , & issueQuery , map [string ]interface {}{
1350- "owner" : githubv4 .String (owner ),
1351- "repo" : githubv4 .String (repo ),
1352- "issueNumber" : githubv4 .Int (issueNumber ), // #nosec G115 - issue numbers are always small positive integers
1353- "duplicateNumber" : githubv4 .Int (duplicateOf ), // #nosec G115 - issue numbers are always small positive integers
1354- })
1388+ // Get target issue ID (and duplicate issue ID if needed)
1389+ issueID , duplicateIssueID , err := fetchIssueIDs (ctx , gqlClient , owner , repo , issueNumber , duplicateOf )
13551390 if err != nil {
13561391 return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to find issues" , err ), nil
13571392 }
13581393
1359- var mutation struct {
1360- CloseIssue struct {
1361- Issue struct {
1362- ID githubv4.ID
1363- Number githubv4.Int
1364- URL githubv4.String
1365- State githubv4.String
1366- }
1367- } `graphql:"closeIssue(input: $input)"`
1368- }
1394+ // Do all logic
1395+ switch state {
1396+ case "open" :
1397+ // Use ReopenIssue mutation for opening
1398+ var mutation struct {
1399+ ReopenIssue struct {
1400+ Issue struct {
1401+ ID githubv4.ID
1402+ Number githubv4.Int
1403+ URL githubv4.String
1404+ State githubv4.String
1405+ }
1406+ } `graphql:"reopenIssue(input: $input)"`
1407+ }
13691408
1370- duplicateStateReason := IssueClosedStateReasonDuplicate
1371- err = gqlClient .Mutate (ctx , & mutation , CloseIssueInput {
1372- IssueID : issueQuery .Repository .Issue .ID ,
1373- StateReason : & duplicateStateReason ,
1374- DuplicateIssueID : & issueQuery .Repository .DuplicateIssue .ID ,
1375- }, nil )
1376- if err != nil {
1377- return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to close issue as duplicate" , err ), nil
1409+ err = gqlClient .Mutate (ctx , & mutation , githubv4.ReopenIssueInput {
1410+ IssueID : issueID ,
1411+ }, nil )
1412+ if err != nil {
1413+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to reopen issue" , err ), nil
1414+ }
1415+ case "closed" :
1416+ // Use CloseIssue mutation for closing
1417+ var mutation struct {
1418+ CloseIssue struct {
1419+ Issue struct {
1420+ ID githubv4.ID
1421+ Number githubv4.Int
1422+ URL githubv4.String
1423+ State githubv4.String
1424+ }
1425+ } `graphql:"closeIssue(input: $input)"`
1426+ }
1427+
1428+ closeInput := CloseIssueInput {
1429+ IssueID : issueID ,
1430+ StateReason : getCloseStateReason (stateReason ),
1431+ }
1432+
1433+ // Set duplicate issue ID if needed
1434+ if stateReason == "duplicate" {
1435+ closeInput .DuplicateIssueID = & duplicateIssueID
1436+ }
1437+
1438+ err = gqlClient .Mutate (ctx , & mutation , closeInput , nil )
1439+ if err != nil {
1440+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "Failed to close issue" , err ), nil
1441+ }
13781442 }
13791443 }
13801444
0 commit comments