Skip to content

Commit 45cac89

Browse files
committed
Bypass issue form for type clears
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea8faa5c-7f26-4e2d-bf9c-6f0b5f173e8c
1 parent c089d32 commit 45cac89

2 files changed

Lines changed: 69 additions & 17 deletions

File tree

pkg/github/issues.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2047,6 +2047,12 @@ var issueWriteFormParams = map[string]struct{}{
20472047
"_ui_submitted": {},
20482048
}
20492049

2050+
func shouldIssueWriteDeferToForm(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) bool {
2051+
issueType, issueTypeProvided, err := OptionalParamOK[string](args, "type")
2052+
explicitTypeClear := err == nil && issueTypeProvided && issueType == "" && args["method"] == "update"
2053+
return !explicitTypeClear && shouldDeferToForm(ctx, deps, req, args, issueWriteFormParams)
2054+
}
2055+
20502056
// issueWriteAwaitingFormResult builds the "awaiting form submission" stub
20512057
// returned when issue_write hands off to the MCP App form. The body is shared
20522058
// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so
@@ -2224,7 +2230,7 @@ Options are:
22242230

22252231
// Hand off to the interactive MCP App form unless this call must
22262232
// execute now (see shouldDeferToForm).
2227-
if shouldDeferToForm(ctx, deps, req, args, issueWriteFormParams) {
2233+
if shouldIssueWriteDeferToForm(ctx, deps, req, args) {
22282234
issueNumber := 0
22292235
if method == "update" {
22302236
n, numErr := RequiredInt(args, "issue_number")
@@ -2336,7 +2342,7 @@ Options are:
23362342
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
23372343
AssigneesProvided: assigneesProvided,
23382344
LabelsProvided: labelsProvided,
2339-
ClearIssueType: issueTypeProvided && issueType == "",
2345+
IssueTypeProvided: issueTypeProvided,
23402346
})
23412347
return result, nil, err
23422348
default:
@@ -2407,8 +2413,8 @@ type UpdateIssueOptions struct {
24072413
AssigneesProvided bool
24082414
// LabelsProvided sends the labels field even when the slice is empty.
24092415
LabelsProvided bool
2410-
// ClearIssueType sends an explicit null issue type.
2411-
ClearIssueType bool
2416+
// IssueTypeProvided sends the type field, including an explicit clear for an empty value.
2417+
IssueTypeProvided bool
24122418
}
24132419

24142420
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) {
@@ -2419,7 +2425,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
24192425
for _, opt := range opts {
24202426
updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided
24212427
updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided
2422-
updateOptions.ClearIssueType = updateOptions.ClearIssueType || opt.ClearIssueType
2428+
updateOptions.IssueTypeProvided = updateOptions.IssueTypeProvided || opt.IssueTypeProvided
24232429
}
24242430

24252431
// Create the issue request with only provided fields
@@ -2493,7 +2499,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
24932499
}
24942500
}
24952501

2496-
updatedIssue, resp, err := patchIssue(ctx, client, owner, repo, issueNumber, issueRequest, updateOptions.ClearIssueType)
2502+
updatedIssue, resp, err := patchIssue(ctx, client, owner, repo, issueNumber, issueRequest, issueType, updateOptions.IssueTypeProvided)
24972503
if err != nil {
24982504
return ghErrors.NewGitHubAPIErrorResponse(ctx,
24992505
"failed to update issue",
@@ -2631,8 +2637,8 @@ type updateIssueRequestWithNullableType struct {
26312637
Type *string `json:"type"`
26322638
}
26332639

2634-
func patchIssue(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, issueRequest github.UpdateIssueRequest, clearIssueType bool) (*github.Issue, *github.Response, error) {
2635-
if !clearIssueType {
2640+
func patchIssue(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, issueRequest github.UpdateIssueRequest, issueType string, issueTypeProvided bool) (*github.Issue, *github.Response, error) {
2641+
if !issueTypeProvided || issueType != "" {
26362642
return client.Issues.Update(ctx, owner, repo, issueNumber, issueRequest)
26372643
}
26382644

pkg/github/issues_test.go

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"maps"
910
"net/http"
1011
"strings"
1112
"sync/atomic"
@@ -1859,7 +1860,6 @@ func Test_issueWriteHasNonFormParams(t *testing.T) {
18591860
{name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: false},
18601861
{name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: false},
18611862
{name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: false},
1862-
{name: "type clear present", args: map[string]any{"title": "t", "type": ""}, want: false},
18631863
{name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: false},
18641864
{name: "state present", args: map[string]any{"state": "closed"}, want: false},
18651865
{name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false},
@@ -2844,17 +2844,28 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
28442844
func TestIssueWriteUpdatesIssueType(t *testing.T) {
28452845
tests := []struct {
28462846
name string
2847-
issueTypeInput string
2847+
args map[string]any
28482848
wantRequestBody string
28492849
}{
28502850
{
2851-
name: "set issue type",
2852-
issueTypeInput: "Bug",
2851+
name: "omit issue type",
2852+
args: map[string]any{
2853+
"title": "Updated title",
2854+
},
2855+
wantRequestBody: `{"title":"Updated title"}`,
2856+
},
2857+
{
2858+
name: "set issue type",
2859+
args: map[string]any{
2860+
"type": "Bug",
2861+
},
28532862
wantRequestBody: `{"type":"Bug"}`,
28542863
},
28552864
{
2856-
name: "clear issue type",
2857-
issueTypeInput: "",
2865+
name: "clear issue type",
2866+
args: map[string]any{
2867+
"type": "",
2868+
},
28582869
wantRequestBody: `{"type":null}`,
28592870
},
28602871
}
@@ -2876,13 +2887,14 @@ func TestIssueWriteUpdatesIssueType(t *testing.T) {
28762887
}
28772888
serverTool := IssueWrite(translations.NullTranslationHelper)
28782889
handler := serverTool.Handler(deps)
2879-
request := createMCPRequest(map[string]any{
2890+
requestArgs := map[string]any{
28802891
"method": "update",
28812892
"owner": "owner",
28822893
"repo": "repo",
28832894
"issue_number": float64(123),
2884-
"type": tc.issueTypeInput,
2885-
})
2895+
}
2896+
maps.Copy(requestArgs, tc.args)
2897+
request := createMCPRequest(requestArgs)
28862898

28872899
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
28882900
require.NoError(t, err)
@@ -2893,6 +2905,40 @@ func TestIssueWriteUpdatesIssueType(t *testing.T) {
28932905
}
28942906
}
28952907

2908+
func TestIssueWriteClearTypeBypassesMCPAppForm(t *testing.T) {
2909+
var gotRequestBody []byte
2910+
var readErr error
2911+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
2912+
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
2913+
gotRequestBody, readErr = io.ReadAll(r.Body)
2914+
w.WriteHeader(http.StatusOK)
2915+
_, _ = w.Write([]byte(`{"number":123,"html_url":"https://github.com/owner/repo/issues/123"}`))
2916+
},
2917+
}))
2918+
deps := BaseDeps{
2919+
Client: client,
2920+
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
2921+
featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
2922+
}
2923+
serverTool := IssueWrite(translations.NullTranslationHelper)
2924+
handler := serverTool.Handler(deps)
2925+
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
2926+
"method": "update",
2927+
"owner": "owner",
2928+
"repo": "repo",
2929+
"issue_number": float64(123),
2930+
"type": "",
2931+
})
2932+
2933+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
2934+
require.NoError(t, err)
2935+
require.False(t, result.IsError)
2936+
require.NoError(t, readErr)
2937+
require.JSONEq(t, `{"type":null}`, string(gotRequestBody))
2938+
textContent := getTextResult(t, result)
2939+
require.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/123")
2940+
}
2941+
28962942
func Test_UpdateIssue(t *testing.T) {
28972943
// Verify tool definition
28982944
serverTool := IssueWrite(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)