mirror-github.paniser.workers.dev/github/github-mcp-server
Advanced tools
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| package github | ||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "net/http" | ||
| "testing" | ||
| "github.com/google/go-github/v87/github" | ||
| "github.com/google/jsonschema-go/jsonschema" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "github.com/github/github-mcp-server/internal/toolsnaps" | ||
| "github.com/github/github-mcp-server/pkg/translations" | ||
| ) | ||
| func Test_GetCodeQualityFinding(t *testing.T) { | ||
| // Verify tool definition once | ||
| toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) | ||
| require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) | ||
| assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) | ||
| assert.NotEmpty(t, toolDef.Tool.Description) | ||
| // InputSchema is of type any, need to cast to *jsonschema.Schema | ||
| schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) | ||
| require.True(t, ok, "InputSchema should be *jsonschema.Schema") | ||
| assert.Contains(t, schema.Properties, "owner") | ||
| assert.Contains(t, schema.Properties, "repo") | ||
| assert.Contains(t, schema.Properties, "findingNumber") | ||
| assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "findingNumber"}) | ||
| type codeQualityRule struct { | ||
| ID *string `json:"id,omitempty"` | ||
| Title *string `json:"title,omitempty"` | ||
| Description *string `json:"description,omitempty"` | ||
| Help *string `json:"help,omitempty"` | ||
| Severity *string `json:"severity,omitempty"` | ||
| Category *string `json:"category,omitempty"` | ||
| } | ||
| type codeQualityLocation struct { | ||
| Path *string `json:"path,omitempty"` | ||
| StartLine *int `json:"start_line,omitempty"` | ||
| StartColumn *int `json:"start_column,omitempty"` | ||
| EndLine *int `json:"end_line,omitempty"` | ||
| EndColumn *int `json:"end_column,omitempty"` | ||
| } | ||
| type codeQualityMessage struct { | ||
| Text string `json:"text"` | ||
| Markdown string `json:"markdown"` | ||
| } | ||
| type codeQualityFinding struct { | ||
| Number *int `json:"number,omitempty"` | ||
| State *string `json:"state,omitempty"` | ||
| URL *string `json:"url,omitempty"` | ||
| Rule *codeQualityRule `json:"rule,omitempty"` | ||
| Location *codeQualityLocation `json:"location,omitempty"` | ||
| Message *codeQualityMessage `json:"message,omitempty"` | ||
| CreatedAt *github.Timestamp `json:"created_at,omitempty"` | ||
| } | ||
| // Setup mock finding for success case | ||
| mockFinding := &codeQualityFinding{ | ||
| Number: github.Ptr(42), | ||
| State: github.Ptr("open"), | ||
| Rule: &codeQualityRule{ | ||
| ID: github.Ptr("test-rule"), | ||
| Description: github.Ptr("Test Rule Description"), | ||
| }, | ||
| } | ||
| tests := []struct { | ||
| name string | ||
| mockedClient *http.Client | ||
| requestArgs map[string]any | ||
| expectError bool | ||
| expectedFinding *codeQualityFinding | ||
| expectedErrMsg string | ||
| }{ | ||
| { | ||
| name: "successful finding fetch", | ||
| mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, mockFinding), | ||
| }), | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "findingNumber": float64(42), | ||
| }, | ||
| expectError: false, | ||
| expectedFinding: mockFinding, | ||
| }, | ||
| { | ||
| name: "finding fetch fails", | ||
| mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: func(w http.ResponseWriter, _ *http.Request) { | ||
| w.WriteHeader(http.StatusNotFound) | ||
| _, _ = w.Write([]byte(`{"message": "Not Found"}`)) | ||
| }, | ||
| }), | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "findingNumber": float64(9999), | ||
| }, | ||
| expectError: true, | ||
| expectedErrMsg: "failed to get finding", | ||
| }, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| // Setup client with mock | ||
| client := mustNewGHClient(t, tc.mockedClient) | ||
| deps := BaseDeps{ | ||
| Client: client, | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| // Create call request | ||
| request := createMCPRequest(tc.requestArgs) | ||
| // Call handler with new signature | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| // Verify results | ||
| if tc.expectError { | ||
| require.NoError(t, err) | ||
| require.True(t, result.IsError) | ||
| errorContent := getErrorResult(t, result) | ||
| assert.Contains(t, errorContent.Text, tc.expectedErrMsg) | ||
| return | ||
| } | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| // Parse the result and get the text content if no error | ||
| textContent := getTextResult(t, result) | ||
| // Unmarshal and verify the result | ||
| var returnedFinding codeQualityFinding | ||
| err = json.Unmarshal([]byte(textContent.Text), &returnedFinding) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, *tc.expectedFinding.Number, *returnedFinding.Number) | ||
| assert.Equal(t, *tc.expectedFinding.State, *returnedFinding.State) | ||
| assert.Equal(t, *tc.expectedFinding.Rule.ID, *returnedFinding.Rule.ID) | ||
| }) | ||
| } | ||
| } |
| package github | ||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "github.com/google/jsonschema-go/jsonschema" | ||
| "github.com/modelcontextprotocol/go-sdk/mcp" | ||
| 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" | ||
| ) | ||
| func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { | ||
| return NewTool( | ||
| ToolsetMetadataCodeQuality, | ||
| mcp.Tool{ | ||
| Name: "get_code_quality_finding", | ||
| Description: t("TOOL_GET_CODE_QUALITY_FINDING_DESCRIPTION", "Get details of a specific code quality finding in a GitHub repository."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
| Title: t("TOOL_GET_CODE_QUALITY_FINDING_USER_TITLE", "Get code quality finding"), | ||
| ReadOnlyHint: true, | ||
| }, | ||
| InputSchema: &jsonschema.Schema{ | ||
| Type: "object", | ||
| Properties: map[string]*jsonschema.Schema{ | ||
| "owner": { | ||
| Type: "string", | ||
| Description: "The owner of the repository.", | ||
| }, | ||
| "repo": { | ||
| Type: "string", | ||
| Description: "The name of the repository.", | ||
| }, | ||
| "findingNumber": { | ||
| Type: "number", | ||
| Description: "The number of the finding.", | ||
| }, | ||
| }, | ||
| Required: []string{"owner", "repo", "findingNumber"}, | ||
| }, | ||
| }, | ||
| []scopes.Scope{scopes.Repo}, | ||
| func(ctx context.Context, deps ToolDependencies, _ *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 | ||
| } | ||
| repo, err := RequiredParam[string](args, "repo") | ||
| if err != nil { | ||
| return utils.NewToolResultError(err.Error()), nil, nil | ||
| } | ||
| findingNumber, err := RequiredInt(args, "findingNumber") | ||
| 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 | ||
| } | ||
| apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) | ||
| req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) | ||
| if err != nil { | ||
| return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil | ||
| } | ||
| finding := make(map[string]any) | ||
| resp, err := client.Do(req, &finding) | ||
| if err != nil { | ||
| return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", 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 get finding", resp, body), nil, nil | ||
| } | ||
| r, err := json.Marshal(finding) | ||
| if err != nil { | ||
| return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil | ||
| } | ||
| return utils.NewToolResultText(string(r)), nil, nil | ||
| }, | ||
| ) | ||
| } |
| 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") | ||
| } |
| 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 | ||
| } |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| 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> | ||
| ); |
| <!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> |
@@ -15,3 +15,4 @@ name: Build UI | ||
| 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 | ||
@@ -18,0 +19,0 @@ |
@@ -224,3 +224,11 @@ package main | ||
| if len(tool.RequiredScopes) > 0 { | ||
| fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) | ||
| // Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes), | ||
| // so when multiple required scopes are listed, render them as alternatives | ||
| // rather than implying all are required. | ||
| scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`" | ||
| if len(tool.RequiredScopes) > 1 { | ||
| fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList) | ||
| } else { | ||
| fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList) | ||
| } | ||
@@ -261,2 +269,4 @@ // Only show accepted scopes if they differ from required scopes | ||
| conditional := inventory.ConditionalSchemaPropertyDescriptions() | ||
| for i, propName := range paramNames { | ||
@@ -287,3 +297,7 @@ prop := schema.Properties[propName] | ||
| fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) | ||
| if cond, isConditional := conditional[propName]; isConditional { | ||
| fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional ā %s)", propName, description, typeStr, requiredStr, cond) | ||
| } else { | ||
| fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) | ||
| } | ||
| if i < len(paramNames)-1 { | ||
@@ -290,0 +304,0 @@ buf.WriteString("\n") |
@@ -141,2 +141,3 @@ package main | ||
| Port: viper.GetInt("port"), | ||
| ListenHost: viper.GetString("listen-host"), | ||
| BaseURL: viper.GetString("base-url"), | ||
@@ -188,2 +189,3 @@ ResourcePath: viper.GetString("base-path"), | ||
| httpCmd.Flags().Int("port", 8082, "HTTP server port") | ||
| httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") | ||
| httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") | ||
@@ -209,2 +211,3 @@ httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") | ||
| _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) | ||
| _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) | ||
| _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) | ||
@@ -211,0 +214,0 @@ _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) |
+3
-3
@@ -1,2 +0,2 @@ | ||
| FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build | ||
| FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS ui-build | ||
| WORKDIR /app | ||
@@ -10,3 +10,3 @@ COPY ui/package*.json ./ui/ | ||
| FROM golang:1.25.11-alpine@sha256:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build | ||
| FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build | ||
| ARG VERSION="dev" | ||
@@ -34,3 +34,3 @@ | ||
| # Make a stage to run the app | ||
| FROM gcr.io/distroless/base-debian12@sha256:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 | ||
| FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 | ||
@@ -37,0 +37,0 @@ # Add required MCP server annotation |
@@ -47,2 +47,4 @@ # Feature Flags | ||
| - `repo`: Repository name (string, required) | ||
| - `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) | ||
@@ -70,7 +72,29 @@ | ||
| - `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 ā 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) | ||
| - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner 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` | ||
@@ -97,6 +121,6 @@ | ||
| - `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) | ||
| - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) | ||
| - **list_issue_fields** - List issue fields | ||
| - **Required OAuth Scopes**: `repo`, `read:org` | ||
| - **Required OAuth Scopes (any of)**: `repo`, `read:org` | ||
| - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` | ||
@@ -204,3 +228,3 @@ - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) | ||
| - **Required OAuth Scopes**: `repo` | ||
| - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional) | ||
| - `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional) | ||
| - `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional) | ||
@@ -207,0 +231,0 @@ - `issue_number`: The issue number to update (number, required) |
@@ -41,2 +41,4 @@ # Insiders Features | ||
| - `repo`: Repository name (string, required) | ||
| - `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) | ||
@@ -64,7 +66,29 @@ | ||
| - `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 ā 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) | ||
| - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner 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` | ||
@@ -91,6 +115,6 @@ | ||
| - `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) | ||
| - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) | ||
| - **list_issue_fields** - List issue fields | ||
| - **Required OAuth Scopes**: `repo`, `read:org` | ||
| - **Required OAuth Scopes (any of)**: `repo`, `read:org` | ||
| - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` | ||
@@ -97,0 +121,0 @@ - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) |
@@ -25,2 +25,3 @@ # Remote GitHub MCP Server š | ||
| | <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/workflow-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/workflow-light.png"><img src="../pkg/octicons/icons/workflow-light.png" width="20" height="20" alt="workflow"></picture><br>`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | ||
| | <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/code-square-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/code-square-light.png"><img src="../pkg/octicons/icons/code-square-light.png" width="20" height="20" alt="code-square"></picture><br>`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) | | ||
| | <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/codescan-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/codescan-light.png"><img src="../pkg/octicons/icons/codescan-light.png" width="20" height="20" alt="codescan"></picture><br>`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | ||
@@ -27,0 +28,0 @@ | <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/copilot-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/copilot-light.png"><img src="../pkg/octicons/icons/copilot-light.png" width="20" height="20" alt="copilot"></picture><br>`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | |
@@ -154,2 +154,3 @@ # Toolsets and Icons | ||
| | Actions | `workflow` | | ||
| | Code Quality | `code-square` | | ||
| | Code Security | `codescan` | | ||
@@ -156,0 +157,0 @@ | Secret Protection | `shield-lock` | |
@@ -6,5 +6,2 @@ package errors | ||
| "fmt" | ||
| "net/http" | ||
| "testing" | ||
| "time" | ||
| "github.com/google/go-github/v87/github" | ||
@@ -14,2 +11,5 @@ "github.com/modelcontextprotocol/go-sdk/mcp" | ||
| "github.com/stretchr/testify/require" | ||
| "net/http" | ||
| "testing" | ||
| "time" | ||
| ) | ||
@@ -692,2 +692,1 @@ | ||
| } | ||
@@ -279,3 +279,3 @@ package github | ||
| // follows repo visibility. | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues) | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) | ||
| return result, nil, nil | ||
@@ -388,3 +388,3 @@ }, | ||
| // follows repo visibility. | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) | ||
| return result, nil, nil | ||
@@ -597,3 +597,3 @@ }, | ||
| // follows repo visibility. | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) | ||
| result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) | ||
| return result, nil, nil | ||
@@ -600,0 +600,0 @@ }, |
+2
-10
@@ -104,9 +104,3 @@ package github | ||
| result := utils.NewToolResultText(string(r)) | ||
| // Gist contents are user-authored (untrusted); confidentiality is | ||
| // the IFC join of each gist's own public/secret flag. | ||
| visibilities := make([]bool, 0, len(gists)) | ||
| for _, g := range gists { | ||
| visibilities = append(visibilities, g.GetPublic()) | ||
| } | ||
| result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList) | ||
| result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList()) | ||
| return result, nil, nil | ||
@@ -171,5 +165,3 @@ }, | ||
| result := utils.NewToolResultText(string(r)) | ||
| // Gist contents are user-authored (untrusted); confidentiality | ||
| // derives from the gist's own public/secret flag. | ||
| result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic())) | ||
| result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist()) | ||
| return result, nil, nil | ||
@@ -176,0 +168,0 @@ }, |
@@ -479,3 +479,3 @@ package github | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "high"}, | ||
| map[string]any{"name": "bug", "confidence": "HIGH"}, | ||
| }, | ||
@@ -485,3 +485,3 @@ }, | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "high"}, | ||
| map[string]any{"name": "bug", "confidence": "HIGH"}, | ||
| }, | ||
@@ -497,3 +497,3 @@ }, | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"}, | ||
| }, | ||
@@ -503,3 +503,3 @@ }, | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, | ||
| map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"}, | ||
| }, | ||
@@ -509,2 +509,18 @@ }, | ||
| { | ||
| name: "label confidence is normalized", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": " high\t"}, | ||
| }, | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "labels": []any{ | ||
| map[string]any{"name": "bug", "confidence": "HIGH"}, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "invalid confidence value", | ||
@@ -536,3 +552,3 @@ requestArgs: map[string]any{ | ||
| errorContent := getErrorResult(t, result) | ||
| assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high") | ||
| assert.Contains(t, errorContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH") | ||
| return | ||
@@ -751,3 +767,3 @@ } | ||
| "issue_type": "bug", | ||
| "confidence": "high", | ||
| "confidence": "HIGH", | ||
| }, | ||
@@ -757,3 +773,3 @@ expectedReq: map[string]any{ | ||
| "value": "bug", | ||
| "confidence": "high", | ||
| "confidence": "HIGH", | ||
| }, | ||
@@ -770,3 +786,3 @@ }, | ||
| "rationale": "Asks for dark mode support", | ||
| "confidence": "medium", | ||
| "confidence": "MEDIUM", | ||
| }, | ||
@@ -777,3 +793,3 @@ expectedReq: map[string]any{ | ||
| "rationale": "Asks for dark mode support", | ||
| "confidence": "medium", | ||
| "confidence": "MEDIUM", | ||
| }, | ||
@@ -789,3 +805,3 @@ }, | ||
| "issue_type": "bug", | ||
| "confidence": "low", | ||
| "confidence": "LOW", | ||
| }, | ||
@@ -795,6 +811,22 @@ expectedReq: map[string]any{ | ||
| "value": "bug", | ||
| "confidence": "low", | ||
| "confidence": "LOW", | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "type confidence is normalized", | ||
| requestArgs: map[string]any{ | ||
| "owner": "owner", | ||
| "repo": "repo", | ||
| "issue_number": float64(1), | ||
| "issue_type": "bug", | ||
| "confidence": " medium ", | ||
| }, | ||
| expectedReq: map[string]any{ | ||
| "type": map[string]any{ | ||
| "value": "bug", | ||
| "confidence": "MEDIUM", | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
@@ -835,3 +867,3 @@ | ||
| }, | ||
| expectedErrText: "confidence must be one of: low, medium, high", | ||
| expectedErrText: "confidence must be one of: LOW, MEDIUM, HIGH", | ||
| }, | ||
@@ -1615,3 +1647,3 @@ { | ||
| t.Run("successful set with confidence", func(t *testing.T) { | ||
| confidence := "high" | ||
| confidence := "HIGH" | ||
| matchers := []githubv4mock.Matcher{ | ||
@@ -1697,3 +1729,3 @@ githubv4mock.NewQueryMatcher( | ||
| "text_value": "hello", | ||
| "confidence": "high", | ||
| "confidence": " high ", | ||
| }, | ||
@@ -1727,7 +1759,7 @@ }, | ||
| textContent := getTextResult(t, result) | ||
| assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") | ||
| assert.Contains(t, textContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH") | ||
| }) | ||
| t.Run("confidence is sent when supplied", func(t *testing.T) { | ||
| confidence := "high" | ||
| confidence := "HIGH" | ||
| matchers := []githubv4mock.Matcher{ | ||
@@ -1813,3 +1845,3 @@ githubv4mock.NewQueryMatcher( | ||
| "text_value": "hello", | ||
| "confidence": "high", | ||
| "confidence": "HIGH", | ||
| }, | ||
@@ -1816,0 +1848,0 @@ }, |
@@ -25,2 +25,3 @@ package github | ||
| GetUser = "GET /user" | ||
| GetUsersByUsername = "GET /users/{username}" | ||
| GetUserStarred = "GET /user/starred" | ||
@@ -105,2 +106,5 @@ GetUsersGistsByUsername = "GET /users/{username}/gists" | ||
| // Code quality endpoints | ||
| GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}" | ||
| // Code scanning endpoints | ||
@@ -107,0 +111,0 @@ GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts" |
+27
-10
@@ -20,2 +20,6 @@ package github | ||
| func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool { | ||
| return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) | ||
| } | ||
| // attachStaticIFCLabel attaches a fixed IFC label to a successful tool result | ||
@@ -29,3 +33,3 @@ // when IFC labels are enabled. It is used by tools whose label does not depend | ||
| func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult { | ||
| if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { | ||
| if !shouldAttachIFCLabel(ctx, deps, r) { | ||
| return r | ||
@@ -54,3 +58,3 @@ } | ||
| ) *mcp.CallToolResult { | ||
| if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { | ||
| if !shouldAttachIFCLabel(ctx, deps, r) { | ||
| return r | ||
@@ -92,3 +96,3 @@ } | ||
| ) *mcp.CallToolResult { | ||
| if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { | ||
| if !shouldAttachIFCLabel(ctx, deps, r) { | ||
| return r | ||
@@ -104,8 +108,7 @@ } | ||
| // attachJoinedIFCLabel attaches an IFC label computed by joining a set of | ||
| // per-item visibilities (true == private for repositories, true == public for | ||
| // gists) when IFC labels are enabled. joinFn is the lattice join for the | ||
| // relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The | ||
| // visibility slice is cheap to build from an already-fetched response, so | ||
| // callers may construct it unconditionally and let this helper own the | ||
| // feature-flag gate. | ||
| // per-item visibilities (true == private) when IFC labels are enabled. joinFn | ||
| // is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or | ||
| // ifc.LabelProjectList). The visibility slice is cheap to build from an | ||
| // already-fetched response, so callers may construct it unconditionally and let | ||
| // this helper own the feature-flag gate. | ||
| func attachJoinedIFCLabel( | ||
@@ -118,3 +121,3 @@ ctx context.Context, | ||
| ) *mcp.CallToolResult { | ||
| if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { | ||
| if !shouldAttachIFCLabel(ctx, deps, r) { | ||
| return r | ||
@@ -126,2 +129,16 @@ } | ||
| func attachProjectVisibilityIFCLabel( | ||
| ctx context.Context, | ||
| deps ToolDependencies, | ||
| r *mcp.CallToolResult, | ||
| isPrivate bool, | ||
| labelFn func(isPrivate bool) ifc.SecurityLabel, | ||
| ) *mcp.CallToolResult { | ||
| if !shouldAttachIFCLabel(ctx, deps, r) { | ||
| return r | ||
| } | ||
| setIFCLabel(r, labelFn(isPrivate)) | ||
| return r | ||
| } | ||
| // newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility | ||
@@ -128,0 +145,0 @@ // IFC label to a tool result, for handlers that have several return paths and |
@@ -22,2 +22,6 @@ package github | ||
| func normalizeConfidence(confidence string) string { | ||
| return strings.ToUpper(strings.TrimSpace(confidence)) | ||
| } | ||
| // issueUpdateTool is a helper to create single-field issue update tools. | ||
@@ -285,3 +289,3 @@ func issueUpdateTool( | ||
| Name: "update_issue_labels", | ||
| Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), | ||
| Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
@@ -330,4 +334,4 @@ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", | ||
| Enum: []any{"LOW", "MEDIUM", "HIGH"}, | ||
| }, | ||
@@ -404,4 +408,5 @@ "is_suggestion": { | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| confidence = normalizeConfidence(confidence) | ||
| if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { | ||
| return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil | ||
| } | ||
@@ -512,3 +517,3 @@ isSuggestion, err := OptionalParam[bool](v, "is_suggestion") | ||
| Name: "update_issue_type", | ||
| Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), | ||
| Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
@@ -548,4 +553,4 @@ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", | ||
| Enum: []any{"LOW", "MEDIUM", "HIGH"}, | ||
| }, | ||
@@ -591,4 +596,5 @@ "is_suggestion": { | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| confidence = normalizeConfidence(confidence) | ||
| if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { | ||
| return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil | ||
| } | ||
@@ -997,4 +1003,4 @@ isSuggestion, err := OptionalParam[bool](args, "is_suggestion") | ||
| Type: "string", | ||
| Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", | ||
| Enum: []any{"low", "medium", "high"}, | ||
| Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", | ||
| Enum: []any{"LOW", "MEDIUM", "HIGH"}, | ||
| }, | ||
@@ -1122,4 +1128,5 @@ "is_suggestion": { | ||
| } | ||
| if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { | ||
| return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil | ||
| confidence = normalizeConfidence(confidence) | ||
| if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { | ||
| return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil | ||
| } | ||
@@ -1126,0 +1133,0 @@ if confidence != "" { |
@@ -369,2 +369,192 @@ package github | ||
| func Test_detectOwnerType(t *testing.T) { | ||
| t.Run("uses organization account type", func(t *testing.T) { | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ | ||
| "login": "github", | ||
| "type": "Organization", | ||
| }), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| ownerType, err := detectOwnerType(context.Background(), client, "github", 1) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "org", ownerType) | ||
| }) | ||
| t.Run("uses user account type", func(t *testing.T) { | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ | ||
| "login": "octocat", | ||
| "type": "User", | ||
| }), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "user", ownerType) | ||
| }) | ||
| t.Run("falls back to project probes", func(t *testing.T) { | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), | ||
| GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, "org", ownerType) | ||
| }) | ||
| } | ||
| func Test_ProjectsList_IFC_InsidersMode(t *testing.T) { | ||
| toolDef := ProjectsList(translations.NullTranslationHelper) | ||
| t.Run("list_projects joins returned project visibilities", func(t *testing.T) { | ||
| projects := []map[string]any{ | ||
| {"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true}, | ||
| {"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false}, | ||
| } | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| deps := BaseDeps{ | ||
| Client: client, | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "list_projects", | ||
| "owner": "octo-org", | ||
| "owner_type": "org", | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "untrusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "private", ifcMap["confidentiality"]) | ||
| }) | ||
| t.Run("list_project_fields uses project metadata label", func(t *testing.T) { | ||
| fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} | ||
| project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), | ||
| GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| deps := BaseDeps{ | ||
| Client: client, | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "list_project_fields", | ||
| "owner": "octo-org", | ||
| "owner_type": "org", | ||
| "project_number": float64(1), | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "trusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "private", ifcMap["confidentiality"]) | ||
| }) | ||
| t.Run("list_project_items uses project content label", func(t *testing.T) { | ||
| items := []map[string]any{verbosePullRequestProjectItemFixture()} | ||
| project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), | ||
| GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| deps := BaseDeps{ | ||
| Client: client, | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "list_project_items", | ||
| "owner": "octo-org", | ||
| "owner_type": "org", | ||
| "project_number": float64(1), | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "untrusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "private", ifcMap["confidentiality"]) | ||
| }) | ||
| t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) { | ||
| gqlMockedClient := githubv4mock.NewMockedHTTPClient( | ||
| githubv4mock.NewQueryMatcher( | ||
| statusUpdatesOrgQuery{}, | ||
| map[string]any{ | ||
| "owner": githubv4.String("octo-org"), | ||
| "projectNumber": githubv4.Int(1), | ||
| "first": githubv4.Int(50), | ||
| "after": (*githubv4.String)(nil), | ||
| }, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "organization": map[string]any{ | ||
| "projectV2": map[string]any{ | ||
| "public": true, | ||
| "statusUpdates": map[string]any{ | ||
| "nodes": []map[string]any{}, | ||
| "pageInfo": map[string]any{ | ||
| "hasNextPage": false, | ||
| "hasPreviousPage": false, | ||
| "startCursor": "", | ||
| "endCursor": "", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| ), | ||
| ) | ||
| deps := BaseDeps{ | ||
| Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})), | ||
| GQLClient: githubv4.NewClient(gqlMockedClient), | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "list_project_status_updates", | ||
| "owner": "octo-org", | ||
| "owner_type": "org", | ||
| "project_number": float64(1), | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "untrusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "public", ifcMap["confidentiality"]) | ||
| }) | ||
| } | ||
| func Test_ProjectsGet(t *testing.T) { | ||
@@ -442,2 +632,75 @@ // Verify tool definition once | ||
| func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) { | ||
| toolDef := ProjectsGet(translations.NullTranslationHelper) | ||
| t.Run("get_project uses project metadata label", func(t *testing.T) { | ||
| project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false} | ||
| mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ | ||
| GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), | ||
| }) | ||
| client := mustNewGHClient(t, mockedClient) | ||
| deps := BaseDeps{ | ||
| Client: client, | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "get_project", | ||
| "owner": "octo-org", | ||
| "owner_type": "org", | ||
| "project_number": float64(1), | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "trusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "private", ifcMap["confidentiality"]) | ||
| }) | ||
| t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) { | ||
| gqlMockedClient := githubv4mock.NewMockedHTTPClient( | ||
| githubv4mock.NewQueryMatcher( | ||
| statusUpdateNodeQuery{}, | ||
| map[string]any{ | ||
| "id": githubv4.ID("SU_abc123"), | ||
| }, | ||
| githubv4mock.DataResponse(map[string]any{ | ||
| "node": map[string]any{ | ||
| "id": "SU_abc123", | ||
| "body": "On track", | ||
| "status": "ON_TRACK", | ||
| "createdAt": "2026-01-15T10:00:00Z", | ||
| "startDate": "2026-01-01", | ||
| "targetDate": "2026-03-01", | ||
| "creator": map[string]any{"login": "octocat"}, | ||
| "project": map[string]any{"public": true}, | ||
| }, | ||
| }), | ||
| ), | ||
| ) | ||
| deps := BaseDeps{ | ||
| GQLClient: githubv4.NewClient(gqlMockedClient), | ||
| featureChecker: featureCheckerFor(FeatureFlagIFCLabels), | ||
| } | ||
| handler := toolDef.Handler(deps) | ||
| request := createMCPRequest(map[string]any{ | ||
| "method": "get_project_status_update", | ||
| "status_update_id": "SU_abc123", | ||
| }) | ||
| result, err := handler(ContextWithDeps(context.Background(), deps), &request) | ||
| require.NoError(t, err) | ||
| require.False(t, result.IsError) | ||
| require.NotNil(t, result.Meta) | ||
| ifcMap := unmarshalIFC(t, result.Meta["ifc"]) | ||
| assert.Equal(t, "untrusted", ifcMap["integrity"]) | ||
| assert.Equal(t, "public", ifcMap["confidentiality"]) | ||
| }) | ||
| } | ||
| func Test_ProjectsGet_GetProjectField(t *testing.T) { | ||
@@ -1096,2 +1359,3 @@ toolDef := ProjectsGet(translations.NullTranslationHelper) | ||
| "projectV2": map[string]any{ | ||
| "public": false, | ||
| "statusUpdates": map[string]any{ | ||
@@ -1167,2 +1431,3 @@ "nodes": []map[string]any{ | ||
| "creator": map[string]any{"login": "octocat"}, | ||
| "project": map[string]any{"public": false}, | ||
| }, | ||
@@ -1169,0 +1434,0 @@ }), |
@@ -241,3 +241,3 @@ package github | ||
| t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { | ||
| t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { | ||
| deps := BaseDeps{ | ||
@@ -244,0 +244,0 @@ Client: mustNewGHClient(t, makeMockClient([]repoFixture{ |
@@ -176,4 +176,5 @@ package github | ||
| // no extra API call. The join math is shared with search_issues via | ||
| // ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is | ||
| // private if any matched repository is private, otherwise public. The | ||
| // ifc.LabelSearchIssues: public-only results stay public-untrusted, | ||
| // mixed-visibility results become private-untrusted, and all-private results | ||
| // become private-trusted. The | ||
| // feature-flag check is centralized here (mirroring the attach* helpers in | ||
@@ -306,5 +307,5 @@ // ifc_labels.go) so the handler can call this unconditionally. | ||
| callResult := utils.NewToolResultText(string(r)) | ||
| // Code search spans repositories and exposes file contents | ||
| // (untrusted). Confidentiality is the IFC join across every matched | ||
| // repository's visibility, read directly from the search response. | ||
| // Code search spans repositories; the IFC label is the conservative | ||
| // join across every matched repository's visibility, read directly | ||
| // from the search response. | ||
| visibilities := make([]bool, 0, len(result.CodeResults)) | ||
@@ -598,5 +599,5 @@ for _, code := range result.CodeResults { | ||
| callResult := utils.NewToolResultText(string(r)) | ||
| // Commit search spans repositories and exposes commit content | ||
| // (untrusted). Confidentiality is the IFC join across every matched | ||
| // repository's visibility, read directly from the search response. | ||
| // Commit search spans repositories; the IFC label is the conservative | ||
| // join across every matched repository's visibility, read directly | ||
| // from the search response. | ||
| visibilities := make([]bool, 0, len(result.Commits)) | ||
@@ -603,0 +604,0 @@ for _, commit := range result.Commits { |
+14
-2
@@ -8,6 +8,7 @@ package github | ||
| "github.com/google/go-github/v87/github" | ||
| "github.com/shurcooL/githubv4" | ||
| "github.com/github/github-mcp-server/pkg/inventory" | ||
| "github.com/github/github-mcp-server/pkg/translations" | ||
| "github.com/google/go-github/v87/github" | ||
| "github.com/shurcooL/githubv4" | ||
| ) | ||
@@ -80,2 +81,7 @@ | ||
| } | ||
| ToolsetMetadataCodeQuality = inventory.ToolsetMetadata{ | ||
| ID: "code_quality", | ||
| Description: "GitHub Code Quality related tools", | ||
| Icon: "code-square", | ||
| } | ||
| ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{ | ||
@@ -240,2 +246,5 @@ ID: "code_security", | ||
| // Code quality tools | ||
| GetCodeQualityFinding(t), | ||
| // Code security tools | ||
@@ -297,2 +306,5 @@ GetCodeScanningAlert(t), | ||
| // UI tools (insiders only) | ||
| UIGet(t), | ||
| // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) | ||
@@ -299,0 +311,0 @@ GranularCreateIssue(t), |
@@ -58,2 +58,3 @@ package github | ||
| PullRequestWriteUIResourceURI, | ||
| PullRequestEditUIResourceURI, | ||
| } | ||
@@ -60,0 +61,0 @@ for _, uri := range uris { |
@@ -110,2 +110,29 @@ package github | ||
| ) | ||
| 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 | ||
| }, | ||
| ) | ||
| } |
@@ -128,2 +128,43 @@ package http | ||
| func TestResolveListenAddress(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| host string | ||
| port int | ||
| want string | ||
| }{ | ||
| { | ||
| name: "empty host falls back to :port", | ||
| host: "", | ||
| port: 8082, | ||
| want: ":8082", | ||
| }, | ||
| { | ||
| name: "ipv4 host is joined with port", | ||
| host: "127.0.0.1", | ||
| port: 9090, | ||
| want: "127.0.0.1:9090", | ||
| }, | ||
| { | ||
| name: "ipv6 host is bracketed and joined with port", | ||
| host: "::1", | ||
| port: 9090, | ||
| want: "[::1]:9090", | ||
| }, | ||
| { | ||
| name: "hostname is joined with port", | ||
| host: "localhost", | ||
| port: 8082, | ||
| want: "localhost:8082", | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := resolveListenAddress(tt.host, tt.port) | ||
| assert.Equal(t, tt.want, got) | ||
| }) | ||
| } | ||
| } | ||
| func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { | ||
@@ -130,0 +171,0 @@ // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags |
+18
-2
@@ -8,5 +8,7 @@ package http | ||
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "os" | ||
| "os/signal" | ||
| "strconv" | ||
| "syscall" | ||
@@ -36,5 +38,9 @@ "time" | ||
| // Port to listen on (default: 8082) | ||
| // Port to listen on (default: 8082). | ||
| Port int | ||
| // ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1"). | ||
| // When empty, the server binds to all interfaces. Combined with Port. | ||
| ListenHost string | ||
| // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. | ||
@@ -197,3 +203,3 @@ // If not set, the server will derive the URL from incoming request headers. | ||
| addr := fmt.Sprintf(":%d", cfg.Port) | ||
| addr := resolveListenAddress(cfg.ListenHost, cfg.Port) | ||
| httpSvr := http.Server{ | ||
@@ -229,2 +235,12 @@ Addr: addr, | ||
| // resolveListenAddress returns the address string passed to http.Server. | ||
| // When host is empty the server binds to all interfaces on the given port; | ||
| // otherwise host and port are joined into a single address. | ||
| func resolveListenAddress(host string, port int) string { | ||
| if host == "" { | ||
| return fmt.Sprintf(":%d", port) | ||
| } | ||
| return net.JoinHostPort(host, strconv.Itoa(port)) | ||
| } | ||
| func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { | ||
@@ -231,0 +247,0 @@ // Build inventory with all tools to extract scope information |
+95
-22
@@ -9,2 +9,38 @@ package ifc | ||
| func TestLabelListIssues(t *testing.T) { | ||
| t.Parallel() | ||
| t.Run("public repo issues are untrusted and public", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelListIssues(false) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| }) | ||
| t.Run("private repo issues are trusted and private", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelListIssues(true) | ||
| assert.Equal(t, IntegrityTrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) | ||
| }) | ||
| } | ||
| func TestLabelRepoUserContent(t *testing.T) { | ||
| t.Parallel() | ||
| t.Run("public repo user content is untrusted and public", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelRepoUserContent(false) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| }) | ||
| t.Run("private repo user content is trusted and private", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelRepoUserContent(true) | ||
| assert.Equal(t, IntegrityTrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) | ||
| }) | ||
| } | ||
| func TestLabelSearchIssues(t *testing.T) { | ||
@@ -15,3 +51,4 @@ t.Parallel() | ||
| name string | ||
| visibilities []bool | ||
| visibilities []bool // true == private | ||
| wantIntegrity Integrity | ||
| wantConfidential Confidentiality | ||
@@ -21,2 +58,3 @@ }{ | ||
| name: "empty result is treated as public", | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPublic, | ||
@@ -27,2 +65,3 @@ }, | ||
| visibilities: []bool{false}, | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPublic, | ||
@@ -33,12 +72,15 @@ }, | ||
| visibilities: []bool{false, false, false}, | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPublic, | ||
| }, | ||
| { | ||
| name: "any private match flips to private", | ||
| name: "mixed public and private repos become untrusted private", | ||
| visibilities: []bool{false, true, false}, | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPrivate, | ||
| }, | ||
| { | ||
| name: "all private repos stay private", | ||
| name: "all private repos stay trusted private", | ||
| visibilities: []bool{true, true}, | ||
| wantIntegrity: IntegrityTrusted, | ||
| wantConfidential: ConfidentialityPrivate, | ||
@@ -52,3 +94,3 @@ }, | ||
| label := LabelSearchIssues(tc.visibilities) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, tc.wantIntegrity, label.Integrity) | ||
| assert.Equal(t, tc.wantConfidential, label.Confidentiality) | ||
@@ -217,3 +259,3 @@ }) | ||
| t.Parallel() | ||
| label := LabelGist(true) | ||
| label := LabelGist() | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
@@ -223,7 +265,7 @@ assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| t.Run("secret gist is untrusted and private", func(t *testing.T) { | ||
| t.Run("secret gist is untrusted and public", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelGist(false) | ||
| label := LabelGist() | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) | ||
| assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| }) | ||
@@ -235,5 +277,32 @@ } | ||
| label := LabelGistList() | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| } | ||
| func TestLabelProject(t *testing.T) { | ||
| t.Parallel() | ||
| t.Run("public project is untrusted and public", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelProject(false) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| }) | ||
| t.Run("private project metadata is trusted and private", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelProject(true) | ||
| assert.Equal(t, IntegrityTrusted, label.Integrity) | ||
| assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) | ||
| }) | ||
| } | ||
| func TestLabelProjectList(t *testing.T) { | ||
| t.Parallel() | ||
| tests := []struct { | ||
| name string | ||
| visibilities []bool // true == public | ||
| visibilities []bool // true == private | ||
| wantIntegrity Integrity | ||
| wantConfidential Confidentiality | ||
@@ -243,17 +312,21 @@ }{ | ||
| name: "empty result is treated as public", | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPublic, | ||
| }, | ||
| { | ||
| name: "all public gists stay public", | ||
| visibilities: []bool{true, true}, | ||
| name: "all public projects stay public", | ||
| visibilities: []bool{false, false}, | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPublic, | ||
| }, | ||
| { | ||
| name: "any secret gist flips to private", | ||
| visibilities: []bool{true, false, true}, | ||
| name: "mixed public and private projects become untrusted private", | ||
| visibilities: []bool{false, true}, | ||
| wantIntegrity: IntegrityUntrusted, | ||
| wantConfidential: ConfidentialityPrivate, | ||
| }, | ||
| { | ||
| name: "all secret gists stay private", | ||
| visibilities: []bool{false, false}, | ||
| name: "all private projects stay trusted private", | ||
| visibilities: []bool{true, true}, | ||
| wantIntegrity: IntegrityTrusted, | ||
| wantConfidential: ConfidentialityPrivate, | ||
@@ -266,4 +339,4 @@ }, | ||
| t.Parallel() | ||
| label := LabelGistList(tc.visibilities) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
| label := LabelProjectList(tc.visibilities) | ||
| assert.Equal(t, tc.wantIntegrity, label.Integrity) | ||
| assert.Equal(t, tc.wantConfidential, label.Confidentiality) | ||
@@ -274,8 +347,8 @@ }) | ||
| func TestLabelProject(t *testing.T) { | ||
| func TestLabelProjectContent(t *testing.T) { | ||
| t.Parallel() | ||
| t.Run("public project is untrusted and public", func(t *testing.T) { | ||
| t.Run("public project content is untrusted and public", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelProject(true) | ||
| label := LabelProjectContent(false) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
@@ -285,5 +358,5 @@ assert.Equal(t, ConfidentialityPublic, label.Confidentiality) | ||
| t.Run("private project is untrusted and private", func(t *testing.T) { | ||
| t.Run("private project content is untrusted and private", func(t *testing.T) { | ||
| t.Parallel() | ||
| label := LabelProject(false) | ||
| label := LabelProjectContent(true) | ||
| assert.Equal(t, IntegrityUntrusted, label.Integrity) | ||
@@ -290,0 +363,0 @@ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) |
+87
-35
@@ -79,6 +79,7 @@ // Package ifc provides Information Flow Control labels for annotating MCP tool outputs. | ||
| // restricted to their collaborators (resolved client-side from the marker). | ||
| // Issue contents are attacker-controllable, so integrity is always untrusted. | ||
| // Public repository issue contents are attacker-controllable, while private | ||
| // repository issues are treated as trusted collaborator-authored data. | ||
| func LabelListIssues(isPrivate bool) SecurityLabel { | ||
| if isPrivate { | ||
| return PrivateUntrusted() | ||
| return PrivateTrusted() | ||
| } | ||
@@ -88,2 +89,14 @@ return PublicUntrusted() | ||
| // LabelRepoUserContent returns the IFC label for user-authored content scoped | ||
| // to a repository when that tool has not opted into a more specific integrity | ||
| // policy. Public repository content is untrusted because it may be authored by | ||
| // outside contributors. Private repository content is trusted because users who | ||
| // can read it are trusted collaborators. | ||
| func LabelRepoUserContent(isPrivate bool) SecurityLabel { | ||
| if isPrivate { | ||
| return PrivateTrusted() | ||
| } | ||
| return PublicUntrusted() | ||
| } | ||
| // LabelGetFileContents returns the IFC label for a get_file_contents result. | ||
@@ -104,7 +117,8 @@ // Public repository file contents may be authored by anyone via pull requests | ||
| // | ||
| // Integrity is always untrusted because results expose user-authored content. | ||
| // | ||
| // Confidentiality follows the IFC meet (greatest lower bound): if any matched | ||
| // repository is private the joined label is private; otherwise public. The | ||
| // reader set is opaque (the "private" marker); the client engine resolves | ||
| // Public-only results are untrusted and public. All-private results are trusted | ||
| // and private because private repository content is treated as trusted | ||
| // collaborator-authored data. Mixed public/private results are untrusted and | ||
| // private: the public items keep the joined payload's integrity untrusted, | ||
| // while the private items keep the joined payload's confidentiality private. | ||
| // The reader set is opaque (the "private" marker); the client engine resolves | ||
| // concrete readers on demand at egress decision time. | ||
@@ -125,8 +139,18 @@ // | ||
| func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { | ||
| var anyPrivate, anyPublic bool | ||
| for _, isPrivate := range repoVisibilities { | ||
| if isPrivate { | ||
| return PrivateUntrusted() | ||
| anyPrivate = true | ||
| } else { | ||
| anyPublic = true | ||
| } | ||
| } | ||
| return PublicUntrusted() | ||
| switch { | ||
| case anyPrivate && anyPublic: | ||
| return PrivateUntrusted() | ||
| case anyPrivate: | ||
| return PrivateTrusted() | ||
| default: | ||
| return PublicUntrusted() | ||
| } | ||
| } | ||
@@ -268,11 +292,6 @@ | ||
| // Integrity is untrusted: gist contents are arbitrary user-authored text. | ||
| // Confidentiality derives from the gist's own visibility rather than any | ||
| // repository ā public gists are universally readable, while secret gists are | ||
| // restricted to those who hold the gist URL (modeled with the opaque "private" | ||
| // marker). | ||
| func LabelGist(isPublic bool) SecurityLabel { | ||
| if isPublic { | ||
| return PublicUntrusted() | ||
| } | ||
| return PrivateUntrusted() | ||
| // Confidentiality is public because secret gists are URL-accessible and cannot | ||
| // be modeled as private to a GitHub reader set. | ||
| func LabelGist() SecurityLabel { | ||
| return PublicUntrusted() | ||
| } | ||
@@ -283,13 +302,23 @@ | ||
| // | ||
| // Integrity is untrusted (user-authored content). Confidentiality follows the | ||
| // IFC meet: if any gist in the result is secret the joined label is private; | ||
| // otherwise public. An empty result is treated as public-untrusted. | ||
| // Integrity is untrusted (user-authored content). Confidentiality is public | ||
| // because even secret gists are URL-accessible. | ||
| // | ||
| // See LabelSearchIssues for why list results carry a single joined label | ||
| // rather than one label per item. | ||
| func LabelGistList(gistVisibilities []bool) SecurityLabel { | ||
| for _, isPublic := range gistVisibilities { | ||
| if !isPublic { | ||
| return PrivateUntrusted() | ||
| } | ||
| func LabelGistList() SecurityLabel { | ||
| return PublicUntrusted() | ||
| } | ||
| // LabelProject returns the IFC label for GitHub Project metadata (Projects v2), | ||
| // such as get_project results and project field definitions. | ||
| // | ||
| // Public project metadata can contain public user-authored text, so it is | ||
| // untrusted. Private project metadata is treated as trusted | ||
| // collaborator-controlled data. | ||
| // | ||
| // Confidentiality derives from the project's own privacy ā private projects | ||
| // restrict the reader set, while public projects are universally readable. | ||
| func LabelProject(isPrivate bool) SecurityLabel { | ||
| if isPrivate { | ||
| return PrivateTrusted() | ||
| } | ||
@@ -299,16 +328,39 @@ return PublicUntrusted() | ||
| // LabelProject returns the IFC label for a GitHub Project (Projects v2) and its | ||
| // items, status updates, and field definitions. | ||
| // LabelProjectList returns the IFC label for a list_projects result, joining | ||
| // the per-project labels across every returned project. | ||
| // | ||
| // Integrity is untrusted: project titles, item content, and status update | ||
| // bodies are user-authored free text. Confidentiality derives from the | ||
| // project's own public flag ā public projects are universally readable, while | ||
| // private projects restrict the reader set. | ||
| func LabelProject(isPublic bool) SecurityLabel { | ||
| if isPublic { | ||
| // Public-only results are untrusted and public. All-private results are trusted | ||
| // and private. Mixed public/private results are untrusted and private: public | ||
| // items keep the joined payload's integrity untrusted, while private items keep | ||
| // the joined payload's confidentiality private. | ||
| func LabelProjectList(projectVisibilities []bool) SecurityLabel { | ||
| var anyPrivate, anyPublic bool | ||
| for _, isPrivate := range projectVisibilities { | ||
| if isPrivate { | ||
| anyPrivate = true | ||
| } else { | ||
| anyPublic = true | ||
| } | ||
| } | ||
| switch { | ||
| case anyPrivate && anyPublic: | ||
| return PrivateUntrusted() | ||
| case anyPrivate: | ||
| return PrivateTrusted() | ||
| default: | ||
| return PublicUntrusted() | ||
| } | ||
| return PrivateUntrusted() | ||
| } | ||
| // LabelProjectContent returns the IFC label for project results that can include | ||
| // item content, field values, or status update bodies. These can aggregate | ||
| // content from a variety of sources, so integrity remains untrusted even when | ||
| // the project is private. | ||
| func LabelProjectContent(isPrivate bool) SecurityLabel { | ||
| if isPrivate { | ||
| return PrivateUntrusted() | ||
| } | ||
| return PublicUntrusted() | ||
| } | ||
| // LabelTeam returns the IFC label for organization team membership data | ||
@@ -315,0 +367,0 @@ // (get_teams, get_team_members). |
@@ -10,2 +10,4 @@ package inventory | ||
| "strings" | ||
| "github.com/google/jsonschema-go/jsonschema" | ||
| ) | ||
@@ -410,2 +412,93 @@ | ||
| // uiOnlySchemaProperties lists input-schema property names that should only | ||
| // be visible to clients that advertise MCP Apps UI support. They live on the | ||
| // static schema (so toolsnaps and the feature-flag / insiders docs document | ||
| // the full UI-capable surface; the main README renders the stripped | ||
| // non-UI schema) and are stripped per-request when the same gate that hides | ||
| // _meta.ui is true. | ||
| var uiOnlySchemaProperties = []string{ | ||
| "show_ui", // explicit "render the MCP App form" toggle on form-backed write tools | ||
| } | ||
| // ConditionalSchemaPropertyDescriptions returns a map of schema property name | ||
| // to a human-readable description of the condition under which the property | ||
| // is visible to clients. The doc generator uses this to annotate conditional | ||
| // parameters so readers can see at a glance which fields are not always | ||
| // available. This is the single source of truth for the conditional-property | ||
| // surface ā entries here must correspond to a strip rule in | ||
| // ToolsForRegistration. | ||
| func ConditionalSchemaPropertyDescriptions() map[string]string { | ||
| const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui" | ||
| out := make(map[string]string, len(uiOnlySchemaProperties)) | ||
| for _, name := range uiOnlySchemaProperties { | ||
| out[name] = uiOnlyCondition | ||
| } | ||
| return out | ||
| } | ||
| // stripUIOnlySchemaProperties removes UI-capability-gated input-schema | ||
| // properties (currently just "show_ui") from each tool's static schema. | ||
| // Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage) | ||
| // are passed through untouched ā no such tool currently declares a gated | ||
| // property, and inferring intent from an opaque schema is not safe. | ||
| // Tools without any gated property are returned as-is so we only allocate | ||
| // when a change is actually made (mirrors the stripMetaKeys pattern). | ||
| func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool { | ||
| result := make([]ServerTool, 0, len(tools)) | ||
| for _, tool := range tools { | ||
| if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil { | ||
| result = append(result, *stripped) | ||
| } else { | ||
| result = append(result, tool) | ||
| } | ||
| } | ||
| return result | ||
| } | ||
| // stripSchemaProperties removes the named keys from tool.Tool.InputSchema's | ||
| // Properties map (and Required list, if present) and returns a modified copy. | ||
| // Returns nil when the schema is not a *jsonschema.Schema or no listed key | ||
| // is present, signalling no change. | ||
| func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool { | ||
| if tool.Tool.InputSchema == nil || len(keys) == 0 { | ||
| return nil | ||
| } | ||
| schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) | ||
| if !ok || schema == nil || len(schema.Properties) == 0 { | ||
| return nil | ||
| } | ||
| hasKey := false | ||
| for _, key := range keys { | ||
| if _, exists := schema.Properties[key]; exists { | ||
| hasKey = true | ||
| break | ||
| } | ||
| } | ||
| if !hasKey { | ||
| return nil | ||
| } | ||
| toolCopy := tool | ||
| schemaCopy := *schema | ||
| newProps := make(map[string]*jsonschema.Schema, len(schema.Properties)) | ||
| for k, v := range schema.Properties { | ||
| if !slices.Contains(keys, k) { | ||
| newProps[k] = v | ||
| } | ||
| } | ||
| schemaCopy.Properties = newProps | ||
| if len(schemaCopy.Required) > 0 { | ||
| newRequired := make([]string, 0, len(schemaCopy.Required)) | ||
| for _, r := range schemaCopy.Required { | ||
| if !slices.Contains(keys, r) { | ||
| newRequired = append(newRequired, r) | ||
| } | ||
| } | ||
| schemaCopy.Required = newRequired | ||
| } | ||
| toolCopy.Tool.InputSchema = &schemaCopy | ||
| return &toolCopy | ||
| } | ||
| // stripMetaKeys removes the specified Meta keys from a single tool. | ||
@@ -412,0 +505,0 @@ // Returns a modified copy if changes were made, nil otherwise. |
@@ -172,4 +172,5 @@ package inventory | ||
| // ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as | ||
| // RegisterTools would expose them: with MCP Apps UI metadata stripped when | ||
| // the client cannot consume it. Useful for documentation generators and | ||
| // RegisterTools would expose them: with MCP Apps UI metadata stripped and | ||
| // UI-capability-gated input-schema properties (e.g. show_ui) removed when | ||
| // the client cannot consume them. Useful for documentation generators and | ||
| // diagnostics that need the same view of the tool surface the server would | ||
@@ -190,2 +191,3 @@ // register. | ||
| tools = stripMCPAppsMetadata(tools) | ||
| tools = stripUIOnlySchemaProperties(tools) | ||
| } | ||
@@ -211,5 +213,6 @@ return tools | ||
| // | ||
| // MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools | ||
| // when either the MCP Apps feature flag is not enabled for this request, or | ||
| // the client did not advertise the io.modelcontextprotocol/ui extension. The | ||
| // MCP Apps UI metadata (`_meta.ui`) and UI-capability-gated input-schema | ||
| // properties (e.g. `show_ui`) are stripped from the registered tools when | ||
| // either the MCP Apps feature flag is not enabled for this request, or the | ||
| // client did not advertise the io.modelcontextprotocol/ui extension. The | ||
| // strip happens here (rather than at Build() time) so the per-request | ||
@@ -216,0 +219,0 @@ // context is in scope ā HTTP feature checkers that read insiders mode or |
@@ -22,2 +22,3 @@ # Required Octicons for the GitHub MCP Server | ||
| codescan | ||
| code-square | ||
| comment-discussion | ||
@@ -24,0 +25,0 @@ copilot |
+24
-0
@@ -62,1 +62,25 @@ package utils //nolint:revive //TODO: figure out a better name for this package | ||
| } | ||
| // 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, | ||
| } | ||
| } |
+1
-1
@@ -32,5 +32,5 @@ { | ||
| "typescript": "^5.7.0", | ||
| "vite": "^8.0.13", | ||
| "vite": "^8.0.16", | ||
| "vite-plugin-singlefile": "^2.3.3" | ||
| } | ||
| } |
| // 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`. | ||
@@ -9,3 +9,3 @@ | ||
| const apps = ["get-me", "issue-write", "pr-write"]; | ||
| const apps = ["get-me", "issue-write", "pr-write", "pr-edit"]; | ||
@@ -12,0 +12,0 @@ for (const app of apps) { |
+1397
-20
@@ -1,2 +0,2 @@ | ||
| import { StrictMode, useState, useCallback, useEffect } from "react"; | ||
| import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; | ||
| import { createRoot } from "react-dom/client"; | ||
@@ -11,2 +11,6 @@ import { | ||
| FormControl, | ||
| CounterLabel, | ||
| ActionMenu, | ||
| ActionList, | ||
| Label, | ||
| } from "@primer/react"; | ||
@@ -16,2 +20,7 @@ import { | ||
| CheckCircleIcon, | ||
| TagIcon, | ||
| PersonIcon, | ||
| RepoIcon, | ||
| MilestoneIcon, | ||
| LockIcon, | ||
| } from "@primer/octicons-react"; | ||
@@ -32,2 +41,241 @@ import { AppProvider } from "../../components/AppProvider"; | ||
| interface LabelItem { | ||
| id: string; | ||
| text: string; | ||
| color: string; | ||
| } | ||
| interface AssigneeItem { | ||
| id: string; | ||
| text: string; | ||
| } | ||
| interface MilestoneItem { | ||
| id: string; | ||
| number: number; | ||
| text: string; | ||
| description: string; | ||
| } | ||
| interface IssueTypeItem { | ||
| id: string; | ||
| text: string; | ||
| } | ||
| type IssueState = "open" | "closed"; | ||
| type StateReason = "completed" | "not_planned" | "duplicate"; | ||
| type IssueFieldPrimitive = string | number | boolean; | ||
| interface IssueFieldOption { | ||
| id: string; | ||
| name: string; | ||
| description: string; | ||
| color: string; | ||
| } | ||
| interface IssueFieldItem { | ||
| id: string; | ||
| name: string; | ||
| data_type: string; | ||
| description: string; | ||
| options: IssueFieldOption[]; | ||
| } | ||
| interface IssueFieldValue { | ||
| value?: IssueFieldPrimitive; | ||
| optionName?: string; | ||
| cleared?: boolean; | ||
| } | ||
| interface IssueFieldSubmission { | ||
| field_name: string; | ||
| value?: IssueFieldPrimitive; | ||
| field_option_name?: string; | ||
| delete?: boolean; | ||
| } | ||
| interface RepositoryItem { | ||
| id: string; | ||
| owner: string; | ||
| name: string; | ||
| fullName: string; | ||
| isPrivate: boolean; | ||
| } | ||
| // Calculate text color based on background luminance | ||
| function getContrastColor(hexColor: string): string { | ||
| const r = parseInt(hexColor.substring(0, 2), 16); | ||
| const g = parseInt(hexColor.substring(2, 4), 16); | ||
| const b = parseInt(hexColor.substring(4, 6), 16); | ||
| const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; | ||
| return luminance > 0.5 ? "#000000" : "#ffffff"; | ||
| } | ||
| const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [ | ||
| { value: "completed", label: "Completed", description: "The work is done" }, | ||
| { value: "not_planned", label: "Not planned", description: "The issue won't be worked on" }, | ||
| { value: "duplicate", label: "Duplicate", description: "Another issue tracks this" }, | ||
| ]; | ||
| function normalizeSwatchColor(color: string): string { | ||
| const trimmed = color.trim(); | ||
| if (!trimmed) return "var(--borderColor-default, var(--color-border-default))"; | ||
| if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) { | ||
| return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; | ||
| } | ||
| return trimmed.toLowerCase(); | ||
| } | ||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| } | ||
| function stringValue(value: unknown): string | undefined { | ||
| if (typeof value === "string" && value.trim()) return value; | ||
| if (typeof value === "number" && Number.isFinite(value)) return String(value); | ||
| return undefined; | ||
| } | ||
| function parseIssueState(value: unknown): IssueState | null { | ||
| return value === "open" || value === "closed" ? value : null; | ||
| } | ||
| function parseStateReason(value: unknown): StateReason | null { | ||
| return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null; | ||
| } | ||
| function normalizeRawIssueFieldValue( | ||
| field: IssueFieldItem | undefined, | ||
| rawValue: unknown | ||
| ): IssueFieldValue | null { | ||
| if (rawValue === null || rawValue === undefined) return null; | ||
| if (isRecord(rawValue)) { | ||
| const optionName = | ||
| stringValue(rawValue.optionName) || | ||
| stringValue(rawValue.field_option_name) || | ||
| stringValue(rawValue.name); | ||
| if (field?.data_type === "single_select" && optionName) { | ||
| return { optionName }; | ||
| } | ||
| return normalizeRawIssueFieldValue( | ||
| field, | ||
| rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name | ||
| ); | ||
| } | ||
| if (field?.data_type === "single_select") { | ||
| const optionName = stringValue(rawValue); | ||
| return optionName ? { optionName } : null; | ||
| } | ||
| if ( | ||
| typeof rawValue === "string" || | ||
| typeof rawValue === "number" || | ||
| typeof rawValue === "boolean" | ||
| ) { | ||
| return { value: rawValue }; | ||
| } | ||
| return null; | ||
| } | ||
| function parseStringIssueFieldValue( | ||
| entry: string, | ||
| fieldsByName: Map<string, IssueFieldItem> | ||
| ): [string, IssueFieldValue] | null { | ||
| const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/); | ||
| if (!match) return null; | ||
| const fieldName = match[1].trim(); | ||
| const field = fieldsByName.get(fieldName); | ||
| if (!field) return null; | ||
| const normalized = normalizeRawIssueFieldValue(field, match[2].trim()); | ||
| return normalized ? [fieldName, normalized] : null; | ||
| } | ||
| function normalizeIssueFieldEntry( | ||
| entry: unknown, | ||
| fieldsByName: Map<string, IssueFieldItem> | ||
| ): [string, IssueFieldValue] | null { | ||
| if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName); | ||
| if (!isRecord(entry)) return null; | ||
| const fieldRecord = isRecord(entry.field) ? entry.field : undefined; | ||
| const entryName = stringValue(entry.name); | ||
| const fieldName = | ||
| stringValue(entry.field_name) || | ||
| stringValue(entry.fieldName) || | ||
| (fieldRecord ? stringValue(fieldRecord.name) : undefined) || | ||
| entryName; | ||
| if (!fieldName) return null; | ||
| const field = fieldsByName.get(fieldName); | ||
| if (!field) return null; | ||
| if (entry.delete === true || entry.cleared === true) { | ||
| return [fieldName, { cleared: true }]; | ||
| } | ||
| const directOptionName = | ||
| stringValue(entry.field_option_name) || | ||
| stringValue(entry.fieldOptionName) || | ||
| stringValue(entry.optionName) || | ||
| (field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined); | ||
| if (directOptionName) return [fieldName, { optionName: directOptionName }]; | ||
| const optionRecord = isRecord(entry.option) ? entry.option : undefined; | ||
| const optionName = optionRecord ? stringValue(optionRecord.name) : undefined; | ||
| if (optionName) return [fieldName, { optionName }]; | ||
| const normalized = normalizeRawIssueFieldValue( | ||
| field, | ||
| entry.value ?? entry.text ?? entry.number ?? entry.date | ||
| ); | ||
| return normalized ? [fieldName, normalized] : null; | ||
| } | ||
| function normalizeIssueFieldValues( | ||
| input: unknown, | ||
| fields: IssueFieldItem[] | ||
| ): Record<string, IssueFieldValue> { | ||
| const fieldsByName = new Map(fields.map((field) => [field.name, field])); | ||
| const values: Record<string, IssueFieldValue> = {}; | ||
| if (Array.isArray(input)) { | ||
| for (const item of input) { | ||
| const normalized = normalizeIssueFieldEntry(item, fieldsByName); | ||
| if (normalized) values[normalized[0]] = normalized[1]; | ||
| } | ||
| return values; | ||
| } | ||
| if (!isRecord(input)) return values; | ||
| const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName); | ||
| if (normalizedEntry) { | ||
| values[normalizedEntry[0]] = normalizedEntry[1]; | ||
| return values; | ||
| } | ||
| for (const [fieldName, rawValue] of Object.entries(input)) { | ||
| const field = fieldsByName.get(fieldName); | ||
| if (!field) continue; | ||
| if (isRecord(rawValue)) { | ||
| const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName); | ||
| if (nested) { | ||
| values[fieldName] = nested[1]; | ||
| continue; | ||
| } | ||
| } | ||
| const normalized = normalizeRawIssueFieldValue(field, rawValue); | ||
| if (normalized) values[fieldName] = normalized; | ||
| } | ||
| return values; | ||
| } | ||
| function SuccessView({ | ||
@@ -38,2 +286,3 @@ issue, | ||
| submittedTitle, | ||
| submittedLabels, | ||
| isUpdate, | ||
@@ -46,2 +295,3 @@ openLink, | ||
| submittedTitle: string; | ||
| submittedLabels: LabelItem[]; | ||
| isUpdate: boolean; | ||
@@ -126,2 +376,18 @@ openLink: (url: string) => Promise<void>; | ||
| </Text> | ||
| {submittedLabels.length > 0 && ( | ||
| <Box display="flex" gap={1} mt={2} flexWrap="wrap"> | ||
| {submittedLabels.map((label) => ( | ||
| <Label | ||
| key={label.id} | ||
| sx={{ | ||
| backgroundColor: `#${label.color}`, | ||
| color: getContrastColor(label.color), | ||
| borderColor: `#${label.color}`, | ||
| }} | ||
| > | ||
| {label.text} | ||
| </Label> | ||
| ))} | ||
| </Box> | ||
| )} | ||
| </Box> | ||
@@ -140,2 +406,40 @@ </Box> | ||
| // Labels state | ||
| const [availableLabels, setAvailableLabels] = useState<LabelItem[]>([]); | ||
| const [selectedLabels, setSelectedLabels] = useState<LabelItem[]>([]); | ||
| const [labelsLoading, setLabelsLoading] = useState(false); | ||
| const [labelsFilter, setLabelsFilter] = useState(""); | ||
| // Assignees state | ||
| const [availableAssignees, setAvailableAssignees] = useState<AssigneeItem[]>([]); | ||
| const [selectedAssignees, setSelectedAssignees] = useState<AssigneeItem[]>([]); | ||
| const [assigneesLoading, setAssigneesLoading] = useState(false); | ||
| const [assigneesFilter, setAssigneesFilter] = useState(""); | ||
| // Milestones state | ||
| const [availableMilestones, setAvailableMilestones] = useState<MilestoneItem[]>([]); | ||
| const [selectedMilestone, setSelectedMilestone] = useState<MilestoneItem | null>(null); | ||
| const [milestonesLoading, setMilestonesLoading] = useState(false); | ||
| // Issue types state | ||
| const [availableIssueTypes, setAvailableIssueTypes] = useState<IssueTypeItem[]>([]); | ||
| const [selectedIssueType, setSelectedIssueType] = useState<IssueTypeItem | null>(null); | ||
| const [issueTypesLoading, setIssueTypesLoading] = useState(false); | ||
| // State transition state | ||
| const [currentState, setCurrentState] = useState<IssueState>("open"); | ||
| const [stateReason, setStateReason] = useState<StateReason>("completed"); | ||
| const [duplicateOf, setDuplicateOf] = useState(""); | ||
| const [prefilledStateChange, setPrefilledStateChange] = useState<IssueState | null>(null); | ||
| // Issue fields state | ||
| const [availableIssueFields, setAvailableIssueFields] = useState<IssueFieldItem[]>([]); | ||
| const [fieldValues, setFieldValues] = useState<Record<string, IssueFieldValue>>({}); | ||
| // 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({ | ||
@@ -145,15 +449,530 @@ appName: "github-mcp-server-issue-write", | ||
| // Get method and issue_number from toolInput | ||
| const method = (toolInput?.method as string) || "create"; | ||
| const issueNumber = toolInput?.issue_number as number | undefined; | ||
| const isUpdateMode = method === "update" && issueNumber !== undefined; | ||
| const owner = (toolInput?.owner as string) || ""; | ||
| const repo = (toolInput?.repo as string) || ""; | ||
| // Pre-fill from toolInput | ||
| // Initialize from toolInput or selected repo | ||
| const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; | ||
| const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; | ||
| // Search repositories when filter changes | ||
| useEffect(() => { | ||
| if (toolInput?.title) setTitle(toolInput.title as string); | ||
| if (toolInput?.body) setBody(toolInput.body as string); | ||
| if (!app || !repoFilter.trim()) { | ||
| setRepoSearchResults([]); | ||
| return; | ||
| } | ||
| const searchRepos = async () => { | ||
| setRepoSearchLoading(true); | ||
| try { | ||
| const result = await callTool("search_repositories", { | ||
| query: repoFilter, | ||
| perPage: 10, | ||
| }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c) => c.type === "text" | ||
| ); | ||
| if (textContent && textContent.type === "text" && textContent.text) { | ||
| const data = JSON.parse(textContent.text); | ||
| const repos = (data.repositories || data.items || []).map( | ||
| (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ | ||
| id: String(r.id || r.full_name), | ||
| owner: | ||
| typeof r.owner === "string" | ||
| ? r.owner | ||
| : r.owner?.login || r.full_name?.split("/")[0] || "", | ||
| name: r.name || r.full_name?.split("/")[1] || "", | ||
| fullName: r.full_name || "", | ||
| isPrivate: r.private || false, | ||
| }) | ||
| ); | ||
| setRepoSearchResults(repos); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to search repositories:", e); | ||
| } finally { | ||
| setRepoSearchLoading(false); | ||
| } | ||
| }; | ||
| const debounce = setTimeout(searchRepos, 300); | ||
| return () => clearTimeout(debounce); | ||
| }, [app, callTool, repoFilter]); | ||
| // Load labels, assignees, milestones, issue types, and issue fields when owner/repo available | ||
| useEffect(() => { | ||
| if (!owner || !repo || !app) return; | ||
| const loadLabels = async () => { | ||
| setLabelsLoading(true); | ||
| try { | ||
| const result = await callTool("ui_get", { method: "labels", owner, repo }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c: { type: string }) => c.type === "text" | ||
| ); | ||
| if (textContent && "text" in textContent) { | ||
| const data = JSON.parse(textContent.text as string); | ||
| const labels = (data.labels || []).map( | ||
| (l: { name: string; color: string; id: string }) => ({ | ||
| id: l.id || l.name, | ||
| text: l.name, | ||
| color: l.color, | ||
| }) | ||
| ); | ||
| setAvailableLabels(labels); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to load labels:", e); | ||
| } finally { | ||
| setLabelsLoading(false); | ||
| } | ||
| }; | ||
| const loadAssignees = async () => { | ||
| setAssigneesLoading(true); | ||
| try { | ||
| const result = await callTool("ui_get", { method: "assignees", owner, repo }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c: { type: string }) => c.type === "text" | ||
| ); | ||
| if (textContent && "text" in textContent) { | ||
| const data = JSON.parse(textContent.text as string); | ||
| const assignees = (data.assignees || []).map( | ||
| (a: { login: string }) => ({ | ||
| id: a.login, | ||
| text: a.login, | ||
| }) | ||
| ); | ||
| setAvailableAssignees(assignees); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to load assignees:", e); | ||
| } finally { | ||
| setAssigneesLoading(false); | ||
| } | ||
| }; | ||
| const loadMilestones = async () => { | ||
| setMilestonesLoading(true); | ||
| try { | ||
| const result = await callTool("ui_get", { method: "milestones", owner, repo }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c: { type: string }) => c.type === "text" | ||
| ); | ||
| if (textContent && "text" in textContent) { | ||
| const data = JSON.parse(textContent.text as string); | ||
| const milestones = (data.milestones || []).map( | ||
| (m: { number: number; title: string; description: string }) => ({ | ||
| id: String(m.number), | ||
| number: m.number, | ||
| text: m.title, | ||
| description: m.description || "", | ||
| }) | ||
| ); | ||
| setAvailableMilestones(milestones); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Failed to load milestones:", e); | ||
| } finally { | ||
| setMilestonesLoading(false); | ||
| } | ||
| }; | ||
| const loadIssueTypes = async () => { | ||
| setIssueTypesLoading(true); | ||
| try { | ||
| const result = await callTool("ui_get", { method: "issue_types", owner }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c: { type: string }) => c.type === "text" | ||
| ); | ||
| if (textContent && "text" in textContent) { | ||
| const data = JSON.parse(textContent.text as string); | ||
| // ui_get returns array directly or wrapped in issue_types/types | ||
| const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); | ||
| const types = typesArray.map( | ||
| (t: { id: number; name: string; description?: string } | string) => { | ||
| if (typeof t === "string") { | ||
| return { id: t, text: t }; | ||
| } | ||
| return { id: String(t.id || t.name), text: t.name }; | ||
| } | ||
| ); | ||
| setAvailableIssueTypes(types); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| // Issue types may not be available for all repos/orgs | ||
| console.debug("Issue types not available:", e); | ||
| } finally { | ||
| setIssueTypesLoading(false); | ||
| } | ||
| }; | ||
| const loadIssueFields = async () => { | ||
| try { | ||
| const result = await callTool("ui_get", { method: "issue_fields", owner, repo }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c: { type: string }) => c.type === "text" | ||
| ); | ||
| if (textContent && "text" in textContent) { | ||
| const data = JSON.parse(textContent.text as string); | ||
| const fields = (data.fields || []) | ||
| .map( | ||
| (field: { | ||
| id?: string; | ||
| name?: string; | ||
| data_type?: string; | ||
| description?: string; | ||
| options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; | ||
| }) => ({ | ||
| id: String(field.id || field.name || ""), | ||
| name: field.name || "", | ||
| data_type: field.data_type || "text", | ||
| description: field.description || "", | ||
| options: (field.options || []) | ||
| .map((option) => ({ | ||
| id: String(option.id || option.name || ""), | ||
| name: option.name || "", | ||
| description: option.description || "", | ||
| color: option.color || "", | ||
| })) | ||
| .filter((option) => option.name), | ||
| }) | ||
| ) | ||
| .filter((field: IssueFieldItem) => field.name); | ||
| setAvailableIssueFields(fields); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.debug("Issue fields not available:", e); | ||
| setAvailableIssueFields([]); | ||
| } | ||
| }; | ||
| loadLabels(); | ||
| loadAssignees(); | ||
| loadMilestones(); | ||
| loadIssueTypes(); | ||
| loadIssueFields(); | ||
| }, [owner, repo, app, callTool]); | ||
| // Track which prefill fields have been applied to avoid re-applying after user edits | ||
| const prefillApplied = useRef<{ | ||
| title: boolean; | ||
| body: boolean; | ||
| labels: boolean; | ||
| assignees: boolean; | ||
| milestone: boolean; | ||
| type: boolean; | ||
| issueFields: boolean; | ||
| }>({ | ||
| title: false, | ||
| body: false, | ||
| labels: false, | ||
| assignees: false, | ||
| milestone: false, | ||
| type: false, | ||
| issueFields: false, | ||
| }); | ||
| // Store existing issue data for matching when available lists load | ||
| interface ExistingIssueData { | ||
| labels: string[]; | ||
| assignees: string[]; | ||
| milestoneNumber: number | null; | ||
| issueType: string | null; | ||
| fieldValues: unknown; | ||
| } | ||
| const [existingIssueData, setExistingIssueData] = useState<ExistingIssueData | null>(null); | ||
| // Reset all transient form/result state when toolInput changes (new invocation). | ||
| // Without this, the SuccessView from a previous submit stays visible and stale | ||
| // form values (e.g. body) bleed through because prefill effects use truthy guards | ||
| // that won't overwrite with empty values. The repo is re-initialized from the new | ||
| // invocation here (rather than in a separate effect) so it isn't wiped by this reset. | ||
| useEffect(() => { | ||
| prefillApplied.current = { | ||
| title: false, | ||
| body: false, | ||
| labels: false, | ||
| assignees: false, | ||
| milestone: false, | ||
| type: false, | ||
| issueFields: false, | ||
| }; | ||
| setExistingIssueData(null); | ||
| setTitle(""); | ||
| setBody(""); | ||
| setSelectedLabels([]); | ||
| setSelectedAssignees([]); | ||
| setSelectedMilestone(null); | ||
| setSelectedIssueType(null); | ||
| setCurrentState("open"); | ||
| setStateReason("completed"); | ||
| setDuplicateOf(""); | ||
| setPrefilledStateChange(null); | ||
| setFieldValues({}); | ||
| setSuccessIssue(null); | ||
| setError(null); | ||
| // Clear available metadata (and filters) so prefill effects, which are gated | ||
| // on these lists being non-empty, can't match against the previous repo's data | ||
| // before the new repo's ui_get calls resolve. | ||
| setAvailableLabels([]); | ||
| setAvailableAssignees([]); | ||
| setAvailableMilestones([]); | ||
| setAvailableIssueTypes([]); | ||
| setAvailableIssueFields([]); | ||
| setLabelsFilter(""); | ||
| setAssigneesFilter(""); | ||
| if (toolInput?.owner && toolInput?.repo) { | ||
| setSelectedRepo({ | ||
| id: `${toolInput.owner}/${toolInput.repo}`, | ||
| owner: toolInput.owner as string, | ||
| name: toolInput.repo as string, | ||
| fullName: `${toolInput.owner}/${toolInput.repo}`, | ||
| isPrivate: false, | ||
| }); | ||
| } else { | ||
| setSelectedRepo(null); | ||
| } | ||
| }, [toolInput]); | ||
| const handleSubmit = useCallback(async () => { | ||
| // Load existing issue data when in update mode | ||
| useEffect(() => { | ||
| if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) { | ||
| return; | ||
| } | ||
| const loadExistingIssue = async () => { | ||
| try { | ||
| const result = await callTool("issue_read", { | ||
| method: "get", | ||
| owner, | ||
| repo, | ||
| issue_number: issueNumber, | ||
| }); | ||
| if (result && !result.isError && result.content) { | ||
| const textContent = result.content.find( | ||
| (c) => c.type === "text" | ||
| ); | ||
| if (textContent && textContent.type === "text" && textContent.text) { | ||
| const issueData = JSON.parse(textContent.text); | ||
| const issueState = parseIssueState(issueData.state); | ||
| if (issueState) { | ||
| setCurrentState(issueState); | ||
| } | ||
| // Pre-fill title and body immediately | ||
| if (issueData.title && !prefillApplied.current.title) { | ||
| setTitle(issueData.title); | ||
| prefillApplied.current.title = true; | ||
| } | ||
| if (issueData.body && !prefillApplied.current.body) { | ||
| setBody(issueData.body); | ||
| prefillApplied.current.body = true; | ||
| } | ||
| // Pre-fill assignees immediately from issue data | ||
| const assigneeLogins = (issueData.assignees || []) | ||
| .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login) | ||
| .filter(Boolean) as string[]; | ||
| if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) { | ||
| setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login }))); | ||
| prefillApplied.current.assignees = true; | ||
| } | ||
| // Pre-fill issue type immediately from issue data | ||
| const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null); | ||
| if (issueTypeName && !prefillApplied.current.type) { | ||
| setSelectedIssueType({ id: issueTypeName, text: issueTypeName }); | ||
| prefillApplied.current.type = true; | ||
| } | ||
| // Extract data for deferred matching when available lists load (for labels and milestones) | ||
| const labelNames = (issueData.labels || []) | ||
| .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name) | ||
| .filter(Boolean) as string[]; | ||
| const milestoneNumber = issueData.milestone | ||
| ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone) | ||
| : null; | ||
| setExistingIssueData({ | ||
| labels: labelNames, | ||
| assignees: assigneeLogins, | ||
| milestoneNumber, | ||
| issueType: issueTypeName, | ||
| fieldValues: issueData.field_values || issueData.fieldValues || [], | ||
| }); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.error("Error loading existing issue:", e); | ||
| } | ||
| }; | ||
| loadExistingIssue(); | ||
| }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]); | ||
| // Apply existing labels when available labels load | ||
| useEffect(() => { | ||
| if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return; | ||
| const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text)); | ||
| if (matched.length > 0) { | ||
| setSelectedLabels(matched); | ||
| prefillApplied.current.labels = true; | ||
| } | ||
| }, [existingIssueData, availableLabels]); | ||
| // Apply existing milestone when available milestones load | ||
| useEffect(() => { | ||
| if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return; | ||
| const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber); | ||
| if (matched) { | ||
| setSelectedMilestone(matched); | ||
| } | ||
| prefillApplied.current.milestone = true; | ||
| }, [existingIssueData, availableMilestones]); | ||
| // Pre-fill title and body immediately (don't wait for data loading) | ||
| useEffect(() => { | ||
| if (toolInput?.title && !prefillApplied.current.title) { | ||
| setTitle(toolInput.title as string); | ||
| prefillApplied.current.title = true; | ||
| } | ||
| if (toolInput?.body && !prefillApplied.current.body) { | ||
| setBody(toolInput.body as string); | ||
| prefillApplied.current.body = true; | ||
| } | ||
| }, [toolInput]); | ||
| // Pre-fill requested state transition controls from tool input | ||
| useEffect(() => { | ||
| const state = parseIssueState(toolInput?.state); | ||
| if (state) { | ||
| setPrefilledStateChange(state); | ||
| } | ||
| const reason = parseStateReason(toolInput?.state_reason); | ||
| if (reason) { | ||
| setStateReason(reason); | ||
| } | ||
| if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) { | ||
| setDuplicateOf(String(toolInput.duplicate_of)); | ||
| } | ||
| }, [toolInput]); | ||
| // Pre-fill labels once available data is loaded | ||
| useEffect(() => { | ||
| if ( | ||
| toolInput?.labels && | ||
| Array.isArray(toolInput.labels) && | ||
| availableLabels.length > 0 && | ||
| !prefillApplied.current.labels | ||
| ) { | ||
| const prefillLabels = availableLabels.filter((l) => | ||
| (toolInput.labels as string[]).includes(l.text) | ||
| ); | ||
| if (prefillLabels.length > 0) { | ||
| setSelectedLabels(prefillLabels); | ||
| prefillApplied.current.labels = true; | ||
| } | ||
| } | ||
| }, [toolInput, availableLabels]); | ||
| // Pre-fill assignees once available data is loaded | ||
| useEffect(() => { | ||
| if ( | ||
| toolInput?.assignees && | ||
| Array.isArray(toolInput.assignees) && | ||
| availableAssignees.length > 0 && | ||
| !prefillApplied.current.assignees | ||
| ) { | ||
| const prefillAssignees = availableAssignees.filter((a) => | ||
| (toolInput.assignees as string[]).includes(a.text) | ||
| ); | ||
| if (prefillAssignees.length > 0) { | ||
| setSelectedAssignees(prefillAssignees); | ||
| prefillApplied.current.assignees = true; | ||
| } | ||
| } | ||
| }, [toolInput, availableAssignees]); | ||
| // Pre-fill milestone once available data is loaded | ||
| useEffect(() => { | ||
| if ( | ||
| toolInput?.milestone && | ||
| availableMilestones.length > 0 && | ||
| !prefillApplied.current.milestone | ||
| ) { | ||
| const milestone = availableMilestones.find( | ||
| (m) => m.number === Number(toolInput.milestone) | ||
| ); | ||
| if (milestone) { | ||
| setSelectedMilestone(milestone); | ||
| prefillApplied.current.milestone = true; | ||
| } | ||
| } | ||
| }, [toolInput, availableMilestones]); | ||
| // Pre-fill issue type once available data is loaded | ||
| useEffect(() => { | ||
| if ( | ||
| toolInput?.type && | ||
| availableIssueTypes.length > 0 && | ||
| !prefillApplied.current.type | ||
| ) { | ||
| const issueType = availableIssueTypes.find( | ||
| (t) => t.text === toolInput.type | ||
| ); | ||
| if (issueType) { | ||
| setSelectedIssueType(issueType); | ||
| prefillApplied.current.type = true; | ||
| } | ||
| } | ||
| }, [toolInput, availableIssueTypes]); | ||
| // Pre-fill custom fields once field definitions are loaded | ||
| useEffect(() => { | ||
| if (!availableIssueFields.length || prefillApplied.current.issueFields) return; | ||
| const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields); | ||
| if (Object.keys(toolInputValues).length > 0) { | ||
| setFieldValues(toolInputValues); | ||
| prefillApplied.current.issueFields = true; | ||
| return; | ||
| } | ||
| const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields); | ||
| if (Object.keys(existingValues).length > 0) { | ||
| setFieldValues(existingValues); | ||
| prefillApplied.current.issueFields = true; | ||
| } | ||
| }, [toolInput, existingIssueData, availableIssueFields]); | ||
| const issueFieldsByName = useMemo( | ||
| () => new Map(availableIssueFields.map((field) => [field.name, field])), | ||
| [availableIssueFields] | ||
| ); | ||
| const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => { | ||
| prefillApplied.current.issueFields = true; | ||
| setFieldValues((prev) => ({ ...prev, [fieldName]: value })); | ||
| }, []); | ||
| const handleSubmit = useCallback(async (stateChange?: IssueState) => { | ||
| if (!title.trim()) { | ||
@@ -168,2 +987,12 @@ setError("Title is required"); | ||
| const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null; | ||
| let duplicateIssueNumber: number | undefined; | ||
| if (requestedState === "closed" && stateReason === "duplicate") { | ||
| duplicateIssueNumber = Number(duplicateOf); | ||
| if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) { | ||
| setError("Duplicate issue number is required"); | ||
| return; | ||
| } | ||
| } | ||
| setIsSubmitting(true); | ||
@@ -183,2 +1012,7 @@ setError(null); | ||
| delete params.state; | ||
| delete params.state_reason; | ||
| delete params.duplicate_of; | ||
| delete params.issue_fields; | ||
| if (isUpdateMode && issueNumber) { | ||
@@ -188,2 +1022,47 @@ params.issue_number = issueNumber; | ||
| if (selectedLabels.length > 0) { | ||
| params.labels = selectedLabels.map((l) => l.text); | ||
| } | ||
| if (selectedAssignees.length > 0) { | ||
| params.assignees = selectedAssignees.map((a) => a.text); | ||
| } | ||
| if (selectedMilestone) { | ||
| params.milestone = selectedMilestone.number; | ||
| } | ||
| if (selectedIssueType) { | ||
| params.type = selectedIssueType.text; | ||
| } | ||
| if (requestedState) { | ||
| params.state = requestedState; | ||
| if (requestedState === "closed") { | ||
| params.state_reason = stateReason; | ||
| if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) { | ||
| params.duplicate_of = duplicateIssueNumber; | ||
| } | ||
| } | ||
| } | ||
| const issueFields = Object.entries(fieldValues) | ||
| .map(([fieldName, value]): IssueFieldSubmission | null => { | ||
| if (value.cleared) return { field_name: fieldName, delete: true }; | ||
| if (value.optionName !== undefined) { | ||
| return { field_name: fieldName, field_option_name: value.optionName }; | ||
| } | ||
| if (value.value !== undefined && value.value !== "") { | ||
| const field = issueFieldsByName.get(fieldName); | ||
| const fieldValue = | ||
| field?.data_type === "number" && typeof value.value === "string" | ||
| ? Number(value.value) | ||
| : value.value; | ||
| if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null; | ||
| return { field_name: fieldName, value: fieldValue }; | ||
| } | ||
| return null; | ||
| }) | ||
| .filter((field): field is IssueFieldSubmission => field !== null); | ||
| if (issueFields.length > 0) { | ||
| params.issue_fields = issueFields; | ||
| } | ||
| const result = await callTool("issue_write", params); | ||
@@ -229,4 +1108,101 @@ | ||
| } | ||
| }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); | ||
| }, [ | ||
| title, | ||
| body, | ||
| owner, | ||
| repo, | ||
| selectedLabels, | ||
| selectedAssignees, | ||
| selectedMilestone, | ||
| selectedIssueType, | ||
| isUpdateMode, | ||
| issueNumber, | ||
| stateReason, | ||
| duplicateOf, | ||
| prefilledStateChange, | ||
| fieldValues, | ||
| issueFieldsByName, | ||
| toolInput, | ||
| callTool, | ||
| setModelContext, | ||
| ]); | ||
| // Filtered items for dropdowns | ||
| const filteredLabels = useMemo(() => { | ||
| if (!labelsFilter) return availableLabels; | ||
| const lowerFilter = labelsFilter.toLowerCase(); | ||
| return availableLabels.filter((l) => | ||
| l.text.toLowerCase().includes(lowerFilter) | ||
| ); | ||
| }, [availableLabels, labelsFilter]); | ||
| const filteredAssignees = useMemo(() => { | ||
| if (!assigneesFilter) return availableAssignees; | ||
| const lowerFilter = assigneesFilter.toLowerCase(); | ||
| return availableAssignees.filter((a) => | ||
| a.text.toLowerCase().includes(lowerFilter) | ||
| ); | ||
| }, [availableAssignees, assigneesFilter]); | ||
| const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0]; | ||
| const renderIssueFieldInput = (field: IssueFieldItem) => { | ||
| const fieldValue = fieldValues[field.name] || {}; | ||
| if (field.data_type === "single_select") { | ||
| const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; | ||
| const selectedOption = field.options.find((option) => option.name === selectedOptionName); | ||
| return ( | ||
| <Box sx={{ flex: 1, minWidth: 0 }}> | ||
| <ActionMenu> | ||
| <ActionMenu.Button size="small" sx={{ maxWidth: "100%" }}> | ||
| {selectedOption ? selectedOption.name : "Select option"} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <ActionList selectionVariant="single"> | ||
| {field.options.length === 0 ? ( | ||
| <ActionList.Item disabled>No options available</ActionList.Item> | ||
| ) : ( | ||
| field.options.map((option) => ( | ||
| <ActionList.Item | ||
| key={option.id || option.name} | ||
| selected={selectedOptionName === option.name} | ||
| onSelect={() => updateIssueFieldValue(field.name, { optionName: option.name })} | ||
| > | ||
| <ActionList.LeadingVisual> | ||
| <Box | ||
| sx={{ | ||
| width: 14, | ||
| height: 14, | ||
| borderRadius: "50%", | ||
| backgroundColor: normalizeSwatchColor(option.color), | ||
| borderWidth: 1, | ||
| borderStyle: "solid", | ||
| borderColor: "border.default", | ||
| }} | ||
| /> | ||
| </ActionList.LeadingVisual> | ||
| {option.name} | ||
| </ActionList.Item> | ||
| )) | ||
| )} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| </Box> | ||
| ); | ||
| } | ||
| return ( | ||
| <TextInput | ||
| type={field.data_type === "number" ? "number" : field.data_type === "date" ? "date" : "text"} | ||
| value={fieldValue.cleared ? "" : String(fieldValue.value ?? "")} | ||
| onChange={(e) => updateIssueFieldValue(field.name, { value: e.target.value })} | ||
| block | ||
| contrast | ||
| sx={{ flex: 1 }} | ||
| /> | ||
| ); | ||
| }; | ||
| const body_node = (() => { | ||
@@ -256,2 +1232,3 @@ if (appError) { | ||
| submittedTitle={title} | ||
| submittedLabels={selectedLabels} | ||
| isUpdate={isUpdateMode} | ||
@@ -272,3 +1249,3 @@ openLink={openLink} | ||
| > | ||
| {/* Header */} | ||
| {/* Repository picker */} | ||
| <Box | ||
@@ -283,12 +1260,79 @@ display="flex" | ||
| borderBottomColor="border.default" | ||
| sx={{ minWidth: 0, overflow: "hidden" }} | ||
| > | ||
| <Box sx={{ color: "fg.default", flexShrink: 0, display: "flex", mr: 1 }}> | ||
| <IssueOpenedIcon 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(""); | ||
| // Clear metadata when switching repos | ||
| setAvailableLabels([]); | ||
| setSelectedLabels([]); | ||
| setAvailableAssignees([]); | ||
| setSelectedAssignees([]); | ||
| setAvailableMilestones([]); | ||
| setSelectedMilestone(null); | ||
| setAvailableIssueTypes([]); | ||
| setSelectedIssueType(null); | ||
| setAvailableIssueFields([]); | ||
| setFieldValues({}); | ||
| }} | ||
| > | ||
| <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> | ||
| <Text sx={{ fontWeight: "semibold", whiteSpace: "nowrap" }}> | ||
| {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"} | ||
| </Text> | ||
| <Text sx={{ color: "fg.muted", fontSize: 0, ml: 1 }}> | ||
| {owner}/{repo} | ||
| </Text> | ||
| </Box> | ||
@@ -332,7 +1376,340 @@ | ||
| {/* Submit button */} | ||
| <Box display="flex" justifyContent="flex-end" gap={2}> | ||
| {/* Metadata section */} | ||
| <Box display="flex" gap={4} mb={3} sx={{ flexWrap: "wrap" }}> | ||
| {/* Labels dropdown */} | ||
| <ActionMenu> | ||
| <ActionMenu.Button size="small" leadingVisual={TagIcon}> | ||
| Labels | ||
| {selectedLabels.length > 0 && ( | ||
| <CounterLabel sx={{ ml: 1 }}>{selectedLabels.length}</CounterLabel> | ||
| )} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default"> | ||
| <TextInput | ||
| placeholder="Filter labels" | ||
| value={labelsFilter} | ||
| onChange={(e) => setLabelsFilter(e.target.value)} | ||
| size="small" | ||
| block | ||
| /> | ||
| </Box> | ||
| <ActionList selectionVariant="multiple"> | ||
| {labelsLoading ? ( | ||
| <ActionList.Item disabled> | ||
| <Spinner size="small" /> Loading... | ||
| </ActionList.Item> | ||
| ) : filteredLabels.length === 0 ? ( | ||
| <ActionList.Item disabled>No labels available</ActionList.Item> | ||
| ) : ( | ||
| filteredLabels.map((label) => ( | ||
| <ActionList.Item | ||
| key={label.id} | ||
| selected={selectedLabels.some((l) => l.id === label.id)} | ||
| onSelect={() => { | ||
| setSelectedLabels((prev) => | ||
| prev.some((l) => l.id === label.id) | ||
| ? prev.filter((l) => l.id !== label.id) | ||
| : [...prev, label] | ||
| ); | ||
| }} | ||
| > | ||
| <ActionList.LeadingVisual> | ||
| <Box | ||
| sx={{ | ||
| width: 14, | ||
| height: 14, | ||
| borderRadius: "50%", | ||
| backgroundColor: `#${label.color}`, | ||
| }} | ||
| /> | ||
| </ActionList.LeadingVisual> | ||
| {label.text} | ||
| </ActionList.Item> | ||
| )) | ||
| )} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| {/* Assignees dropdown */} | ||
| <ActionMenu> | ||
| <ActionMenu.Button size="small" leadingVisual={PersonIcon}> | ||
| Assignees | ||
| {selectedAssignees.length > 0 && ( | ||
| <CounterLabel sx={{ ml: 1 }}>{selectedAssignees.length}</CounterLabel> | ||
| )} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default"> | ||
| <TextInput | ||
| placeholder="Search people" | ||
| value={assigneesFilter} | ||
| onChange={(e) => setAssigneesFilter(e.target.value)} | ||
| size="small" | ||
| block | ||
| /> | ||
| </Box> | ||
| <ActionList selectionVariant="multiple"> | ||
| {assigneesLoading ? ( | ||
| <ActionList.Item disabled> | ||
| <Spinner size="small" /> Loading... | ||
| </ActionList.Item> | ||
| ) : filteredAssignees.length === 0 ? ( | ||
| <ActionList.Item disabled>No assignees available</ActionList.Item> | ||
| ) : ( | ||
| filteredAssignees.map((assignee) => ( | ||
| <ActionList.Item | ||
| key={assignee.id} | ||
| selected={selectedAssignees.some((a) => 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} | ||
| </ActionList.Item> | ||
| )) | ||
| )} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| {/* Milestones dropdown */} | ||
| <ActionMenu> | ||
| <ActionMenu.Button size="small" leadingVisual={MilestoneIcon}> | ||
| {selectedMilestone ? selectedMilestone.text : "Milestone"} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <ActionList selectionVariant="single"> | ||
| {milestonesLoading ? ( | ||
| <ActionList.Item disabled> | ||
| <Spinner size="small" /> Loading... | ||
| </ActionList.Item> | ||
| ) : availableMilestones.length === 0 ? ( | ||
| <ActionList.Item disabled>No milestones</ActionList.Item> | ||
| ) : ( | ||
| <> | ||
| {selectedMilestone && ( | ||
| <ActionList.Item | ||
| onSelect={() => setSelectedMilestone(null)} | ||
| > | ||
| Clear selection | ||
| </ActionList.Item> | ||
| )} | ||
| {availableMilestones.map((milestone) => ( | ||
| <ActionList.Item | ||
| key={milestone.id} | ||
| selected={selectedMilestone?.id === milestone.id} | ||
| onSelect={() => setSelectedMilestone(milestone)} | ||
| > | ||
| {milestone.text} | ||
| {milestone.description && ( | ||
| <ActionList.Description> | ||
| {milestone.description} | ||
| </ActionList.Description> | ||
| )} | ||
| </ActionList.Item> | ||
| ))} | ||
| </> | ||
| )} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| {/* Issue Types dropdown */} | ||
| <ActionMenu> | ||
| <ActionMenu.Button size="small" leadingVisual={IssueOpenedIcon}> | ||
| {selectedIssueType ? selectedIssueType.text : "Type"} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <ActionList selectionVariant="single"> | ||
| {issueTypesLoading ? ( | ||
| <ActionList.Item disabled> | ||
| <Spinner size="small" /> Loading... | ||
| </ActionList.Item> | ||
| ) : availableIssueTypes.length === 0 ? ( | ||
| <ActionList.Item disabled>No issue types</ActionList.Item> | ||
| ) : ( | ||
| <> | ||
| {selectedIssueType && ( | ||
| <ActionList.Item | ||
| onSelect={() => setSelectedIssueType(null)} | ||
| > | ||
| Clear selection | ||
| </ActionList.Item> | ||
| )} | ||
| {availableIssueTypes.map((type) => ( | ||
| <ActionList.Item | ||
| key={type.id} | ||
| selected={selectedIssueType?.id === type.id} | ||
| onSelect={() => setSelectedIssueType(type)} | ||
| > | ||
| {type.text} | ||
| </ActionList.Item> | ||
| ))} | ||
| </> | ||
| )} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| </Box> | ||
| {/* Fields section */} | ||
| {availableIssueFields.length > 0 && ( | ||
| <Box mb={3}> | ||
| <Text sx={{ fontWeight: "semibold", display: "block", mb: 3 }}> | ||
| Fields | ||
| </Text> | ||
| <Box | ||
| display="grid" | ||
| sx={{ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 2 }} | ||
| > | ||
| {availableIssueFields.map((field) => { | ||
| const fieldValue = fieldValues[field.name]; | ||
| const hasFieldValue = | ||
| fieldValue && | ||
| !fieldValue.cleared && | ||
| (fieldValue.optionName !== undefined || | ||
| (fieldValue.value !== undefined && fieldValue.value !== "")); | ||
| return ( | ||
| <Box key={field.id || field.name}> | ||
| <Text sx={{ fontWeight: "semibold", fontSize: 1, display: "block" }}> | ||
| {field.name} | ||
| </Text> | ||
| {field.description && ( | ||
| <Text sx={{ color: "fg.muted", fontSize: 0, display: "block", mt: 1, mb: 2 }}> | ||
| {field.description} | ||
| </Text> | ||
| )} | ||
| <Box display="flex" alignItems="center" gap={2} mt={field.description ? 0 : 2}> | ||
| {renderIssueFieldInput(field)} | ||
| {hasFieldValue && ( | ||
| <Button | ||
| variant="invisible" | ||
| size="small" | ||
| sx={{ fontSize: 0, color: "fg.muted" }} | ||
| onClick={() => updateIssueFieldValue(field.name, { cleared: true })} | ||
| > | ||
| Clear | ||
| </Button> | ||
| )} | ||
| </Box> | ||
| </Box> | ||
| ); | ||
| })} | ||
| </Box> | ||
| </Box> | ||
| )} | ||
| {/* Selected labels display */} | ||
| {selectedLabels.length > 0 && ( | ||
| <Box display="flex" gap={1} mb={3} flexWrap="wrap"> | ||
| {selectedLabels.map((label) => ( | ||
| <Label | ||
| key={label.id} | ||
| sx={{ | ||
| backgroundColor: `#${label.color}`, | ||
| color: getContrastColor(label.color), | ||
| borderColor: `#${label.color}`, | ||
| }} | ||
| > | ||
| {label.text} | ||
| </Label> | ||
| ))} | ||
| </Box> | ||
| )} | ||
| {/* Selected metadata display */} | ||
| {(selectedAssignees.length > 0 || selectedMilestone) && ( | ||
| <Box mb={3} sx={{ fontSize: 0, color: "fg.muted" }}> | ||
| {selectedAssignees.length > 0 && ( | ||
| <Text as="div"> | ||
| Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} | ||
| </Text> | ||
| )} | ||
| {selectedMilestone && ( | ||
| <Text as="div">Milestone: {selectedMilestone.text}</Text> | ||
| )} | ||
| </Box> | ||
| )} | ||
| {/* State and submit actions */} | ||
| <Box | ||
| display="flex" | ||
| justifyContent={isUpdateMode ? "space-between" : "flex-end"} | ||
| alignItems="center" | ||
| gap={3} | ||
| sx={{ flexWrap: "wrap" }} | ||
| > | ||
| {isUpdateMode && ( | ||
| <Box> | ||
| {currentState === "open" ? ( | ||
| <> | ||
| <Box display="flex" alignItems="center" gap={0}> | ||
| <Button | ||
| size="small" | ||
| variant="danger" | ||
| onClick={() => void handleSubmit("closed")} | ||
| disabled={isSubmitting || !title.trim() || (stateReason === "duplicate" && !duplicateOf.trim())} | ||
| sx={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }} | ||
| > | ||
| Close issue | ||
| </Button> | ||
| <ActionMenu> | ||
| <ActionMenu.Button | ||
| size="small" | ||
| sx={{ ml: "-1px", borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }} | ||
| > | ||
| {selectedStateReason.label} | ||
| </ActionMenu.Button> | ||
| <ActionMenu.Overlay width="medium"> | ||
| <ActionList selectionVariant="single"> | ||
| {stateReasonOptions.map((option) => ( | ||
| <ActionList.Item | ||
| key={option.value} | ||
| selected={stateReason === option.value} | ||
| onSelect={() => setStateReason(option.value)} | ||
| > | ||
| {option.label} | ||
| <ActionList.Description>{option.description}</ActionList.Description> | ||
| </ActionList.Item> | ||
| ))} | ||
| </ActionList> | ||
| </ActionMenu.Overlay> | ||
| </ActionMenu> | ||
| </Box> | ||
| {stateReason === "duplicate" && ( | ||
| <FormControl sx={{ mt: 2 }}> | ||
| <FormControl.Label sx={{ fontSize: 0 }}>Duplicate of</FormControl.Label> | ||
| <TextInput | ||
| type="number" | ||
| placeholder="Issue number" | ||
| value={duplicateOf} | ||
| onChange={(e) => setDuplicateOf(e.target.value)} | ||
| size="small" | ||
| sx={{ width: 140 }} | ||
| /> | ||
| </FormControl> | ||
| )} | ||
| </> | ||
| ) : ( | ||
| <Button | ||
| size="small" | ||
| onClick={() => void handleSubmit("open")} | ||
| disabled={isSubmitting || !title.trim()} | ||
| > | ||
| Reopen issue | ||
| </Button> | ||
| )} | ||
| </Box> | ||
| )} | ||
| <Button | ||
| variant="primary" | ||
| onClick={handleSubmit} | ||
| onClick={() => void handleSubmit()} | ||
| disabled={isSubmitting || !title.trim()} | ||
@@ -339,0 +1716,0 @@ > |
+476
-23
@@ -1,2 +0,2 @@ | ||
| import { StrictMode, useState, useCallback, useEffect } from "react"; | ||
| import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; | ||
| import { createRoot } from "react-dom/client"; | ||
@@ -15,2 +15,4 @@ import { | ||
| ButtonGroup, | ||
| CounterLabel, | ||
| Label, | ||
| } from "@primer/react"; | ||
@@ -20,3 +22,8 @@ import { | ||
| CheckCircleIcon, | ||
| RepoIcon, | ||
| LockIcon, | ||
| GitBranchIcon, | ||
| TriangleDownIcon, | ||
| PersonIcon, | ||
| PeopleIcon, | ||
| } from "@primer/octicons-react"; | ||
@@ -36,2 +43,29 @@ import { AppProvider } from "../../components/AppProvider"; | ||
| 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({ | ||
@@ -139,5 +173,24 @@ pr, | ||
| // 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({ | ||
@@ -147,8 +200,44 @@ 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 | ||
@@ -158,2 +247,4 @@ useEffect(() => { | ||
| 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); | ||
@@ -163,7 +254,149 @@ if (toolInput?.maintainer_can_modify !== undefined) { | ||
| } | ||
| 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; } | ||
@@ -180,6 +413,7 @@ setIsSubmitting(true); | ||
| body: body.trim(), | ||
| head, | ||
| base, | ||
| head: headBranch, | ||
| base: baseBranch, | ||
| draft: isDraft, | ||
| maintainer_can_modify: maintainerCanModify, | ||
| reviewers: selectedReviewers.map(reviewerValue), | ||
| _ui_submitted: true | ||
@@ -215,3 +449,3 @@ }); | ||
| } | ||
| }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); | ||
| }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); | ||
@@ -254,3 +488,3 @@ if (successPR) { | ||
| > | ||
| {/* Header */} | ||
| {/* Repository picker */} | ||
| <Box | ||
@@ -265,17 +499,152 @@ display="flex" | ||
| 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> | ||
| <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> | ||
| {/* 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> | ||
| </Box> | ||
| {/* Error banner */} | ||
@@ -304,5 +673,89 @@ {error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>} | ||
| {/* 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)} /> | ||
@@ -316,3 +769,3 @@ <Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text> | ||
| onClick={handleSubmit} | ||
| disabled={isSubmitting || !owner || !repo} | ||
| disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch} | ||
| > | ||
@@ -331,3 +784,3 @@ {isSubmitting ? ( | ||
| variant="primary" | ||
| disabled={isSubmitting || !owner || !repo} | ||
| disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch} | ||
| sx={{ px: 2 }} | ||
@@ -334,0 +787,0 @@ aria-label="Select pull request type" |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display