Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- main
pull_request:
branches:
- main
- "**"
paths-ignore:
- "docs/**"

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ devops
# Go workspace
go.work
go.work.sum

# CLI outputs
.devops/
18 changes: 9 additions & 9 deletions cli/config/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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{
Expand Down Expand Up @@ -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",
},
},
Expand Down Expand Up @@ -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",
},
Expand All @@ -136,7 +136,7 @@ func TestFromContext(t *testing.T) {
},
expectPanic: false,
expected: ProjectDefinition{
Name: "nested-project",
ID: "nested-project",
Codebase: Codebase{
Language: "rust",
},
Expand Down Expand Up @@ -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
Expand All @@ -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) })
}
86 changes: 83 additions & 3 deletions cli/config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"`
}
Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Loading