diff --git a/README.md b/README.md index e8fa2a91..0ae9e58f 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,17 @@ Bootstraps the CLI configuration in your project's folder. This command creates - **Remote mode (fetch configuration from Codacy):** ```bash codacy-cli init --api-token --provider --organization --repository + + # or, with a repository token instead of an account API token + codacy-cli init --project-token --provider --organization --repository ``` **Flags:** -- `--api-token` (string): Codacy API token (optional; enables fetching remote config) -- `--provider` (string): Provider (`gh`, `gl`, `bb`), required with `--api-token` -- `--organization` (string): Organization name, required with `--api-token` -- `--repository` (string): Repository name, required with `--api-token` +- `--api-token` (string): Codacy account API token (optional; enables fetching remote config) +- `--project-token` (string): [Codacy repository token](https://docs.codacy.com/codacy-api/api-tokens/#repository-api-tokens), alternative to `--api-token`. Falls back to the `CODACY_PROJECT_TOKEN` environment variable, but only when `--provider`, `--organization` and `--repository` are also given — the variable alone never switches a local run to remote mode +- `--provider` (string): Provider (`gh`, `gl`, `bb`), required with `--api-token` or `--project-token` +- `--organization` (string): Organization name, required with `--api-token` or `--project-token` +- `--repository` (string): Repository name, required with `--api-token` or `--project-token` ### `config reset` — Reset Configuration @@ -90,7 +94,7 @@ codacy-cli config reset --api-token --provider --organization - Overwrites existing `.codacy/codacy.yaml` and tool configurations - Creates or updates `.codacy/.gitignore` file -**Flags:** Same as `init` command (api-token, provider, organization, repository) +**Flags:** Same as `init` command (api-token, project-token, provider, organization, repository) ### `config discover` — Discover Project Languages diff --git a/cmd/analyze.go b/cmd/analyze.go index bd9dff97..1ae3ae65 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -336,7 +336,7 @@ func checkIfConfigExistsAndIsNeeded(toolName string, cliLocalMode bool) error { "toolConfigPath": repoConfigPath, }) return nil - } else if (!cliLocalMode && initFlags.ApiToken != "") || cliLocalMode { + } else if (!cliLocalMode && initFlags.HasRemoteToken()) || cliLocalMode { if err := configsetup.CreateToolConfigurationFile(toolName, initFlags); err != nil { return fmt.Errorf("failed to create config file for tool %s: %w", toolName, err) } @@ -351,7 +351,7 @@ func checkIfConfigExistsAndIsNeeded(toolName string, cliLocalMode bool) error { logger.Debug("Config file not found for tool, using tool defaults", logrus.Fields{ "tool": toolName, "toolConfigPath": toolConfigPath, - "message": "No API token provided", + "message": "No API token or project token provided", }) } } else if err != nil { @@ -507,6 +507,8 @@ var analyzeCmd = &cobra.Command{ Supports API token, provider, and repository flags to automatically fetch tool configurations from Codacy API if they don't exist locally.`, Run: func(cmd *cobra.Command, args []string) { + cmdutils.PrepareRemoteFlags(cmd, &initFlags) + // Validate paths before proceeding if err := validatePaths(args); err != nil { fmt.Println(err) @@ -519,7 +521,7 @@ Supports API token, provider, and repository flags to automatically fetch tool c log.Fatalf("Failed to get current working directory: %v", err) } - cliLocalMode := len(initFlags.ApiToken) == 0 + cliLocalMode := !initFlags.HasRemoteToken() var toolsToRun map[string]*plugins.ToolInfo diff --git a/cmd/cmdutils/flags.go b/cmd/cmdutils/flags.go index dd09db44..a0299e57 100644 --- a/cmd/cmdutils/flags.go +++ b/cmd/cmdutils/flags.go @@ -1,16 +1,68 @@ package cmdutils import ( + "errors" + "fmt" + "log" + "os" + "codacy/cli-v2/domain" "github.com/spf13/cobra" ) +// ProjectTokenEnvVar is also the codacy-coverage-reporter's variable, so it is often +// exported for unrelated reasons - see ResolveProjectToken. +const ProjectTokenEnvVar = "CODACY_PROJECT_TOKEN" + // AddCloudFlags adds the common cloud-related flags to a cobra command. // The flags will be bound to the provided flags struct. func AddCloudFlags(cmd *cobra.Command, flags *domain.InitFlags) { - cmd.Flags().StringVar(&flags.ApiToken, "api-token", "", "Optional Codacy API token. If defined, configurations will be fetched from Codacy") - cmd.Flags().StringVar(&flags.Provider, "provider", "", "Provider (e.g., gh, bb, gl) to fetch configurations from Codacy. Required when api-token is provided") - cmd.Flags().StringVar(&flags.Organization, "organization", "", "Remote organization name to fetch configurations from Codacy. Required when api-token is provided") - cmd.Flags().StringVar(&flags.Repository, "repository", "", "Remote repository name to fetch configurations from Codacy. Required when api-token is provided") + cmd.Flags().StringVar(&flags.ApiToken, "api-token", "", "Optional Codacy account API token. If defined, configurations will be fetched from Codacy") + cmd.Flags().StringVar(&flags.ProjectToken, "project-token", "", "Optional Codacy repository token, an alternative to api-token for fetching configurations from Codacy. Falls back to CODACY_PROJECT_TOKEN when provider, organization and repository are given. See https://docs.codacy.com/codacy-api/api-tokens/#repository-api-tokens") + cmd.Flags().StringVar(&flags.Provider, "provider", "", "Provider (e.g., gh, bb, gl) to fetch configurations from Codacy. Required when api-token or project-token is provided") + cmd.Flags().StringVar(&flags.Organization, "organization", "", "Remote organization name to fetch configurations from Codacy. Required when api-token or project-token is provided") + cmd.Flags().StringVar(&flags.Repository, "repository", "", "Remote repository name to fetch configurations from Codacy. Required when api-token or project-token is provided") +} + +// ResolveProjectToken falls back to CODACY_PROJECT_TOKEN, but only once the repository +// coordinates are known. The variable is the coverage reporter's and is routinely exported +// CI-wide, so on its own it must not switch a local run into remote mode. +func ResolveProjectToken(flags *domain.InitFlags) { + if flags.HasRemoteToken() || !flags.HasRepositoryCoordinates() { + return + } + flags.ProjectToken = os.Getenv(ProjectTokenEnvVar) +} + +// ValidateRemoteFlags rejects a token given without the repository coordinates every +// remote read needs, so commands fail with a clear message instead of requesting a URL +// with empty path segments. +func ValidateRemoteFlags(flags domain.InitFlags) error { + if !flags.HasRemoteToken() || flags.HasRepositoryCoordinates() { + return nil + } + return errors.New("when using --api-token or --project-token, you must also provide --provider, --organization, and --repository flags") +} + +// exit is swappable so the failure path stays testable. +var exit = os.Exit + +// PrepareRemoteFlags resolves the environment fallback and rejects an incomplete remote +// setup, so every command that takes cloud flags behaves the same way. +func PrepareRemoteFlags(cmd *cobra.Command, flags *domain.InitFlags) { + ResolveProjectToken(flags) + + err := ValidateRemoteFlags(*flags) + if err == nil { + return + } + + fmt.Printf("Error: %s.\n", err) + fmt.Println("Please provide all required flags and try again.") + fmt.Println() + if errHelp := cmd.Help(); errHelp != nil { + log.Printf("Warning: Failed to display command help: %v", errHelp) + } + exit(1) } diff --git a/cmd/cmdutils/flags_test.go b/cmd/cmdutils/flags_test.go new file mode 100644 index 00000000..028fd242 --- /dev/null +++ b/cmd/cmdutils/flags_test.go @@ -0,0 +1,157 @@ +package cmdutils + +import ( + "io" + "testing" + + "codacy/cli-v2/domain" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func completeFlags() domain.InitFlags { + return domain.InitFlags{Provider: "gh", Organization: "org", Repository: "repo"} +} + +func TestAddCloudFlags(t *testing.T) { + t.Setenv(ProjectTokenEnvVar, "token-from-env") + + cmd := &cobra.Command{Use: "test"} + flags := domain.InitFlags{} + AddCloudFlags(cmd, &flags) + + for _, name := range []string{"api-token", "project-token", "provider", "organization", "repository"} { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag %s should be registered", name) + } + + // the environment must not leak in at registration time - see ResolveProjectToken + assert.Equal(t, "", cmd.Flags().Lookup("project-token").DefValue) + assert.Equal(t, domain.InitFlags{}, flags) +} + +func TestAddCloudFlagsBindsValues(t *testing.T) { + cmd := &cobra.Command{Use: "test", Run: func(*cobra.Command, []string) {}} + flags := domain.InitFlags{} + AddCloudFlags(cmd, &flags) + + cmd.SetArgs([]string{"--project-token", "tok", "--provider", "gh", "--organization", "org", "--repository", "repo"}) + assert.NoError(t, cmd.Execute()) + + assert.Equal(t, domain.InitFlags{ProjectToken: "tok", Provider: "gh", Organization: "org", Repository: "repo"}, flags) + assert.NoError(t, ValidateRemoteFlags(flags)) +} + +func TestResolveProjectToken(t *testing.T) { + tests := []struct { + name string + env string + flags domain.InitFlags + expectedToken string + }{ + {"environment applies once coordinates are known", "env-token", completeFlags(), "env-token"}, + {"environment ignored without coordinates", "env-token", domain.InitFlags{}, ""}, + {"environment ignored with partial coordinates", "env-token", domain.InitFlags{Provider: "gh", Organization: "org"}, ""}, + {"explicit flag wins over environment", "env-token", domain.InitFlags{ProjectToken: "flag-token", Provider: "gh", Organization: "org", Repository: "repo"}, "flag-token"}, + {"account token is not overwritten", "env-token", domain.InitFlags{ApiToken: "api", Provider: "gh", Organization: "org", Repository: "repo"}, ""}, + {"unset environment leaves flags alone", "", completeFlags(), ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(ProjectTokenEnvVar, tt.env) + + flags := tt.flags + ResolveProjectToken(&flags) + + assert.Equal(t, tt.expectedToken, flags.ProjectToken) + }) + } +} + +func TestValidateRemoteFlags(t *testing.T) { + tests := []struct { + name string + flags domain.InitFlags + expectError bool + }{ + {"no token needs no coordinates", domain.InitFlags{}, false}, + {"no token with partial coordinates", domain.InitFlags{Provider: "gh"}, false}, + {"account token with coordinates", domain.InitFlags{ApiToken: "api", Provider: "gh", Organization: "org", Repository: "repo"}, false}, + {"repository token with coordinates", domain.InitFlags{ProjectToken: "project", Provider: "gh", Organization: "org", Repository: "repo"}, false}, + {"repository token without coordinates", domain.InitFlags{ProjectToken: "project"}, true}, + {"account token without coordinates", domain.InitFlags{ApiToken: "api"}, true}, + {"token missing provider", domain.InitFlags{ProjectToken: "project", Organization: "org", Repository: "repo"}, true}, + {"token missing organization", domain.InitFlags{ProjectToken: "project", Provider: "gh", Repository: "repo"}, true}, + {"token missing repository", domain.InitFlags{ProjectToken: "project", Provider: "gh", Organization: "org"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRemoteFlags(tt.flags) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// stopped reports whether PrepareRemoteFlags terminated the command. The exit stub panics +// so a helper that kept running past it would fail the assertion instead of passing. +func stopped(t *testing.T, flags *domain.InitFlags) bool { + t.Helper() + + original := exit + defer func() { exit = original }() + + type exitCall struct{ code int } + exit = func(code int) { panic(exitCall{code}) } + + cmd := &cobra.Command{Use: "test"} + cmd.SetOut(io.Discard) + + didExit := false + func() { + defer func() { + if r := recover(); r != nil { + call, ok := r.(exitCall) + assert.True(t, ok, "unexpected panic: %v", r) + assert.Equal(t, 1, call.code) + didExit = true + } + }() + PrepareRemoteFlags(cmd, flags) + }() + + return didExit +} + +func TestPrepareRemoteFlags(t *testing.T) { + t.Run("stops on a token without coordinates", func(t *testing.T) { + flags := domain.InitFlags{ProjectToken: "tok"} + assert.True(t, stopped(t, &flags)) + }) + + t.Run("continues without any token", func(t *testing.T) { + flags := domain.InitFlags{} + assert.False(t, stopped(t, &flags)) + }) + + t.Run("continues when the environment token is ignored", func(t *testing.T) { + t.Setenv(ProjectTokenEnvVar, "env-token") + + flags := domain.InitFlags{} + assert.False(t, stopped(t, &flags)) + assert.Equal(t, "", flags.ProjectToken) + }) + + t.Run("adopts the environment token with coordinates", func(t *testing.T) { + t.Setenv(ProjectTokenEnvVar, "env-token") + + flags := completeFlags() + assert.False(t, stopped(t, &flags)) + assert.Equal(t, "env-token", flags.ProjectToken) + }) +} diff --git a/cmd/config.go b/cmd/config.go index 924d64e2..7e20ce69 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -51,12 +51,14 @@ var configResetCmd = &cobra.Command{ currentCliMode = "local" // Default to local as per existing logic } - apiTokenFlagProvided := len(configResetInitFlags.ApiToken) > 0 + // Resolve the environment fallback first, so a CI run that exports the token and + // passes the repository coordinates counts as an explicit remote reset below. + cmdutils.PrepareRemoteFlags(cmd, &configResetInitFlags) - // If current mode is 'remote', prevent resetting to local without explicit API token for a remote reset. - if currentCliMode == "remote" && !apiTokenFlagProvided { + // If current mode is 'remote', prevent resetting to local without a token for a remote reset. + if currentCliMode == "remote" && !configResetInitFlags.HasRemoteToken() { fmt.Println("Error: Your Codacy CLI is currently configured in 'remote' (cloud) mode.") - fmt.Println("To reset your configuration using remote settings, you must provide the --api-token, --provider, --organization, and --repository flags.") + fmt.Println("To reset your configuration using remote settings, you must provide --api-token (or --project-token) together with the --provider, --organization, and --repository flags.") fmt.Println("Running 'config reset' without these flags is not permitted while configured for 'remote' mode.") fmt.Println("This prevents an accidental switch to a local default configuration.") fmt.Println() @@ -66,19 +68,6 @@ var configResetCmd = &cobra.Command{ os.Exit(1) } - // Validate flags: if API token is provided, other related flags must also be provided. - if apiTokenFlagProvided { - if configResetInitFlags.Provider == "" || configResetInitFlags.Organization == "" || configResetInitFlags.Repository == "" { - fmt.Println("Error: When using --api-token, you must also provide --provider, --organization, and --repository flags.") - fmt.Println("Please provide all required flags and try again.") - fmt.Println() - if errHelp := cmd.Help(); errHelp != nil { - log.Fatalf("Failed to display command help: %v", errHelp) - } - os.Exit(1) - } - } - codacyConfigFile := config.Config.ProjectConfigFile() // Check if the main configuration file exists if _, err := os.Stat(codacyConfigFile); os.IsNotExist(err) { @@ -106,7 +95,7 @@ func runConfigResetLogic(cmd *cobra.Command, args []string, flags domain.InitFla } // Determine if running in local mode (no API token) - cliLocalMode := len(flags.ApiToken) == 0 + cliLocalMode := !flags.HasRemoteToken() if cliLocalMode { fmt.Println() @@ -125,7 +114,7 @@ func runConfigResetLogic(cmd *cobra.Command, args []string, flags domain.InitFla } } else { // API token provided, fetch configuration from Codacy - fmt.Println("API token specified. Fetching and applying repository-specific configurations from Codacy...") + fmt.Println("Token specified. Fetching and applying repository-specific configurations from Codacy...") if err := configsetup.BuildRepositoryConfigurationFiles(flags); err != nil { log.Fatalf("Failed to build repository-specific configuration files: %v", err) } @@ -158,6 +147,8 @@ var configDiscoverCmd = &cobra.Command{ "In Cloud mode, tools are only added if enabled in the cloud for the repository.", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { + cmdutils.PrepareRemoteFlags(cmd, &configResetInitFlags) + discoverPath = args[0] // Check if path exists @@ -306,7 +297,7 @@ func updateCodacyYAMLForTools(detectedTools map[string]struct{}, codacyYAMLPath candidateToolsToAdd := detectedTools - if cliMode == "remote" && initFlags.ApiToken != "" { + if cliMode == "remote" && initFlags.HasRemoteToken() { fmt.Println("Cloud mode: Verifying tools against repository settings...") cloudTools, err := codacyclient.GetRepositoryTools(initFlags) if err != nil { diff --git a/cmd/configsetup/default_config.go b/cmd/configsetup/default_config.go index f5a87455..ae55e331 100644 --- a/cmd/configsetup/default_config.go +++ b/cmd/configsetup/default_config.go @@ -103,7 +103,7 @@ func CreateConfigurationFilesForDiscoveredTools(discoveredToolNames map[string]s currentCliMode = "local" // Default to local } - if currentCliMode == "remote" && initFlags.ApiToken != "" { + if currentCliMode == "remote" && initFlags.HasRemoteToken() { // Remote mode - create configurations based on cloud repository settings return createRemoteToolConfigurationsForDiscovered(discoveredToolNames, initFlags) } @@ -200,7 +200,7 @@ func createDefaultConfigurationsForSpecificTools(discoveredToolNames map[string] // createToolConfigurationsForUUIDs creates tool configurations for specific UUIDs func createToolConfigurationsForUUIDs(uuids []string, toolsConfigDir string, initFlags domain.InitFlags) error { for _, uuid := range uuids { - patternsConfig, err := codacyclient.GetToolPatternsConfig(initFlags, uuid, true) + patternsConfig, err := codacyclient.GetToolPatternsConfig(domain.InitFlags{}, uuid, true) if err != nil { logToolConfigWarning(uuid, "Failed to get default patterns", err) continue diff --git a/cmd/configsetup/repository_config.go b/cmd/configsetup/repository_config.go index 8a541a71..58f178ce 100644 --- a/cmd/configsetup/repository_config.go +++ b/cmd/configsetup/repository_config.go @@ -98,7 +98,7 @@ func CreateToolConfigurationFile(toolName string, flags domain.InitFlags) error return fmt.Errorf("tool '%s' not found in supported tools", toolName) } - patternsConfig, err := codacyclient.GetToolPatternsConfig(flags, toolUUID, true) + patternsConfig, err := codacyclient.GetToolPatternsConfig(domain.InitFlags{}, toolUUID, true) if err != nil { return fmt.Errorf("failed to get default patterns: %w", err) } diff --git a/cmd/init.go b/cmd/init.go index 5359a968..c92a0bbf 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -25,6 +25,8 @@ var initCmd = &cobra.Command{ Short: "Bootstraps project configuration", Long: "Bootstraps project configuration, creates codacy configuration file", Run: func(cmd *cobra.Command, args []string) { + cmdutils.PrepareRemoteFlags(cmd, &initFlags) + // Create local codacy directory first if err := config.Config.CreateLocalCodacyDir(); err != nil { log.Fatalf("Failed to create local codacy directory: %v", err) @@ -36,11 +38,11 @@ var initCmd = &cobra.Command{ log.Fatalf("Failed to create tools-configs directory: %v", err) } - cliLocalMode := len(initFlags.ApiToken) == 0 + cliLocalMode := !initFlags.HasRemoteToken() if cliLocalMode { fmt.Println() - fmt.Println("ℹ️ No project token was specified, fetching codacy default configurations") + fmt.Println("ℹ️ No API token or project token was specified, fetching codacy default configurations") noTools := []domain.Tool{} err := configsetup.CreateConfigurationFiles(noTools, cliLocalMode, initFlags) if err != nil { diff --git a/codacy-client/client.go b/codacy-client/client.go index d46776d9..761b8aa4 100644 --- a/codacy-client/client.go +++ b/codacy-client/client.go @@ -16,7 +16,7 @@ const timeout = 10 * time.Second // CodacyApiBase is the base URL for the Codacy API var CodacyApiBase = "https://app.codacy.com" -func getRequest(url string, apiToken string) ([]byte, error) { +func getRequest(url string, flags domain.InitFlags) ([]byte, error) { client, err := httpclient.New(httpclient.WithTimeout(timeout)) if err != nil { return nil, fmt.Errorf("failed to create http client: %w", err) @@ -27,8 +27,12 @@ func getRequest(url string, apiToken string) ([]byte, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - if apiToken != "" { - req.Header.Set("api-token", apiToken) + // A repository token is only accepted by the repository-scoped v3 reads this client uses; + // when both are given the account token wins, since it works everywhere. + if flags.ApiToken != "" { + req.Header.Set("api-token", flags.ApiToken) + } else if flags.ProjectToken != "" { + req.Header.Set("project-token", flags.ProjectToken) } resp, err := client.Do(req) @@ -58,7 +62,7 @@ func GetPage[T any]( initFlags domain.InitFlags, parser func([]byte) ([]T, string, error), ) ([]T, string, error) { - response, err := getRequest(url, initFlags.ApiToken) + response, err := getRequest(url, initFlags) if err != nil { return nil, "", fmt.Errorf("failed to get page: %w", err) } @@ -213,7 +217,7 @@ func GetRepositoryTools(initFlags domain.InitFlags) ([]domain.Tool, error) { initFlags.Organization, initFlags.Repository) - bodyResponse, err := getRequest(baseURL, initFlags.ApiToken) + bodyResponse, err := getRequest(baseURL, initFlags) if err != nil { return nil, fmt.Errorf("failed to get repository tools: %w", err) } @@ -253,7 +257,7 @@ func GetRepositoryTools(initFlags domain.InitFlags) ([]domain.Tool, error) { func GetToolsVersions() ([]domain.Tool, error) { baseURL := fmt.Sprintf("%s/api/v3/tools", CodacyApiBase) - bodyResponse, err := getRequest(baseURL, "") + bodyResponse, err := getRequest(baseURL, domain.InitFlags{}) if err != nil { return nil, fmt.Errorf("failed to get tool versions: %w", err) } @@ -275,7 +279,7 @@ func GetRepositoryLanguages(initFlags domain.InitFlags) ([]domain.RepositoryLang initFlags.Organization, initFlags.Repository) - bodyResponse, err := getRequest(baseURL, initFlags.ApiToken) + bodyResponse, err := getRequest(baseURL, initFlags) if err != nil { return nil, fmt.Errorf("failed to get repository languages: %w", err) } @@ -293,7 +297,7 @@ func GetRepositoryLanguages(initFlags domain.InitFlags) ([]domain.RepositoryLang func GetLanguageTools() ([]domain.LanguageTool, error) { baseURL := fmt.Sprintf("%s/api/v3/languages/tools", CodacyApiBase) - bodyResponse, err := getRequest(baseURL, "") + bodyResponse, err := getRequest(baseURL, domain.InitFlags{}) if err != nil { return nil, fmt.Errorf("failed to get language tools: %w", err) } diff --git a/codacy-client/client_test.go b/codacy-client/client_test.go index c320e311..12e95f63 100644 --- a/codacy-client/client_test.go +++ b/codacy-client/client_test.go @@ -19,7 +19,7 @@ func TestGetRequest_Success(t *testing.T) { defer ts.Close() initFlags := domain.InitFlags{ApiToken: "dummy"} - resp, err := getRequest(ts.URL, initFlags.ApiToken) + resp, err := getRequest(ts.URL, initFlags) assert.NoError(t, err) assert.Contains(t, string(resp), "ok") } @@ -31,7 +31,7 @@ func TestGetRequest_Failure(t *testing.T) { defer ts.Close() initFlags := domain.InitFlags{ApiToken: "dummy"} - _, err := getRequest(ts.URL, initFlags.ApiToken) + _, err := getRequest(ts.URL, initFlags) assert.Error(t, err) } @@ -107,3 +107,31 @@ func TestGetToolPatternsConfig_Empty(t *testing.T) { assert.NoError(t, err) assert.Empty(t, patterns) } + +func TestGetRequest_AuthHeaders(t *testing.T) { + tests := []struct { + name string + flags domain.InitFlags + expectedApiToken string + expectedProjectToken string + }{ + {"account token", domain.InitFlags{ApiToken: "api"}, "api", ""}, + {"repository token", domain.InitFlags{ProjectToken: "project"}, "", "project"}, + {"both tokens prefer account token", domain.InitFlags{ApiToken: "api", ProjectToken: "project"}, "api", ""}, + {"no token", domain.InitFlags{}, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.expectedApiToken, r.Header.Get("api-token")) + assert.Equal(t, tt.expectedProjectToken, r.Header.Get("project-token")) + w.Write([]byte(`{"data": "ok"}`)) + })) + defer ts.Close() + + _, err := getRequest(ts.URL, tt.flags) + assert.NoError(t, err) + }) + } +} diff --git a/domain/initFlags.go b/domain/initFlags.go index 1dd512ca..69d6c372 100644 --- a/domain/initFlags.go +++ b/domain/initFlags.go @@ -3,7 +3,19 @@ package domain // InitFlags represents the flags for the init command type InitFlags struct { ApiToken string + ProjectToken string Provider string Organization string Repository string } + +// HasRemoteToken reports whether a token allowing remote configuration download was provided. +func (f InitFlags) HasRemoteToken() bool { + return f.ApiToken != "" || f.ProjectToken != "" +} + +// HasRepositoryCoordinates reports whether the repository every remote read is scoped to +// was fully identified. +func (f InitFlags) HasRepositoryCoordinates() bool { + return f.Provider != "" && f.Organization != "" && f.Repository != "" +} diff --git a/domain/initFlags_test.go b/domain/initFlags_test.go new file mode 100644 index 00000000..4fba238e --- /dev/null +++ b/domain/initFlags_test.go @@ -0,0 +1,48 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHasRemoteToken(t *testing.T) { + tests := []struct { + name string + flags InitFlags + expected bool + }{ + {"no token", InitFlags{}, false}, + {"account token", InitFlags{ApiToken: "api"}, true}, + {"repository token", InitFlags{ProjectToken: "project"}, true}, + {"both tokens", InitFlags{ApiToken: "api", ProjectToken: "project"}, true}, + {"coordinates without token", InitFlags{Provider: "gh", Organization: "org", Repository: "repo"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.flags.HasRemoteToken()) + }) + } +} + +func TestHasRepositoryCoordinates(t *testing.T) { + tests := []struct { + name string + flags InitFlags + expected bool + }{ + {"complete", InitFlags{Provider: "gh", Organization: "org", Repository: "repo"}, true}, + {"empty", InitFlags{}, false}, + {"missing provider", InitFlags{Organization: "org", Repository: "repo"}, false}, + {"missing organization", InitFlags{Provider: "gh", Repository: "repo"}, false}, + {"missing repository", InitFlags{Provider: "gh", Organization: "org"}, false}, + {"token alone is not coordinates", InitFlags{ProjectToken: "tok"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.flags.HasRepositoryCoordinates()) + }) + } +}