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 */}