MCP Apps with extra functionality (#1974)

* PoC full flow (hello world example)

* add avatar resource domain

* add postmessage logic and richer UI

* add create issue ui

* update ui for issue creatioon

* fix

* ignore banner

* update docs after rebase

* update toolsnap for get_me

* new UI changes

* update docs

* update workflows that need ui build

* add UI diff

* fix build ui step for windows runners to use git bash

* fix UI diff

* refactor issue creation UI

* add AvatarWithFallback component and update UserCard to use it; enhance CreateIssueApp to manage existing issue data

* fix formatting of button labels

* add create pull request functionality with UI support and insiders

* update docs

* add test for insiders mode handling in ServerTool schema

* remove `show_ui` param for now

* make insiders mode metadata stripping generic

* remove ui diff

* fix CI

* remove redundant mention of old app name

* add node types to fix ide issues for ts code

* remove unused TriangleDownIcon import

* update @primer/behaviors and electron-to-chromium versions in package-lock.json

* add check to ensure base and head are not the same when creating a new PR

* remove old show_ui

* fix gitignore for dist so builds dont break

* add tests for insiders mode handling and metadata stripping in ServerTool

* remove unused state and components from CreatePRApp

* fix ui build

* update docker build to fix npm issue

* remove reference to show_ui

* allow insiders to work for non-ui features

* formalise insiders inventory support

* update docs

* fix overflow issues and replace pull request dropdown with matching UI from dotcom

* fix createpullrequest test

* consolidate fetching tools under `ui_get` tool to remove toolset deps

* fix issue data prefill in issue_write form

* fix link component when updating issue

* fix avatar URL

* fix broken issue update logic

* remove dbg

* fix for new GetFlags

* revert to original required fields for create_pull_request

* fix for UI form submission

* Simplify MCP App UIs for basic branch

Remove advanced features to be kept in mcp-ui-apps-advanced:
- Strip labels, assignees, milestones, issue types, repo picker from issue-write
- Strip repo picker, branch selectors from pr-write
- Delete ui_get tool (ui_tools.go, ui_tools_test.go, ui_get.snap)
- Remove UIGet registration from tools.go

Basic forms retain: title, body, submit with _ui_submitted,
draft/regular split button (PR), MarkdownEditor, and SuccessView.

* Fix header spacing in issue-write and pr-write UIs

Add proper spacing between icon, title text, and repo name in the
header bar for both issue-write and create-pull-request forms.

* fix UI spacing

* Revert "Simplify MCP App UIs for basic branch"

This reverts commit 24174b91e222e45b47913ff6b760db809f48660b.

* Undo dependency downgrades in ui/package-lock.json

* Update ui/src/apps/pr-write/App.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update ui/src/apps/issue-write/App.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Implement pagination for uiGetBranches (#2012)

* Initial plan

* Implement pagination for uiGetBranches function

Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com>

* update to new insiders feature flag func

* ensure transient state is reset on successive tool calls

* Mark ui_get as app-only visibility

ui_get backs only the MCP App views and has no business in the agent's
tool list. Per the MCP Apps 2026-01-26 spec, omitting _meta.ui.visibility
defaults to ["model","app"], which exposes the tool to the model. Declare
visibility ["app"] so the host hides it from tools/list while the views can
still invoke it via tools/call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update ui_get toolsnap for app-only visibility

Regenerated via UPDATE_TOOLSNAPS to capture the new _meta.ui.visibility.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Assert ui_get declares app-only visibility

Locks in the _meta.ui.visibility ["app"] contract so a future edit can't
silently re-expose the UI data tool to the model.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add ui_get to insiders feature docs

Regenerated docs/feature-flags.md and docs/insiders-features.md to include
the ui_get tool entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address ui_get review feedback

- Paginate the labels GraphQL query (cursor-based) so repos with more than
  100 labels return a complete list instead of silently truncating.
- Emit an empty due_on for milestones without a due date instead of
  formatting the zero time as "0001-01-01".
- Use NewGitHubAPIErrorResponse in uiGetIssueTypes to preserve GitHub
  response context, matching the other REST-backed methods.
- Extend tests to cover the labels (GraphQL), milestones (including the
  no-due-date case) and issue_types methods, plus the issue_types error path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix repo reset and stale base-branch in MCP App views

- Re-initialize selectedRepo from toolInput inside the reset-on-invocation
  effect instead of a separate effect. The two effects both depended on
  toolInput and ran in declaration order, so the reset wiped the just-
  initialized repo and the picker never reflected the invocation's owner/repo.
- Set the default base branch with a functional update in pr-write so a base
  prefilled from toolInput.base (or chosen by the user) isn't overwritten by
  a stale baseBranch value captured before the branches request resolved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix issue-write repo owner mapping and clear stale UI state on reset

- issue-write: derive owner/name from full_name since search_repositories
  minimal output omits the owner object (mirrors pr-write)
- pr-write/issue-write: clear available branch/label/assignee/milestone/type
  lists and filters in the toolInput reset effect so prefill effects can't
  match against the previous repo's stale data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Merge remote-tracking branch 'origin/main' into mcp-ui-apps-advanced

* feat: add pull request editing functionality with reviewers support

* feat: implement interactive form handling for issue and pull request creation and updates

* Close response body per page in ui_get pagination loops

Avoids leaking HTTP connections when paging through assignees,
milestones, branches, collaborators, and teams.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Cache pr-edit.html in build-ui action

The build-ui cache only saved get-me/issue-write/pr-write HTML, so once a
cache entry was stored it restored an incomplete ui_dist on later runs and
skipped the rebuild, leaving pr-edit.html absent and panicking the tests.
Add pr-edit.html to the cached paths and bump the cache key to v2 to evict
the incomplete entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: tommaso-moro <tommaso-moro@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Matt Holloway
2026-06-16 16:45:16 +01:00
committed by GitHub
parent 6586b84b1a
commit 4e8eb81dac
22 changed files with 4041 additions and 228 deletions
+2 -1
View File
@@ -12,7 +12,8 @@ runs:
pkg/github/ui_dist/get-me.html
pkg/github/ui_dist/issue-write.html
pkg/github/ui_dist/pr-write.html
key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }}
pkg/github/ui_dist/pr-edit.html
key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }}
enableCrossOsArchive: true
- name: Set up Node.js
+1
View File
@@ -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
+24 -2
View File
@@ -44,7 +44,8 @@ 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)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)
- **get_me** - Get my user profile
@@ -67,12 +68,33 @@ runtime behavior (such as output formatting) won't appear here.
- `milestone`: Milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `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/pull request
+24 -2
View File
@@ -38,7 +38,8 @@ 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)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)
- **get_me** - Get my user profile
@@ -61,12 +62,33 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `milestone`: Milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `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/pull request
@@ -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"
},
"show_ui": {
"description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.",
"type": "boolean"
+45
View File
@@ -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"
}
@@ -1,4 +1,13 @@
{
"_meta": {
"ui": {
"resourceUri": "ui://github-mcp-server/pr-edit",
"visibility": [
"model",
"app"
]
}
},
"annotations": {
"title": "Edit pull request"
},
+44 -9
View File
@@ -1753,8 +1753,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": {},
@@ -1763,13 +1762,17 @@ var issueWriteFormParams = map[string]struct{}{
"title": {},
"body": {},
"issue_number": {},
"issue_fields": {},
"state": {},
"state_reason": {},
"duplicate_of": {},
"show_ui": {},
"_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 {
@@ -1783,6 +1786,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
@@ -1953,14 +1986,15 @@ Options are:
}
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !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")
@@ -2209,14 +2243,15 @@ Options are:
}
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !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")
+31 -154
View File
@@ -1562,7 +1562,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) {
@@ -1596,78 +1597,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",
@@ -1676,14 +1609,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) {
@@ -1698,65 +1630,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",
@@ -1766,14 +1648,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) {
@@ -1790,7 +1671,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")
@@ -1812,7 +1693,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",
"show_ui=false should skip UI form")
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
"show_ui=false call should execute directly and return issue URL")
@@ -1834,7 +1715,7 @@ 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",
"show_ui=true should still route through the form")
})
@@ -1893,10 +1774,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},
}
@@ -1919,14 +1800,10 @@ func Test_issueWriteSchemaClassification(t *testing.T) {
// Schema properties the MCP App form cannot represent — their presence
// must trigger the safety-net bypass via issueWriteHasNonFormParams.
knownNonForm := map[string]struct{}{
"assignees": {},
"labels": {},
"milestone": {},
"type": {},
"state": {},
"state_reason": {},
"duplicate_of": {},
"issue_fields": {}, // only on the FF-enabled IssueWrite variant
"assignees": {},
"labels": {},
"milestone": {},
"type": {},
}
cases := []struct {
+99 -2
View File
@@ -587,6 +587,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.
@@ -599,10 +602,25 @@ var pullRequestWriteFormParams = map[string]struct{}{
"base": {},
"draft": {},
"maintainer_can_modify": {},
"reviewers": {},
"show_ui": {},
"_ui_submitted": {},
}
var pullRequestUpdateFormParams = map[string]struct{}{
"owner": {},
"repo": {},
"pullNumber": {},
"title": {},
"body": {},
"state": {},
"draft": {},
"base": {},
"maintainer_can_modify": {},
"reviewers": {},
"_ui_submitted": {},
}
// pullRequestWriteHasNonFormParams reports whether the call carries any parameter
// the create_pull_request MCP App form cannot represent (anything outside
// pullRequestWriteFormParams). Such calls must bypass the UI form and execute
@@ -619,6 +637,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(
@@ -671,6 +701,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",
},
},
// show_ui is hidden from clients that do not advertise MCP App
// UI support. The strip happens per-request in
// inventory.ToolsForRegistration; it is present in the static
@@ -710,7 +747,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
}
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !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
@@ -751,6 +795,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),
@@ -786,6 +835,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()),
@@ -863,10 +942,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
@@ -880,6 +965,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 {
+109 -12
View File
@@ -2629,7 +2629,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) {
@@ -2669,18 +2670,18 @@ 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")
@@ -2703,7 +2704,7 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
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",
"show_ui=false should skip UI form")
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"show_ui=false call should execute directly and return PR URL")
@@ -2725,7 +2726,7 @@ 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",
"show_ui=true should still route through the form")
})
@@ -2770,6 +2771,102 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
})
}
// 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()
@@ -2779,10 +2876,10 @@ 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: "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"}, "show_ui": true, "_ui_submitted": true}, want: false},
{name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false},
{name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false},
{name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true},
{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},
}
+3
View File
@@ -291,6 +291,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),
+27
View File
@@ -107,4 +107,31 @@ func RegisterUIResources(s *mcp.Server, readOnly bool) {
}, 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
},
)
}
+1
View File
@@ -55,6 +55,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) {
GetMeUIResourceURI,
IssueWriteUIResourceURI,
PullRequestWriteUIResourceURI,
PullRequestEditUIResourceURI,
}
for _, uri := range uris {
t.Run(uri, func(t *testing.T) {
+516
View File
@@ -0,0 +1,516 @@
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 != nil && resp.Body != nil {
_ = resp.Body.Close()
}
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 != nil && resp.Body != nil {
_ = resp.Body.Close()
}
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 != nil && resp.Body != nil {
_ = resp.Body.Close()
}
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 != nil && resp.Body != nil {
_ = resp.Body.Close()
}
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 != nil && resp.Body != nil {
_ = resp.Body.Close()
}
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
}
+414
View File
@@ -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")
}
+24
View File
@@ -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,
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
// Build all UI apps in a single Node process.
//
// Replaces three serial `cross-env APP=<app> vite build` invocations: doing it
// in one process avoids paying Vite/plugin startup cost three times and is
// Replaces serial `cross-env APP=<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;
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import {
Box,
Text,
TextInput,
Button,
Flash,
Spinner,
FormControl,
ActionMenu,
ActionList,
Checkbox,
ButtonGroup,
CounterLabel,
Label,
} from "@primer/react";
import {
GitPullRequestIcon,
CheckCircleIcon,
GitBranchIcon,
LockIcon,
PersonIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { MarkdownEditor } from "../../components/MarkdownEditor";
interface PRResult {
ID?: string;
number?: number;
title?: string;
url?: string;
html_url?: string;
URL?: string;
}
interface BranchItem {
name: string;
protected: boolean;
}
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
type PRState = "open" | "closed";
interface InitialPRState {
title: string;
body: string;
state: PRState;
base: string;
draft: boolean;
maintainerCanModify: boolean;
reviewers: string[];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
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 sameReviewerValues(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, index) => value === sortedB[index]);
}
function parseUserReviewer(value: unknown): ReviewerItem | null {
if (typeof value === "string") return reviewerFromValue(value);
const record = asRecord(value);
const login = asString(record?.login);
if (!login) return null;
return { kind: "user", id: login, text: login, avatar: asString(record?.avatar_url) };
}
function parseTeamReviewer(value: unknown, fallbackOrg: string): ReviewerItem | null {
if (typeof value === "string") {
if (value.includes("/")) return reviewerFromValue(value);
return { kind: "team", id: `${fallbackOrg}/${value}`, text: `${fallbackOrg}/${value}`, org: fallbackOrg };
}
const record = asRecord(value);
const slug = asString(record?.slug) || asString(record?.name);
if (!slug) return null;
const organization = asRecord(record?.organization);
const org = asString(record?.org) || asString(organization?.login) || fallbackOrg;
const id = org ? `${org}/${slug}` : slug;
return { kind: "team", id, text: id, org };
}
function reviewersFromValues(values: unknown): ReviewerItem[] | undefined {
if (!Array.isArray(values)) return undefined;
return values
.map((value) => (typeof value === "string" ? reviewerFromValue(value) : null))
.filter((value): value is ReviewerItem => value !== null);
}
function extractRequestedReviewers(prData: Record<string, unknown>, owner: string): ReviewerItem[] {
const requestedReviewers = Array.isArray(prData.requested_reviewers) ? prData.requested_reviewers : [];
const requestedTeams = Array.isArray(prData.requested_teams) ? prData.requested_teams : [];
return [
...requestedReviewers.map(parseUserReviewer).filter((value): value is ReviewerItem => value !== null),
...requestedTeams.map((team) => parseTeamReviewer(team, owner)).filter((value): value is ReviewerItem => value !== null),
];
}
function parsePRState(value: unknown): PRState {
return value === "closed" ? "closed" : "open";
}
function buildInitialState(prData: Record<string, unknown>, owner: string): InitialPRState {
const base = asRecord(prData.base);
const requestedReviewers = extractRequestedReviewers(prData, owner);
return {
title: asString(prData.title) || "",
body: asString(prData.body) || "",
state: parsePRState(prData.state),
base: asString(base?.ref) || "",
draft: asBoolean(prData.draft) || false,
maintainerCanModify: asBoolean(prData.maintainer_can_modify) || false,
reviewers: requestedReviewers.map(reviewerValue),
};
}
function SuccessView({
pr,
owner,
repo,
submittedTitle,
openLink,
}: {
pr: PRResult;
owner: string;
repo: string;
submittedTitle: string;
openLink: (url: string) => Promise<void>;
}) {
const prUrl = pr.html_url || pr.url || pr.URL || "#";
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
mb={3}
pb={3}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
>
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
<CheckCircleIcon size={16} />
</Box>
<Text sx={{ fontWeight: "semibold" }}>
Pull request updated successfully
</Text>
</Box>
<Box
display="flex"
alignItems="flex-start"
gap={2}
p={3}
bg="canvas.subtle"
borderRadius={2}
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
>
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
if (prUrl === "#") return;
void openLink(prUrl);
}}
style={{
fontWeight: 600,
fontSize: "14px",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--fgColor-accent, var(--color-accent-fg))",
textDecoration: "none",
}}
>
{pr.title || submittedTitle}
{pr.number && (
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
#{pr.number}
</Text>
)}
</a>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{owner}/{repo}
</Text>
</Box>
</Box>
</Box>
);
}
function EditPRApp() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [prState, setPRState] = useState<PRState>("open");
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
const [initialValues, setInitialValues] = useState<InitialPRState | null>(null);
const [isLoadingPR, setIsLoadingPR] = useState(false);
const [submittedTitle, setSubmittedTitle] = useState("");
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [branchesLoading, setBranchesLoading] = useState(false);
const [baseFilter, setBaseFilter] = useState("");
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-edit-pull-request",
});
const owner = (toolInput?.owner as string) || "";
const repo = (toolInput?.repo as string) || "";
const pullNumber = asNumber(toolInput?.pullNumber);
useEffect(() => {
setTitle("");
setBody("");
setPRState("open");
setIsDraft(false);
setMaintainerCanModify(false);
setBaseBranch("");
setSelectedReviewers([]);
setAvailableBranches([]);
setAvailableReviewers([]);
setBaseFilter("");
setReviewersFilter("");
setInitialValues(null);
setSuccessPR(null);
setError(null);
setSubmittedTitle("");
}, [toolInput]);
useEffect(() => {
if (!app || !owner || !repo || !pullNumber) return;
let cancelled = false;
const loadPullRequest = async () => {
setIsLoadingPR(true);
try {
const result = await callTool("pull_request_read", { method: "get", owner, repo, pullNumber });
if (cancelled) return;
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to load pull request";
setError(errorMessage);
return;
}
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text" || !textContent.text) {
setError("Pull request details were not returned");
return;
}
const prData = JSON.parse(textContent.text) as Record<string, unknown>;
const initialState = buildInitialState(prData, owner);
const toolInputReviewers = reviewersFromValues(toolInput?.reviewers);
setInitialValues(initialState);
setTitle(asString(toolInput?.title) ?? initialState.title);
setBody(asString(toolInput?.body) ?? initialState.body);
setPRState(parsePRState(asString(toolInput?.state) ?? initialState.state));
setIsDraft(asBoolean(toolInput?.draft) ?? initialState.draft);
setBaseBranch(asString(toolInput?.base) ?? initialState.base);
setMaintainerCanModify(asBoolean(toolInput?.maintainer_can_modify) ?? initialState.maintainerCanModify);
setSelectedReviewers(toolInputReviewers ?? extractRequestedReviewers(prData, owner));
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load pull request");
}
} finally {
if (!cancelled) setIsLoadingPR(false);
}
};
loadPullRequest();
return () => {
cancelled = true;
};
}, [app, callTool, owner, repo, pullNumber, toolInput]);
useEffect(() => {
if (!owner || !repo || !app) return;
let cancelled = false;
const loadBranches = async () => {
setBranchesLoading(true);
try {
const result = await callTool("ui_get", { method: "branches", owner, repo });
if (cancelled) return;
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);
const defaultBranch = branches.find((b: BranchItem) => b.name === "main" || b.name === "master");
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
}
}
} catch (e) {
console.error("Failed to load branches:", e);
} finally {
if (!cancelled) setBranchesLoading(false);
}
};
const loadReviewers = async () => {
setReviewersLoading(true);
try {
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
if (cancelled) return;
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 {
if (!cancelled) setReviewersLoading(false);
}
};
loadBranches();
loadReviewers();
return () => {
cancelled = true;
};
}, [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]);
const filteredBaseBranches = useMemo(() => {
if (!baseFilter.trim()) return availableBranches;
return availableBranches.filter((branch) => branch.name.toLowerCase().includes(baseFilter.toLowerCase()));
}, [availableBranches, baseFilter]);
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 || !pullNumber) { setError("Pull request information not available"); return; }
if (!baseBranch) { setError("Base branch is required"); return; }
if (!initialValues) { setError("Pull request details are still loading"); return; }
const selectedReviewerValues = selectedReviewers.map(reviewerValue);
const params: Record<string, unknown> = { owner, repo, pullNumber, _ui_submitted: true };
if (title.trim() !== initialValues.title) params.title = title.trim();
if (body !== initialValues.body) params.body = body;
if (prState !== initialValues.state) params.state = prState;
if (baseBranch !== initialValues.base) params.base = baseBranch;
if (isDraft !== initialValues.draft) params.draft = isDraft;
if (maintainerCanModify !== initialValues.maintainerCanModify) params.maintainer_can_modify = maintainerCanModify;
if (!sameReviewerValues(selectedReviewerValues, initialValues.reviewers)) params.reviewers = selectedReviewerValues;
const hasChanges = Object.keys(params).some((key) => !["owner", "repo", "pullNumber", "_ui_submitted"].includes(key));
if (!hasChanges) {
setError("No changes to update");
return;
}
setIsSubmitting(true);
setError(null);
setSubmittedTitle(title);
try {
const result = await callTool("update_pull_request", params);
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to update pull request";
setError(errorMessage);
} else {
const textContent = result.content?.find((c) => c.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
void setModelContext({
structuredContent: prData,
content: [
{
type: "text",
text: `Pull request #${pullNumber} in ${owner}/${repo} was updated by the user via the edit-pull-request view.`,
},
],
});
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]);
if (successPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>
);
}
if (!app && !appError) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (appError) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">{appError.message}</Flash>
</AppProvider>
);
}
if (toolInput === null) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (!owner || !repo || !pullNumber) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">Pull request owner, repo, and pullNumber are required.</Flash>
</AppProvider>
);
}
return (
<AppProvider hostContext={hostContext}>
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
gap={2}
mb={3}
pb={2}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "open.fg", flexShrink: 0 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Text sx={{ fontWeight: "semibold" }}>#{pullNumber} · {owner}/{repo}</Text>
{title && (
<Text as="div" sx={{ color: "fg.muted", fontSize: 0, mt: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title}
</Text>
)}
</Box>
</Box>
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
{isLoadingPR && !initialValues ? (
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
<Text sx={{ color: "fg.muted", ml: 2 }}>Loading pull request...</Text>
</Box>
) : (
<>
<FormControl sx={{ mb: 3 }}>
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
<TextInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
block
contrast
/>
</FormControl>
<Box sx={{ mb: 3 }}>
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
Description
</Text>
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
</Box>
<Box sx={{ mb: 3, flex: "1 1 240px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3} mb={3}>
<Box>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>state</Text>
<ButtonGroup>
<Button size="small" variant={prState === "open" ? "primary" : "default"} onClick={() => setPRState("open")}>
Open
</Button>
<Button size="small" variant={prState === "closed" ? "primary" : "default"} onClick={() => setPRState("closed")}>
Closed
</Button>
</ButtonGroup>
</Box>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mb: "6px" }}>
<Checkbox checked={isDraft} onChange={(e) => setIsDraft(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Mark as draft</Text>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
</Box>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !initialValues || !title.trim() || !baseBranch}
>
{isSubmitting ? (
<><Spinner size="small" sx={{ mr: 1 }} />Updating...</>
) : (
"Update pull request"
)}
</Button>
</Box>
</>
)}
</Box>
</AppProvider>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<EditPRApp />
</StrictMode>
);
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edit pull request</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+476 -23
View File
@@ -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<string | null>(null);
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
// Branch state
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [headBranch, setHeadBranch] = useState<string>("");
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<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
// Repository state
const [selectedRepo, setSelectedRepo] = useState<RepositoryItem | null>(null);
const [repoSearchResults, setRepoSearchResults] = useState<RepositoryItem[]>([]);
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 */}
<Box
display="flex"
alignItems="center"
@@ -252,19 +486,154 @@ function CreatePRApp() {
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "fg.default", flexShrink: 0, display: "flex", mr: 1 }}>
<GitPullRequestIcon size={16} />
<Box sx={{ minWidth: 0, maxWidth: "100%" }}>
<ActionMenu>
<ActionMenu.Button
size="small"
leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon}
sx={{ maxWidth: "100%", overflow: "hidden", "& > span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
>
{selectedRepo ? selectedRepo.fullName : "Select repository"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box px={3} py={2}>
<TextInput
placeholder="Search repositories..."
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
sx={{ width: "100%" }}
size="small"
autoFocus
/>
</Box>
<ActionList.Divider />
{repoSearchLoading ? (
<Box display="flex" justifyContent="center" p={3}>
<Spinner size="small" />
</Box>
) : repoSearchResults.length > 0 ? (
repoSearchResults.map((r) => (
<ActionList.Item
key={r.id}
selected={selectedRepo?.id === r.id}
onSelect={() => {
setSelectedRepo(r);
setRepoFilter("");
setAvailableBranches([]);
setBaseBranch("");
setHeadBranch("");
setAvailableReviewers([]);
setSelectedReviewers([]);
setReviewersFilter("");
}}
>
<ActionList.LeadingVisual>
{r.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{r.fullName}
</ActionList.Item>
))
) : selectedRepo ? (
<ActionList.Item key={selectedRepo.id} selected onSelect={() => setRepoFilter("")}>
<ActionList.LeadingVisual>
{selectedRepo.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{selectedRepo.fullName}
</ActionList.Item>
) : (
<Box px={3} py={2}>
<Text sx={{ color: "fg.muted", fontSize: 1 }}>Type to search repositories...</Text>
</Box>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
</Box>
{/* Branch selectors */}
<Box display="flex" gap={2} mb={3} alignItems="flex-end" sx={{ minWidth: 0, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ color: "fg.muted", pb: 1, px: 1, flexShrink: 0 }}></Text>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>compare</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{headBranch || "Select head"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={headFilter}
onChange={(e) => setHeadFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredHeadBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredHeadBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={headBranch === branch.name}
onSelect={() => { setHeadBranch(branch.name); setHeadFilter(""); }}
>
{branch.name}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ fontWeight: "semibold", whiteSpace: "nowrap" }}>New pull request</Text>
<Text sx={{ color: "fg.muted", fontSize: 0, ml: 1 }}>
{owner}/{repo}
</Text>
{head && base && (
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{base} {head}
</Text>
)}
</Box>
{/* Error banner */}
@@ -290,9 +659,93 @@ function CreatePRApp() {
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
</Box>
{/* Reviewers */}
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
{/* Options and Submit */}
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2 }}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
</Box>
@@ -301,7 +754,7 @@ function CreatePRApp() {
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !owner || !repo}
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
>
{isSubmitting ? (
<><Spinner size="small" sx={{ mr: 1 }} />Creating...</>
@@ -315,7 +768,7 @@ function CreatePRApp() {
<ActionMenu.Anchor>
<Button
variant="primary"
disabled={isSubmitting || !owner || !repo}
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
sx={{ px: 2 }}
aria-label="Select pull request type"
>