Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,12 +591,14 @@ The following sets of tools are available (all are on by default):
- **update_issue** - Edit issue
- `assignees`: New assignees (string[], optional)
- `body`: New description (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `issue_number`: Issue number to update (number, required)
- `labels`: New labels (string[], optional)
- `milestone`: New milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: New title (string, optional)
- `type`: New issue type (string, optional)

Expand Down
13 changes: 13 additions & 0 deletions pkg/github/__toolsnaps__/update_issue.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"description": "New description",
"type": "string"
},
"duplicate_of": {
"description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
"type": "number"
},
"issue_number": {
"description": "Issue number to update",
"type": "number"
Expand Down Expand Up @@ -48,6 +52,15 @@
],
"type": "string"
},
"state_reason": {
"description": "Reason for the state change. Ignored unless state is changed.",
"enum": [
"completed",
"not_planned",
"duplicate"
],
"type": "string"
},
"title": {
"description": "New title",
"type": "string"
Expand Down
258 changes: 234 additions & 24 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,90 @@ import (
"github.com/shurcooL/githubv4"
)

// CloseIssueInput represents the input for closing an issue via the GraphQL API.
// Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
type CloseIssueInput struct {
IssueID githubv4.ID `json:"issueId"`
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
StateReason *IssueClosedStateReason `json:"stateReason,omitempty"`
DuplicateIssueID *githubv4.ID `json:"duplicateIssueId,omitempty"`
}

// IssueClosedStateReason represents the reason an issue was closed.
// Used to extend the functionality of the githubv4 library to support closing issues as duplicates.
type IssueClosedStateReason string

const (
IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED"
IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE"
IssueClosedStateReasonNotPlanned IssueClosedStateReason = "NOT_PLANNED"
)

// fetchIssueIDs retrieves issue IDs via the GraphQL API.
// When duplicateOf is 0, it fetches only the main issue ID.
// When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query.
func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) {
// Build query variables common to both cases
vars := map[string]interface{}{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers
}

if duplicateOf == 0 {
// Only fetch the main issue ID
var query struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}

if err := gqlClient.Query(ctx, &query, vars); err != nil {
return "", "", fmt.Errorf("failed to get issue ID")
}

return query.Repository.Issue.ID, "", nil
}

// Fetch both issue IDs in a single query
var query struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}

// Add duplicate issue number to variables
vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers

if err := gqlClient.Query(ctx, &query, vars); err != nil {
return "", "", fmt.Errorf("failed to get issue ID")
}

return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil
}

// getCloseStateReason converts a string state reason to the appropriate enum value
func getCloseStateReason(stateReason string) *IssueClosedStateReason {
switch stateReason {
case "not_planned":
reason := IssueClosedStateReasonNotPlanned
return &reason
case "duplicate":
reason := IssueClosedStateReasonDuplicate
return &reason
default: // Default to "completed" for empty or "completed" values
reason := IssueClosedStateReasonCompleted
return &reason
}
}

// IssueFragment represents a fragment of an issue node in the GraphQL API.
type IssueFragment struct {
Number githubv4.Int
Expand Down Expand Up @@ -1100,7 +1184,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
}

// UpdateIssue creates a tool to update an existing issue in a GitHub repository.
func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
func UpdateIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("update_issue",
mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Expand All @@ -1125,10 +1209,6 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithString("body",
mcp.Description("New description"),
),
mcp.WithString("state",
mcp.Description("New state"),
mcp.Enum("open", "closed"),
),
mcp.WithArray("labels",
mcp.Description("New labels"),
mcp.Items(
Expand All @@ -1151,6 +1231,17 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithString("type",
mcp.Description("New issue type"),
),
mcp.WithString("state",
mcp.Description("New state"),
mcp.Enum("open", "closed"),
),
mcp.WithString("state_reason",
mcp.Description("Reason for the state change. Ignored unless state is changed."),
mcp.Enum("completed", "not_planned", "duplicate"),
),
mcp.WithNumber("duplicate_of",
mcp.Description("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
Expand All @@ -1168,6 +1259,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t

// Create the issue request with only provided fields
issueRequest := &github.IssueRequest{}
hasNonStateUpdates := false

// Set optional parameters if provided
title, err := OptionalParam[string](request, "title")
Expand All @@ -1176,6 +1268,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
if title != "" {
issueRequest.Title = github.Ptr(title)
hasNonStateUpdates = true
}

body, err := OptionalParam[string](request, "body")
Expand All @@ -1184,14 +1277,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
if body != "" {
issueRequest.Body = github.Ptr(body)
}

state, err := OptionalParam[string](request, "state")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if state != "" {
issueRequest.State = github.Ptr(state)
hasNonStateUpdates = true
}

// Get labels
Expand All @@ -1201,6 +1287,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
if len(labels) > 0 {
issueRequest.Labels = &labels
hasNonStateUpdates = true
}

// Get assignees
Expand All @@ -1210,6 +1297,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
if len(assignees) > 0 {
issueRequest.Assignees = &assignees
hasNonStateUpdates = true
}

milestone, err := OptionalIntParam(request, "milestone")
Expand All @@ -1219,6 +1307,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
if milestone != 0 {
milestoneNum := milestone
issueRequest.Milestone = &milestoneNum
hasNonStateUpdates = true
}

// Get issue type
Expand All @@ -1228,30 +1317,151 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
if issueType != "" {
issueRequest.Type = github.Ptr(issueType)
hasNonStateUpdates = true
}

client, err := getClient(ctx)
// Handle state, state_reason and duplicateOf parameters
state, err := OptionalParam[string](request, "state")
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
return mcp.NewToolResultError(err.Error()), nil
}
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)

stateReason, err := OptionalParam[string](request, "state_reason")
if err != nil {
return nil, fmt.Errorf("failed to update issue: %w", err)
return mcp.NewToolResultError(err.Error()), nil
}
if stateReason != "" && state == "" {
return mcp.NewToolResultError("state_reason can only be used when state is also provided"), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
duplicateOf, err := OptionalIntParam(request, "duplicate_of")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if duplicateOf != 0 && stateReason != "duplicate" {
return mcp.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil
}

// Determine if there were any updates at all
if !hasNonStateUpdates && state == "" {
return mcp.NewToolResultError("No update parameters provided."), nil
}

// Use REST API for non-state updates
if hasNonStateUpdates {
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

_, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to update issue",
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil
}
}

// Use GraphQL API for state updates
if state != "" {
gqlClient, err := getGQLClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GraphQL client: %w", err)
}

// Mandate specifying duplicateOf when trying to close as duplicate
if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 {
return mcp.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil
}

// Get target issue ID (and duplicate issue ID if needed)
issueID, duplicateIssueID, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, duplicateOf)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil
}

// Do all logic
switch state {
case "open":
// Use ReopenIssue mutation for opening
var mutation struct {
ReopenIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"reopenIssue(input: $input)"`
}

err = gqlClient.Mutate(ctx, &mutation, githubv4.ReopenIssueInput{
IssueID: issueID,
}, nil)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil
}
case "closed":
// Use CloseIssue mutation for closing
var mutation struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"closeIssue(input: $input)"`
}

closeInput := CloseIssueInput{
IssueID: issueID,
StateReason: getCloseStateReason(stateReason),
}

// Set duplicate issue ID if needed
if stateReason == "duplicate" {
closeInput.DuplicateIssueID = &duplicateIssueID
}

err = gqlClient.Mutate(ctx, &mutation, closeInput, nil)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil
}
}
return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil
}

// Get the final state of the issue to return
client, err := getClient(ctx)
if err != nil {
return nil, err
}

finalIssue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get issue", resp, err), nil
}
defer func() {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
}()

// Return minimal response with just essential information
minimalResponse := MinimalResponse{
ID: fmt.Sprintf("%d", updatedIssue.GetID()),
URL: updatedIssue.GetHTMLURL(),
ID: fmt.Sprintf("%d", finalIssue.GetID()),
URL: finalIssue.GetHTMLURL(),
}

r, err := json.Marshal(minimalResponse)
Expand Down
Loading
Loading