diff --git a/README.md b/README.md index dff62321b8..c777428e3a 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,7 @@ The following sets of tools are available: - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **list_pull_requests** - List pull requests diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0b75a61bac..4f17004c29 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -44,6 +44,7 @@ runtime behavior (such as output formatting) won't appear here. - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -71,6 +72,27 @@ runtime behavior (such as output formatting) won't appear here. - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) + ### `remote_mcp_issue_fields` - **issue_write** - Create or update issue @@ -240,7 +262,7 @@ runtime behavior (such as output formatting) won't appear here. - `owner`: Repository owner (username or organization) (string, required) - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required) - **resolve_review_thread** - Resolve Review Thread - **Required OAuth Scopes**: `repo` diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 881030f020..b60aa4fd56 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -38,6 +38,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -65,6 +66,27 @@ The list below is generated from the Go source. It covers tool **inventory and s - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) + ### `remote_mcp_issue_fields` - **issue_write** - Create or update issue diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index a8a94ce690..3bd3404d49 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -42,6 +42,13 @@ "description": "Repository name", "type": "string" }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, "title": { "description": "PR title", "type": "string" diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap index 7e6d33a274..20f1ab62b6 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -37,4 +37,4 @@ "type": "object" }, "name": "request_pull_request_reviewers" -} +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap new file mode 100644 index 0000000000..7f13d97c1c --- /dev/null +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -0,0 +1,45 @@ +{ + "_meta": { + "ui": { + "visibility": [ + "app" + ] + } + }, + "annotations": { + "readOnlyHint": true, + "title": "Get UI data" + }, + "description": "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers).", + "inputSchema": { + "properties": { + "method": { + "description": "The type of data to fetch", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for all methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + "type": "string" + } + }, + "required": [ + "method", + "owner" + ], + "type": "object" + }, + "name": "ui_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index 640df79702..cadc391ef4 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-edit", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Edit pull request" }, @@ -61,4 +70,4 @@ "type": "object" }, "name": "update_pull_request" -} +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6e9cdae53b..4c96080cc5 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1764,8 +1764,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" // issueWriteFormParams are the parameters the issue_write MCP App form collects -// and re-sends on submit. The form only supports title/body editing (plus the -// routing/identity fields), so any other parameter present on a call cannot be +// and re-sends on submit. Any other parameter present on a call cannot be // represented by the form. var issueWriteFormParams = map[string]struct{}{ "method": {}, @@ -1774,12 +1773,16 @@ var issueWriteFormParams = map[string]struct{}{ "title": {}, "body": {}, "issue_number": {}, + "issue_fields": {}, + "state": {}, + "state_reason": {}, + "duplicate_of": {}, "_ui_submitted": {}, } // issueWriteHasNonFormParams reports whether the call carries any parameter the // issue_write MCP App form cannot represent (anything outside issueWriteFormParams, -// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass +// e.g. labels, assignees, milestones or issue types). Such calls must bypass // the UI form and execute directly so the supplied values aren't silently dropped. func issueWriteHasNonFormParams(args map[string]any) bool { for key, value := range args { @@ -1793,6 +1796,36 @@ func issueWriteHasNonFormParams(args map[string]any) bool { return false } +// issueWriteAwaitingFormResult builds the "awaiting form submission" stub +// returned when issue_write hands off to the MCP App form. The body is shared +// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so +// agents that bail on error don't claim success or chain dependent tool calls +// while the user is still interacting with the form; the host renders the UI +// regardless because rendering is keyed off the tool's _meta.ui resourceUri. +func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) *mcp.CallToolResult { + var msg string + if method == "update" { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for editing issue #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was updated, "+ + "and do not claim the operation succeeded. The issue has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + issueNumber, owner, repo, + ) + } else { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for creating a new issue in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was created, "+ + "and do not claim the operation succeeded. The issue has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + ) + } + return utils.NewToolResultAwaitingFormSubmission(msg) +} + // IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write // (with the issue_fields parameter). LegacyIssueWrite is served when the flag // is off. Both register under the tool name "issue_write"; exactly one is @@ -1946,14 +1979,15 @@ Options are: uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") @@ -2178,14 +2212,15 @@ Options are: uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d794ad1679..f1d833dfc9 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1561,7 +1561,8 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create an issue") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -1595,78 +1596,10 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "non-UI client should execute directly") }) - t.Run("UI client with state change skips form and executes directly", func(t *testing.T) { - mockBaseIssue := &github.Issue{ - Number: github.Ptr(1), - Title: github.Ptr("Test"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), - } - issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - }, - }) - closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ - "closeIssue": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - "number": 1, - "url": "https://github.com/owner/repo/issues/1", - "state": "CLOSED", - }, - }, - }) - completedReason := IssueClosedStateReasonCompleted - - closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), - })) - closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(1), - }, - issueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - }{}, - CloseIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - StateReason: &completedReason, - }, - nil, - closeSuccessResponse, - ), - )) - - closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - closeHandler := serverTool.Handler(closeDeps) - + t.Run("UI client with state change routes through UI form", func(t *testing.T) { + // state/state_reason/duplicate_of are form params (the issue-write view + // renders close/reopen controls), so a call carrying them must go to + // the form rather than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "update", "owner": "owner", @@ -1675,14 +1608,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "state": "closed", "state_reason": "completed", }) - result, err := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to update issue", - "state change should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", - "state change should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", + "state change should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client update without state change returns form message", func(t *testing.T) { @@ -1697,65 +1629,15 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to update issue #1", + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", "update without state should show UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) - t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) { - // The MCP App form does not collect or re-send issue_fields, so a call - // carrying them must bypass the form and apply the values directly. - fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ - "title": "Issue with fields", - "body": "", - "labels": []any{}, - "assignees": []any{}, - "issue_field_values": []any{ - map[string]any{"field_id": float64(101), "value": "P1"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(125), - Title: github.Ptr("Issue with fields"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), - State: github.Ptr("open"), - }), - ), - })) - fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - issueFieldWriteMetadataQuery{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issueFields": map[string]any{ - "nodes": []any{ - map[string]any{ - "__typename": "IssueFieldSingleSelect", - "fullDatabaseId": "101", - "name": "Priority", - "dataType": "single_select", - "options": []any{ - map[string]any{"fullDatabaseId": "9001", "name": "P1"}, - }, - }, - }, - }, - }, - }), - ), - )) - - fieldsDeps := BaseDeps{ - Client: fieldsClient, - GQLClient: fieldsGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - fieldsHandler := serverTool.Handler(fieldsDeps) - + t.Run("UI client with issue_fields routes through UI form", func(t *testing.T) { + // issue_fields is now a form param (the issue-write view renders a + // per-field editor), so a call carrying it must go to the form rather + // than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "create", "owner": "owner", @@ -1765,14 +1647,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { map[string]any{"field_name": "Priority", "field_option_name": "P1"}, }, }) - result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", - "issue_fields should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125", - "issue_fields call should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue", + "issue_fields should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with labels skips form and executes directly", func(t *testing.T) { @@ -1789,7 +1670,7 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "labels should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "labels call should execute directly and return issue URL") @@ -1810,10 +1691,10 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true}, - {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true}, - {name: "state present", args: map[string]any{"state": "closed"}, want: true}, - {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true}, - {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true}, + {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: false}, + {name: "state present", args: map[string]any{"state": "closed"}, want: false}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: false}, {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false}, } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 05028850d7..c9e13b57fc 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -544,6 +544,9 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" +// PullRequestEditUIResourceURI is the URI for the update_pull_request tool's MCP App UI resource. +const PullRequestEditUIResourceURI = "ui://github-mcp-server/pr-edit" + // pullRequestWriteFormParams are the parameters the create_pull_request MCP App // form collects and re-sends on submit. Any other parameter present on a call // cannot be represented by the form. @@ -556,6 +559,21 @@ var pullRequestWriteFormParams = map[string]struct{}{ "base": {}, "draft": {}, "maintainer_can_modify": {}, + "reviewers": {}, + "_ui_submitted": {}, +} + +var pullRequestUpdateFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "pullNumber": {}, + "title": {}, + "body": {}, + "state": {}, + "draft": {}, + "base": {}, + "maintainer_can_modify": {}, + "reviewers": {}, "_ui_submitted": {}, } @@ -575,6 +593,18 @@ func pullRequestWriteHasNonFormParams(args map[string]any) bool { return false } +func pullRequestUpdateHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestUpdateFormParams[key]; !ok { + return true + } + } + return false +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -627,6 +657,13 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Type: "boolean", Description: "Allow maintainer edits", }, + "reviewers": { + Type: "array", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, }, Required: []string{"owner", "repo", "title", "head", "base"}, }, @@ -650,7 +687,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) { - return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for creating a new pull request in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was created, "+ + "and do not claim the operation succeeded. The pull request has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + )), nil, nil } // When creating PR, title/head/base are required @@ -691,6 +735,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), @@ -726,6 +775,36 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create pull request", resp, bodyBytes), nil, nil } + if len(reviewers) > 0 { + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + reviewersRequest := github.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + } + + _, reviewerResp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pr.GetNumber(), reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + reviewerResp, + err, + ), nil, nil + } + defer func() { + if reviewerResp != nil && reviewerResp.Body != nil { + _ = reviewerResp.Body.Close() + } + }() + + if reviewerResp.StatusCode != http.StatusCreated && reviewerResp.StatusCode != http.StatusOK { + bodyBytes, err := io.ReadAll(reviewerResp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request reviewers", reviewerResp, bodyBytes), nil, nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), @@ -803,10 +882,16 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestEditUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -820,6 +905,18 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestUpdateHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for editing pull request #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was updated, "+ + "and do not claim the operation succeeded. The pull request has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + pullNumber, owner, repo, + )), nil, nil + } + _, draftProvided := args["draft"] var draftValue bool if draftProvided { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index aff71e4c1a..6686223434 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2450,7 +2450,8 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create a pull request") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -2490,24 +2491,120 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { // A parameter the form does not collect must bypass the form rather than // be silently dropped. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ - "owner": "owner", - "repo": "repo", - "title": "Test PR", - "head": "feature", - "base": "main", - "reviewers": []any{"octocat"}, + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "unknown_param": "value", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create a pull request", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "non-form param should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-form param call should execute directly and return PR URL") }) } +// Test_UpdatePullRequest_MCPAppsFeature_UIGate verifies the form-routing +// behavior for update_pull_request: UI clients without _ui_submitted get a +// pending-form stub (marked IsError so agents don't claim success), UI clients +// with _ui_submitted execute directly, non-UI clients execute directly, and +// UI clients carrying non-form params bypass the form. +func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { + t.Parallel() + + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Updated"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, + Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, + User: &github.User{Login: github.Ptr("testuser")}, + } + + serverTool := UpdatePullRequest(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing pull request #42") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "submitted form should execute successfully: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "submitted form should return the updated PR URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "non-UI client should execute directly: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should return the updated PR URL") + }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "unknown_param": "value", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "non-form param should skip UI form") + }) +} + func Test_pullRequestWriteHasNonFormParams(t *testing.T) { t.Parallel() @@ -2517,8 +2614,8 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { want bool }{ {name: "no params", args: map[string]any{}, want: false}, - {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, - {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, + {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "reviewers": []any{"octocat"}, "_ui_submitted": true}, want: false}, + {name: "unknown param present", args: map[string]any{"title": "t", "unknown_param": "value"}, want: true}, {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..e8d85ef488 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -290,6 +290,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListLabels(t), LabelWrite(t), + // UI tools (insiders only) + UIGet(t), + // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) GranularCreateIssue(t), GranularUpdateIssueTitle(t), diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index ab3ebfd163..063ed265bc 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -103,4 +103,31 @@ func RegisterUIResources(s *mcp.Server) { }, nil }, ) + + s.AddResource( + &mcp.Resource{ + URI: PullRequestEditUIResourceURI, + Name: "pr_edit_ui", + Description: "MCP App UI for editing GitHub pull requests", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-edit.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestEditUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, + }, + }, + }, nil + }, + ) } diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 928950ac73..3e432c3992 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -54,6 +54,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) { GetMeUIResourceURI, IssueWriteUIResourceURI, PullRequestWriteUIResourceURI, + PullRequestEditUIResourceURI, } for _, uri := range uris { t.Run(uri, func(t *testing.T) { diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go new file mode 100644 index 0000000000..1a21338611 --- /dev/null +++ b/pkg/github/ui_tools.go @@ -0,0 +1,501 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// UIGet creates a tool to fetch UI data for MCP Apps. +func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataContext, // Use context toolset so it's always available + mcp.Tool{ + Name: "ui_get", + Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), + ReadOnlyHint: true, + }, + // ui_get only backs MCP App views; declaring app-only visibility keeps + // it out of the agent's tool list while remaining callable by the views + // via tools/call (per the MCP Apps 2026-01-26 spec). + Meta: mcp.Meta{ + "ui": map[string]any{ + "visibility": []string{"app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, + Description: "The type of data to fetch", + }, + "owner": { + Type: "string", + Description: "Repository owner (required for all methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + }, + }, + Required: []string{"method", "owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "labels": + return uiGetLabels(ctx, deps, args, owner) + case "assignees": + return uiGetAssignees(ctx, deps, args, owner) + case "milestones": + return uiGetMilestones(ctx, deps, args, owner) + case "issue_types": + return uiGetIssueTypes(ctx, deps, owner) + case "branches": + return uiGetBranches(ctx, deps, args, owner) + case "issue_fields": + return uiGetIssueFields(ctx, deps, args, owner) + case "reviewers": + return uiGetReviewers(ctx, deps, args, owner) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) + st.FeatureFlagEnable = MCPAppsFeatureFlag + return st +} + +func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "cursor": (*githubv4.String)(nil), + } + + labels := make([]map[string]any, 0) + var totalCount int + for { + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil + } + for _, labelNode := range query.Repository.Labels.Nodes { + labels = append(labels, map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + }) + } + totalCount = int(query.Repository.Labels.TotalCount) + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor) + } + + response := map[string]any{ + "labels": labels, + "totalCount": totalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.ListOptions{PerPage: 100} + var allAssignees []*github.User + + for { + assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil + } + allAssignees = append(allAssignees, assignees...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]string, len(allAssignees)) + for i, u := range allAssignees { + result[i] = map[string]string{ + "login": u.GetLogin(), + "avatar_url": u.GetAvatarURL(), + } + } + + out, err := json.Marshal(map[string]any{ + "assignees": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.MilestoneListOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allMilestones []*github.Milestone + for { + milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil + } + allMilestones = append(allMilestones, milestones...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]any, len(allMilestones)) + for i, m := range allMilestones { + dueOn := "" + if m.DueOn != nil { + dueOn = m.GetDueOn().Format("2006-01-02") + } + result[i] = map[string]any{ + "number": m.GetNumber(), + "title": m.GetTitle(), + "description": m.GetDescription(), + "state": m.GetState(), + "open_issues": m.GetOpenIssues(), + "due_on": dueOn, + } + } + + out, err := json.Marshal(map[string]any{ + "milestones": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) { + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allBranches []*github.Branch + for { + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil + } + allBranches = append(allBranches, branches...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + minimalBranches := make([]MinimalBranch, 0, len(allBranches)) + for _, branch := range allBranches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(map[string]any{ + "branches": minimalBranches, + "totalCount": len(minimalBranches), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if !deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { + return marshalUIGetIssueFields(nil) + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + + return marshalUIGetIssueFields(fields) +} + +func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) { + resultFields := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + if !uiSupportedIssueFieldDataType(field.DataType) { + continue + } + + fieldResult := map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + "description": field.Description, + } + + if field.DataType == "single_select" { + fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...) + sort.SliceStable(fieldOptions, func(i, j int) bool { + left, leftOK := issueFieldOptionPriority(fieldOptions[i]) + right, rightOK := issueFieldOptionPriority(fieldOptions[j]) + if leftOK != rightOK { + return leftOK + } + return left < right + }) + + options := make([]map[string]string, 0, len(fieldOptions)) + for _, option := range fieldOptions { + options = append(options, map[string]string{ + "name": option.Name, + "description": option.Description, + "color": option.Color, + }) + } + fieldResult["options"] = options + } + + resultFields = append(resultFields, fieldResult) + } + + r, err := json.Marshal(map[string]any{ + "fields": resultFields, + "totalCount": len(resultFields), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiSupportedIssueFieldDataType(dataType string) bool { + switch dataType { + case "text", "number", "date", "single_select": + return true + default: + return false + } +} + +func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) { + if option.Priority == nil { + return 0, false + } + return *option.Priority, true +} + +func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + collaboratorOpts := &github.ListCollaboratorsOptions{ + Affiliation: "all", + ListOptions: github.ListOptions{PerPage: 100}, + } + var allCollaborators []*github.User + for { + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil + } + allCollaborators = append(allCollaborators, collaborators...) + if resp.NextPage == 0 { + break + } + collaboratorOpts.Page = resp.NextPage + } + + teamOpts := &github.ListOptions{PerPage: 100} + var allTeams []*github.Team + for { + teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil + } + allTeams = append(allTeams, teams...) + if resp.NextPage == 0 { + break + } + teamOpts.Page = resp.NextPage + } + + users := make([]map[string]string, 0, len(allCollaborators)) + for _, user := range allCollaborators { + login := user.GetLogin() + if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") { + continue + } + users = append(users, map[string]string{ + "login": login, + "avatar_url": user.GetAvatarURL(), + }) + } + + teams := make([]map[string]string, len(allTeams)) + for i, team := range allTeams { + teams[i] = map[string]string{ + "slug": team.GetSlug(), + "name": team.GetName(), + "org": owner, + } + } + + r, err := json.Marshal(map[string]any{ + "users": users, + "teams": teams, + "totalCount": len(users) + len(teams), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go new file mode 100644 index 0000000000..2fded6b20e --- /dev/null +++ b/pkg/github/ui_tools_test.go @@ -0,0 +1,414 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_UIGet(t *testing.T) { + // Verify tool definition + serverTool := UIGet(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "ui_get", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"}) + assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only") + assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag") + + // ui_get must be app-only so the host hides it from the agent's tool list + // while keeping it callable by the views (MCP Apps 2026-01-26 spec). + ui, ok := tool.Meta["ui"].(map[string]any) + require.True(t, ok, "ui_get should declare _meta.ui") + assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only") + + // Setup mock data + mockAssignees := []*github.User{ + {Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")}, + {Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")}, + } + + mockBranches := []*github.Branch{ + {Name: github.Ptr("main"), Protected: github.Ptr(true)}, + {Name: github.Ptr("feature"), Protected: github.Ptr(false)}, + } + + dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + mockMilestones := []*github.Milestone{ + {Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, + {Number: github.Ptr(2), Title: github.Ptr("no due date")}, + } + + mockIssueTypes := []*github.IssueType{ + {Name: github.Ptr("Bug")}, + {Name: github.Ptr("Feature")}, + } + + mockReviewers := []*github.User{ + {Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")}, + {Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")}, + {Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")}, + } + + mockReviewerTeams := []*github.Team{ + {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + } + + tests := []struct { + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResult func(t *testing.T, responseText string) + }{ + { + name: "successful assignees fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees), + }), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "assignees") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful branches fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches), + }), + requestArgs: map[string]any{ + "method": "branches", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "branches") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful milestones fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones), + }), + requestArgs: map[string]any{ + "method": "milestones", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + milestones, ok := response["milestones"].([]any) + require.True(t, ok, "milestones should be a list") + require.Len(t, milestones, 2) + first := milestones[0].(map[string]any) + assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted") + second := milestones[1].(map[string]any) + assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time") + }, + }, + { + name: "successful issue_types fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var issueTypes []map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes)) + require.Len(t, issueTypes, 2) + assert.Equal(t, "Bug", issueTypes[0]["name"]) + }, + }, + { + name: "issue_types API error returns response context", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "successful labels fetch", + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "cursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + "pageInfo": map[string]any{ + "hasNextPage": githubv4.Boolean(false), + "endCursor": githubv4.String(""), + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "labels", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + labels, ok := response["labels"].([]any) + require.True(t, ok, "labels should be a list") + require.Len(t, labels, 1) + assert.Equal(t, "bug", labels[0].(map[string]any)["name"]) + assert.Equal(t, float64(1), response["totalCount"]) + }, + }, + { + name: "issue_fields feature disabled returns empty list", + requestArgs: map[string]any{ + "method": "issue_fields", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + fields, ok := response["fields"].([]any) + require.True(t, ok, "fields should be a list") + assert.Empty(t, fields) + assert.Equal(t, float64(0), response["totalCount"]) + }, + }, + { + name: "successful reviewers fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers), + "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), + }), + requestArgs: map[string]any{ + "method": "reviewers", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + users, ok := response["users"].([]any) + require.True(t, ok, "users should be a list") + require.Len(t, users, 1) + assert.Equal(t, "octocat", users[0].(map[string]any)["login"]) + teams, ok := response["teams"].([]any) + require.True(t, ok, "teams should be a list") + require.Len(t, teams, 1) + assert.Equal(t, "docs", teams[0].(map[string]any)["slug"]) + assert.Equal(t, "owner", teams[0].(map[string]any)["org"]) + assert.Equal(t, float64(2), response["totalCount"]) + }, + }, + { + name: "missing method parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "missing owner parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter for assignees", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown", + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup deps with REST and/or GraphQL mocks + deps := BaseDeps{} + if tc.mockedClient != nil { + client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient)) + require.NoError(t, err) + deps.Client = client + } + if tc.mockedGQLClient != nil { + deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient) + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + if tc.validateResult != nil { + tc.validateResult(t, textContent.Text) + } + }) + } +} + +func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) { + priorityLow := 1 + priorityHigh := 2 + result, _, err := marshalUIGetIssueFields([]IssueField{ + { + ID: "field-1", + DatabaseID: 123, + Name: "Priority", + Description: "How urgent this is", + DataType: "single_select", + Visibility: "public", + Options: []IssueSingleSelectFieldOption{ + {ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh}, + {ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow}, + {ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"}, + }, + }, + { + ID: "field-2", + Name: "Unsupported", + DataType: "iteration", + }, + { + ID: "field-3", + Name: "Notes", + DataType: "text", + }, + }) + require.NoError(t, err) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + fields := response["fields"].([]any) + require.Len(t, fields, 2) + assert.Equal(t, float64(2), response["totalCount"]) + + singleSelectField := fields[0].(map[string]any) + assert.NotContains(t, singleSelectField, "full_database_id") + assert.NotContains(t, singleSelectField, "visibility") + options := singleSelectField["options"].([]any) + require.Len(t, options, 3) + assert.Equal(t, "Low", options[0].(map[string]any)["name"]) + assert.Equal(t, "High", options[1].(map[string]any)["name"]) + assert.Equal(t, "No priority", options[2].(map[string]any)["name"]) + assert.NotContains(t, options[0].(map[string]any), "id") + assert.NotContains(t, options[0].(map[string]any), "priority") + + textField := fields[1].(map[string]any) + assert.NotContains(t, textField, "options") +} diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 1bfd800e28..99c37602bc 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -59,3 +59,27 @@ func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.Call IsError: false, } } + +// NewToolResultAwaitingFormSubmission signals to the agent that a tool call +// has been intercepted to show an MCP App form to the user and has NOT +// performed the requested operation. The agent must stop, not chain dependent +// tool calls, and not claim the operation succeeded. The result is marked +// IsError=true so agents that bail on error don't proceed; the host still +// renders the UI because rendering is keyed off the tool's _meta.ui, not the +// result. The MCP App form will submit the operation directly when the user +// clicks submit, after which a ui/update-model-context call delivers the real +// outcome to the agent. +func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + StructuredContent: map[string]any{ + "status": "awaiting_user_submission", + "reason": "An interactive form is being shown to the user. The operation has not been performed.", + }, + IsError: true, + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 13d78a25a8..18e1e085c9 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1694,381 +1694,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5417,53 +5042,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs index c99d846039..9efa58524c 100644 --- a/ui/scripts/build.mjs +++ b/ui/scripts/build.mjs @@ -1,12 +1,12 @@ // Build all UI apps in a single Node process. // -// Replaces three serial `cross-env APP= vite build` invocations: doing it -// in one process avoids paying Vite/plugin startup cost three times and is +// Replaces serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost for each app and is // portable without `cross-env`. import { build } from "vite"; -const apps = ["get-me", "issue-write", "pr-write"]; +const apps = ["get-me", "issue-write", "pr-write", "pr-edit"]; for (const app of apps) { process.env.APP = app; diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 6c46b8c081..6372e2d503 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -8,10 +8,19 @@ import { Flash, Spinner, FormControl, + CounterLabel, + ActionMenu, + ActionList, + Label, } from "@primer/react"; import { IssueOpenedIcon, CheckCircleIcon, + TagIcon, + PersonIcon, + RepoIcon, + MilestoneIcon, + LockIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -27,11 +36,251 @@ interface IssueResult { URL?: string; } +interface LabelItem { + id: string; + text: string; + color: string; +} + +interface AssigneeItem { + id: string; + text: string; +} + +interface MilestoneItem { + id: string; + number: number; + text: string; + description: string; +} + +interface IssueTypeItem { + id: string; + text: string; +} + +type IssueState = "open" | "closed"; +type StateReason = "completed" | "not_planned" | "duplicate"; +type IssueFieldPrimitive = string | number | boolean; + +interface IssueFieldOption { + id: string; + name: string; + description: string; + color: string; +} + +interface IssueFieldItem { + id: string; + name: string; + data_type: string; + description: string; + options: IssueFieldOption[]; +} + +interface IssueFieldValue { + value?: IssueFieldPrimitive; + optionName?: string; + cleared?: boolean; +} + +interface IssueFieldSubmission { + field_name: string; + value?: IssueFieldPrimitive; + field_option_name?: string; + delete?: boolean; +} + +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +// Calculate text color based on background luminance +function getContrastColor(hexColor: string): string { + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#000000" : "#ffffff"; +} + +const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [ + { value: "completed", label: "Completed", description: "The work is done" }, + { value: "not_planned", label: "Not planned", description: "The issue won't be worked on" }, + { value: "duplicate", label: "Duplicate", description: "Another issue tracks this" }, +]; + +function normalizeSwatchColor(color: string): string { + const trimmed = color.trim(); + if (!trimmed) return "var(--borderColor-default, var(--color-border-default))"; + if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + } + return trimmed.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function parseIssueState(value: unknown): IssueState | null { + return value === "open" || value === "closed" ? value : null; +} + +function parseStateReason(value: unknown): StateReason | null { + return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null; +} + +function normalizeRawIssueFieldValue( + field: IssueFieldItem | undefined, + rawValue: unknown +): IssueFieldValue | null { + if (rawValue === null || rawValue === undefined) return null; + + if (isRecord(rawValue)) { + const optionName = + stringValue(rawValue.optionName) || + stringValue(rawValue.field_option_name) || + stringValue(rawValue.name); + if (field?.data_type === "single_select" && optionName) { + return { optionName }; + } + return normalizeRawIssueFieldValue( + field, + rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name + ); + } + + if (field?.data_type === "single_select") { + const optionName = stringValue(rawValue); + return optionName ? { optionName } : null; + } + + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" + ) { + return { value: rawValue }; + } + + return null; +} + +function parseStringIssueFieldValue( + entry: string, + fieldsByName: Map +): [string, IssueFieldValue] | null { + const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/); + if (!match) return null; + + const fieldName = match[1].trim(); + const field = fieldsByName.get(fieldName); + if (!field) return null; + + const normalized = normalizeRawIssueFieldValue(field, match[2].trim()); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldEntry( + entry: unknown, + fieldsByName: Map +): [string, IssueFieldValue] | null { + if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName); + if (!isRecord(entry)) return null; + + const fieldRecord = isRecord(entry.field) ? entry.field : undefined; + const entryName = stringValue(entry.name); + const fieldName = + stringValue(entry.field_name) || + stringValue(entry.fieldName) || + (fieldRecord ? stringValue(fieldRecord.name) : undefined) || + entryName; + if (!fieldName) return null; + + const field = fieldsByName.get(fieldName); + if (!field) return null; + + if (entry.delete === true || entry.cleared === true) { + return [fieldName, { cleared: true }]; + } + + const directOptionName = + stringValue(entry.field_option_name) || + stringValue(entry.fieldOptionName) || + stringValue(entry.optionName) || + (field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined); + if (directOptionName) return [fieldName, { optionName: directOptionName }]; + + const optionRecord = isRecord(entry.option) ? entry.option : undefined; + const optionName = optionRecord ? stringValue(optionRecord.name) : undefined; + if (optionName) return [fieldName, { optionName }]; + + const normalized = normalizeRawIssueFieldValue( + field, + entry.value ?? entry.text ?? entry.number ?? entry.date + ); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldValues( + input: unknown, + fields: IssueFieldItem[] +): Record { + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + const values: Record = {}; + + if (Array.isArray(input)) { + for (const item of input) { + const normalized = normalizeIssueFieldEntry(item, fieldsByName); + if (normalized) values[normalized[0]] = normalized[1]; + } + return values; + } + + if (!isRecord(input)) return values; + + const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName); + if (normalizedEntry) { + values[normalizedEntry[0]] = normalizedEntry[1]; + return values; + } + + for (const [fieldName, rawValue] of Object.entries(input)) { + const field = fieldsByName.get(fieldName); + if (!field) continue; + + if (isRecord(rawValue)) { + const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName); + if (nested) { + values[fieldName] = nested[1]; + continue; + } + } + + const normalized = normalizeRawIssueFieldValue(field, rawValue); + if (normalized) values[fieldName] = normalized; + } + + return values; +} + function SuccessView({ issue, owner, repo, submittedTitle, + submittedLabels, isUpdate, openLink, }: { @@ -39,6 +288,7 @@ function SuccessView({ owner: string; repo: string; submittedTitle: string; + submittedLabels: LabelItem[]; isUpdate: boolean; openLink: (url: string) => Promise; }) { @@ -118,6 +368,22 @@ function SuccessView({ {owner}/{repo} + {submittedLabels.length > 0 && ( + + {submittedLabels.map((label) => ( + + ))} + + )} @@ -131,23 +397,576 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); + // Labels state + const [availableLabels, setAvailableLabels] = useState([]); + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelsLoading, setLabelsLoading] = useState(false); + const [labelsFilter, setLabelsFilter] = useState(""); + + // Assignees state + const [availableAssignees, setAvailableAssignees] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [assigneesFilter, setAssigneesFilter] = useState(""); + + // Milestones state + const [availableMilestones, setAvailableMilestones] = useState([]); + const [selectedMilestone, setSelectedMilestone] = useState(null); + const [milestonesLoading, setMilestonesLoading] = useState(false); + + // Issue types state + const [availableIssueTypes, setAvailableIssueTypes] = useState([]); + const [selectedIssueType, setSelectedIssueType] = useState(null); + const [issueTypesLoading, setIssueTypesLoading] = useState(false); + + // State transition state + const [currentState, setCurrentState] = useState("open"); + const [stateReason, setStateReason] = useState("completed"); + const [duplicateOf, setDuplicateOf] = useState(""); + const [prefilledStateChange, setPrefilledStateChange] = useState(null); + + // Issue fields state + const [availableIssueFields, setAvailableIssueFields] = useState([]); + const [fieldValues, setFieldValues] = useState>({}); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-issue-write", }); + // Get method and issue_number from toolInput const method = (toolInput?.method as string) || "create"; const issueNumber = toolInput?.issue_number as number | undefined; const isUpdateMode = method === "update" && issueNumber !== undefined; - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - // Pre-fill from toolInput + // Initialize from toolInput or selected repo + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; + + // Search repositories when filter changes + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { + query: repoFilter, + perPage: 10, + }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: + typeof r.owner === "string" + ? r.owner + : r.owner?.login || r.full_name?.split("/")[0] || "", + name: r.name || r.full_name?.split("/")[1] || "", + fullName: r.full_name || "", + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load labels, assignees, milestones, issue types, and issue fields when owner/repo available useEffect(() => { - if (toolInput?.title) setTitle(toolInput.title as string); - if (toolInput?.body) setBody(toolInput.body as string); + if (!owner || !repo || !app) return; + + const loadLabels = async () => { + setLabelsLoading(true); + try { + const result = await callTool("ui_get", { method: "labels", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const labels = (data.labels || []).map( + (l: { name: string; color: string; id: string }) => ({ + id: l.id || l.name, + text: l.name, + color: l.color, + }) + ); + setAvailableLabels(labels); + } + } + } catch (e) { + console.error("Failed to load labels:", e); + } finally { + setLabelsLoading(false); + } + }; + + const loadAssignees = async () => { + setAssigneesLoading(true); + try { + const result = await callTool("ui_get", { method: "assignees", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const assignees = (data.assignees || []).map( + (a: { login: string }) => ({ + id: a.login, + text: a.login, + }) + ); + setAvailableAssignees(assignees); + } + } + } catch (e) { + console.error("Failed to load assignees:", e); + } finally { + setAssigneesLoading(false); + } + }; + + const loadMilestones = async () => { + setMilestonesLoading(true); + try { + const result = await callTool("ui_get", { method: "milestones", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const milestones = (data.milestones || []).map( + (m: { number: number; title: string; description: string }) => ({ + id: String(m.number), + number: m.number, + text: m.title, + description: m.description || "", + }) + ); + setAvailableMilestones(milestones); + } + } + } catch (e) { + console.error("Failed to load milestones:", e); + } finally { + setMilestonesLoading(false); + } + }; + + const loadIssueTypes = async () => { + setIssueTypesLoading(true); + try { + const result = await callTool("ui_get", { method: "issue_types", owner }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + // ui_get returns array directly or wrapped in issue_types/types + const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); + const types = typesArray.map( + (t: { id: number; name: string; description?: string } | string) => { + if (typeof t === "string") { + return { id: t, text: t }; + } + return { id: String(t.id || t.name), text: t.name }; + } + ); + setAvailableIssueTypes(types); + } + } + } catch (e) { + // Issue types may not be available for all repos/orgs + console.debug("Issue types not available:", e); + } finally { + setIssueTypesLoading(false); + } + }; + + const loadIssueFields = async () => { + try { + const result = await callTool("ui_get", { method: "issue_fields", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const fields = (data.fields || []) + .map( + (field: { + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }) => ({ + id: String(field.id || field.name || ""), + name: field.name || "", + data_type: field.data_type || "text", + description: field.description || "", + options: (field.options || []) + .map((option) => ({ + id: String(option.id || option.name || ""), + name: option.name || "", + description: option.description || "", + color: option.color || "", + })) + .filter((option) => option.name), + }) + ) + .filter((field: IssueFieldItem) => field.name); + setAvailableIssueFields(fields); + } + } + } catch (e) { + console.debug("Issue fields not available:", e); + setAvailableIssueFields([]); + } + }; + + loadLabels(); + loadAssignees(); + loadMilestones(); + loadIssueTypes(); + loadIssueFields(); + }, [owner, repo, app, callTool]); + + // Track which prefill fields have been applied to avoid re-applying after user edits + const prefillApplied = useRef<{ + title: boolean; + body: boolean; + labels: boolean; + assignees: boolean; + milestone: boolean; + type: boolean; + issueFields: boolean; + }>({ + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }); + + // Store existing issue data for matching when available lists load + interface ExistingIssueData { + labels: string[]; + assignees: string[]; + milestoneNumber: number | null; + issueType: string | null; + fieldValues: unknown; + } + const [existingIssueData, setExistingIssueData] = useState(null); + + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values (e.g. body) bleed through because prefill effects use truthy guards + // that won't overwrite with empty values. The repo is re-initialized from the new + // invocation here (rather than in a separate effect) so it isn't wiped by this reset. + useEffect(() => { + prefillApplied.current = { + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }; + setExistingIssueData(null); + setTitle(""); + setBody(""); + setSelectedLabels([]); + setSelectedAssignees([]); + setSelectedMilestone(null); + setSelectedIssueType(null); + setCurrentState("open"); + setStateReason("completed"); + setDuplicateOf(""); + setPrefilledStateChange(null); + setFieldValues({}); + setSuccessIssue(null); + setError(null); + // Clear available metadata (and filters) so prefill effects, which are gated + // on these lists being non-empty, can't match against the previous repo's data + // before the new repo's ui_get calls resolve. + setAvailableLabels([]); + setAvailableAssignees([]); + setAvailableMilestones([]); + setAvailableIssueTypes([]); + setAvailableIssueFields([]); + setLabelsFilter(""); + setAssigneesFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } }, [toolInput]); - const handleSubmit = useCallback(async () => { + // Load existing issue data when in update mode + useEffect(() => { + if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) { + return; + } + + const loadExistingIssue = async () => { + try { + const result = await callTool("issue_read", { + method: "get", + owner, + repo, + issue_number: issueNumber, + }); + + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const issueData = JSON.parse(textContent.text); + + const issueState = parseIssueState(issueData.state); + if (issueState) { + setCurrentState(issueState); + } + + // Pre-fill title and body immediately + if (issueData.title && !prefillApplied.current.title) { + setTitle(issueData.title); + prefillApplied.current.title = true; + } + if (issueData.body && !prefillApplied.current.body) { + setBody(issueData.body); + prefillApplied.current.body = true; + } + + // Pre-fill assignees immediately from issue data + const assigneeLogins = (issueData.assignees || []) + .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login) + .filter(Boolean) as string[]; + if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) { + setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login }))); + prefillApplied.current.assignees = true; + } + + // Pre-fill issue type immediately from issue data + const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null); + if (issueTypeName && !prefillApplied.current.type) { + setSelectedIssueType({ id: issueTypeName, text: issueTypeName }); + prefillApplied.current.type = true; + } + + // Extract data for deferred matching when available lists load (for labels and milestones) + const labelNames = (issueData.labels || []) + .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name) + .filter(Boolean) as string[]; + + const milestoneNumber = issueData.milestone + ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone) + : null; + + setExistingIssueData({ + labels: labelNames, + assignees: assigneeLogins, + milestoneNumber, + issueType: issueTypeName, + fieldValues: issueData.field_values || issueData.fieldValues || [], + }); + } + } + } catch (e) { + console.error("Error loading existing issue:", e); + } + }; + + loadExistingIssue(); + }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]); + + // Apply existing labels when available labels load + useEffect(() => { + if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return; + const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text)); + if (matched.length > 0) { + setSelectedLabels(matched); + prefillApplied.current.labels = true; + } + }, [existingIssueData, availableLabels]); + + // Apply existing milestone when available milestones load + useEffect(() => { + if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return; + const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber); + if (matched) { + setSelectedMilestone(matched); + } + prefillApplied.current.milestone = true; + }, [existingIssueData, availableMilestones]); + + // Pre-fill title and body immediately (don't wait for data loading) + useEffect(() => { + if (toolInput?.title && !prefillApplied.current.title) { + setTitle(toolInput.title as string); + prefillApplied.current.title = true; + } + if (toolInput?.body && !prefillApplied.current.body) { + setBody(toolInput.body as string); + prefillApplied.current.body = true; + } + }, [toolInput]); + + // Pre-fill requested state transition controls from tool input + useEffect(() => { + const state = parseIssueState(toolInput?.state); + if (state) { + setPrefilledStateChange(state); + } + + const reason = parseStateReason(toolInput?.state_reason); + if (reason) { + setStateReason(reason); + } + + if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) { + setDuplicateOf(String(toolInput.duplicate_of)); + } + }, [toolInput]); + + // Pre-fill labels once available data is loaded + useEffect(() => { + if ( + toolInput?.labels && + Array.isArray(toolInput.labels) && + availableLabels.length > 0 && + !prefillApplied.current.labels + ) { + const prefillLabels = availableLabels.filter((l) => + (toolInput.labels as string[]).includes(l.text) + ); + if (prefillLabels.length > 0) { + setSelectedLabels(prefillLabels); + prefillApplied.current.labels = true; + } + } + }, [toolInput, availableLabels]); + + // Pre-fill assignees once available data is loaded + useEffect(() => { + if ( + toolInput?.assignees && + Array.isArray(toolInput.assignees) && + availableAssignees.length > 0 && + !prefillApplied.current.assignees + ) { + const prefillAssignees = availableAssignees.filter((a) => + (toolInput.assignees as string[]).includes(a.text) + ); + if (prefillAssignees.length > 0) { + setSelectedAssignees(prefillAssignees); + prefillApplied.current.assignees = true; + } + } + }, [toolInput, availableAssignees]); + + // Pre-fill milestone once available data is loaded + useEffect(() => { + if ( + toolInput?.milestone && + availableMilestones.length > 0 && + !prefillApplied.current.milestone + ) { + const milestone = availableMilestones.find( + (m) => m.number === Number(toolInput.milestone) + ); + if (milestone) { + setSelectedMilestone(milestone); + prefillApplied.current.milestone = true; + } + } + }, [toolInput, availableMilestones]); + + // Pre-fill issue type once available data is loaded + useEffect(() => { + if ( + toolInput?.type && + availableIssueTypes.length > 0 && + !prefillApplied.current.type + ) { + const issueType = availableIssueTypes.find( + (t) => t.text === toolInput.type + ); + if (issueType) { + setSelectedIssueType(issueType); + prefillApplied.current.type = true; + } + } + }, [toolInput, availableIssueTypes]); + + // Pre-fill custom fields once field definitions are loaded + useEffect(() => { + if (!availableIssueFields.length || prefillApplied.current.issueFields) return; + + const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields); + if (Object.keys(toolInputValues).length > 0) { + setFieldValues(toolInputValues); + prefillApplied.current.issueFields = true; + return; + } + + const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields); + if (Object.keys(existingValues).length > 0) { + setFieldValues(existingValues); + prefillApplied.current.issueFields = true; + } + }, [toolInput, existingIssueData, availableIssueFields]); + + const issueFieldsByName = useMemo( + () => new Map(availableIssueFields.map((field) => [field.name, field])), + [availableIssueFields] + ); + + const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => { + prefillApplied.current.issueFields = true; + setFieldValues((prev) => ({ ...prev, [fieldName]: value })); + }, []); + + const handleSubmit = useCallback(async (stateChange?: IssueState) => { if (!title.trim()) { setError("Title is required"); return; @@ -157,6 +976,16 @@ function CreateIssueApp() { return; } + const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null; + let duplicateIssueNumber: number | undefined; + if (requestedState === "closed" && stateReason === "duplicate") { + duplicateIssueNumber = Number(duplicateOf); + if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) { + setError("Duplicate issue number is required"); + return; + } + } + setIsSubmitting(true); setError(null); @@ -171,10 +1000,60 @@ function CreateIssueApp() { _ui_submitted: true }; + delete params.state; + delete params.state_reason; + delete params.duplicate_of; + delete params.issue_fields; + if (isUpdateMode && issueNumber) { params.issue_number = issueNumber; } + if (selectedLabels.length > 0) { + params.labels = selectedLabels.map((l) => l.text); + } + if (selectedAssignees.length > 0) { + params.assignees = selectedAssignees.map((a) => a.text); + } + if (selectedMilestone) { + params.milestone = selectedMilestone.number; + } + if (selectedIssueType) { + params.type = selectedIssueType.text; + } + + if (requestedState) { + params.state = requestedState; + if (requestedState === "closed") { + params.state_reason = stateReason; + if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) { + params.duplicate_of = duplicateIssueNumber; + } + } + } + + const issueFields = Object.entries(fieldValues) + .map(([fieldName, value]): IssueFieldSubmission | null => { + if (value.cleared) return { field_name: fieldName, delete: true }; + if (value.optionName !== undefined) { + return { field_name: fieldName, field_option_name: value.optionName }; + } + if (value.value !== undefined && value.value !== "") { + const field = issueFieldsByName.get(fieldName); + const fieldValue = + field?.data_type === "number" && typeof value.value === "string" + ? Number(value.value) + : value.value; + if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null; + return { field_name: fieldName, value: fieldValue }; + } + return null; + }) + .filter((field): field is IssueFieldSubmission => field !== null); + if (issueFields.length > 0) { + params.issue_fields = issueFields; + } + const result = await callTool("issue_write", params); if (result.isError) { @@ -215,7 +1094,104 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); + }, [ + title, + body, + owner, + repo, + selectedLabels, + selectedAssignees, + selectedMilestone, + selectedIssueType, + isUpdateMode, + issueNumber, + stateReason, + duplicateOf, + prefilledStateChange, + fieldValues, + issueFieldsByName, + toolInput, + callTool, + setModelContext, + ]); + + // Filtered items for dropdowns + const filteredLabels = useMemo(() => { + if (!labelsFilter) return availableLabels; + const lowerFilter = labelsFilter.toLowerCase(); + return availableLabels.filter((l) => + l.text.toLowerCase().includes(lowerFilter) + ); + }, [availableLabels, labelsFilter]); + + const filteredAssignees = useMemo(() => { + if (!assigneesFilter) return availableAssignees; + const lowerFilter = assigneesFilter.toLowerCase(); + return availableAssignees.filter((a) => + a.text.toLowerCase().includes(lowerFilter) + ); + }, [availableAssignees, assigneesFilter]); + + const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0]; + + const renderIssueFieldInput = (field: IssueFieldItem) => { + const fieldValue = fieldValues[field.name] || {}; + + if (field.data_type === "single_select") { + const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; + const selectedOption = field.options.find((option) => option.name === selectedOptionName); + return ( + + + + {selectedOption ? selectedOption.name : "Select option"} + + + + {field.options.length === 0 ? ( + No options available + ) : ( + field.options.map((option) => ( + updateIssueFieldValue(field.name, { optionName: option.name })} + > + + + + {option.name} + + )) + )} + + + + + ); + } + + return ( + updateIssueFieldValue(field.name, { value: e.target.value })} + block + contrast + sx={{ flex: 1 }} + /> + ); + }; const body_node = (() => { if (appError) { @@ -241,6 +1217,7 @@ function CreateIssueApp() { owner={owner} repo={repo} submittedTitle={title} + submittedLabels={selectedLabels} isUpdate={isUpdateMode} openLink={openLink} /> @@ -256,7 +1233,7 @@ function CreateIssueApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + // Clear metadata when switching repos + setAvailableLabels([]); + setSelectedLabels([]); + setAvailableAssignees([]); + setSelectedAssignees([]); + setAvailableMilestones([]); + setSelectedMilestone(null); + setAvailableIssueTypes([]); + setSelectedIssueType(null); + setAvailableIssueFields([]); + setFieldValues({}); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")} + > + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + + Type to search repositories... + + + )} + + + - - {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"} - - - {owner}/{repo} - {/* Error banner */} @@ -314,11 +1358,344 @@ function CreateIssueApp() { /> - {/* Submit button */} - + {/* Metadata section */} + + {/* Labels dropdown */} + + + Labels + {selectedLabels.length > 0 && ( + {selectedLabels.length} + )} + + + + setLabelsFilter(e.target.value)} + size="small" + block + /> + + + {labelsLoading ? ( + + Loading... + + ) : filteredLabels.length === 0 ? ( + No labels available + ) : ( + filteredLabels.map((label) => ( + l.id === label.id)} + onSelect={() => { + setSelectedLabels((prev) => + prev.some((l) => l.id === label.id) + ? prev.filter((l) => l.id !== label.id) + : [...prev, label] + ); + }} + > + + + + {label.text} + + )) + )} + + + + + {/* Assignees dropdown */} + + + Assignees + {selectedAssignees.length > 0 && ( + {selectedAssignees.length} + )} + + + + setAssigneesFilter(e.target.value)} + size="small" + block + /> + + + {assigneesLoading ? ( + + Loading... + + ) : filteredAssignees.length === 0 ? ( + No assignees available + ) : ( + filteredAssignees.map((assignee) => ( + a.id === assignee.id)} + onSelect={() => { + setSelectedAssignees((prev) => + prev.some((a) => a.id === assignee.id) + ? prev.filter((a) => a.id !== assignee.id) + : [...prev, assignee] + ); + }} + > + {assignee.text} + + )) + )} + + + + + {/* Milestones dropdown */} + + + {selectedMilestone ? selectedMilestone.text : "Milestone"} + + + + {milestonesLoading ? ( + + Loading... + + ) : availableMilestones.length === 0 ? ( + No milestones + ) : ( + <> + {selectedMilestone && ( + setSelectedMilestone(null)} + > + Clear selection + + )} + {availableMilestones.map((milestone) => ( + setSelectedMilestone(milestone)} + > + {milestone.text} + {milestone.description && ( + + {milestone.description} + + )} + + ))} + + )} + + + + + {/* Issue Types dropdown */} + + + {selectedIssueType ? selectedIssueType.text : "Type"} + + + + {issueTypesLoading ? ( + + Loading... + + ) : availableIssueTypes.length === 0 ? ( + No issue types + ) : ( + <> + {selectedIssueType && ( + setSelectedIssueType(null)} + > + Clear selection + + )} + {availableIssueTypes.map((type) => ( + setSelectedIssueType(type)} + > + {type.text} + + ))} + + )} + + + + + + {/* Fields section */} + {availableIssueFields.length > 0 && ( + + + Fields + + + {availableIssueFields.map((field) => { + const fieldValue = fieldValues[field.name]; + const hasFieldValue = + fieldValue && + !fieldValue.cleared && + (fieldValue.optionName !== undefined || + (fieldValue.value !== undefined && fieldValue.value !== "")); + + return ( + + + {field.name} + + {field.description && ( + + {field.description} + + )} + + {renderIssueFieldInput(field)} + {hasFieldValue && ( + + )} + + + ); + })} + + + )} + + {/* Selected labels display */} + {selectedLabels.length > 0 && ( + + {selectedLabels.map((label) => ( + + ))} + + )} + + {/* Selected metadata display */} + {(selectedAssignees.length > 0 || selectedMilestone) && ( + + {selectedAssignees.length > 0 && ( + + Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} + + )} + {selectedMilestone && ( + Milestone: {selectedMilestone.text} + )} + + )} + + {/* State and submit actions */} + + {isUpdateMode && ( + + {currentState === "open" ? ( + <> + + + + + {selectedStateReason.label} + + + + {stateReasonOptions.map((option) => ( + setStateReason(option.value)} + > + {option.label} + {option.description} + + ))} + + + + + {stateReason === "duplicate" && ( + + Duplicate of + setDuplicateOf(e.target.value)} + size="small" + sx={{ width: 140 }} + /> + + )} + + ) : ( + + )} + + )} + + + + + + + setIsDraft(e.target.checked)} /> + Mark as draft + + + + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + + + + setMaintainerCanModify(e.target.checked)} /> + Allow maintainer edits + + + + + + )} + + + ); +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/pr-edit/index.html b/ui/src/apps/pr-edit/index.html new file mode 100644 index 0000000000..9fa60aa992 --- /dev/null +++ b/ui/src/apps/pr-edit/index.html @@ -0,0 +1,12 @@ + + + + + + Edit pull request + + +
+ + + diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 245753a1bc..769523d41b 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -12,11 +12,18 @@ import { ActionList, Checkbox, ButtonGroup, + CounterLabel, + Label, } from "@primer/react"; import { GitPullRequestIcon, CheckCircleIcon, + RepoIcon, + LockIcon, + GitBranchIcon, TriangleDownIcon, + PersonIcon, + PeopleIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -31,6 +38,33 @@ interface PRResult { URL?: string; } +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +interface BranchItem { + name: string; + protected: boolean; +} + +type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string }; + +function reviewerFromValue(value: string): ReviewerItem { + if (value.includes("/")) { + const [org, slug] = value.split("/", 2); + return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org }; + } + return { kind: "user", id: value, text: value }; +} + +function reviewerValue(reviewer: ReviewerItem): string { + return reviewer.kind === "team" ? reviewer.id : reviewer.text; +} + function SuccessView({ pr, owner, @@ -133,32 +167,231 @@ function CreatePRApp() { const [error, setError] = useState(null); const [successPR, setSuccessPR] = useState(null); + // Branch state + const [availableBranches, setAvailableBranches] = useState([]); + const [baseBranch, setBaseBranch] = useState(""); + const [headBranch, setHeadBranch] = useState(""); + const [branchesLoading, setBranchesLoading] = useState(false); + const [baseFilter, setBaseFilter] = useState(""); + const [headFilter, setHeadFilter] = useState(""); + + // Options const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); + const [availableReviewers, setAvailableReviewers] = useState([]); + const [selectedReviewers, setSelectedReviewers] = useState([]); + const [reviewersLoading, setReviewersLoading] = useState(false); + const [reviewersFilter, setReviewersFilter] = useState(""); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - const head = (toolInput?.head as string) || ""; - const base = (toolInput?.base as string) || ""; + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; const [submittedTitle, setSubmittedTitle] = useState(""); + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values bleed through because the prefill effect below only sets when + // toolInput has truthy values and never clears. The repo is re-initialized from + // the new invocation here (rather than in a separate effect) so it isn't wiped + // by this reset. + useEffect(() => { + setTitle(""); + setBody(""); + setHeadBranch(""); + setBaseBranch(""); + setIsDraft(false); + setMaintainerCanModify(true); + setSuccessPR(null); + setError(null); + setSubmittedTitle(""); + // Clear branch list and filters so a new invocation doesn't briefly show stale + // branches from the previous repo (or allow selecting invalid options) before the + // new repo's ui_get branches call resolves. + setAvailableBranches([]); + setBaseFilter(""); + setHeadFilter(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } + }, [toolInput]); + // Pre-fill from toolInput useEffect(() => { if (toolInput?.title) setTitle(toolInput.title as string); if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.head) setHeadBranch(toolInput.head as string); + if (toolInput?.base) setBaseBranch(toolInput.base as string); if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); if (toolInput?.maintainer_can_modify !== undefined) { setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); } + if (Array.isArray(toolInput?.reviewers)) { + setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue)); + } }, [toolInput]); + // Search repositories + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '', + name: r.name || '', + fullName: r.full_name || '', + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load branches and reviewers when repo is selected + useEffect(() => { + if (!owner || !repo || !app) return; + + const loadBranches = async () => { + setBranchesLoading(true); + try { + const result = await callTool("ui_get", { method: "branches", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const branches = (data.branches || data || []).map( + (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) + ); + setAvailableBranches(branches); + if (branches.length > 0) { + const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master'); + // Functional update so a base branch already prefilled from + // toolInput.base (or chosen by the user) isn't overwritten by a + // stale closure value captured before the request resolved. + if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name); + } + } + } + } catch (e) { + console.error("Failed to load branches:", e); + } finally { + setBranchesLoading(false); + } + }; + + const loadReviewers = async () => { + setReviewersLoading(true); + try { + const result = await callTool("ui_get", { method: "reviewers", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const users = (data.users || []).map( + (u: { login: string; avatar_url?: string }) => ({ + kind: "user" as const, + id: u.login, + text: u.login, + avatar: u.avatar_url, + }) + ); + const teams = (data.teams || []).map( + (t: { slug: string; name?: string; org: string }) => ({ + kind: "team" as const, + id: `${t.org}/${t.slug}`, + text: `${t.org}/${t.slug}`, + org: t.org, + }) + ); + setAvailableReviewers([...users, ...teams]); + } + } + } catch (e) { + console.error("Failed to load reviewers:", e); + } finally { + setReviewersLoading(false); + } + }; + + loadBranches(); + loadReviewers(); + }, [owner, repo, app, callTool]); + + useEffect(() => { + if (availableReviewers.length === 0) return; + setSelectedReviewers((prev) => + prev.map((reviewer) => + availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer + ) + ); + }, [availableReviewers]); + + // Filters + const filteredBaseBranches = useMemo(() => { + if (!baseFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase())); + }, [availableBranches, baseFilter]); + + const filteredHeadBranches = useMemo(() => { + if (!headFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase())); + }, [availableBranches, headFilter]); + + const filteredReviewers = useMemo(() => { + if (!reviewersFilter.trim()) return availableReviewers; + const lowerFilter = reviewersFilter.toLowerCase(); + return availableReviewers.filter((reviewer) => + reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter) + ); + }, [availableReviewers, reviewersFilter]); + const handleSubmit = useCallback(async () => { if (!title.trim()) { setError("Title is required"); return; } if (!owner || !repo) { setError("Repository information not available"); return; } + if (!baseBranch) { setError("Base branch is required"); return; } + if (!headBranch) { setError("Head branch is required"); return; } + if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; } setIsSubmitting(true); setError(null); @@ -170,10 +403,11 @@ function CreatePRApp() { owner, repo, title: title.trim(), body: body.trim(), - head, - base, + head: headBranch, + base: baseBranch, draft: isDraft, maintainer_can_modify: maintainerCanModify, + reviewers: selectedReviewers.map(reviewerValue), _ui_submitted: true }); @@ -204,7 +438,7 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); + }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); if (successPR) { return ( @@ -242,7 +476,7 @@ function CreatePRApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + setAvailableBranches([]); + setBaseBranch(""); + setHeadBranch(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")}> + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + Type to search repositories... + + )} + + + + + + + {/* Branch selectors */} + + + base + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} + + + + + setBaseFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredBaseBranches.length === 0 ? ( + No branches found + ) : ( + filteredBaseBranches.map((branch) => ( + { setBaseBranch(branch.name); setBaseFilter(""); }} + > + {branch.name} + {branch.protected && } + + )) + )} + + + + + + + + + compare + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} + + + + + setHeadFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredHeadBranches.length === 0 ? ( + No branches found + ) : ( + filteredHeadBranches.map((branch) => ( + { setHeadBranch(branch.name); setHeadFilter(""); }} + > + {branch.name} + + )) + )} + + + - New pull request - - {owner}/{repo} - - {head && base && ( - - {base} ← {head} - - )} {/* Error banner */} @@ -290,9 +659,93 @@ function CreatePRApp() { + {/* Reviewers */} + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + {/* Options and Submit */} - + setMaintainerCanModify(e.target.checked)} /> Allow maintainer edits @@ -301,7 +754,7 @@ function CreatePRApp() {