Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,7 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
- **OAuth Challenge Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
- `body`: Issue body content. For 'update', an empty string clears the body; omit body to keep it unchanged. (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'. (number, optional)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
Expand All @@ -1040,7 +1040,7 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
- `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`: Issue title (string, optional)
- `title`: Issue title. Required for 'create'. For 'update', an empty string is rejected; omit title to keep it unchanged. (string, optional)
- `type`: Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional)

- **list_issue_fields** - List issue fields
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/__toolsnaps__/issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"type": "array"
},
"body": {
"description": "Issue body content",
"description": "Issue body content. For 'update', an empty string clears the body; omit body to keep it unchanged.",
"type": "string"
},
"duplicate_of": {
Expand Down Expand Up @@ -126,7 +126,7 @@
"type": "string"
},
"title": {
"description": "Issue title",
"description": "Issue title. Required for 'create'. For 'update', an empty string is rejected; omit title to keep it unchanged.",
"type": "string"
},
"type": {
Expand Down
29 changes: 24 additions & 5 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -2746,11 +2746,11 @@ Options are:
},
"title": {
Type: "string",
Description: "Issue title",
Description: "Issue title. Required for 'create'. For 'update', an empty string is rejected; omit title to keep it unchanged.",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - README.md is now regenerated with script/generate-docs and included in 6903c15 (2 lines: the two issue_write schema entries). The other four files the generator touched had no content change, so I reverted them to keep the diff to the tool docs that actually moved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already regenerated: 6903c15 includes the updated README.md (issue_write now documents that an empty body clears it, an empty title is rejected, and omitting a field keeps it unchanged) plus the regenerated issue_write.snap. This comment was written against the previous head.

},
"body": {
Type: "string",
Description: "Issue body content",
Description: "Issue body content. For 'update', an empty string clears the body; omit body to keep it unchanged.",
},
"assignees": {
Type: "array",
Expand Down Expand Up @@ -2883,12 +2883,16 @@ Options are:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
titleValue, titleProvided := args["title"]
titleProvided = titleProvided && titleValue != nil

// Optional parameters
body, err := OptionalParam[string](args, "body")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
bodyValue, bodyProvided := args["body"]
bodyProvided = bodyProvided && bodyValue != nil

// Get assignees
assignees, err := OptionalStringArrayParam(args, "assignees")
Expand Down Expand Up @@ -3017,6 +3021,8 @@ Options are:
AssigneesProvided: assigneesProvided,
LabelsProvided: labelsProvided,
IssueTypeProvided: issueTypeProvided,
TitleProvided: titleProvided,
BodyProvided: bodyProvided,
}))
default:
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
Expand Down Expand Up @@ -3385,6 +3391,11 @@ type UpdateIssueOptions struct {
LabelsProvided bool
// IssueTypeProvided sends the type field, including an explicit clear.
IssueTypeProvided bool
// TitleProvided sends the title field, including an explicitly empty value.
// An empty title is rejected before the request is sent.
TitleProvided bool
// BodyProvided sends the body field, so an empty string clears the body.
BodyProvided bool
}

func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) {
Expand All @@ -3406,17 +3417,25 @@ func updateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided
updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided
updateOptions.IssueTypeProvided = updateOptions.IssueTypeProvided || opt.IssueTypeProvided
updateOptions.TitleProvided = updateOptions.TitleProvided || opt.TitleProvided
updateOptions.BodyProvided = updateOptions.BodyProvided || opt.BodyProvided
}

// Create the issue request with only provided fields
issueRequest := github.UpdateIssueRequest{}

// Set optional parameters if provided
if title != "" {
// Set optional parameters if provided. An empty body is meaningful (it
// clears the body), and an empty title is rejected rather than treated as
// "not provided", so both are decided by presence, not by value.
if updateOptions.TitleProvided && title == "" {
return utils.NewToolResultError("title cannot be empty, omit it to keep the current title"), nil, nil
}

if title != "" || updateOptions.TitleProvided {
issueRequest.Title = new(title)
}

if body != "" {
if body != "" || updateOptions.BodyProvided {
issueRequest.Body = new(body)
}

Expand Down
63 changes: 63 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5252,6 +5252,69 @@ func Test_UpdateIssueClearsLabelsAndAssignees(t *testing.T) {
assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL)
}

func Test_UpdateIssueClearsBodyWhenExplicitlyEmpty(t *testing.T) {
serverTool := IssueWrite(translations.NullTranslationHelper)
updatedIssue := &github.Issue{
Number: new(8),
HTMLURL: new("https://github.com/owner/repo/issues/8"),
}

client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"body": "",
}).andThen(mockResponse(t, http.StatusOK, updatedIssue)),
}))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(8),
"body": "",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)

require.NoError(t, err)
if result.IsError {
t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
}
textContent := getTextResult(t, result)

var updateResp MinimalResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp))
assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL)
}

func Test_UpdateIssueRejectsExplicitlyEmptyTitle(t *testing.T) {
serverTool := IssueWrite(translations.NullTranslationHelper)
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(8),
"title": "",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)

require.NoError(t, err)
require.True(t, result.IsError, "an explicitly empty title must be rejected instead of silently ignored")
assert.Contains(t, getErrorResult(t, result).Text, "title cannot be empty")
}

func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
Expand Down