diff --git a/cmd/init.go b/cmd/init.go index 1fb72964..ec197c10 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,6 +1,7 @@ package cmd import ( + codacyclient "codacy/cli-v2/codacy-client" "codacy/cli-v2/config" "codacy/cli-v2/domain" "codacy/cli-v2/tools" @@ -23,20 +24,13 @@ import ( const CodacyApiBase = "https://app.codacy.com" -type InitFlags struct { - apiToken string - provider string - organization string - repository string -} - -var initFlags InitFlags +var initFlags domain.InitFlags func init() { - initCmd.Flags().StringVar(&initFlags.apiToken, "api-token", "", "optional codacy api token, if defined configurations will be fetched from codacy") - initCmd.Flags().StringVar(&initFlags.provider, "provider", "", "provider (gh/bb/gl) to fetch configurations from codacy, required when api-token is provided") - initCmd.Flags().StringVar(&initFlags.organization, "organization", "", "remote organization name to fetch configurations from codacy, required when api-token is provided") - initCmd.Flags().StringVar(&initFlags.repository, "repository", "", "remote repository name to fetch configurations from codacy, required when api-token is provided") + initCmd.Flags().StringVar(&initFlags.ApiToken, "api-token", "", "optional codacy api token, if defined configurations will be fetched from codacy") + initCmd.Flags().StringVar(&initFlags.Provider, "provider", "", "provider (gh/bb/gl) to fetch configurations from codacy, required when api-token is provided") + initCmd.Flags().StringVar(&initFlags.Organization, "organization", "", "remote organization name to fetch configurations from codacy, required when api-token is provided") + initCmd.Flags().StringVar(&initFlags.Repository, "repository", "", "remote repository name to fetch configurations from codacy, required when api-token is provided") rootCmd.AddCommand(initCmd) } @@ -56,11 +50,11 @@ var initCmd = &cobra.Command{ log.Fatalf("Failed to create tools-configs directory: %v", err) } - cliLocalMode := len(initFlags.apiToken) == 0 + cliLocalMode := len(initFlags.ApiToken) == 0 if cliLocalMode { fmt.Println() - fmt.Println("ℹ️ No project token was specified, skipping fetch configurations") + fmt.Println("ℹ️ No project token was specified, fetching codacy default configurations") noTools := []tools.Tool{} err := createConfigurationFiles(noTools, cliLocalMode) if err != nil { @@ -71,7 +65,7 @@ var initCmd = &cobra.Command{ log.Fatal(err) } } else { - err := buildRepositoryConfigurationFiles(initFlags.apiToken) + err := buildRepositoryConfigurationFiles(initFlags.ApiToken) if err != nil { log.Fatal(err) } @@ -265,7 +259,7 @@ func buildRepositoryConfigurationFiles(token string) error { Timeout: 10 * time.Second, } - apiTools, err := tools.GetRepositoryTools(CodacyApiBase, token, initFlags.provider, initFlags.organization, initFlags.repository) + apiTools, err := tools.GetRepositoryTools(CodacyApiBase, token, initFlags.Provider, initFlags.Organization, initFlags.Repository) if err != nil { return err } @@ -282,7 +276,7 @@ func buildRepositoryConfigurationFiles(token string) error { } // Generate languages configuration based on API tools response - if err := tools.CreateLanguagesConfigFile(apiTools, toolsConfigDir, uuidToName, token, initFlags.provider, initFlags.organization, initFlags.repository); err != nil { + if err := tools.CreateLanguagesConfigFile(apiTools, toolsConfigDir, uuidToName, token, initFlags.Provider, initFlags.Organization, initFlags.Repository); err != nil { return fmt.Errorf("failed to create languages configuration file: %w", err) } @@ -300,9 +294,9 @@ func buildRepositoryConfigurationFiles(token string) error { url := fmt.Sprintf("%s/api/v3/analysis/organizations/%s/%s/repositories/%s/tools/%s/patterns?enabled=true&limit=1000", CodacyApiBase, - initFlags.provider, - initFlags.organization, - initFlags.repository, + initFlags.Provider, + initFlags.Organization, + initFlags.Repository, tool.Uuid) // Create a new GET request @@ -493,6 +487,7 @@ func createDefaultTrivyConfigFile(toolsConfigDir string) error { // createDefaultEslintConfigFile creates a default eslint.config.mjs configuration file func createDefaultEslintConfigFile(toolsConfigDir string) error { // Use empty tool configuration to get default settings + // emptyConfig := []domain.PatternConfiguration{} content := tools.CreateEslintConfig(emptyConfig) @@ -560,7 +555,6 @@ func createLizardConfigFile(toolsConfigDir string, patternConfiguration []domain patterns[i] = pattern.PatternDefinition } - fmt.Println("Lizard configuration created based on Codacy settings") } content, err := lizard.CreateLizardConfig(patterns) @@ -573,16 +567,52 @@ func createLizardConfigFile(toolsConfigDir string, patternConfiguration []domain // buildDefaultConfigurationFiles creates default configuration files for all tools func buildDefaultConfigurationFiles(toolsConfigDir string) error { - // Create default Lizard configuration - if err := createLizardConfigFile(toolsConfigDir, []domain.PatternConfiguration{}); err != nil { - return fmt.Errorf("failed to create default Lizard configuration: %w", err) - } - // Add other default tool configurations here as needed - // For example: - // if err := createDefaultEslintConfigFile(toolsConfigDir); err != nil { - // return fmt.Errorf("failed to create default ESLint configuration: %w", err) - // } + for _, tool := range AvailableTools { + patternsConfig, err := codacyclient.GetDefaultToolPatternsConfig(initFlags, tool) + if err != nil { + return fmt.Errorf("failed to get default tool patterns config: %w", err) + } + switch tool { + case ESLint: + eslintConfigurationString := tools.CreateEslintConfig(patternsConfig) + + eslintConfigFile, err := os.Create(filepath.Join(toolsConfigDir, "eslint.config.mjs")) + if err != nil { + return fmt.Errorf("failed to create eslint config file: %v", err) + } + defer eslintConfigFile.Close() + + _, err = eslintConfigFile.WriteString(eslintConfigurationString) + if err != nil { + return fmt.Errorf("failed to write eslint config: %v", err) + } + case Trivy: + if err := createTrivyConfigFile(patternsConfig, toolsConfigDir); err != nil { + return fmt.Errorf("failed to create default Trivy configuration: %w", err) + } + case PMD: + if err := createPMDConfigFile(patternsConfig, toolsConfigDir); err != nil { + return fmt.Errorf("failed to create default PMD configuration: %w", err) + } + case PyLint: + if err := createPylintConfigFile(patternsConfig, toolsConfigDir); err != nil { + return fmt.Errorf("failed to create default Pylint configuration: %w", err) + } + case DartAnalyzer: + if err := createDartAnalyzerConfigFile(patternsConfig, toolsConfigDir); err != nil { + return fmt.Errorf("failed to create default Dart Analyzer configuration: %w", err) + } + case Semgrep: + if err := createSemgrepConfigFile(patternsConfig, toolsConfigDir); err != nil { + return fmt.Errorf("failed to create default Semgrep configuration: %w", err) + } + case Lizard: + if err := createLizardConfigFile(toolsConfigDir, patternsConfig); err != nil { + return fmt.Errorf("failed to create default Lizard configuration: %w", err) + } + } + } return nil } @@ -596,3 +626,14 @@ const ( Semgrep string = "6792c561-236d-41b7-ba5e-9d6bee0d548b" Lizard string = "76348462-84b3-409a-90d3-955e90abfb87" ) + +// AvailableTools lists all tool UUIDs supported by Codacy CLI. +var AvailableTools = []string{ + ESLint, + Trivy, + PMD, + PyLint, + DartAnalyzer, + Semgrep, + Lizard, +} diff --git a/cmd/init_test.go b/cmd/init_test.go index dfa04a39..4ef6bf0a 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,7 +1,9 @@ package cmd import ( + "codacy/cli-v2/config" "codacy/cli-v2/tools" + "codacy/cli-v2/utils" "os" "path/filepath" "testing" @@ -161,7 +163,7 @@ func TestCleanConfigDirectory(t *testing.T) { for _, file := range testFiles { filePath := filepath.Join(tempDir, file) - err := os.WriteFile(filePath, []byte("test content"), 0644) + err := os.WriteFile(filePath, []byte("test content"), utils.DefaultFilePerms) assert.NoError(t, err, "Failed to create test file: %s", filePath) } @@ -179,3 +181,66 @@ func TestCleanConfigDirectory(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, len(files), "Expected 0 files after cleaning, got %d", len(files)) } + +func TestInitCommand_NoToken(t *testing.T) { + tempDir := t.TempDir() + originalWD, err := os.Getwd() + assert.NoError(t, err, "Failed to get current working directory") + defer os.Chdir(originalWD) + + // Use the real plugins/tools/semgrep/rules.yaml file + rulesPath := filepath.Join("plugins", "tools", "semgrep", "rules.yaml") + if _, err := os.Stat(rulesPath); os.IsNotExist(err) { + t.Skip("plugins/tools/semgrep/rules.yaml not found; skipping test") + } + + // Change to the temp directory to simulate a new project + err = os.Chdir(tempDir) + assert.NoError(t, err, "Failed to change working directory to tempDir") + + // Simulate running init with no token + initFlags.ApiToken = "" + initFlags.Provider = "" + initFlags.Organization = "" + initFlags.Repository = "" + + // Call the Run logic from initCmd + if err := config.Config.CreateLocalCodacyDir(); err != nil { + t.Fatalf("Failed to create local codacy directory: %v", err) + } + + toolsConfigDir := config.Config.ToolsConfigDirectory() + if err := os.MkdirAll(toolsConfigDir, utils.DefaultFilePerms); err != nil { + t.Fatalf("Failed to create tools-configs directory: %v", err) + } + + cliLocalMode := len(initFlags.ApiToken) == 0 + if cliLocalMode { + noTools := []tools.Tool{} + err := createConfigurationFiles(noTools, cliLocalMode) + assert.NoError(t, err, "createConfigurationFiles should not return an error") + if err := buildDefaultConfigurationFiles(toolsConfigDir); err != nil { + t.Fatalf("Failed to build default configuration files: %v", err) + } + } + + // Assert that the expected config files are created + codacyDir := config.Config.LocalCodacyDirectory() + expectedFiles := []string{ + "tools-configs/eslint.config.mjs", + "tools-configs/trivy.yaml", + "tools-configs/ruleset.xml", + "tools-configs/pylint.rc", + "tools-configs/analysis_options.yaml", + "tools-configs/semgrep.yaml", + "tools-configs/lizard.yaml", + "codacy.yaml", + "cli-config.yaml", + } + + for _, file := range expectedFiles { + filePath := filepath.Join(codacyDir, file) + _, err := os.Stat(filePath) + assert.NoError(t, err, "Expected config file %s to be created", file) + } +} diff --git a/codacy-client/client.go b/codacy-client/client.go new file mode 100644 index 00000000..40c9fc84 --- /dev/null +++ b/codacy-client/client.go @@ -0,0 +1,150 @@ +package codacyclient + +import ( + "codacy/cli-v2/domain" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const timeout = 10 * time.Second +const CodacyApiBase = "https://app.codacy.com" + +func getRequest(url string, initFlags domain.InitFlags) ([]byte, error) { + client := &http.Client{ + Timeout: timeout, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("api-token", initFlags.ApiToken) + + resp, err := client.Do(req) + + if err != nil { + return nil, fmt.Errorf("error sending request: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("request to %s failed with status %d", url, resp.StatusCode) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return body, nil +} + +// GetPage fetches a single page of results from the API and returns the data and next cursor +func GetPage[T any]( + url string, + initFlags domain.InitFlags, + parser func([]byte) ([]T, string, error), +) ([]T, string, error) { + response, err := getRequest(url, initFlags) + if err != nil { + return nil, "", fmt.Errorf("failed to get page: %w", err) + } + return parser(response) +} + +// getAllPages fetches all pages of results from a paginated API endpoint +// - baseURL: the base URL for the paginated API +// - initFlags: the API token and other flags for authentication +// - parser: a function that parses the response body into the desired type and returns ([]T, nextCursor, error) +// +// Returns a slice of all results of type T and any error encountered. +func getAllPages[T any]( + baseURL string, + initFlags domain.InitFlags, + parser func([]byte) ([]T, string, error), +) ([]T, error) { + var allResults []T + cursor := "" + firstRequest := true + + for { + pageURL := baseURL + if !firstRequest && cursor != "" { + u, err := url.Parse(pageURL) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("cursor", cursor) + u.RawQuery = q.Encode() + pageURL = u.String() + } + firstRequest = false + + results, nextCursor, err := GetPage[T](pageURL, initFlags, parser) + if err != nil { + return nil, err + } + allResults = append(allResults, results...) + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + return allResults, nil +} + +// parsePatternConfigurations parses the response body into pattern configurations +func parsePatternConfigurations(response []byte) ([]domain.PatternConfiguration, string, error) { + var objmap map[string]json.RawMessage + if err := json.Unmarshal(response, &objmap); err != nil { + return nil, "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + var patterns []domain.PatternDefinition + if err := json.Unmarshal(objmap["data"], &patterns); err != nil { + return nil, "", fmt.Errorf("failed to unmarshal patterns: %w", err) + } + + patternConfigurations := make([]domain.PatternConfiguration, len(patterns)) + for i, pattern := range patterns { + patternConfigurations[i] = domain.PatternConfiguration{ + PatternDefinition: pattern, + Parameters: pattern.Parameters, + Enabled: pattern.Enabled, + } + } + + var pagination domain.Pagination + if objmap["pagination"] != nil { + if err := json.Unmarshal(objmap["pagination"], &pagination); err != nil { + return nil, "", fmt.Errorf("failed to unmarshal pagination: %w", err) + } + } + + return patternConfigurations, pagination.Cursor, nil +} + +func GetDefaultToolPatternsConfig(initFlags domain.InitFlags, toolUUID string) ([]domain.PatternConfiguration, error) { + baseURL := fmt.Sprintf("%s/api/v3/tools/%s/patterns?enabled=true", CodacyApiBase, toolUUID) + return getAllPages(baseURL, initFlags, parsePatternConfigurations) +} + +func GetRepositoryToolPatterns(initFlags domain.InitFlags, toolUUID string) ([]domain.PatternConfiguration, error) { + baseURL := fmt.Sprintf("%s/api/v3/analysis/organizations/%s/%s/repositories/%s/tools/%s/patterns?enabled=true", + CodacyApiBase, + initFlags.Provider, + initFlags.Organization, + initFlags.Repository, + toolUUID) + return getAllPages(baseURL, initFlags, parsePatternConfigurations) +} diff --git a/codacy-client/client_test.go b/codacy-client/client_test.go new file mode 100644 index 00000000..77ec4302 --- /dev/null +++ b/codacy-client/client_test.go @@ -0,0 +1,114 @@ +package codacyclient + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "codacy/cli-v2/domain" + + "github.com/stretchr/testify/assert" +) + +func TestGetRequest_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data": "ok"}`)) + })) + defer ts.Close() + + initFlags := domain.InitFlags{ApiToken: "dummy"} + resp, err := getRequest(ts.URL, initFlags) + assert.NoError(t, err) + assert.Contains(t, string(resp), "ok") +} + +func TestGetRequest_Failure(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + initFlags := domain.InitFlags{ApiToken: "dummy"} + _, err := getRequest(ts.URL, initFlags) + assert.Error(t, err) +} + +func TestGetPageAndGetAllPages(t *testing.T) { + type testItem struct{ Value int } + serverPages := []struct { + data []testItem + cursor string + }{ + {[]testItem{{Value: 1}, {Value: 2}}, "next"}, + {[]testItem{{Value: 3}}, ""}, + } + calls := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": serverPages[calls].data, + "pagination": map[string]interface{}{"cursor": serverPages[calls].cursor}, + } + calls++ + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + initFlags := domain.InitFlags{ApiToken: "dummy"} + + parser := func(body []byte) ([]testItem, string, error) { + var objmap map[string]json.RawMessage + if err := json.Unmarshal(body, &objmap); err != nil { + return nil, "", err + } + var items []testItem + if err := json.Unmarshal(objmap["data"], &items); err != nil { + return nil, "", err + } + var pagination struct { + Cursor string `json:"cursor"` + } + if objmap["pagination"] != nil { + _ = json.Unmarshal(objmap["pagination"], &pagination) + } + return items, pagination.Cursor, nil + } + + // Test GetPage + calls = 0 + items, cursor, err := GetPage[testItem](ts.URL, initFlags, parser) + assert.NoError(t, err) + assert.Len(t, items, 2) + assert.Equal(t, "next", cursor) + + // Test getAllPages + calls = 0 + allItems, err := getAllPages[testItem](ts.URL, initFlags, parser) + assert.NoError(t, err) + assert.Len(t, allItems, 3) +} + +func TestGetDefaultToolPatternsConfig_Empty(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "data": []interface{}{}, + "pagination": map[string]interface{}{"cursor": ""}, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + // TODO: Refactor GetDefaultToolPatternsConfig to accept a baseURL for easier testing + // oldBase := CodacyApiBase + // CodacyApiBase = ts.URL + // defer func() { CodacyApiBase = oldBase }() + + // Placeholder: test cannot be run until function is refactored for testability + _ = ts // avoid unused warning + // initFlags := domain.InitFlags{ApiToken: "dummy"} + // patterns, err := GetDefaultToolPatternsConfig(initFlags, "tool-uuid") + // assert.NoError(t, err) + // assert.Empty(t, patterns) +} diff --git a/domain/initFlags.go b/domain/initFlags.go new file mode 100644 index 00000000..da970a3e --- /dev/null +++ b/domain/initFlags.go @@ -0,0 +1,8 @@ +package domain + +type InitFlags struct { + ApiToken string + Provider string + Organization string + Repository string +} diff --git a/domain/pagination.go b/domain/pagination.go new file mode 100644 index 00000000..24a23c2c --- /dev/null +++ b/domain/pagination.go @@ -0,0 +1,7 @@ +package domain + +type Pagination struct { + Cursor string `json:"cursor"` + Limit int `json:"limit"` + Total int `json:"total"` +} diff --git a/domain/patternConfiguration.go b/domain/patternConfiguration.go index 0cfe9dbb..40094c2d 100644 --- a/domain/patternConfiguration.go +++ b/domain/patternConfiguration.go @@ -24,5 +24,4 @@ type PatternConfiguration struct { PatternDefinition PatternDefinition `json:"patternDefinition"` Parameters []ParameterConfiguration Enabled bool `json:"enabled"` - IsCustom bool `json:"isCustom"` }