Skip to content

Commit e58c822

Browse files
committed
add close as duplicate functionality
1 parent 23a3257 commit e58c822

File tree

4 files changed

+154
-23
lines changed

4 files changed

+154
-23
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,13 +591,14 @@ The following sets of tools are available (all are on by default):
591591
- **update_issue** - Edit issue
592592
- `assignees`: New assignees (string[], optional)
593593
- `body`: New description (string, optional)
594+
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
594595
- `issue_number`: Issue number to update (number, required)
595596
- `labels`: New labels (string[], optional)
596597
- `milestone`: New milestone number (number, optional)
597598
- `owner`: Repository owner (string, required)
598599
- `repo`: Repository name (string, required)
599600
- `state`: New state (string, optional)
600-
- `state_reason`: Reason for the state change, ignored unless state is changed. (string, optional)
601+
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
601602
- `title`: New title (string, optional)
602603
- `type`: New issue type (string, optional)
603604

pkg/github/__toolsnaps__/update_issue.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"description": "New description",
1818
"type": "string"
1919
},
20+
"duplicate_of": {
21+
"description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
22+
"type": "number"
23+
},
2024
"issue_number": {
2125
"description": "Issue number to update",
2226
"type": "number"
@@ -49,7 +53,7 @@
4953
"type": "string"
5054
},
5155
"state_reason": {
52-
"description": "Reason for the state change, ignored unless state is changed.",
56+
"description": "Reason for the state change. Ignored unless state is changed.",
5357
"enum": [
5458
"completed",
5559
"not_planned",

pkg/github/issues.go

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2241
type 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

pkg/github/tools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
6262
AddWriteTools(
6363
toolsets.NewServerTool(CreateIssue(getClient, t)),
6464
toolsets.NewServerTool(AddIssueComment(getClient, t)),
65-
toolsets.NewServerTool(UpdateIssue(getClient, t)),
65+
toolsets.NewServerTool(UpdateIssue(getClient, getGQLClient, t)),
6666
toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
6767
toolsets.NewServerTool(AddSubIssue(getClient, t)),
6868
toolsets.NewServerTool(RemoveSubIssue(getClient, t)),

0 commit comments

Comments
 (0)