diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap
new file mode 100644
index 000000000..8cb2ebdf9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/ui_get.snap
@@ -0,0 +1,36 @@
+{
+ "annotations": {
+ "readOnlyHint": true,
+ "title": "Get UI data"
+ },
+ "description": "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches).",
+ "inputSchema": {
+ "properties": {
+ "method": {
+ "description": "The type of data to fetch",
+ "enum": [
+ "labels",
+ "assignees",
+ "milestones",
+ "issue_types",
+ "branches"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner (required for all methods)",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name (required for labels, assignees, milestones, branches)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "method",
+ "owner"
+ ],
+ "type": "object"
+ },
+ "name": "ui_get"
+}
\ No newline at end of file
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 676976140..6e57bf339 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -295,6 +295,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
GetLabelForLabelsToolset(t),
ListLabels(t),
LabelWrite(t),
+
+ // UI tools (insiders only)
+ UIGet(t),
}
}
diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go
new file mode 100644
index 000000000..33c2b9c32
--- /dev/null
+++ b/pkg/github/ui_tools.go
@@ -0,0 +1,308 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ 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/v79/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)."),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"),
+ ReadOnlyHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "method": {
+ Type: "string",
+ Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches"},
+ 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)",
+ },
+ },
+ 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)
+ default:
+ return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
+ }
+ })
+ st.InsidersOnly = true
+ 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
+ } `graphql:"labels(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ vars := map[string]any{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ }
+
+ if err := client.Query(ctx, &query, vars); err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
+ }
+
+ labels := make([]map[string]any, len(query.Repository.Labels.Nodes))
+ for i, labelNode := range query.Repository.Labels.Nodes {
+ labels[i] = map[string]any{
+ "id": fmt.Sprintf("%v", labelNode.ID),
+ "name": string(labelNode.Name),
+ "color": string(labelNode.Color),
+ "description": string(labelNode.Description),
+ }
+ }
+
+ response := map[string]any{
+ "labels": labels,
+ "totalCount": int(query.Repository.Labels.TotalCount),
+ }
+
+ out, err := json.Marshal(response)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal labels: %w", err)
+ }
+
+ return utils.NewToolResultText(string(out)), nil, nil
+}
+
+func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
+ }
+
+ opts := &github.ListOptions{PerPage: 100}
+ var allAssignees []*github.User
+
+ for {
+ assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil
+ }
+ allAssignees = append(allAssignees, assignees...)
+ if resp.NextPage == 0 {
+ break
+ }
+ opts.Page = resp.NextPage
+ }
+
+ result := make([]map[string]string, len(allAssignees))
+ for i, u := range allAssignees {
+ result[i] = map[string]string{
+ "login": u.GetLogin(),
+ "avatar_url": u.GetAvatarURL(),
+ }
+ }
+
+ out, err := json.Marshal(map[string]any{
+ "assignees": result,
+ "totalCount": len(result),
+ })
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil
+ }
+
+ return utils.NewToolResultText(string(out)), nil, nil
+}
+
+func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
+ repo, err := RequiredParam[string](args, "repo")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
+ }
+
+ opts := &github.MilestoneListOptions{
+ State: "open",
+ ListOptions: github.ListOptions{PerPage: 100},
+ }
+
+ var allMilestones []*github.Milestone
+ for {
+ milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil
+ }
+ allMilestones = append(allMilestones, milestones...)
+ if resp.NextPage == 0 {
+ break
+ }
+ opts.Page = resp.NextPage
+ }
+
+ result := make([]map[string]any, len(allMilestones))
+ for i, m := range allMilestones {
+ result[i] = map[string]any{
+ "number": m.GetNumber(),
+ "title": m.GetTitle(),
+ "description": m.GetDescription(),
+ "state": m.GetState(),
+ "open_issues": m.GetOpenIssues(),
+ "due_on": m.GetDueOn().Format("2006-01-02"),
+ }
+ }
+
+ 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 utils.NewToolResultErrorFromErr("failed to list issue types", 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 nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.BranchListOptions{
+ ListOptions: github.ListOptions{PerPage: 100},
+ }
+
+ 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
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list branches", resp, body), nil, nil
+ }
+
+ minimalBranches := make([]MinimalBranch, 0, len(branches))
+ for _, branch := range branches {
+ minimalBranches = append(minimalBranches, convertToMinimalBranch(branch))
+ }
+
+ r, err := json.Marshal(map[string]any{
+ "branches": minimalBranches,
+ "totalCount": len(minimalBranches),
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return utils.NewToolResultText(string(r)), nil, nil
+}
diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go
new file mode 100644
index 000000000..f34c83839
--- /dev/null
+++ b/pkg/github/ui_tools_test.go
@@ -0,0 +1,168 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v79/github"
+ "github.com/google/jsonschema-go/jsonschema"
+ "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.True(t, serverTool.InsidersOnly, "ui_get should be insiders 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)},
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ validateResult func(t *testing.T, response map[string]interface{})
+ }{
+ {
+ name: "successful assignees fetch",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees),
+ }),
+ requestArgs: map[string]interface{}{
+ "method": "assignees",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ validateResult: func(t *testing.T, response map[string]interface{}) {
+ 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]interface{}{
+ "method": "branches",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ validateResult: func(t *testing.T, response map[string]interface{}) {
+ assert.Contains(t, response, "branches")
+ assert.Contains(t, response, "totalCount")
+ },
+ },
+ {
+ name: "missing method parameter",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: method",
+ },
+ {
+ name: "missing owner parameter",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
+ requestArgs: map[string]interface{}{
+ "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]interface{}{
+ "method": "assignees",
+ "owner": "owner",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "unknown method",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
+ requestArgs: map[string]interface{}{
+ "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 client with mock
+ client := github.NewClient(tc.mockedClient)
+ deps := BaseDeps{
+ Client: client,
+ }
+ 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)
+
+ var response map[string]interface{}
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ if tc.validateResult != nil {
+ tc.validateResult(t, response)
+ }
+ })
+ }
+}
diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx
index 16f17bdaa..2a79fdef5 100644
--- a/ui/src/apps/issue-write/App.tsx
+++ b/ui/src/apps/issue-write/App.tsx
@@ -1,4 +1,4 @@
-import { StrictMode, useState, useCallback, useEffect } from "react";
+import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";
import {
Box,
@@ -8,10 +8,19 @@ import {
Flash,
Spinner,
FormControl,
+ CounterLabel,
+ ActionMenu,
+ ActionList,
+ Label,
} from "@primer/react";
import {
IssueOpenedIcon,
CheckCircleIcon,
+ TagIcon,
+ PersonIcon,
+ RepoIcon,
+ MilestoneIcon,
+ LockIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
@@ -27,17 +36,59 @@ interface IssueResult {
URL?: string;
}
+interface LabelItem {
+ id: string;
+ text: string;
+ color: string;
+}
+
+interface AssigneeItem {
+ id: string;
+ text: string;
+}
+
+interface MilestoneItem {
+ id: string;
+ number: number;
+ text: string;
+ description: string;
+}
+
+interface IssueTypeItem {
+ id: string;
+ text: string;
+}
+
+interface RepositoryItem {
+ id: string;
+ owner: string;
+ name: string;
+ fullName: string;
+ isPrivate: boolean;
+}
+
+// Calculate text color based on background luminance
+function getContrastColor(hexColor: string): string {
+ const r = parseInt(hexColor.substring(0, 2), 16);
+ const g = parseInt(hexColor.substring(2, 4), 16);
+ const b = parseInt(hexColor.substring(4, 6), 16);
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance > 0.5 ? "#000000" : "#ffffff";
+}
+
function SuccessView({
issue,
owner,
repo,
submittedTitle,
+ submittedLabels,
isUpdate,
}: {
issue: IssueResult;
owner: string;
repo: string;
submittedTitle: string;
+ submittedLabels: LabelItem[];
isUpdate: boolean;
}) {
const issueUrl = issue.html_url || issue.url || issue.URL || "#";
@@ -108,6 +159,22 @@ function SuccessView({
{owner}/{repo}
+ {submittedLabels.length > 0 && (
+
+ {submittedLabels.map((label) => (
+
+ ))}
+
+ )}
@@ -121,22 +188,420 @@ function CreateIssueApp() {
const [error, setError] = useState(null);
const [successIssue, setSuccessIssue] = useState(null);
+ // Labels state
+ const [availableLabels, setAvailableLabels] = useState([]);
+ const [selectedLabels, setSelectedLabels] = useState([]);
+ const [labelsLoading, setLabelsLoading] = useState(false);
+ const [labelsFilter, setLabelsFilter] = useState("");
+
+ // Assignees state
+ const [availableAssignees, setAvailableAssignees] = useState([]);
+ const [selectedAssignees, setSelectedAssignees] = useState([]);
+ const [assigneesLoading, setAssigneesLoading] = useState(false);
+ const [assigneesFilter, setAssigneesFilter] = useState("");
+
+ // Milestones state
+ const [availableMilestones, setAvailableMilestones] = useState([]);
+ const [selectedMilestone, setSelectedMilestone] = useState(null);
+ const [milestonesLoading, setMilestonesLoading] = useState(false);
+
+ // Issue types state
+ const [availableIssueTypes, setAvailableIssueTypes] = useState([]);
+ const [selectedIssueType, setSelectedIssueType] = useState(null);
+ const [issueTypesLoading, setIssueTypesLoading] = useState(false);
+
+ // Repository state
+ const [selectedRepo, setSelectedRepo] = useState(null);
+ const [repoSearchResults, setRepoSearchResults] = useState([]);
+ const [repoSearchLoading, setRepoSearchLoading] = useState(false);
+ const [repoFilter, setRepoFilter] = useState("");
+
const { app, error: appError, toolInput, callTool } = useMcpApp({
appName: "github-mcp-server-issue-write",
});
+ // Get method and issue_number from toolInput
const method = (toolInput?.method as string) || "create";
const issueNumber = toolInput?.issue_number as number | undefined;
const isUpdateMode = method === "update" && issueNumber !== undefined;
- const owner = (toolInput?.owner as string) || "";
- const repo = (toolInput?.repo as string) || "";
- // Pre-fill from toolInput
+ // Initialize from toolInput or selected repo
+ const owner = selectedRepo?.owner || (toolInput?.owner as string) || "";
+ const repo = selectedRepo?.name || (toolInput?.repo as string) || "";
+
+ // Initialize selectedRepo from toolInput
+ useEffect(() => {
+ if (toolInput?.owner && toolInput?.repo && !selectedRepo) {
+ setSelectedRepo({
+ id: `${toolInput.owner}/${toolInput.repo}`,
+ owner: toolInput.owner as string,
+ name: toolInput.repo as string,
+ fullName: `${toolInput.owner}/${toolInput.repo}`,
+ isPrivate: false,
+ });
+ }
+ }, [toolInput, selectedRepo]);
+
+ // Search repositories when filter changes
+ useEffect(() => {
+ if (!app || !repoFilter.trim()) {
+ setRepoSearchResults([]);
+ return;
+ }
+
+ const searchRepos = async () => {
+ setRepoSearchLoading(true);
+ try {
+ const result = await callTool("search_repositories", {
+ query: repoFilter,
+ perPage: 10,
+ });
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c) => c.type === "text"
+ );
+ if (textContent && textContent.type === "text" && textContent.text) {
+ const data = JSON.parse(textContent.text);
+ const repos = (data.repositories || data.items || []).map(
+ (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({
+ id: String(r.id || r.full_name),
+ owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || '',
+ name: r.name || '',
+ fullName: r.full_name || `${typeof r.owner === 'string' ? r.owner : r.owner?.login}/${r.name}`,
+ isPrivate: r.private || false,
+ })
+ );
+ setRepoSearchResults(repos);
+ }
+ }
+ } catch (e) {
+ console.error("Failed to search repositories:", e);
+ } finally {
+ setRepoSearchLoading(false);
+ }
+ };
+
+ const debounce = setTimeout(searchRepos, 300);
+ return () => clearTimeout(debounce);
+ }, [app, callTool, repoFilter]);
+
+ // Load labels, assignees, milestones, and issue types when owner/repo available
useEffect(() => {
- if (toolInput?.title) setTitle(toolInput.title as string);
- if (toolInput?.body) setBody(toolInput.body as string);
+ if (!owner || !repo || !app) return;
+
+ const loadLabels = async () => {
+ setLabelsLoading(true);
+ try {
+ const result = await callTool("ui_get", { method: "labels", owner, repo });
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c: { type: string }) => c.type === "text"
+ );
+ if (textContent && "text" in textContent) {
+ const data = JSON.parse(textContent.text as string);
+ const labels = (data.labels || []).map(
+ (l: { name: string; color: string; id: string }) => ({
+ id: l.id || l.name,
+ text: l.name,
+ color: l.color,
+ })
+ );
+ setAvailableLabels(labels);
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load labels:", e);
+ } finally {
+ setLabelsLoading(false);
+ }
+ };
+
+ const loadAssignees = async () => {
+ setAssigneesLoading(true);
+ try {
+ const result = await callTool("ui_get", { method: "assignees", owner, repo });
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c: { type: string }) => c.type === "text"
+ );
+ if (textContent && "text" in textContent) {
+ const data = JSON.parse(textContent.text as string);
+ const assignees = (data.assignees || []).map(
+ (a: { login: string }) => ({
+ id: a.login,
+ text: a.login,
+ })
+ );
+ setAvailableAssignees(assignees);
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load assignees:", e);
+ } finally {
+ setAssigneesLoading(false);
+ }
+ };
+
+ const loadMilestones = async () => {
+ setMilestonesLoading(true);
+ try {
+ const result = await callTool("ui_get", { method: "milestones", owner, repo });
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c: { type: string }) => c.type === "text"
+ );
+ if (textContent && "text" in textContent) {
+ const data = JSON.parse(textContent.text as string);
+ const milestones = (data.milestones || []).map(
+ (m: { number: number; title: string; description: string }) => ({
+ id: String(m.number),
+ number: m.number,
+ text: m.title,
+ description: m.description || "",
+ })
+ );
+ setAvailableMilestones(milestones);
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load milestones:", e);
+ } finally {
+ setMilestonesLoading(false);
+ }
+ };
+
+ const loadIssueTypes = async () => {
+ setIssueTypesLoading(true);
+ try {
+ const result = await callTool("ui_get", { method: "issue_types", owner });
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c: { type: string }) => c.type === "text"
+ );
+ if (textContent && "text" in textContent) {
+ const data = JSON.parse(textContent.text as string);
+ // ui_get returns array directly or wrapped in issue_types/types
+ const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []);
+ const types = typesArray.map(
+ (t: { id: number; name: string; description?: string } | string) => {
+ if (typeof t === "string") {
+ return { id: t, text: t };
+ }
+ return { id: String(t.id || t.name), text: t.name };
+ }
+ );
+ setAvailableIssueTypes(types);
+ }
+ }
+ } catch (e) {
+ // Issue types may not be available for all repos/orgs
+ console.debug("Issue types not available:", e);
+ } finally {
+ setIssueTypesLoading(false);
+ }
+ };
+
+ loadLabels();
+ loadAssignees();
+ loadMilestones();
+ loadIssueTypes();
+ }, [owner, repo, app, callTool]);
+
+ // Track which prefill fields have been applied to avoid re-applying after user edits
+ const prefillApplied = useRef<{
+ title: boolean;
+ body: boolean;
+ labels: boolean;
+ assignees: boolean;
+ milestone: boolean;
+ type: boolean;
+ }>({ title: false, body: false, labels: false, assignees: false, milestone: false, type: false });
+
+ // Store existing issue data for matching when available lists load
+ interface ExistingIssueData {
+ labels: string[];
+ assignees: string[];
+ milestoneNumber: number | null;
+ issueType: string | null;
+ }
+ const [existingIssueData, setExistingIssueData] = useState(null);
+
+ // Reset prefill tracking when toolInput changes (new invocation)
+ useEffect(() => {
+ prefillApplied.current = { title: false, body: false, labels: false, assignees: false, milestone: false, type: false };
+ setExistingIssueData(null);
}, [toolInput]);
+ // Load existing issue data when in update mode
+ useEffect(() => {
+ if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) {
+ return;
+ }
+
+ const loadExistingIssue = async () => {
+ try {
+ const result = await callTool("issue_read", {
+ method: "get",
+ owner,
+ repo,
+ issue_number: issueNumber,
+ });
+
+ if (result && !result.isError && result.content) {
+ const textContent = result.content.find(
+ (c) => c.type === "text"
+ );
+ if (textContent && textContent.type === "text" && textContent.text) {
+ const issueData = JSON.parse(textContent.text);
+
+ // Pre-fill title and body immediately
+ if (issueData.title && !prefillApplied.current.title) {
+ setTitle(issueData.title);
+ prefillApplied.current.title = true;
+ }
+ if (issueData.body && !prefillApplied.current.body) {
+ setBody(issueData.body);
+ prefillApplied.current.body = true;
+ }
+
+ // Pre-fill assignees immediately from issue data
+ const assigneeLogins = (issueData.assignees || [])
+ .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login)
+ .filter(Boolean) as string[];
+ if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) {
+ setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login })));
+ prefillApplied.current.assignees = true;
+ }
+
+ // Pre-fill issue type immediately from issue data
+ const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null);
+ if (issueTypeName && !prefillApplied.current.type) {
+ setSelectedIssueType({ id: issueTypeName, text: issueTypeName });
+ prefillApplied.current.type = true;
+ }
+
+ // Extract data for deferred matching when available lists load (for labels and milestones)
+ const labelNames = (issueData.labels || [])
+ .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name)
+ .filter(Boolean) as string[];
+
+ const milestoneNumber = issueData.milestone
+ ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone)
+ : null;
+
+ setExistingIssueData({ labels: labelNames, assignees: assigneeLogins, milestoneNumber, issueType: issueTypeName });
+ }
+ }
+ } catch (e) {
+ console.error("Error loading existing issue:", e);
+ }
+ };
+
+ loadExistingIssue();
+ }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]);
+
+ // Apply existing labels when available labels load
+ useEffect(() => {
+ if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return;
+ const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text));
+ if (matched.length > 0) {
+ setSelectedLabels(matched);
+ prefillApplied.current.labels = true;
+ }
+ }, [existingIssueData, availableLabels]);
+
+ // Apply existing milestone when available milestones load
+ useEffect(() => {
+ if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return;
+ const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber);
+ if (matched) {
+ setSelectedMilestone(matched);
+ }
+ prefillApplied.current.milestone = true;
+ }, [existingIssueData, availableMilestones]);
+
+ // Pre-fill title and body immediately (don't wait for data loading)
+ useEffect(() => {
+ if (toolInput?.title && !prefillApplied.current.title) {
+ setTitle(toolInput.title as string);
+ prefillApplied.current.title = true;
+ }
+ if (toolInput?.body && !prefillApplied.current.body) {
+ setBody(toolInput.body as string);
+ prefillApplied.current.body = true;
+ }
+ }, [toolInput]);
+
+ // Pre-fill labels once available data is loaded
+ useEffect(() => {
+ if (
+ toolInput?.labels &&
+ Array.isArray(toolInput.labels) &&
+ availableLabels.length > 0 &&
+ !prefillApplied.current.labels
+ ) {
+ const prefillLabels = availableLabels.filter((l) =>
+ (toolInput.labels as string[]).includes(l.text)
+ );
+ if (prefillLabels.length > 0) {
+ setSelectedLabels(prefillLabels);
+ prefillApplied.current.labels = true;
+ }
+ }
+ }, [toolInput, availableLabels]);
+
+ // Pre-fill assignees once available data is loaded
+ useEffect(() => {
+ if (
+ toolInput?.assignees &&
+ Array.isArray(toolInput.assignees) &&
+ availableAssignees.length > 0 &&
+ !prefillApplied.current.assignees
+ ) {
+ const prefillAssignees = availableAssignees.filter((a) =>
+ (toolInput.assignees as string[]).includes(a.text)
+ );
+ if (prefillAssignees.length > 0) {
+ setSelectedAssignees(prefillAssignees);
+ prefillApplied.current.assignees = true;
+ }
+ }
+ }, [toolInput, availableAssignees]);
+
+ // Pre-fill milestone once available data is loaded
+ useEffect(() => {
+ if (
+ toolInput?.milestone &&
+ availableMilestones.length > 0 &&
+ !prefillApplied.current.milestone
+ ) {
+ const milestone = availableMilestones.find(
+ (m) => m.number === Number(toolInput.milestone)
+ );
+ if (milestone) {
+ setSelectedMilestone(milestone);
+ prefillApplied.current.milestone = true;
+ }
+ }
+ }, [toolInput, availableMilestones]);
+
+ // Pre-fill issue type once available data is loaded
+ useEffect(() => {
+ if (
+ toolInput?.type &&
+ availableIssueTypes.length > 0 &&
+ !prefillApplied.current.type
+ ) {
+ const issueType = availableIssueTypes.find(
+ (t) => t.text === toolInput.type
+ );
+ if (issueType) {
+ setSelectedIssueType(issueType);
+ prefillApplied.current.type = true;
+ }
+ }
+ }, [toolInput, availableIssueTypes]);
+
const handleSubmit = useCallback(async () => {
if (!title.trim()) {
setError("Title is required");
@@ -164,6 +629,19 @@ function CreateIssueApp() {
params.issue_number = issueNumber;
}
+ if (selectedLabels.length > 0) {
+ params.labels = selectedLabels.map((l) => l.text);
+ }
+ if (selectedAssignees.length > 0) {
+ params.assignees = selectedAssignees.map((a) => a.text);
+ }
+ if (selectedMilestone) {
+ params.milestone = selectedMilestone.number;
+ }
+ if (selectedIssueType) {
+ params.type = selectedIssueType.text;
+ }
+
const result = await callTool("issue_write", params);
if (result.isError) {
@@ -191,7 +669,24 @@ function CreateIssueApp() {
} finally {
setIsSubmitting(false);
}
- }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool]);
+ }, [title, body, owner, repo, selectedLabels, selectedAssignees, selectedMilestone, selectedIssueType, callTool]);
+
+ // Filtered items for dropdowns
+ const filteredLabels = useMemo(() => {
+ if (!labelsFilter) return availableLabels;
+ const lowerFilter = labelsFilter.toLowerCase();
+ return availableLabels.filter((l) =>
+ l.text.toLowerCase().includes(lowerFilter)
+ );
+ }, [availableLabels, labelsFilter]);
+
+ const filteredAssignees = useMemo(() => {
+ if (!assigneesFilter) return availableAssignees;
+ const lowerFilter = assigneesFilter.toLowerCase();
+ return availableAssignees.filter((a) =>
+ a.text.toLowerCase().includes(lowerFilter)
+ );
+ }, [availableAssignees, assigneesFilter]);
if (appError) {
return (
@@ -216,6 +711,7 @@ function CreateIssueApp() {
owner={owner}
repo={repo}
submittedTitle={title}
+ submittedLabels={selectedLabels}
isUpdate={isUpdateMode}
/>
);
@@ -230,7 +726,7 @@ function CreateIssueApp() {
bg="canvas.subtle"
p={3}
>
- {/* Header */}
+ {/* Repository picker */}
-
-
+
+
+ span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
+ >
+ {selectedRepo ? selectedRepo.fullName : "Select repository"}
+
+
+
+
+ setRepoFilter(e.target.value)}
+ sx={{ width: "100%" }}
+ size="small"
+ autoFocus
+ />
+
+
+ {repoSearchLoading ? (
+
+
+
+ ) : repoSearchResults.length > 0 ? (
+ repoSearchResults.map((r) => (
+ {
+ setSelectedRepo(r);
+ setRepoFilter("");
+ // Clear metadata when switching repos
+ setAvailableLabels([]);
+ setSelectedLabels([]);
+ setAvailableAssignees([]);
+ setSelectedAssignees([]);
+ setAvailableMilestones([]);
+ setSelectedMilestone(null);
+ setAvailableIssueTypes([]);
+ setSelectedIssueType(null);
+ }}
+ >
+
+ {r.isPrivate ? : }
+
+ {r.fullName}
+
+ ))
+ ) : selectedRepo ? (
+ setRepoFilter("")}
+ >
+
+ {selectedRepo.isPrivate ? : }
+
+ {selectedRepo.fullName}
+
+ ) : (
+
+
+ Type to search repositories...
+
+
+ )}
+
+
+
-
- {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"}
-
-
- {owner}/{repo}
-
{/* Error banner */}
@@ -288,6 +849,222 @@ function CreateIssueApp() {
/>
+ {/* Metadata section */}
+
+ {/* Labels dropdown */}
+
+
+ Labels
+ {selectedLabels.length > 0 && (
+ {selectedLabels.length}
+ )}
+
+
+
+ setLabelsFilter(e.target.value)}
+ size="small"
+ block
+ />
+
+
+ {labelsLoading ? (
+
+ Loading...
+
+ ) : filteredLabels.length === 0 ? (
+ No labels available
+ ) : (
+ filteredLabels.map((label) => (
+ l.id === label.id)}
+ onSelect={() => {
+ setSelectedLabels((prev) =>
+ prev.some((l) => l.id === label.id)
+ ? prev.filter((l) => l.id !== label.id)
+ : [...prev, label]
+ );
+ }}
+ >
+
+
+
+ {label.text}
+
+ ))
+ )}
+
+
+
+
+ {/* Assignees dropdown */}
+
+
+ Assignees
+ {selectedAssignees.length > 0 && (
+ {selectedAssignees.length}
+ )}
+
+
+
+ setAssigneesFilter(e.target.value)}
+ size="small"
+ block
+ />
+
+
+ {assigneesLoading ? (
+
+ Loading...
+
+ ) : filteredAssignees.length === 0 ? (
+ No assignees available
+ ) : (
+ filteredAssignees.map((assignee) => (
+ a.id === assignee.id)}
+ onSelect={() => {
+ setSelectedAssignees((prev) =>
+ prev.some((a) => a.id === assignee.id)
+ ? prev.filter((a) => a.id !== assignee.id)
+ : [...prev, assignee]
+ );
+ }}
+ >
+ {assignee.text}
+
+ ))
+ )}
+
+
+
+
+ {/* Milestones dropdown */}
+
+
+ {selectedMilestone ? selectedMilestone.text : "Milestone"}
+
+
+
+ {milestonesLoading ? (
+
+ Loading...
+
+ ) : availableMilestones.length === 0 ? (
+ No milestones
+ ) : (
+ <>
+ {selectedMilestone && (
+ setSelectedMilestone(null)}
+ >
+ Clear selection
+
+ )}
+ {availableMilestones.map((milestone) => (
+ setSelectedMilestone(milestone)}
+ >
+ {milestone.text}
+ {milestone.description && (
+
+ {milestone.description}
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+
+
+ {/* Issue Types dropdown */}
+
+
+ {selectedIssueType ? selectedIssueType.text : "Type"}
+
+
+
+ {issueTypesLoading ? (
+
+ Loading...
+
+ ) : availableIssueTypes.length === 0 ? (
+ No issue types
+ ) : (
+ <>
+ {selectedIssueType && (
+ setSelectedIssueType(null)}
+ >
+ Clear selection
+
+ )}
+ {availableIssueTypes.map((type) => (
+ setSelectedIssueType(type)}
+ >
+ {type.text}
+
+ ))}
+ >
+ )}
+
+
+
+
+
+ {/* Selected labels display */}
+ {selectedLabels.length > 0 && (
+
+ {selectedLabels.map((label) => (
+
+ ))}
+
+ )}
+
+ {/* Selected metadata display */}
+ {(selectedAssignees.length > 0 || selectedMilestone) && (
+
+ {selectedAssignees.length > 0 && (
+
+ Assigned to: {selectedAssignees.map((a) => a.text).join(", ")}
+
+ )}
+ {selectedMilestone && (
+ Milestone: {selectedMilestone.text}
+ )}
+
+ )}
+
{/* Submit button */}