package github import ( "context" "encoding/json" "net/http" "testing" "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/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Tests for consolidated project tools func Test_ProjectsList(t *testing.T) { // Verify tool definition once toolDef := ProjectsList(translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) assert.Equal(t, "projects_list", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) assert.Contains(t, inputSchema.Properties, "method") assert.Contains(t, inputSchema.Properties, "owner") assert.Contains(t, inputSchema.Properties, "owner_type") assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "query") assert.Contains(t, inputSchema.Properties, "fields") assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) } func Test_ProjectsList_ListProjects(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} tests := []struct { name string mockedClient *http.Client requestArgs map[string]any expectError bool expectedErrMsg string expectedLength int }{ { name: "success organization", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octo-org", "owner_type": "org", }, expectError: false, expectedLength: 1, }, { name: "success user", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octocat", "owner_type": "user", }, expectError: false, expectedLength: 1, }, { name: "missing required parameter method", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", }, expectError: true, expectedErrMsg: "missing required parameter: method", }, { name: "unknown method", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "unknown_method", "owner": "octo-org", "owner_type": "org", }, expectError: true, expectedErrMsg: "unknown method: unknown_method", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(tc.requestArgs) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.Equal(t, tc.expectError, result.IsError) textContent := getTextResult(t, result) if tc.expectError { if tc.expectedErrMsg != "" { assert.Contains(t, textContent.Text, tc.expectedErrMsg) } return } var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) projects, ok := response["projects"].([]any) require.True(t, ok) assert.Equal(t, tc.expectedLength, len(projects)) }) } } func Test_ProjectsList_ListProjectFields(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } 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) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) fieldsList, ok := response["fields"].([]any) require.True(t, ok) assert.Equal(t, 1, len(fieldsList)) }) t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "list_project_fields", "owner": "octo-org", "owner_type": "org", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: project_number") }) } func verbosePullRequestProjectItemFixture() map[string]any { return map[string]any{ "id": 1001, "node_id": "PVTI_1", "content_type": "PullRequest", "item_url": "https://api.github.com/projectsV2/items/1001", "project_url": "https://api.github.com/orgs/octo-org/projectsV2/1", "creator": map[string]any{ "login": "creator", "id": 999, "followers_url": "https://api.github.com/users/creator/followers", }, "content": map[string]any{ "id": 2002, "node_id": "PR_1", "number": 42, "title": "Reduce project item output", "body": "Long pull request body that should not be returned from project item tools.", "state": "closed", "html_url": "https://github.com/cli/cli/pull/42", "url": "https://api.github.com/repos/cli/cli/pulls/42", "diff_url": "https://github.com/cli/cli/pull/42.diff", "patch_url": "https://github.com/cli/cli/pull/42.patch", "draft": false, "merged": true, "created_at": "2026-05-07T18:41:21Z", "updated_at": "2026-05-07T21:21:57Z", "closed_at": "2026-05-07T21:21:55Z", "merged_at": "2026-05-07T21:21:55Z", "user": map[string]any{ "login": "octocat", "id": 123, "followers_url": "https://api.github.com/users/octocat/followers", }, "assignees": []map[string]any{ { "login": "hubot", "events_url": "https://api.github.com/users/hubot/events{/privacy}", }, }, "labels": []map[string]any{ { "name": "bug", "url": "https://api.github.com/repos/cli/cli/labels/bug", }, }, "milestone": map[string]any{ "title": "v1.0", "description": "Verbose milestone description", }, "head": map[string]any{ "ref": "feature", "repo": map[string]any{ "full_name": "fork/cli", "archive_url": "https://api.github.com/repos/fork/cli/{archive_format}{/ref}", }, }, "base": map[string]any{ "ref": "trunk", "repo": map[string]any{ "full_name": "cli/cli", "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", }, }, "_links": map[string]any{ "self": map[string]any{ "href": "https://api.github.com/repos/cli/cli/pulls/42", }, }, "statuses_url": "https://api.github.com/repos/cli/cli/statuses/abc123", }, "fields": []map[string]any{ { "id": 301, "name": "Status", "data_type": "single_select", "value": map[string]any{ "id": "opt1", "name": "Done", "color": "GREEN", "description": "Verbose option description", }, }, }, "created_at": "2026-05-28T07:39:37Z", "updated_at": "2026-05-28T07:40:15Z", } } func assertMinimalPullRequestProjectItem(t *testing.T, rawJSON string, item map[string]any) { t.Helper() assert.Equal(t, float64(1001), item["id"]) assert.Equal(t, "PVTI_1", item["node_id"]) assert.Equal(t, "PullRequest", item["content_type"]) assert.Equal(t, "creator", item["creator"]) assert.Equal(t, "2026-05-28T07:39:37Z", item["created_at"]) assert.Equal(t, "2026-05-28T07:40:15Z", item["updated_at"]) content, ok := item["content"].(map[string]any) require.True(t, ok) assert.Equal(t, float64(42), content["number"]) assert.Equal(t, "Reduce project item output", content["title"]) assert.Equal(t, "closed", content["state"]) assert.Equal(t, "https://github.com/cli/cli/pull/42", content["html_url"]) assert.Equal(t, "cli/cli", content["repository"]) assert.Equal(t, "octocat", content["author"]) assert.Equal(t, true, content["merged"]) assert.Equal(t, "2026-05-07T18:41:21Z", content["created_at"]) assert.Equal(t, "2026-05-07T21:21:57Z", content["updated_at"]) assert.Equal(t, "2026-05-07T21:21:55Z", content["closed_at"]) assert.Equal(t, "2026-05-07T21:21:55Z", content["merged_at"]) assert.Equal(t, []any{"hubot"}, content["assignees"]) assert.Equal(t, []any{"bug"}, content["labels"]) assert.Equal(t, "v1.0", content["milestone"]) fields, ok := item["fields"].([]any) require.True(t, ok) require.Len(t, fields, 1) field, ok := fields[0].(map[string]any) require.True(t, ok) assert.Equal(t, float64(301), field["id"]) assert.Equal(t, "Status", field["name"]) assert.Equal(t, "single_select", field["data_type"]) value, ok := field["value"].(map[string]any) require.True(t, ok) assert.Equal(t, "opt1", value["id"]) assert.Equal(t, "Done", value["name"]) assert.Equal(t, "GREEN", value["color"]) assert.NotContains(t, rawJSON, `"body"`) assert.NotContains(t, rawJSON, `"archive_url"`) assert.NotContains(t, rawJSON, `"followers_url"`) assert.NotContains(t, rawJSON, `"events_url"`) assert.NotContains(t, rawJSON, `"_links"`) assert.NotContains(t, rawJSON, `"head"`) assert.NotContains(t, rawJSON, `"base"`) assert.NotContains(t, rawJSON, `"url":`) assert.NotContains(t, rawJSON, `"statuses_url"`) assert.NotContains(t, rawJSON, `"diff_url"`) } func Test_ProjectsList_ListProjectItems(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) items := []map[string]any{verbosePullRequestProjectItemFixture()} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } 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) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) itemsList, ok := response["items"].([]any) require.True(t, ok) assert.Equal(t, 1, len(itemsList)) item, ok := itemsList[0].(map[string]any) require.True(t, ok) assertMinimalPullRequestProjectItem(t, textContent.Text, item) }) t.Run("rejects fields and field_names together", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "list_project_items", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "fields": []any{"100"}, "field_names": []any{"Status"}, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "provide either 'fields' or 'field_names', not both") }) } 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) { // Verify tool definition once toolDef := ProjectsGet(translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) assert.Equal(t, "projects_get", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) assert.Contains(t, inputSchema.Properties, "method") assert.Contains(t, inputSchema.Properties, "owner") assert.Contains(t, inputSchema.Properties, "owner_type") assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) } func Test_ProjectsGet_GetProject(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) project := map[string]any{"id": 123, "title": "Project Title"} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } 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) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) }) t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "unknown_method", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "unknown method: unknown_method") }) } 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) { toolDef := ProjectsGet(translations.NullTranslationHelper) field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_field", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "field_id": float64(101), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) }) t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_field", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: field_id") }) } func Test_ProjectsGet_GetProjectItem(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) item := verbosePullRequestProjectItemFixture() t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assertMinimalPullRequestProjectItem(t, textContent.Text, response) }) t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) t.Run("rejects fields and field_names together", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), "fields": []any{"100"}, "field_names": []any{"Status"}, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "provide either 'fields' or 'field_names', not both") }) } func Test_ProjectsWrite(t *testing.T) { // Verify tool definition once toolDef := ProjectsWrite(translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) assert.Equal(t, "projects_write", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) assert.Contains(t, inputSchema.Properties, "method") assert.Contains(t, inputSchema.Properties, "owner") assert.Contains(t, inputSchema.Properties, "owner_type") assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "item_id") assert.Contains(t, inputSchema.Properties, "item_type") assert.Contains(t, inputSchema.Properties, "item_owner") assert.Contains(t, inputSchema.Properties, "item_repo") assert.Contains(t, inputSchema.Properties, "issue_number") assert.Contains(t, inputSchema.Properties, "pull_request_number") assert.Contains(t, inputSchema.Properties, "updated_field") assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) assert.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) } func Test_ProjectsWrite_AddProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success organization with issue", func(t *testing.T) { mockedClient := githubv4mock.NewMockedHTTPClient( // Mock resolveIssueNodeID query githubv4mock.NewQueryMatcher( struct { Repository struct { Issue struct { ID githubv4.ID } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` }{}, map[string]any{ "owner": githubv4.String("item-owner"), "repo": githubv4.String("item-repo"), "issueNumber": githubv4.Int(123), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ "id": "I_issue123", }, }, }), ), // Mock project ID query for org githubv4mock.NewQueryMatcher( struct { Organization struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` }{}, map[string]any{ "owner": githubv4.String("octo-org"), "projectNumber": githubv4.Int(1), }, githubv4mock.DataResponse(map[string]any{ "organization": map[string]any{ "projectV2": map[string]any{ "id": "PVT_project1", }, }, }), ), // Mock addProjectV2ItemById mutation githubv4mock.NewMutationMatcher( struct { AddProjectV2ItemByID struct { Item struct { ID githubv4.ID FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, githubv4.AddProjectV2ItemByIdInput{ ProjectID: githubv4.ID("PVT_project1"), ContentID: githubv4.ID("I_issue123"), }, nil, githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ "id": "PVTI_item1", "fullDatabaseId": "1001", }, }, }), ), ) client := githubv4.NewClient(mockedClient) deps := BaseDeps{ GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "add_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_owner": "item-owner", "item_repo": "item-repo", "issue_number": float64(123), "item_type": "issue", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) assert.Equal(t, float64(1001), response["item_id"]) assert.Equal(t, "1001", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) t.Run("success user with pull request", func(t *testing.T) { mockedClient := githubv4mock.NewMockedHTTPClient( // Mock resolvePullRequestNodeID query githubv4mock.NewQueryMatcher( struct { Repository struct { PullRequest struct { ID githubv4.ID } `graphql:"pullRequest(number: $prNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` }{}, map[string]any{ "owner": githubv4.String("item-owner"), "repo": githubv4.String("item-repo"), "prNumber": githubv4.Int(456), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "pullRequest": map[string]any{ "id": "PR_pr456", }, }, }), ), // Mock project ID query for user githubv4mock.NewQueryMatcher( struct { User struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` }{}, map[string]any{ "owner": githubv4.String("octo-user"), "projectNumber": githubv4.Int(2), }, githubv4mock.DataResponse(map[string]any{ "user": map[string]any{ "projectV2": map[string]any{ "id": "PVT_project2", }, }, }), ), // Mock addProjectV2ItemById mutation githubv4mock.NewMutationMatcher( struct { AddProjectV2ItemByID struct { Item struct { ID githubv4.ID FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, githubv4.AddProjectV2ItemByIdInput{ ProjectID: githubv4.ID("PVT_project2"), ContentID: githubv4.ID("PR_pr456"), }, nil, githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ "id": "PVTI_item2", "fullDatabaseId": "1002", }, }, }), ), ) client := githubv4.NewClient(mockedClient) deps := BaseDeps{ GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "add_project_item", "owner": "octo-user", "owner_type": "user", "project_number": float64(2), "item_owner": "item-owner", "item_repo": "item-repo", "pull_request_number": float64(456), "item_type": "pull_request", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) assert.Equal(t, float64(1002), response["item_id"]) assert.Equal(t, "1002", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) t.Run("missing item_type", func(t *testing.T) { mockedClient := githubv4mock.NewMockedHTTPClient() client := githubv4.NewClient(mockedClient) deps := BaseDeps{ GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "add_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_owner": "item-owner", "item_repo": "item-repo", "issue_number": float64(123), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: item_type") }) t.Run("invalid item_type", func(t *testing.T) { mockedClient := githubv4mock.NewMockedHTTPClient() client := githubv4.NewClient(mockedClient) deps := BaseDeps{ GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "add_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_owner": "item-owner", "item_repo": "item-repo", "issue_number": float64(123), "item_type": "invalid_type", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "item_type must be either 'issue' or 'pull_request'") }) t.Run("unknown method", func(t *testing.T) { mockedClient := githubv4mock.NewMockedHTTPClient() client := githubv4.NewClient(mockedClient) deps := BaseDeps{ GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "unknown_method", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "unknown method: unknown_method") }) } func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) updatedItem := verbosePullRequestProjectItemFixture() t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "update_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), "updated_field": map[string]any{ "id": float64(101), "value": "In Progress", }, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assertMinimalPullRequestProjectItem(t, textContent.Text, response) }) t.Run("missing updated_field", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "update_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: updated_field") }) } func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), }) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "delete_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(1001), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "project item successfully deleted") }) t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "delete_project_item", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.True(t, result.IsError) textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) } func TestMinimalProjectFieldValue(t *testing.T) { tests := []struct { name string value any want any }{ { name: "select option", value: map[string]any{ "id": "opt1", "name": "Done", "color": "GREEN", "description": "verbose", }, want: minimalProjectOptionValue{ ID: "opt1", Name: "Done", Color: "GREEN", }, }, { name: "iteration", value: map[string]any{ "id": "iter1", "title": "Sprint 1", "start_date": "2026-05-01", "duration": float64(14), }, want: minimalProjectIterationValue{ ID: "iter1", Title: "Sprint 1", StartDate: "2026-05-01", Duration: 14, }, }, { name: "assignees", value: []any{ map[string]any{"login": "octocat", "followers_url": "https://api.github.com/users/octocat/followers"}, map[string]any{"login": "hubot", "followers_url": "https://api.github.com/users/hubot/followers"}, }, want: []string{"octocat", "hubot"}, }, { name: "labels", value: []any{ map[string]any{"name": "bug", "url": "https://api.github.com/repos/cli/cli/labels/bug"}, map[string]any{"name": "help wanted", "url": "https://api.github.com/repos/cli/cli/labels/help%20wanted"}, }, want: []string{"bug", "help wanted"}, }, { name: "repository", value: map[string]any{ "full_name": "cli/cli", "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", }, want: "cli/cli", }, { name: "linked pull requests", value: []any{ map[string]any{ "number": float64(42), "title": "Reduce output", "state": "open", "html_url": "https://github.com/cli/cli/pull/42", "base": map[string]any{ "repo": map[string]any{ "full_name": "cli/cli", "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", }, }, }, }, want: []minimalProjectPullRequestRef{ { Number: 42, Title: "Reduce output", State: "open", HTMLURL: "https://github.com/cli/cli/pull/42", Repository: "cli/cli", }, }, }, { name: "raw text content", value: map[string]any{ "raw": "plain text", "html": "

plain text

", }, want: "plain text", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.want, minimalProjectFieldValue(tc.value)) }) } } func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) t.Run("success via consolidated tool", func(t *testing.T) { // REST mock for detectOwnerType (when owner_type is omitted) restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), }) // GQL mock for listProjectStatusUpdates gqlMockedClient := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( statusUpdatesUserQuery{}, map[string]any{ "owner": githubv4.String("octocat"), "projectNumber": githubv4.Int(1), "first": githubv4.Int(50), "after": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ "user": map[string]any{ "projectV2": map[string]any{ "public": false, "statusUpdates": map[string]any{ "nodes": []map[string]any{ { "id": "SU_1", "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"}, }, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, }, }, }, }), ), ) gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "list_project_status_updates", "owner": "octocat", "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) updates, ok := response["statusUpdates"].([]any) require.True(t, ok) assert.Len(t, updates, 1) }) } func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) t.Run("success via consolidated tool", 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": false}, }, }), ), ) gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ GQLClient: gqlClient, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "get_project_status_update", "owner": "octocat", "project_number": float64(1), "status_update_id": "SU_abc123", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Equal(t, "SU_abc123", response["id"]) assert.Equal(t, "On track", response["body"]) }) } func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success via consolidated tool", func(t *testing.T) { bodyStr := githubv4.String("Consolidated test") statusStr := githubv4.String("AT_RISK") gqlMockedClient := githubv4mock.NewMockedHTTPClient( // Mock project ID query for user githubv4mock.NewQueryMatcher( struct { User struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` }{}, map[string]any{ "owner": githubv4.String("octocat"), "projectNumber": githubv4.Int(3), }, githubv4mock.DataResponse(map[string]any{ "user": map[string]any{ "projectV2": map[string]any{ "id": "PVT_project3", }, }, }), ), // Mock createProjectV2StatusUpdate mutation githubv4mock.NewMutationMatcher( struct { CreateProjectV2StatusUpdate struct { StatusUpdate statusUpdateNode } `graphql:"createProjectV2StatusUpdate(input: $input)"` }{}, CreateProjectV2StatusUpdateInput{ ProjectID: githubv4.ID("PVT_project3"), Body: &bodyStr, Status: &statusStr, }, nil, githubv4mock.DataResponse(map[string]any{ "createProjectV2StatusUpdate": map[string]any{ "statusUpdate": map[string]any{ "id": "PVTSU_su003", "body": "Consolidated test", "status": "AT_RISK", "createdAt": "2026-02-09T12:00:00Z", "creator": map[string]any{"login": "octocat"}, }, }, }), ), ) gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ GQLClient: gqlClient, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_project_status_update", "owner": "octocat", "owner_type": "user", "project_number": float64(3), "body": "Consolidated test", "status": "AT_RISK", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) textContent := getTextResult(t, result) var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Equal(t, "PVTSU_su003", response["id"]) assert.Equal(t, "Consolidated test", response["body"]) assert.Equal(t, "AT_RISK", response["status"]) }) }