- 
                Notifications
    
You must be signed in to change notification settings  - Fork 2.9k
 
          Add specifying state change reason to update_issue tool
          #1073
        
          New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
23a3257
              e58c822
              fc20058
              a48e5b6
              0702d8e
              5e7e556
              2328eb8
              0c9bc56
              d66d3ac
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| 
          
            
          
           | 
    @@ -18,6 +18,25 @@ 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" | ||
| ) | ||
| 
     | 
||
| // IssueFragment represents a fragment of an issue node in the GraphQL API. | ||
| type IssueFragment struct { | ||
| Number githubv4.Int | ||
| 
          
            
          
           | 
    @@ -1099,7 +1118,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{ | ||
| 
          
            
          
           | 
    @@ -1128,6 +1147,13 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| 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", "reopened"), | ||
| ), | ||
| mcp.WithNumber("duplicate_of", | ||
| mcp.Description("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."), | ||
| ), | ||
| mcp.WithArray("labels", | ||
| mcp.Description("New labels"), | ||
| mcp.Items( | ||
| 
          
            
          
           | 
    @@ -1167,6 +1193,8 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| 
     | 
||
| // Create the issue request with only provided fields | ||
| issueRequest := &github.IssueRequest{} | ||
| restUpdateNeeded := false | ||
| gqlUpdateNeeded := false | ||
| 
     | 
||
| // Set optional parameters if provided | ||
| title, err := OptionalParam[string](request, "title") | ||
| 
        
          
        
         | 
    @@ -1175,6 +1203,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| } | ||
| if title != "" { | ||
| issueRequest.Title = github.Ptr(title) | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| body, err := OptionalParam[string](request, "body") | ||
| 
        
          
        
         | 
    @@ -1183,14 +1212,45 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| } | ||
| if body != "" { | ||
| issueRequest.Body = github.Ptr(body) | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| // Handle state and state_reason parameters | ||
| state, err := OptionalParam[string](request, "state") | ||
| if err != nil { | ||
| return mcp.NewToolResultError(err.Error()), nil | ||
| } | ||
| if state != "" { | ||
| 
     | 
||
| stateReason, err := OptionalParam[string](request, "state_reason") | ||
| if err != nil { | ||
| return mcp.NewToolResultError(err.Error()), nil | ||
| } | ||
| 
     | 
||
| // Validate state_reason usage | ||
| if stateReason != "" && state == "" { | ||
| return mcp.NewToolResultError("state_reason can only be used when state is also provided"), nil | ||
| } | ||
| if state == "open" && stateReason != "" && stateReason != "reopened" { | ||
| return mcp.NewToolResultError("when state is 'open', state_reason can only be 'reopened'"), nil | ||
| } | ||
                
      
                  kerobbi marked this conversation as resolved.
               
              
                Outdated
          
            Show resolved
            Hide resolved
         | 
||
| if state == "closed" && stateReason != "" && stateReason != "completed" && stateReason != "not_planned" && stateReason != "duplicate" { | ||
                
      
                  kerobbi marked this conversation as resolved.
               
              
                Outdated
          
            Show resolved
            Hide resolved
         | 
||
| return mcp.NewToolResultError("when state is 'closed', state_reason can only be 'completed', 'not_planned', or 'duplicate'"), nil | ||
| } | ||
| 
     | 
||
| // Use GraphQL for duplicate closure, REST for everything else | ||
| if state == "closed" && stateReason == "duplicate" { | ||
| gqlUpdateNeeded = true | ||
| } else if state != "" { | ||
| issueRequest.State = github.Ptr(state) | ||
| if stateReason != "" { | ||
| issueRequest.StateReason = github.Ptr(stateReason) | ||
| } | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| duplicateOf, err := OptionalIntParam(request, "duplicate_of") | ||
| if err != nil { | ||
| return mcp.NewToolResultError(err.Error()), nil | ||
| } | ||
| 
     | 
||
| // Get labels | ||
| 
        
          
        
         | 
    @@ -1200,6 +1260,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| } | ||
| if len(labels) > 0 { | ||
| issueRequest.Labels = &labels | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| // Get assignees | ||
| 
        
          
        
         | 
    @@ -1209,6 +1270,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| } | ||
| if len(assignees) > 0 { | ||
| issueRequest.Assignees = &assignees | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| milestone, err := OptionalIntParam(request, "milestone") | ||
| 
        
          
        
         | 
    @@ -1218,6 +1280,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| if milestone != 0 { | ||
| milestoneNum := milestone | ||
| issueRequest.Milestone = &milestoneNum | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| // Get issue type | ||
| 
        
          
        
         | 
    @@ -1227,34 +1290,117 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t | |
| } | ||
| if issueType != "" { | ||
| issueRequest.Type = github.Ptr(issueType) | ||
| restUpdateNeeded = true | ||
| } | ||
| 
     | 
||
| client, err := getClient(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get GitHub client: %w", err) | ||
| if !restUpdateNeeded && !gqlUpdateNeeded { | ||
| return mcp.NewToolResultError("No update parameters provided."), nil | ||
| } | ||
| updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to update issue: %w", err) | ||
| 
     | 
||
| // Handle REST API updates (title, body, state, labels, assignees, milestone, type, state_reason except "duplicate") | ||
| if restUpdateNeeded { | ||
| client, err := getClient(ctx) | ||
| if err != nil { | ||
| 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 | ||
| } | ||
| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
| 
     | 
||
| if resp.StatusCode != http.StatusOK { | ||
| body, err := io.ReadAll(resp.Body) | ||
| // Handle GraphQL API updates (state_reason = "duplicate") | ||
| if gqlUpdateNeeded { | ||
                
       | 
||
| if duplicateOf == 0 { | ||
| return mcp.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil | ||
| } | ||
| 
     | 
||
| gqlClient, err := getGQLClient(ctx) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read response body: %w", err) | ||
| return nil, fmt.Errorf("failed to get GraphQL client: %w", err) | ||
| } | ||
| 
     | 
||
| var issueQuery struct { | ||
| Repository struct { | ||
| Issue struct { | ||
| ID githubv4.ID | ||
| } `graphql:"issue(number: $issueNumber)"` | ||
| DuplicateIssue struct { | ||
| ID githubv4.ID | ||
| } `graphql:"duplicateIssue: issue(number: $duplicateNumber)"` | ||
| } `graphql:"repository(owner: $owner, name: $repo)"` | ||
| } | ||
| 
     | 
||
| err = gqlClient.Query(ctx, &issueQuery, map[string]interface{}{ | ||
| "owner": githubv4.String(owner), | ||
| "repo": githubv4.String(repo), | ||
| "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers | ||
| "duplicateNumber": githubv4.Int(duplicateOf), // #nosec G115 - issue numbers are always small positive integers | ||
| }) | ||
| if err != nil { | ||
| return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil | ||
| } | ||
| 
     | 
||
| var mutation struct { | ||
| CloseIssue struct { | ||
| Issue struct { | ||
| ID githubv4.ID | ||
| Number githubv4.Int | ||
| URL githubv4.String | ||
| State githubv4.String | ||
| } | ||
| } `graphql:"closeIssue(input: $input)"` | ||
| } | ||
| 
     | 
||
| duplicateStateReason := IssueClosedStateReasonDuplicate | ||
| err = gqlClient.Mutate(ctx, &mutation, CloseIssueInput{ | ||
| IssueID: issueQuery.Repository.Issue.ID, | ||
| StateReason: &duplicateStateReason, | ||
| DuplicateIssueID: &issueQuery.Repository.DuplicateIssue.ID, | ||
| }, nil) | ||
| if err != nil { | ||
| return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue as duplicate", 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) | ||
                
      
                  kerobbi marked this conversation as resolved.
               
              
                Outdated
          
            Show resolved
            Hide resolved
         | 
||
| 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{ | ||
| URL: updatedIssue.GetHTMLURL(), | ||
| URL: finalIssue.GetHTMLURL(), | ||
| } | ||
| 
     | 
||
| r, err := json.Marshal(minimalResponse) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to marshal response: %w", err) | ||
| return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil | ||
| } | ||
| 
     | 
||
| return mcp.NewToolResultText(string(r)), nil | ||
| 
          
            
          
           | 
    ||
Uh oh!
There was an error while loading. Please reload this page.