Skip to content

Commit 2328eb8

Browse files
committed
move state and state reason handling to gql
1 parent 5e7e556 commit 2328eb8

File tree

3 files changed

+443
-339
lines changed

3 files changed

+443
-339
lines changed

pkg/github/__toolsnaps__/update_issue.snap

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@
5757
"enum": [
5858
"completed",
5959
"not_planned",
60-
"duplicate",
61-
"reopened"
60+
"duplicate"
6261
],
6362
"type": "string"
6463
},

pkg/github/issues.go

Lines changed: 163 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
41106
type 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

Comments
 (0)