diff --git a/cmd/ld-find-code-refs/main.go b/cmd/ld-find-code-refs/main.go index 86926716f..9366461be 100644 --- a/cmd/ld-find-code-refs/main.go +++ b/cmd/ld-find-code-refs/main.go @@ -62,6 +62,47 @@ var extinctions = &cobra.Command{ }, } +var aliasCmd = &cobra.Command{ + Use: "alias", + Example: "ld-find-code-refs alias --flag-key my-flag", + Short: "Generate aliases for feature flags", + RunE: func(cmd *cobra.Command, args []string) error { + err := o.InitYAMLForAlias() + if err != nil { + return err + } + + opts, err := o.GetOptions() + if err != nil { + return err + } + + // Get the flag-key value from the command + flagKey, _ := cmd.Flags().GetString("flag-key") + + // Create AliasOptions from the global options + aliasOpts := &o.AliasOptions{ + Dir: opts.Dir, + ProjKey: opts.ProjKey, + Projects: opts.Projects, + AccessToken: opts.AccessToken, + BaseUri: opts.BaseUri, + FlagKey: flagKey, + Debug: opts.Debug, + UserAgent: opts.UserAgent, + } + + // Validate the alias options (not the global options) + err = aliasOpts.ValidateAliasOptions() + if err != nil { + return err + } + + log.Init(opts.Debug) + return coderefs.GenerateAliases(*aliasOpts) + }, +} + var cmd = &cobra.Command{ Use: "ld-find-code-refs", RunE: func(cmd *cobra.Command, args []string) error { @@ -90,8 +131,13 @@ func main() { if err := o.Init(cmd.PersistentFlags()); err != nil { panic(err) } + + // Add the flag-key flag to the alias command + aliasCmd.Flags().String("flag-key", "", "Generate aliases for a specific flag key (local mode)") + cmd.AddCommand(prune) cmd.AddCommand(extinctions) + cmd.AddCommand(aliasCmd) if err := cmd.Execute(); err != nil { os.Exit(1) diff --git a/coderefs/coderefs.go b/coderefs/coderefs.go index 1374819d7..4fcd62d6f 100644 --- a/coderefs/coderefs.go +++ b/coderefs/coderefs.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/launchdarkly/ld-find-code-refs/v2/aliases" "github.com/launchdarkly/ld-find-code-refs/v2/internal/git" "github.com/launchdarkly/ld-find-code-refs/v2/internal/helpers" "github.com/launchdarkly/ld-find-code-refs/v2/internal/ld" @@ -206,3 +207,100 @@ func runExtinctions(opts options.Options, matcher search.Matcher, branch ld.Bran } } } + +// GenerateAliases generates aliases for flags based on the provided options +func GenerateAliases(opts options.AliasOptions) error { + absPath, err := validation.NormalizeAndValidatePath(opts.Dir) + if err != nil { + return fmt.Errorf("could not validate directory option: %s", err) + } + + log.Info.Printf("absolute directory path: %s", absPath) + + var flagKeys []string + + if opts.FlagKey != "" { + // Local Mode: Generate aliases for a single flag key + log.Info.Printf("generating aliases for flag key: %s", opts.FlagKey) + flagKeys = []string{opts.FlagKey} + } else { + // API-Connected Mode: Fetch all flags from the API + log.Info.Printf("fetching all flags from LaunchDarkly API") + + ldApi := ld.InitApiClient(ld.ApiOptions{ + ApiKey: opts.AccessToken, + BaseUri: opts.BaseUri, + UserAgent: helpers.GetUserAgent(opts.UserAgent), + }) + + // Determine project key + projKey := opts.ProjKey + if projKey == "" && len(opts.Projects) > 0 { + projKey = opts.Projects[0].Key + } + + flags, err := ldApi.GetFlagKeyList(projKey, false) // Include archived flags + if err != nil { + return fmt.Errorf("could not retrieve flag keys from LaunchDarkly for project `%s`: %w", projKey, err) + } + flagKeys = flags + log.Info.Printf("retrieved %d flags from LaunchDarkly", len(flagKeys)) + } + + // Load alias configuration from .launchdarkly/coderefs.yaml + // We need to get the full options to access the aliases + fullOpts, err := options.GetOptions() + if err != nil { + return fmt.Errorf("error loading configuration: %w", err) + } + + var aliasConfigs []options.Alias + + // Check if we have project-specific aliases + if len(fullOpts.Projects) > 0 { + for _, project := range fullOpts.Projects { + aliasConfigs = append(aliasConfigs, project.Aliases...) + } + } else { + // Use global aliases + aliasConfigs = fullOpts.Aliases + } + + if len(aliasConfigs) == 0 { + log.Warning.Printf("no alias configurations found in .launchdarkly/coderefs.yaml") + return nil + } + + // Generate aliases + aliasMap, err := aliases.GenerateAliases(flagKeys, aliasConfigs, absPath) + if err != nil { + return fmt.Errorf("error generating aliases: %w", err) + } + + // Print the results + if opts.FlagKey != "" { + // Local mode: print aliases for the single flag + if aliases, exists := aliasMap[opts.FlagKey]; exists && len(aliases) > 0 { + fmt.Printf("Aliases for flag '%s':\n", opts.FlagKey) + for _, alias := range aliases { + fmt.Printf(" - %s\n", alias) + } + } else { + fmt.Printf("No aliases found for flag '%s'\n", opts.FlagKey) + } + } else { + // API-connected mode: print aliases for all flags + fmt.Printf("Generated aliases for %d flags:\n\n", len(flagKeys)) + for _, flagKey := range flagKeys { + if aliases, exists := aliasMap[flagKey]; exists && len(aliases) > 0 { + fmt.Printf("Flag '%s':\n", flagKey) + for _, alias := range aliases { + fmt.Printf(" - %s\n", alias) + } + fmt.Println() + } + } + } + + return nil +} diff --git a/coderefs/coderefs_test.go b/coderefs/coderefs_test.go index f50884a85..e12b039ba 100644 --- a/coderefs/coderefs_test.go +++ b/coderefs/coderefs_test.go @@ -2,12 +2,15 @@ package coderefs import ( "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/launchdarkly/ld-find-code-refs/v2/internal/ld" "github.com/launchdarkly/ld-find-code-refs/v2/internal/log" + "github.com/launchdarkly/ld-find-code-refs/v2/options" ) func init() { @@ -54,3 +57,46 @@ func Test_calculateStaleBranches(t *testing.T) { }) } } + +func Test_GenerateAliases(t *testing.T) { + // Create a temporary directory for testing + tmpDir, err := os.MkdirTemp("", "alias-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create .launchdarkly directory + ldDir := filepath.Join(tmpDir, ".launchdarkly") + err = os.MkdirAll(ldDir, 0755) + require.NoError(t, err) + + // Create a simple coderefs.yaml file with alias configuration + configContent := `aliases: + - type: camelcase + - type: snakecase +` + configPath := filepath.Join(ldDir, "coderefs.yaml") + err = os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + + t.Run("local mode with flag key", func(t *testing.T) { + opts := options.AliasOptions{ + Dir: tmpDir, + FlagKey: "test-flag", + } + + // This should not return an error for local mode + err := GenerateAliases(opts) + require.NoError(t, err) + }) + + t.Run("validation error for invalid dir", func(t *testing.T) { + opts := options.AliasOptions{ + Dir: "/nonexistent/directory/path", + FlagKey: "test-flag", + } + + err := GenerateAliases(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not validate directory option") + }) +} diff --git a/options/alias_options.go b/options/alias_options.go new file mode 100644 index 000000000..745236c3d --- /dev/null +++ b/options/alias_options.go @@ -0,0 +1,52 @@ +package options + +import ( + "fmt" + "os" +) + +// AliasOptions contains the options specific to the alias command +type AliasOptions struct { + Dir string `mapstructure:"dir"` + ProjKey string `mapstructure:"projkey"` + Projects []Project `mapstructure:"projects"` + AccessToken string `mapstructure:"accessToken"` + BaseUri string `mapstructure:"baseUri"` + FlagKey string `mapstructure:"flagKey"` + Debug bool `mapstructure:"debug"` + UserAgent string `mapstructure:"userAgent"` +} + +// ValidateAliasOptions validates the alias options based on the mode of operation +func (o *AliasOptions) ValidateAliasOptions() error { + missingRequiredOptions := []string{} + + // Default Dir to current working directory if not provided + if o.Dir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("could not get current working directory: %w", err) + } + o.Dir = cwd + } + + // If FlagKey is provided (Local Mode), AccessToken and ProjKey/Projects are not required + if o.FlagKey != "" { + // Local mode - dir is defaulted above if needed + return nil + } + + // If FlagKey is not provided (API-Connected Mode), AccessToken and ProjKey/Projects are required + if o.AccessToken == "" { + missingRequiredOptions = append(missingRequiredOptions, "accessToken") + } + if o.ProjKey == "" && len(o.Projects) == 0 { + missingRequiredOptions = append(missingRequiredOptions, "projKey/projects") + } + + if len(missingRequiredOptions) > 0 { + return fmt.Errorf("missing required option(s): %v", missingRequiredOptions) + } + + return nil +} \ No newline at end of file diff --git a/options/alias_options_test.go b/options/alias_options_test.go new file mode 100644 index 000000000..b95d2d5f6 --- /dev/null +++ b/options/alias_options_test.go @@ -0,0 +1,74 @@ +package options + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AliasOptions_ValidateAliasOptions(t *testing.T) { + t.Run("local mode validation - only dir required", func(t *testing.T) { + opts := &AliasOptions{ + Dir: "/some/dir", + FlagKey: "test-flag", + } + + err := opts.ValidateAliasOptions() + require.NoError(t, err) + }) + + t.Run("local mode validation - missing dir defaults to cwd", func(t *testing.T) { + opts := &AliasOptions{ + FlagKey: "test-flag", + } + + err := opts.ValidateAliasOptions() + require.NoError(t, err) + assert.NotEmpty(t, opts.Dir) // Dir should be set to current working directory + }) + + t.Run("API mode validation - all required fields present", func(t *testing.T) { + opts := &AliasOptions{ + Dir: "/some/dir", + AccessToken: "token", + ProjKey: "project", + } + + err := opts.ValidateAliasOptions() + require.NoError(t, err) + }) + + t.Run("API mode validation - missing access token", func(t *testing.T) { + opts := &AliasOptions{ + Dir: "/some/dir", + ProjKey: "project", + } + + err := opts.ValidateAliasOptions() + require.Error(t, err) + assert.Contains(t, err.Error(), "accessToken") + }) + + t.Run("API mode validation - missing project key", func(t *testing.T) { + opts := &AliasOptions{ + Dir: "/some/dir", + AccessToken: "token", + } + + err := opts.ValidateAliasOptions() + require.Error(t, err) + assert.Contains(t, err.Error(), "projKey/projects") + }) + + t.Run("API mode validation - with projects instead of projKey", func(t *testing.T) { + opts := &AliasOptions{ + Dir: "/some/dir", + AccessToken: "token", + Projects: []Project{{Key: "project1"}}, + } + + err := opts.ValidateAliasOptions() + require.NoError(t, err) + }) +} \ No newline at end of file diff --git a/options/options.go b/options/options.go index 0a9a266e9..675f5cf1e 100644 --- a/options/options.go +++ b/options/options.go @@ -123,6 +123,34 @@ func InitYAML() error { return nil } +// InitYAMLForAlias initializes YAML configuration without requiring accessToken and dir preconditions +func InitYAMLForAlias() error { + dir := viper.GetString("dir") + if dir == "" { + // Default to current working directory + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("could not get current working directory: %w", err) + } + dir = cwd + } + + absPath, err := validation.NormalizeAndValidatePath(dir) + if err != nil { + return err + } + subdirectoryPath := viper.GetString("subdirectory") + viper.SetConfigName("coderefs") + viper.SetConfigType("yaml") + configPath := filepath.Join(absPath, subdirectoryPath, ".launchdarkly") + viper.AddConfigPath(configPath) + err = viper.ReadInConfig() + if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { + return err + } + return nil +} + // validatePreconditions ensures required flags have been set func validateYAMLPreconditions() error { token := viper.GetString("accessToken")