diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 112d61b..dbd762d 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -7,7 +7,7 @@ on: - main pull_request: branches: - - main + - "**" paths-ignore: - "docs/**" @@ -35,7 +35,7 @@ jobs: go install github.com/fzipp/gocyclo/cmd/gocyclo@latest - name: Run linters - run: pre-commit run --all-files + run: pre-commit run --all-files --verbose test: name: Run unit tests diff --git a/.gitignore b/.gitignore index e57d142..2576645 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ devops # Go workspace go.work go.work.sum + +# CLI outputs +.devops/ diff --git a/cli/config/context_test.go b/cli/config/context_test.go index e02e441..8520801 100644 --- a/cli/config/context_test.go +++ b/cli/config/context_test.go @@ -19,7 +19,7 @@ func TestWithContext(t *testing.T) { { name: "complete project definition", definition: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Description: "A test project", Version: "1.0.0", RepoUrl: "https://github.com/test/project", @@ -38,7 +38,7 @@ func TestWithContext(t *testing.T) { { name: "project with nested operations", definition: ProjectDefinition{ - Name: "complex-project", + ID: "complex-project", Codebase: Codebase{ Language: "python", Install: Operation{ @@ -86,14 +86,14 @@ func TestFromContext(t *testing.T) { name: "context with valid project definition", setupCtx: func() context.Context { definition := ProjectDefinition{ - Name: "test-project", + ID: "test-project", Version: "1.0.0", } return WithContext(context.Background(), definition) }, expectPanic: false, expected: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Version: "1.0.0", }, }, @@ -124,7 +124,7 @@ func TestFromContext(t *testing.T) { name: "nested context with project definition", setupCtx: func() context.Context { definition := ProjectDefinition{ - Name: "nested-project", + ID: "nested-project", Codebase: Codebase{ Language: "rust", }, @@ -136,7 +136,7 @@ func TestFromContext(t *testing.T) { }, expectPanic: false, expected: ProjectDefinition{ - Name: "nested-project", + ID: "nested-project", Codebase: Codebase{ Language: "rust", }, @@ -172,10 +172,10 @@ func TestWithContext_Chaining(t *testing.T) { // Test that WithContext can be chained ctx := context.Background() - definition1 := ProjectDefinition{Name: "project1"} + definition1 := ProjectDefinition{ID: "project1"} ctx1 := WithContext(ctx, definition1) - definition2 := ProjectDefinition{Name: "project2"} + definition2 := ProjectDefinition{ID: "project2"} ctx2 := WithContext(ctx1, definition2) // The last definition should be retrieved @@ -202,6 +202,6 @@ func TestFromContext_TypeAssertion(t *testing.T) { assert.Panics(t, func() { FromContext(ctxWithStruct) }) // Only the correct type should work - ctxWithProject := WithContext(ctx, ProjectDefinition{Name: "test"}) + ctxWithProject := WithContext(ctx, ProjectDefinition{ID: "test"}) assert.NotPanics(t, func() { FromContext(ctxWithProject) }) } diff --git a/cli/config/models.go b/cli/config/models.go index 6392307..76a5653 100644 --- a/cli/config/models.go +++ b/cli/config/models.go @@ -2,10 +2,13 @@ package config import ( "context" + "encoding/json" "fmt" "io" "os" + "regexp" "time" + "unicode" "github.com/jgfranco17/dev-tooling-go/logging" "github.com/jgfranco17/devops/cli/executor" @@ -20,10 +23,18 @@ type ShellExecutor interface { AddEnv(env []string) } +type Manifest struct { + ID string `json:"id"` + Version string `json:"version"` + RepoUrl string `json:"repo_url,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` +} + type ProjectDefinition struct { - Name string `yaml:"name"` - Description string `yaml:"description,omitempty"` + ID string `yaml:"id"` + Name string `yaml:"name,omitempty"` Version string `yaml:"version"` + Description string `yaml:"description,omitempty"` RepoUrl string `yaml:"repo_url"` Codebase Codebase `yaml:"codebase"` } @@ -37,6 +48,27 @@ func (d *ProjectDefinition) ValidateTo(ctx context.Context, w io.Writer) error { fixes := []string{} suggestions := []string{} + if d.ID == "" { + outputs.PrintColoredMessageTo(w, "red", "[✘] ID is required") + fixes = append(fixes, "Set an ID for the project") + } else if err := validateProjectName(d.ID); err != nil { + outputs.PrintColoredMessageTo(w, "red", "[✘] Invalid ID: %s", err.Error()) + fixes = append(fixes, "Use a valid project ID (alphanumeric/dashes/underscores, starts with letter, no whitespace, under 30 chars)") + } else { + outputs.PrintColoredMessageTo(w, "green", "[✔] ID: %s", d.ID) + } + + if d.Name != "" { + outputs.PrintColoredMessageTo(w, "green", "[✔] Name: %s", d.Name) + } + + if d.RepoUrl == "" { + outputs.PrintColoredMessageTo(w, "red", "[✘] Repository URL is required") + fixes = append(fixes, "Set a repository URL for the project") + } else { + outputs.PrintColoredMessageTo(w, "green", "[✔] Repository URL: %s", d.RepoUrl) + } + if d.Codebase.Language == "" { outputs.PrintColoredMessageTo(w, "red", "[✘] Language is required") fixes = append(fixes, "Set a language in the codebase") @@ -48,7 +80,6 @@ func (d *ProjectDefinition) ValidateTo(ctx context.Context, w io.Writer) error { outputs.PrintColoredMessageTo(w, "green", "[✔] Dependencies: %s", d.Codebase.Dependencies) } else { outputs.PrintColoredMessageTo(w, "yellow", "[~] No dependencies defined") - suggestions = append(suggestions, "Set dependencies in the codebase") } if d.Codebase.Install.Steps != nil { @@ -130,6 +161,19 @@ func Load(r io.Reader) (*ProjectDefinition, error) { return &cfg, nil } +func (d *ProjectDefinition) GenerateManifest() ([]byte, error) { + manifest := Manifest{ + ID: d.ID, + Version: d.Version, + Dependencies: d.Codebase.Dependencies, + } + data, err := json.MarshalIndent(&manifest, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to write manifest: %w", err) + } + return data, nil +} + type Codebase struct { Language string `yaml:"language"` Dependencies []string `yaml:"dependencies,omitempty"` @@ -182,3 +226,39 @@ func (op *Operation) Run(ctx context.Context, executor ShellExecutor) error { } return nil } + +// validateProjectName validates that the project ID meets the specified criteria: +// - Contains only alphanumeric characters, dashes, and underscores +// - Starts with a letter +// - Contains no whitespace +// - Is under 30 characters +func validateProjectName(id string) error { + if len(id) >= 30 { + return fmt.Errorf("ID must be under 30 characters (current: %d)", len(id)) + } + + if id == "" { + return fmt.Errorf("ID cannot be empty") + } + + // Check if first character is a letter + firstRune := rune(id[0]) + if !unicode.IsLetter(firstRune) { + return fmt.Errorf("ID must start with a letter") + } + + // Check for whitespace + for _, r := range id { + if unicode.IsSpace(r) { + return fmt.Errorf("ID cannot contain whitespace") + } + } + + // Check that all characters are alphanumeric, dash, or underscore + validNamePattern := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`) + if !validNamePattern.MatchString(id) { + return fmt.Errorf("ID can only contain letters, numbers, dashes, and underscores") + } + + return nil +} diff --git a/cli/config/models_test.go b/cli/config/models_test.go index 89dfee5..859a4f0 100644 --- a/cli/config/models_test.go +++ b/cli/config/models_test.go @@ -40,7 +40,7 @@ func TestProjectDefinition_Test(t *testing.T) { { name: "successful test with steps", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Test: Operation{ Steps: []string{"go test ./...", "go test -race ./..."}, @@ -56,7 +56,7 @@ func TestProjectDefinition_Test(t *testing.T) { { name: "test with no steps should warn", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Test: Operation{ Steps: []string{}, @@ -71,7 +71,7 @@ func TestProjectDefinition_Test(t *testing.T) { { name: "test failure should return error", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Test: Operation{ Steps: []string{"go test ./..."}, @@ -87,7 +87,7 @@ func TestProjectDefinition_Test(t *testing.T) { { name: "test with environment variables", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Test: Operation{ Env: map[string]string{ @@ -114,7 +114,7 @@ func TestProjectDefinition_Test(t *testing.T) { { name: "test with fail_fast enabled", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Test: Operation{ FailFast: true, @@ -163,7 +163,7 @@ func TestProjectDefinition_Build(t *testing.T) { { name: "successful build with steps", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Build: Operation{ Steps: []string{"echo hello", "echo world"}, @@ -179,7 +179,7 @@ func TestProjectDefinition_Build(t *testing.T) { { name: "build with no steps should warn", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Build: Operation{ Steps: []string{}, @@ -192,7 +192,7 @@ func TestProjectDefinition_Build(t *testing.T) { { name: "build failure should return error", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: Codebase{ Build: Operation{ Steps: []string{"false"}, @@ -237,8 +237,8 @@ func TestLoad(t *testing.T) { }{ { name: "valid YAML", - yamlContent: ` -name: test-project + yamlContent: `--- +id: test-project description: A test project version: 1.0.0 repo_url: https://github.com/test/project @@ -254,7 +254,7 @@ codebase: `, expectError: false, validate: func(t *testing.T, cfg *ProjectDefinition) { - assert.Equal(t, "test-project", cfg.Name) + assert.Equal(t, "test-project", cfg.ID) assert.Equal(t, "A test project", cfg.Description) assert.Equal(t, "1.0.0", cfg.Version) assert.Equal(t, "https://github.com/test/project", cfg.RepoUrl) @@ -267,7 +267,7 @@ codebase: { name: "invalid YAML", yamlContent: ` -name: test-project +id: test-project description: A test project version: 1.0.0 repo_url: https://github.com/test/project @@ -451,7 +451,7 @@ func TestProjectDefinition_Validate(t *testing.T) { { name: "complete valid configuration", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", Description: "A test project", Version: "1.0.0", RepoUrl: "https://github.com/test/project", @@ -470,17 +470,18 @@ func TestProjectDefinition_Validate(t *testing.T) { }, }, outputChecks: []string{ - "[✔] Language: go", - "[✔] Dependencies:", - "[✔] Install steps (1)", - "[✔] Test steps (1)", - "[✔] Build steps (1)", + "Language: go", + "Dependencies:", + "Install steps (1)", + "Test steps (1)", + "Build steps (1)", }, }, { name: "missing language should fail", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Test: Operation{ Steps: []string{"go test ./..."}, @@ -492,7 +493,7 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectedError: "found 1 required fixes", outputChecks: []string{ - "[✘] Language is required", + "Language is required", "Fixes:", "Set a language in the codebase", }, @@ -500,7 +501,8 @@ func TestProjectDefinition_Validate(t *testing.T) { { name: "empty language should fail", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "", Test: Operation{ @@ -513,13 +515,14 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectedError: "found 1 required fixes", outputChecks: []string{ - "[✘] Language is required", + "Language is required", }, }, { name: "missing dependencies should warn but pass", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Test: Operation{ @@ -532,18 +535,17 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[~] No dependencies defined", - "[✔] Test steps (1)", - "[✔] Build steps (1)", - "Suggestions:", - "Set dependencies in the codebase", + "Language: go", + "No dependencies defined", + "Test steps (1)", + "Build steps (1)", }, }, { name: "missing test steps should warn but pass", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: []string{"github.com/stretchr/testify"}, @@ -554,10 +556,10 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[✔] Dependencies:", - "[~] No test steps defined", - "[✔] Build steps (1)", + "Language: go", + "Dependencies:", + "No test steps defined", + "Build steps (1)", "Suggestions:", "Set test steps in the codebase", }, @@ -565,7 +567,8 @@ func TestProjectDefinition_Validate(t *testing.T) { { name: "missing build steps should warn but pass", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: []string{"github.com/stretchr/testify"}, @@ -576,10 +579,10 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[✔] Dependencies:", - "[✔] Test steps (1)", - "[~] No build steps defined", + "Language: go", + "Dependencies:", + "Test steps (1)", + "No build steps defined", "Suggestions:", "Set build steps in the codebase", }, @@ -587,7 +590,8 @@ func TestProjectDefinition_Validate(t *testing.T) { { name: "missing install steps should not warn (optional)", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: []string{"github.com/stretchr/testify"}, @@ -600,45 +604,46 @@ func TestProjectDefinition_Validate(t *testing.T) { }, }, outputChecks: []string{ - "[✔] Language: go", - "[✔] Dependencies:", - "[✔] Test steps (1)", - "[✔] Build steps (1)", + "Language: go", + "Dependencies:", + "Test steps (1)", + "Build steps (1)", }, }, { name: "minimal valid configuration with only language", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", }, }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[~] No dependencies defined", - "[~] No test steps defined", - "[~] No build steps defined", + "Language: go", + "No dependencies defined", + "No test steps defined", + "No build steps defined", "Suggestions:", }, }, { name: "multiple warnings should be grouped", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", }, }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[~] No dependencies defined", - "[~] No test steps defined", - "[~] No build steps defined", + "Language: go", + "No dependencies defined", + "No test steps defined", + "No build steps defined", "Suggestions:", - "Set dependencies in the codebase", "Set test steps in the codebase", "Set build steps in the codebase", }, @@ -646,7 +651,8 @@ func TestProjectDefinition_Validate(t *testing.T) { { name: "nil dependencies should not cause issues", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: nil, @@ -660,16 +666,17 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[~] No dependencies defined", - "[✔] Test steps (1)", - "[✔] Build steps (1)", + "Language: go", + "No dependencies defined", + "Test steps (1)", + "Build steps (1)", }, }, { name: "nil steps should not cause issues", project: ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: []string{"github.com/stretchr/testify"}, @@ -686,10 +693,10 @@ func TestProjectDefinition_Validate(t *testing.T) { }, expectWarnings: true, outputChecks: []string{ - "[✔] Language: go", - "[✔] Dependencies:", - "[~] No test steps defined", - "[~] No build steps defined", + "Language: go", + "Dependencies:", + "No test steps defined", + "No build steps defined", }, }, } @@ -729,6 +736,213 @@ func TestProjectDefinition_Validate(t *testing.T) { } } +func TestValidateProjectName(t *testing.T) { + tests := []struct { + name string + projectName string + expectError bool + errorMsg string + }{ + { + name: "valid simple name", + projectName: "test", + expectError: false, + }, + { + name: "valid name with underscores", + projectName: "test_project", + expectError: false, + }, + { + name: "valid name with dashes", + projectName: "test-project", + expectError: false, + }, + { + name: "valid name with numbers", + projectName: "test123", + expectError: false, + }, + { + name: "valid mixed alphanumeric with underscores and dashes", + projectName: "my_test-project2", + expectError: false, + }, + { + name: "valid name at max length (29 chars)", + projectName: "thisIsAVeryLongProjectName29", + expectError: false, + }, + { + name: "valid single character", + projectName: "a", + expectError: false, + }, + { + name: "valid uppercase start", + projectName: "TestProject", + expectError: false, + }, + { + name: "empty name", + projectName: "", + expectError: true, + errorMsg: "ID cannot be empty", + }, + { + name: "name too long", + projectName: "thisIsAnExtremelyLongProjectNameThatExceedsThirtyCharacters", + expectError: true, + errorMsg: "ID must be under 30 characters", + }, + { + name: "starts with number", + projectName: "1test", + expectError: true, + errorMsg: "ID must start with a letter", + }, + { + name: "starts with dash", + projectName: "-test", + expectError: true, + errorMsg: "ID must start with a letter", + }, + { + name: "contains space", + projectName: "test project", + expectError: true, + errorMsg: "ID cannot contain whitespace", + }, + { + name: "leading space", + projectName: " test", + expectError: true, + errorMsg: "ID must start with a letter", // Space character is not a letter + }, + { + name: "trailing space", + projectName: "test ", + expectError: true, + errorMsg: "ID cannot contain whitespace", + }, + + // Invalid names - invalid characters + { + name: "contains special characters", + projectName: "test@project", + expectError: true, + errorMsg: "ID can only contain letters, numbers, dashes, and underscores", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProjectName(tt.projectName) + + if tt.expectError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestProjectDefinition_ValidateNameIntegration(t *testing.T) { + tests := []struct { + name string + projectName string + expectError bool + outputContains []string + }{ + { + name: "valid name shows checkmark", + projectName: "validProject", + expectError: false, + outputContains: []string{ + "ID: validProject", + }, + }, + { + name: "empty name shows error", + projectName: "", + expectError: true, + outputContains: []string{ + "ID is required", + "Set an ID for the project", + }, + }, + { + name: "invalid name shows validation error", + projectName: "123invalid", + expectError: true, + outputContains: []string{ + "Invalid ID: ID must start with a letter", + "Use a valid project ID (alphanumeric/dashes/underscores, starts with letter, no whitespace, under 30 chars)", + }, + }, + { + name: "name with spaces shows whitespace error", + projectName: "invalid name", + expectError: true, + outputContains: []string{ + "Invalid ID: ID cannot contain whitespace", + "Use a valid project ID (alphanumeric/dashes/underscores, starts with letter, no whitespace, under 30 chars)", + }, + }, + { + name: "name too long shows length error", + projectName: "thisNameIsWayTooLongAndExceedsThirtyCharacterLimit", + expectError: true, + outputContains: []string{ + "Invalid ID: ID must be under 30 characters", + "Use a valid project ID (alphanumeric/dashes/underscores, starts with letter, no whitespace, under 30 chars)", + }, + }, + { + name: "name with special characters shows character error", + projectName: "invalid@name", + expectError: true, + outputContains: []string{ + "Invalid ID: ID can only contain letters, numbers, dashes, and underscores", + "Use a valid project ID (alphanumeric/dashes/underscores, starts with letter, no whitespace, under 30 chars)", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + logger := logging.New(os.Stderr, logrus.InfoLevel) + ctx := logging.WithContext(context.Background(), logger) + + project := ProjectDefinition{ + ID: tt.projectName, + RepoUrl: "https://github.com/test/project", + Codebase: Codebase{ + Language: "go", // Valid language to focus on name validation + }, + } + + err := project.ValidateTo(ctx, &buf) + output := buf.String() + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + for _, expectedOutput := range tt.outputContains { + assert.Contains(t, output, expectedOutput, "Expected output to contain: %s", expectedOutput) + } + }) + } +} + func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { t.Run("validation with empty project definition", func(t *testing.T) { var buf bytes.Buffer @@ -741,8 +955,10 @@ func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { output := buf.String() assert.Error(t, err) - assert.Contains(t, err.Error(), "found 1 required fixes") - assert.Contains(t, output, "[✘] Language is required") + assert.Contains(t, err.Error(), "found 3 required fixes") // ID, RepoUrl, and Language + assert.Contains(t, output, "ID is required") + assert.Contains(t, output, "Repository URL is required") + assert.Contains(t, output, "Language is required") }) t.Run("validation with whitespace language should pass", func(t *testing.T) { @@ -751,7 +967,8 @@ func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { ctx := logging.WithContext(context.Background(), logger) project := ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: " ", // whitespace only }, @@ -761,7 +978,8 @@ func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { output := buf.String() assert.NoError(t, err) - assert.Contains(t, output, "[✔] Language: ") // Should show the whitespace + assert.Contains(t, output, "ID: test-project") + assert.Contains(t, output, "Language: ") // Should show the whitespace }) t.Run("validation with complex dependencies", func(t *testing.T) { @@ -770,7 +988,8 @@ func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { ctx := logging.WithContext(context.Background(), logger) project := ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: Codebase{ Language: "go", Dependencies: []string{ @@ -785,7 +1004,8 @@ func TestProjectDefinition_Validate_EdgeCases(t *testing.T) { output := buf.String() assert.NoError(t, err) - assert.Contains(t, output, "[✔] Language: go") - assert.Contains(t, output, "[✔] Dependencies:") + assert.Contains(t, output, "ID: test-project") + assert.Contains(t, output, "Language: go") + assert.Contains(t, output, "Dependencies:") }) } diff --git a/cli/core/commands.go b/cli/core/commands.go index 5c7812a..8a0954f 100644 --- a/cli/core/commands.go +++ b/cli/core/commands.go @@ -4,9 +4,12 @@ import ( "context" "fmt" "os" + "path/filepath" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/jgfranco17/dev-tooling-go/logging" "github.com/jgfranco17/devops/cli/config" "github.com/jgfranco17/devops/cli/executor" "github.com/jgfranco17/devops/internal/doc" @@ -79,6 +82,44 @@ func GetDoctorCommand(shellExecutor BashExecutor) *cobra.Command { return cmd } +func GetManifestCommand() *cobra.Command { + var outputFile string + cmd := &cobra.Command{ + Use: "manifest", + Short: "Generate a manifest file", + Long: "Generate a manifest file for the project.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + cfg := config.FromContext(ctx) + logger := logging.FromContext(ctx) + + manifest, err := cfg.GenerateManifest() + if err != nil { + return fmt.Errorf("failed to generate manifest: %w", err) + } + logger.Debug("Generated manifest content") + + dir := filepath.Dir(outputFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + if err := os.WriteFile(outputFile, manifest, 0644); err != nil { + return fmt.Errorf("failed to write manifest to file %s: %w", outputFile, err) + } + + logger.WithFields(logrus.Fields{ + "path": outputFile, + }).Info("Manifest generated successfully") + return nil + }, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().StringVarP(&outputFile, "output", "o", ".devops/manifest.json", "Output file path") + return cmd +} + func GetDocsCommand() *cobra.Command { var outputFile string cmd := &cobra.Command{ diff --git a/cli/core/commands_test.go b/cli/core/commands_test.go index 04637e0..7e08813 100644 --- a/cli/core/commands_test.go +++ b/cli/core/commands_test.go @@ -66,7 +66,7 @@ func TestGetTestCommand(t *testing.T) { name: "successful test execution", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: config.Codebase{ Test: config.Operation{ Steps: []string{"go test ./...", "go test -race ./..."}, @@ -84,7 +84,7 @@ func TestGetTestCommand(t *testing.T) { name: "test with no steps should warn", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: config.Codebase{ Test: config.Operation{ Steps: []string{}, @@ -101,7 +101,7 @@ func TestGetTestCommand(t *testing.T) { name: "test failure should return error", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: config.Codebase{ Test: config.Operation{ Steps: []string{"go test ./..."}, @@ -119,7 +119,7 @@ func TestGetTestCommand(t *testing.T) { name: "test with environment variables", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: config.Codebase{ Test: config.Operation{ Env: map[string]string{ @@ -148,7 +148,7 @@ func TestGetTestCommand(t *testing.T) { name: "test with fail_fast enabled", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Codebase: config.Codebase{ Test: config.Operation{ FailFast: true, @@ -228,7 +228,7 @@ func TestGetBuildCommand(t *testing.T) { name: "successful build execution", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "build-project", + ID: "build-project", Codebase: config.Codebase{ Build: config.Operation{ Steps: []string{"go build ./...", "go build -o ./bin/app ."}, @@ -246,7 +246,7 @@ func TestGetBuildCommand(t *testing.T) { name: "build with no steps should warn", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "build-project", + ID: "build-project", Codebase: config.Codebase{ Build: config.Operation{ Steps: []string{}, @@ -263,7 +263,7 @@ func TestGetBuildCommand(t *testing.T) { name: "build failure should return error", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "build-project", + ID: "build-project", Codebase: config.Codebase{ Build: config.Operation{ Steps: []string{"go build ./..."}, @@ -281,7 +281,7 @@ func TestGetBuildCommand(t *testing.T) { name: "build with environment variables", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "build-project", + ID: "build-project", Codebase: config.Codebase{ Build: config.Operation{ Env: map[string]string{ @@ -310,7 +310,7 @@ func TestGetBuildCommand(t *testing.T) { name: "build with fail_fast enabled", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "build-project", + ID: "build-project", Codebase: config.Codebase{ Build: config.Operation{ FailFast: true, @@ -405,7 +405,7 @@ func TestGetBuildCommand_Integration(t *testing.T) { logger := logging.New(os.Stderr, logrus.InfoLevel) ctx := logging.WithContext(context.Background(), logger) projectDef := config.ProjectDefinition{ - Name: "integration-build", + ID: "integration-build", Codebase: config.Codebase{ Build: config.Operation{ Steps: []string{"go clean -testcache", "go test -cover ./...", "go build -ldflags=\"-s -w\" -o ./devops .", "chmod +x ./devops"}, @@ -437,7 +437,7 @@ func TestGetTestCommand_Integration(t *testing.T) { logger := logging.New(os.Stderr, logrus.InfoLevel) ctx := logging.WithContext(context.Background(), logger) projectDef := config.ProjectDefinition{ - Name: "integration-test", + ID: "integration-test", Codebase: config.Codebase{ Test: config.Operation{ Steps: []string{"go test ./...", "go test -race ./..."}, @@ -492,7 +492,7 @@ func TestGetDoctorCommand(t *testing.T) { name: "successful validation with complete config", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", Description: "A test project", Version: "1.0.0", RepoUrl: "https://github.com/test/project", @@ -516,7 +516,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with missing language should fail", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Test: config.Operation{ Steps: []string{"go test ./..."}, @@ -533,7 +534,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with missing test steps should warn but pass", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Language: "go", Build: config.Operation{ @@ -548,7 +550,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with missing build steps should warn but pass", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Language: "go", Test: config.Operation{ @@ -563,7 +566,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with missing dependencies should warn but pass", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Language: "go", Test: config.Operation{ @@ -581,7 +585,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with all optional fields missing should warn but pass", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Language: "go", }, @@ -593,7 +598,8 @@ func TestGetDoctorCommand(t *testing.T) { name: "validation with empty language should fail", configSetup: func() config.ProjectDefinition { return config.ProjectDefinition{ - Name: "test-project", + ID: "test-project", + RepoUrl: "https://github.com/test/project", Codebase: config.Codebase{ Language: "", Test: config.Operation{ @@ -630,22 +636,17 @@ func TestGetDoctorCommand(t *testing.T) { // Execute command err := cmd.Execute() - output := buf.String() - if tt.expectedError != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedError) + assert.ErrorContains(t, err, tt.expectedError) } else { assert.NoError(t, err) } - if tt.expectWarnings { // Check for warning messages in output assert.Contains(t, output, "[~]") } - // Verify no shell executor calls were made (doctor only validates config) mockExecutor.AssertExpectations(t) }) } @@ -691,7 +692,7 @@ func TestGetDoctorCommand_Integration(t *testing.T) { logger := logging.New(os.Stderr, logrus.InfoLevel) ctx := logging.WithContext(context.Background(), logger) projectDef := config.ProjectDefinition{ - Name: "integration-doctor", + ID: "integration-doctor", Description: "Integration test project", Version: "2.0.0", RepoUrl: "https://github.com/integration/test", diff --git a/devops-definition.yaml b/devops-definition.yaml index de34503..12b71be 100644 --- a/devops-definition.yaml +++ b/devops-definition.yaml @@ -1,12 +1,13 @@ -name: devops -description: DevOps CLI - Simplifying your CI/CD pipelines +id: devops +name: Devops CLI +description: Simplifying your CI/CD pipelines version: 0.0.2 repo_url: https://github.com/jgfranco17/devops codebase: language: go dependencies: - - go.mod + - https://github.com/jgfranco17/dev-tooling-go install: fail_fast: true env: diff --git a/main.go b/main.go index 700a117..bf3d672 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ func main() { core.GetBuildCommand(executor), core.GetTestCommand(executor), core.GetDoctorCommand(executor), + core.GetManifestCommand(), core.GetDocsCommand(), } command.RegisterCommands(commandsList)