package github import ( "context" "encoding/json" "net/http" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_ProjectsWrite_CreateProject(t *testing.T) { t.Parallel() toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success user project", func(t *testing.T) { t.Parallel() mockedClient := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { User struct { ID string } `graphql:"user(login: $login)"` }{}, map[string]any{ "login": githubv4.String("octocat"), }, githubv4mock.DataResponse(map[string]any{ "user": map[string]any{ "id": "U_octocat", }, }), ), githubv4mock.NewMutationMatcher( struct { CreateProjectV2 struct { ProjectV2 struct { ID string Number int Title string URL string } } `graphql:"createProjectV2(input: $input)"` }{}, githubv4.CreateProjectV2Input{ OwnerID: githubv4.ID("U_octocat"), Title: githubv4.String("New Project"), }, nil, githubv4mock.DataResponse(map[string]any{ "createProjectV2": map[string]any{ "projectV2": map[string]any{ "id": "PVT_project123", "number": 1, "title": "New Project", "url": "https://github.com/users/octocat/projects/1", }, }, }), ), ) deps := BaseDeps{ GQLClient: githubv4.NewClient(mockedClient), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_project", "owner": "octocat", "owner_type": "user", "title": "New Project", }) 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, "PVT_project123", response["id"]) assert.Equal(t, float64(1), response["number"]) assert.Equal(t, "New Project", response["title"]) assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"]) }) t.Run("missing owner_type returns error", func(t *testing.T) { t.Parallel() deps := BaseDeps{ GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_project", "owner": "octocat", "title": "New Project", }) 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, "owner_type is required") }) t.Run("invalid owner_type returns error", func(t *testing.T) { t.Parallel() deps := BaseDeps{ GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_project", "owner": "octocat", "owner_type": "invalid", "title": "New Project", }) 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, "invalid owner_type") assert.Contains(t, textContent.Text, "must be") }) } // resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving // an org project node ID via resolveProjectNodeID. func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher { return githubv4mock.NewQueryMatcher( struct { Organization struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` }{}, map[string]any{ "owner": githubv4.String(owner), "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant }, githubv4mock.DataResponse(map[string]any{ "organization": map[string]any{ "projectV2": map[string]any{ "id": nodeID, }, }, }), ) } func createFieldMatcher() githubv4mock.Matcher { return githubv4mock.NewMutationMatcher( struct { CreateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"createProjectV2Field(input: $input)"` }{}, githubv4.CreateProjectV2FieldInput{ ProjectID: githubv4.ID("PVT_project1"), DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), Name: githubv4.String("Sprint"), }, nil, githubv4mock.DataResponse(map[string]any{ "createProjectV2Field": map[string]any{ "projectV2Field": map[string]any{ "id": "PVTIF_field1", "name": "Sprint", }, }, }), ) } func updateFieldIterationResponse() githubv4mock.GQLResponse { return githubv4mock.DataResponse(map[string]any{ "updateProjectV2Field": map[string]any{ "projectV2Field": map[string]any{ "id": "PVTIF_field1", "name": "Sprint", "configuration": map[string]any{ "iterations": []any{ map[string]any{ "id": "PVTI_iter1", "title": "Sprint 1", "startDate": "2025-01-20", "duration": 7, }, }, }, }, }, }) } func Test_ProjectsWrite_CreateIterationField(t *testing.T) { t.Parallel() toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success with iterations", func(t *testing.T) { t.Parallel() mockGQLClient := githubv4mock.NewMockedHTTPClient( resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), createFieldMatcher(), githubv4mock.NewMutationMatcher( struct { UpdateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string Configuration struct { Iterations []struct { ID string Title string StartDate string Duration int } } } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ FieldID: githubv4.ID("PVTIF_field1"), IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ Duration: githubv4.Int(7), StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, Iterations: []ProjectV2IterationFieldIterationInput{ { Title: githubv4.String("Sprint 1"), StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, Duration: githubv4.Int(7), }, }, }, }, nil, updateFieldIterationResponse(), ), ) deps := BaseDeps{ GQLClient: githubv4.NewClient(mockGQLClient), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_iteration_field", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "field_name": "Sprint", "iteration_duration": float64(7), "start_date": "2025-01-20", "iterations": []any{ map[string]any{ "title": "Sprint 1", "start_date": "2025-01-20", "duration": float64(7), }, }, }) 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, "PVTIF_field1", response["id"]) }) t.Run("success without iterations", func(t *testing.T) { t.Parallel() mockGQLClient := githubv4mock.NewMockedHTTPClient( resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), createFieldMatcher(), githubv4mock.NewMutationMatcher( struct { UpdateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string Configuration struct { Iterations []struct { ID string Title string StartDate string Duration int } } } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ FieldID: githubv4.ID("PVTIF_field1"), IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ Duration: githubv4.Int(7), StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, Iterations: []ProjectV2IterationFieldIterationInput{}, }, }, nil, githubv4mock.DataResponse(map[string]any{ "updateProjectV2Field": map[string]any{ "projectV2Field": map[string]any{ "id": "PVTIF_field1", "name": "Sprint", "configuration": map[string]any{ "iterations": []any{}, }, }, }, }), ), ) deps := BaseDeps{ GQLClient: githubv4.NewClient(mockGQLClient), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_iteration_field", "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "field_name": "Sprint", "iteration_duration": float64(7), "start_date": "2025-01-20", }) 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, "PVTIF_field1", response["id"]) }) t.Run("success with auto-detected owner_type", func(t *testing.T) { t.Parallel() // detectOwnerType uses REST to probe user first, then org mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{ "id": 1, "node_id": "PVT_project1", "title": "Org Project", }), }) mockGQLClient := githubv4mock.NewMockedHTTPClient( resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), createFieldMatcher(), githubv4mock.NewMutationMatcher( struct { UpdateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string Configuration struct { Iterations []struct { ID string Title string StartDate string Duration int } } } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ FieldID: githubv4.ID("PVTIF_field1"), IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ Duration: githubv4.Int(14), StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)}, Iterations: []ProjectV2IterationFieldIterationInput{}, }, }, nil, githubv4mock.DataResponse(map[string]any{ "updateProjectV2Field": map[string]any{ "projectV2Field": map[string]any{ "id": "PVTIF_field1", "name": "Sprint", "configuration": map[string]any{ "iterations": []any{}, }, }, }, }), ), ) deps := BaseDeps{ Client: mustNewGHClient(t, mockRESTClient), GQLClient: githubv4.NewClient(mockGQLClient), Obsv: stubExporters(), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ "method": "create_iteration_field", "owner": "octo-org", "project_number": float64(1), "field_name": "Sprint", "iteration_duration": float64(14), "start_date": "2025-02-01", }) 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, "PVTIF_field1", response["id"]) }) }