diff --git a/.gitignore b/.gitignore index c79d718..d7e0381 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ go.work *.swo *~ +# Worktrees +.worktrees/ + # OS .DS_Store Thumbs.db diff --git a/internal/cli/audit.go b/internal/cli/audit.go index a29f864..d0ae8ec 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -7,6 +7,7 @@ import ( awspkg "github.com/kaustuvprajapati/devopsctl/internal/aws" dockerpkg "github.com/kaustuvprajapati/devopsctl/internal/docker" + gitpkg "github.com/kaustuvprajapati/devopsctl/internal/git" "github.com/kaustuvprajapati/devopsctl/internal/reporter" "github.com/spf13/cobra" ) @@ -95,11 +96,46 @@ var auditDockerCmd = &cobra.Command{ }, } +var gitRepoPath string + var auditGitCmd = &cobra.Command{ Use: "git", Short: "Audit Git repository", + Long: `Audit Git repository for hygiene issues: size, stale branches, large files.`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Git audit not yet implemented") + repoPath := gitRepoPath + if repoPath == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + repoPath = cwd + } + + runner := gitpkg.NewRunner(repoPath, AppConfig.Git) + results, err := runner.RunAll(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: some checks encountered errors: %v\n", err) + } + + report := &reporter.Report{Module: "git", Results: results} + + w, err := resolveWriter(cmd) + if err != nil { + return err + } + if w != os.Stdout { + defer w.Close() + } + + rep := resolveReporter() + if err := rep.Render(w, report); err != nil { + return err + } + + if code := exitCodeForResults(results); code > 0 { + os.Exit(code) + } return nil }, } @@ -107,6 +143,7 @@ var auditGitCmd = &cobra.Command{ func init() { auditDockerCmd.Flags().StringVar(&dockerfilePath, "file", "", "path to Dockerfile (overrides config)") auditDockerCmd.Flags().StringVar(&dockerImage, "image", "", "container image to scan with Trivy") + auditGitCmd.Flags().StringVar(&gitRepoPath, "repo", "", "path to Git repository (defaults to current directory)") auditCmd.AddCommand(auditAWSCmd) auditCmd.AddCommand(auditDockerCmd) auditCmd.AddCommand(auditGitCmd) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9fbfaf4..a708dfc 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,18 +1,120 @@ package cli import ( + "context" + "encoding/json" "fmt" + "os" + "github.com/kaustuvprajapati/devopsctl/internal/doctor" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" "github.com/spf13/cobra" ) +// awsModule wraps AWS checks as a doctor.Module +type awsModule struct{} + +func (m *awsModule) Name() string { return "aws" } + +func (m *awsModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after AWS clients are available + return []reporter.CheckResult{}, nil +} + +// dockerModule wraps Docker checks as a doctor.Module +type dockerModule struct{} + +func (m *dockerModule) Name() string { return "docker" } + +func (m *dockerModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Docker runner is available + return []reporter.CheckResult{}, nil +} + +// terraformModule wraps Terraform checks as a doctor.Module +type terraformModule struct{} + +func (m *terraformModule) Name() string { return "terraform" } + +func (m *terraformModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Terraform runner is available + return []reporter.CheckResult{}, nil +} + +// gitModule wraps Git checks as a doctor.Module +type gitModule struct{} + +func (m *gitModule) Name() string { return "git" } + +func (m *gitModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + // Will be implemented in CLI after Git runner is available + return []reporter.CheckResult{}, nil +} + var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Run all checks and generate health report", Long: `Run all available audit and validation checks, aggregate results, and generate a comprehensive health report.`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Doctor not yet implemented") + engine := doctor.NewEngine() + + // Register all modules + if err := engine.Register(&awsModule{}); err != nil { + return fmt.Errorf("failed to register aws module: %w", err) + } + if err := engine.Register(&dockerModule{}); err != nil { + return fmt.Errorf("failed to register docker module: %w", err) + } + if err := engine.Register(&terraformModule{}); err != nil { + return fmt.Errorf("failed to register terraform module: %w", err) + } + if err := engine.Register(&gitModule{}); err != nil { + return fmt.Errorf("failed to register git module: %w", err) + } + + // Run all modules + reports, err := engine.RunAll(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: some modules encountered errors: %v\n", err) + } + + // Compute summary + summary := doctor.ComputeSummary(reports) + + // Output results + w, err := resolveWriter(cmd) + if err != nil { + return err + } + if w != os.Stdout { + defer w.Close() + } + + rep := resolveReporter() + + // Render each module's results + for _, r := range reports { + report := &reporter.Report{Module: r.Module, Results: r.Results} + if err := rep.Render(w, report); err != nil { + return err + } + if r.Error != "" { + fmt.Fprintf(w, " [ERROR] %s\n", r.Error) + } + } + + // Print summary for JSON output + if jsonOutput { + summaryJSON, _ := json.MarshalIndent(summary, "", " ") + fmt.Fprintf(w, "\n%s\n", summaryJSON) + } + + // Exit with appropriate code + if code := doctor.ExitCode(reports); code > 0 { + os.Exit(code) + } + return nil }, } diff --git a/internal/config/config.go b/internal/config/config.go index 2b56daa..9f8c19d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,53 +6,72 @@ import ( "gopkg.in/yaml.v3" ) -// Config represents the main configuration structure. -type Config struct { - AWS AWSConfig `yaml:"aws"` - Docker DockerConfig `yaml:"docker"` - Terraform TerraformConfig `yaml:"terraform"` - Git GitConfig `yaml:"git"` -} - // AWSConfig holds AWS-specific configuration. type AWSConfig struct { - Region string `yaml:"region"` - Profile string `yaml:"profile"` - KeyAgeDays int `yaml:"key_age_days"` + Enabled bool `yaml:"enabled"` + Region string `yaml:"region"` + Profile string `yaml:"profile"` + KeyAgeDays int `yaml:"key_age_days"` } // DockerConfig holds Docker-specific configuration. type DockerConfig struct { + Enabled bool `yaml:"enabled"` DockerfilePath string `yaml:"dockerfile_path"` } // TerraformConfig holds Terraform-specific configuration. type TerraformConfig struct { - TfDir string `yaml:"tf_dir"` + Enabled bool `yaml:"enabled"` + TfDir string `yaml:"tf_dir"` } // GitConfig holds Git-specific configuration. type GitConfig struct { - RepoSizeMB int `yaml:"repo_size_mb"` - BranchAgeDays int `yaml:"branch_age_days"` - LargeFileMB int `yaml:"large_file_mb"` + Enabled bool `yaml:"enabled"` + RepoSizeMB int `yaml:"repo_size_mb"` + BranchAgeDays int `yaml:"branch_age_days"` + LargeFileMB int `yaml:"large_file_mb"` +} + +// IgnoreConfig holds ignore patterns for check filtering. +type IgnoreConfig struct { + Checks []string `yaml:"checks"` +} + +// Config represents the main configuration structure. +type Config struct { + AWS AWSConfig `yaml:"aws"` + Docker DockerConfig `yaml:"docker"` + Terraform TerraformConfig `yaml:"terraform"` + Git GitConfig `yaml:"git"` + Ignore IgnoreConfig `yaml:"ignore"` } // DefaultConfig returns a Config with sensible defaults. func DefaultConfig() *Config { return &Config{ AWS: AWSConfig{ + Enabled: true, Region: "us-east-1", KeyAgeDays: 90, }, Docker: DockerConfig{ + Enabled: true, DockerfilePath: "Dockerfile", }, + Terraform: TerraformConfig{ + Enabled: true, + }, Git: GitConfig{ + Enabled: true, RepoSizeMB: 500, BranchAgeDays: 90, LargeFileMB: 50, }, + Ignore: IgnoreConfig{ + Checks: []string{}, + }, } } diff --git a/internal/doctor/engine.go b/internal/doctor/engine.go new file mode 100644 index 0000000..aac0bb3 --- /dev/null +++ b/internal/doctor/engine.go @@ -0,0 +1,75 @@ +package doctor + +import ( + "context" + "fmt" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// ModuleReport holds results from a single module execution. +type ModuleReport struct { + Module string `json:"module"` + Results []reporter.CheckResult `json:"results"` + Error string `json:"error,omitempty"` +} + +// Engine orchestrates all registered modules. +type Engine struct { + registry *Registry +} + +// NewEngine creates a new doctor engine. +func NewEngine() *Engine { + return &Engine{ + registry: NewRegistry(), + } +} + +// Register adds a module to the engine. +func (e *Engine) Register(m Module) error { + return e.registry.Register(m) +} + +// RunAll executes all registered modules and returns aggregated results. +func (e *Engine) RunAll(ctx context.Context) ([]ModuleReport, error) { + var reports []ModuleReport + moduleNames := e.registry.List() + + for _, name := range moduleNames { + module, ok := e.registry.Get(name) + if !ok { + continue + } + + report := ModuleReport{Module: name} + + results, err := module.Run(ctx) + if err != nil { + report.Error = err.Error() + // Continue running other modules despite failure + } + report.Results = results + reports = append(reports, report) + } + + // Check if any module failed + var errs []string + for _, r := range reports { + if r.Error != "" { + errs = append(errs, fmt.Sprintf("%s: %s", r.Module, r.Error)) + } + } + + var err error + if len(errs) > 0 { + err = fmt.Errorf("some modules failed: %v", errs) + } + + return reports, err +} + +// Registry returns the underlying registry. +func (e *Engine) Registry() *Registry { + return e.registry +} diff --git a/internal/doctor/engine_test.go b/internal/doctor/engine_test.go new file mode 100644 index 0000000..9f01984 --- /dev/null +++ b/internal/doctor/engine_test.go @@ -0,0 +1,141 @@ +package doctor + +import ( + "context" + "errors" + "testing" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// mockModule implements Module for testing +type mockModule struct { + name string + results []reporter.CheckResult + err error +} + +func (m *mockModule) Name() string { return m.name } + +func (m *mockModule) Run(ctx context.Context) ([]reporter.CheckResult, error) { + return m.results, m.err +} + +// TestRegistryRegister tests module registration +func TestRegistryRegister(t *testing.T) { + registry := NewRegistry() + + module := &mockModule{name: "test", results: []reporter.CheckResult{}} + err := registry.Register(module) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if registry.Len() != 1 { + t.Errorf("Expected 1 module, got %d", registry.Len()) + } +} + +// TestRegistryDuplicateRegistration tests duplicate registration +func TestRegistryDuplicateRegistration(t *testing.T) { + registry := NewRegistry() + + module := &mockModule{name: "test", results: []reporter.CheckResult{}} + registry.Register(module) + + err := registry.Register(module) + if err != ErrModuleAlreadyRegistered { + t.Errorf("Expected ErrModuleAlreadyRegistered, got %v", err) + } +} + +// TestRegistryNilModule tests nil module registration +func TestRegistryNilModule(t *testing.T) { + registry := NewRegistry() + + err := registry.Register(nil) + if err != ErrNilModule { + t.Errorf("Expected ErrNilModule, got %v", err) + } +} + +// TestEngineRunAll tests running all modules +func TestEngineRunAll(t *testing.T) { + engine := NewEngine() + + // Register modules + engine.Register(&mockModule{ + name: "module1", + results: []reporter.CheckResult{ + {CheckName: "check1", Severity: "LOW", ResourceID: "res1"}, + }, + }) + engine.Register(&mockModule{ + name: "module2", + results: []reporter.CheckResult{ + {CheckName: "check2", Severity: "HIGH", ResourceID: "res2"}, + }, + }) + + reports, err := engine.RunAll(context.Background()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(reports) != 2 { + t.Errorf("Expected 2 reports, got %d", len(reports)) + } +} + +// TestEnginePartialFailure tests partial module failure handling +func TestEnginePartialFailure(t *testing.T) { + engine := NewEngine() + + engine.Register(&mockModule{ + name: "success", + results: []reporter.CheckResult{ + {CheckName: "check1", Severity: "LOW"}, + }, + }) + engine.Register(&mockModule{ + name: "failure", + results: nil, + err: errors.New("module error"), + }) + + reports, err := engine.RunAll(context.Background()) + if err == nil { + t.Error("Expected error due to module failure") + } + + if len(reports) != 2 { + t.Errorf("Expected 2 reports, got %d", len(reports)) + } + + // Find the failed module + var failedReport ModuleReport + for _, r := range reports { + if r.Module == "failure" { + failedReport = r + break + } + } + + if failedReport.Error == "" { + t.Error("Expected error message in failed module report") + } +} + +// TestEngineNoModules tests running with no modules +func TestEngineNoModules(t *testing.T) { + engine := NewEngine() + + reports, err := engine.RunAll(context.Background()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(reports) != 0 { + t.Errorf("Expected 0 reports, got %d", len(reports)) + } +} diff --git a/internal/doctor/registry.go b/internal/doctor/registry.go new file mode 100644 index 0000000..e2480ab --- /dev/null +++ b/internal/doctor/registry.go @@ -0,0 +1,79 @@ +package doctor + +import ( + "context" + + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// Module represents an audit/validation module that can be run by the doctor engine. +type Module interface { + // Name returns the module identifier + Name() string + + // Run executes the module and returns check results + Run(ctx context.Context) ([]reporter.CheckResult, error) +} + +// Registry holds registered modules. +type Registry struct { + modules map[string]Module +} + +// NewRegistry creates a new module registry. +func NewRegistry() *Registry { + return &Registry{ + modules: make(map[string]Module), + } +} + +// Register adds a module to the registry. +func (r *Registry) Register(m Module) error { + if m == nil { + return ErrNilModule + } + name := m.Name() + if name == "" { + return ErrEmptyModuleName + } + if _, exists := r.modules[name]; exists { + return ErrModuleAlreadyRegistered + } + r.modules[name] = m + return nil +} + +// Get returns a module by name. +func (r *Registry) Get(name string) (Module, bool) { + m, ok := r.modules[name] + return m, ok +} + +// List returns all registered module names. +func (r *Registry) List() []string { + names := make([]string, 0, len(r.modules)) + for name := range r.modules { + names = append(names, name) + } + return names +} + +// Len returns the number of registered modules. +func (r *Registry) Len() int { + return len(r.modules) +} + +// Errors for registry operations +var ( + ErrNilModule = &RegistryError{"nil module not allowed"} + ErrEmptyModuleName = &RegistryError{"module name cannot be empty"} + ErrModuleAlreadyRegistered = &RegistryError{"module already registered"} +) + +type RegistryError struct { + msg string +} + +func (e *RegistryError) Error() string { + return e.msg +} diff --git a/internal/doctor/scoring.go b/internal/doctor/scoring.go new file mode 100644 index 0000000..f6e42d9 --- /dev/null +++ b/internal/doctor/scoring.go @@ -0,0 +1,79 @@ +package doctor + +import ( + "github.com/kaustuvprajapati/devopsctl/internal/severity" +) + +// Summary holds aggregated scoring information. +type Summary struct { + TotalFindings int `json:"total_findings"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Score int `json:"score"` + ModulesFailed int `json:"modules_failed"` + ModuleErrors map[string]string `json:"module_errors,omitempty"` +} + +// ComputeSummary calculates aggregate statistics from module reports. +func ComputeSummary(reports []ModuleReport) Summary { + summary := Summary{ + ModuleErrors: make(map[string]string), + } + + for _, report := range reports { + if report.Error != "" { + summary.ModulesFailed++ + summary.ModuleErrors[report.Module] = report.Error + continue + } + + for _, result := range report.Results { + summary.TotalFindings++ + + switch severity.Level(result.Severity) { + case severity.Critical: + summary.Critical++ + summary.Score += severity.Critical.Weight() + case severity.High: + summary.High++ + summary.Score += severity.High.Weight() + case severity.Medium: + summary.Medium++ + summary.Score += severity.Medium.Weight() + case severity.Low: + summary.Low++ + summary.Score += severity.Low.Weight() + } + } + } + + return summary +} + +// HighestSeverity returns the highest severity level from all results. +func HighestSeverity(reports []ModuleReport) severity.Level { + levels := make([]severity.Level, 0) + + for _, report := range reports { + for _, result := range report.Results { + levels = append(levels, severity.Level(result.Severity)) + } + } + + if len(levels) == 0 { + return "" + } + + return severity.Highest(levels) +} + +// ExitCode returns the exit code based on highest severity. +func ExitCode(reports []ModuleReport) int { + highest := HighestSeverity(reports) + if highest == "" { + return 0 + } + return highest.ExitCode() +} diff --git a/internal/git/branches.go b/internal/git/branches.go new file mode 100644 index 0000000..20e7f4d --- /dev/null +++ b/internal/git/branches.go @@ -0,0 +1,68 @@ +package git + +import ( + "context" + "strconv" + "strings" + "time" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" + "github.com/kaustuvprajapati/devopsctl/internal/severity" +) + +// CheckStaleBranches checks if any branches have not been updated in the configured number of days. +// Returns LOW severity findings for stale branches. +func CheckStaleBranches(ctx context.Context, client *Client, cfg appconfig.GitConfig) ([]reporter.CheckResult, error) { + output, err := client.Run(ctx, "branch", "-a", "--format=%(refname:short)|%(committerdate:unix)") + if err != nil { + return nil, err + } + + var results []reporter.CheckResult + threshold := time.Duration(cfg.BranchAgeDays) * 24 * time.Hour + now := time.Now() + + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Skip HEAD references + if strings.Contains(line, "HEAD") { + continue + } + + parts := strings.Split(line, "|") + if len(parts) != 2 { + continue + } + + branch := strings.TrimSpace(parts[0]) + // Skip remote tracking branch prefixes + branch = strings.TrimPrefix(branch, "remotes/origin/") + + timestamp := strings.TrimSpace(parts[1]) + unixTime, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + continue + } + + commitTime := time.Unix(unixTime, 0) + age := now.Sub(commitTime) + + if age > threshold { + results = append(results, reporter.CheckResult{ + CheckName: "git-stale-branch", + Severity: string(severity.Low), + ResourceID: branch, + Message: "Branch has not been updated in over " + string(rune(cfg.BranchAgeDays)) + " days", + Recommendation: "Consider deleting stale branches or merging/updating them", + }) + } + } + + return results, nil +} diff --git a/internal/git/branches_test.go b/internal/git/branches_test.go new file mode 100644 index 0000000..c39bfac --- /dev/null +++ b/internal/git/branches_test.go @@ -0,0 +1,122 @@ +package git + +import ( + "testing" + "time" +) + +// TestBranchAgeCalculation tests the age calculation for branches +func TestBranchAgeCalculation(t *testing.T) { + now := time.Now() + + tests := []struct { + daysAgo int + expected time.Duration + }{ + {0, 0}, + {30, 30 * 24 * time.Hour}, + {90, 90 * 24 * time.Hour}, + {180, 180 * 24 * time.Hour}, + } + + for _, tt := range tests { + expectedAge := time.Duration(tt.daysAgo) * 24 * time.Hour + commitTime := now.Add(-expectedAge) + actualAge := now.Sub(commitTime) + + if actualAge != expectedAge { + t.Errorf("Expected age %v, got %v", expectedAge, actualAge) + } + } +} + +// TestBranchNameParsing tests parsing branch names from git output +func TestBranchNameParsing(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"main|1700000000", "main"}, + {"feature/test|1700000000", "feature/test"}, + {"origin/main|1700000000", "origin/main"}, // Keep prefix for remote branches + {"origin/feature|1700000000", "origin/feature"}, + } + + for _, tt := range tests { + parts := splitBranchLine(tt.input) + if parts.branch != tt.expected { + t.Errorf("Expected branch %q, got %q", tt.expected, parts.branch) + } + } +} + +// splitBranchLine is a helper to test parsing logic +func splitBranchLine(line string) struct{ branch string } { + parts := splitAt(line, '|') + branch := trim(parts[0]) + branch = trimPrefix(branch, "remotes/origin/") + return struct{ branch string }{branch} +} + +func splitAt(s string, c byte) []string { + var result []string + current := "" + for i := 0; i < len(s); i++ { + if s[i] == c { + result = append(result, current) + current = "" + } else { + current += string(s[i]) + } + } + result = append(result, current) + return result +} + +func trim(s string) string { + start := 0 + end := len(s) + for start < end && (s[start] == ' ' || s[start] == '\n') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\n') { + end-- + } + return s[start:end] +} + +func trimPrefix(s, prefix string) string { + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):] + } + return s +} + +// TestStaleBranchThreshold tests threshold comparison +func TestStaleBranchThreshold(t *testing.T) { + cfg := struct{ days int }{90} + threshold := time.Duration(cfg.days) * 24 * time.Hour + now := time.Now() + + tests := []struct { + name string + daysAgo int + isStale bool + }{ + {"recent", 30, false}, + {"boundary", 90, false}, // exactly at threshold is not stale + {"old", 91, true}, + {"very old", 180, true}, + } + + for _, tt := range tests { + commitTime := now.Add(-time.Duration(tt.daysAgo) * 24 * time.Hour) + age := now.Sub(commitTime) + isStale := age > threshold + + if isStale != tt.isStale { + t.Errorf("Test %s: expected isStale=%v, got %v (age=%v, threshold=%v)", + tt.name, tt.isStale, isStale, age, threshold) + } + } +} diff --git a/internal/git/client.go b/internal/git/client.go new file mode 100644 index 0000000..e58f73c --- /dev/null +++ b/internal/git/client.go @@ -0,0 +1,91 @@ +package git + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// Client wraps git command execution. +type Client struct { + repoPath string + timeout time.Duration +} + +// NewClient creates a new git client for the given repository path. +func NewClient(repoPath string) *Client { + return &Client{ + repoPath: repoPath, + timeout: 30 * time.Second, + } +} + +// Run executes a git command and returns the output. +// ctx can be used to cancel the command. +func (c *Client) Run(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = c.repoPath + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %w", args[0], err) + } + + return stdout.String(), nil +} + +// RunSimple is a convenience method for running git commands without context. +func (c *Client) RunSimple(args ...string) (string, error) { + return c.Run(context.Background(), args...) +} + +// IsRepo checks if the given path is a git repository. +func (c *Client) IsRepo() bool { + _, err := c.RunSimple("rev-parse", "--is-inside-work-tree") + return err == nil +} + +// CurrentBranch returns the current branch name. +func (c *Client) CurrentBranch() (string, error) { + return c.RunSimple("rev-parse", "--abbrev-ref", "HEAD") +} + +// BranchList returns all local and remote branches. +func (c *Client) BranchList() (string, error) { + return c.RunSimple("branch", "-a") +} + +// LastCommitDate returns the date of the last commit on a branch. +func (c *Client) LastCommitDate(branch string) (time.Time, error) { + output, err := c.RunSimple("log", "-1", "--format=%ci", branch) + if err != nil { + return time.Time{}, err + } + + // Parse the date (format: "2025-12-16 10:00:00 +0000") + layout := "2006-01-02 15:04:05 -0700" + t, err := time.Parse(layout, output[:len(output)-1]) // trim newline + if err != nil { + return time.Time{}, err + } + + return t, nil +} + +// CountObjects returns the count-objects -vH output for repo size. +func (c *Client) CountObjects() (string, error) { + return c.RunSimple("count-objects", "-vH") +} + +// ListFiles lists all tracked files with their sizes. +func (c *Client) ListFiles() (string, error) { + return c.RunSimple("ls-files", "-s") +} diff --git a/internal/git/files.go b/internal/git/files.go new file mode 100644 index 0000000..d3a17e3 --- /dev/null +++ b/internal/git/files.go @@ -0,0 +1,109 @@ +package git + +import ( + "context" + "regexp" + "strconv" + "strings" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" + "github.com/kaustuvprajapati/devopsctl/internal/severity" +) + +// CheckLargeFiles checks for tracked files exceeding the configured size threshold. +// Returns MEDIUM severity findings for each large file. +func CheckLargeFiles(ctx context.Context, client *Client, cfg appconfig.GitConfig) ([]reporter.CheckResult, error) { + output, err := client.Run(ctx, "ls-files", "-s") + if err != nil { + return nil, err + } + + var results []reporter.CheckResult + thresholdKB := cfg.LargeFileMB * 1024 + + // Parse: + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 4 { + continue + } + + size, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + + // Size is in bytes, convert to KB for comparison + sizeKB := size / 1024 + + if sizeKB > thresholdKB { + filename := strings.Join(parts[3:], " ") + results = append(results, reporter.CheckResult{ + CheckName: "git-large-file", + Severity: string(severity.Medium), + ResourceID: filename, + Message: "File exceeds size threshold", + Recommendation: "Consider using Git LFS for large files or removing from version control", + }) + } + } + + return results, nil +} + +// CheckLargeFilesRegex uses regex pattern matching to find large files. +// Alternative implementation for more flexible matching. +func CheckLargeFilesRegex(ctx context.Context, client *Client, cfg appconfig.GitConfig, pattern string) ([]reporter.CheckResult, error) { + output, err := client.Run(ctx, "ls-files", "-z") + if err != nil { + return nil, err + } + + var results []reporter.CheckResult + re := regexp.MustCompile(pattern) + + // Files are null-terminated + files := strings.Split(output, "\x00") + for _, file := range files { + file = strings.TrimSpace(file) + if file == "" || !re.MatchString(file) { + continue + } + + // Get size for matching file + sizeOutput, err := client.Run(ctx, "ls-files", "-s", file) + if err != nil { + continue + } + + parts := strings.Fields(sizeOutput) + if len(parts) < 2 { + continue + } + + size, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + + sizeKB := size / 1024 + if sizeKB > cfg.LargeFileMB*1024 { + results = append(results, reporter.CheckResult{ + CheckName: "git-large-file", + Severity: string(severity.Medium), + ResourceID: file, + Message: "File matches pattern and exceeds size threshold", + Recommendation: "Consider using Git LFS or removing from version control", + }) + } + } + + return results, nil +} diff --git a/internal/git/files_test.go b/internal/git/files_test.go new file mode 100644 index 0000000..6bcc40a --- /dev/null +++ b/internal/git/files_test.go @@ -0,0 +1,102 @@ +package git + +import ( + "testing" +) + +// TestFileSizeThreshold tests file size threshold logic +func TestFileSizeThreshold(t *testing.T) { + thresholdKB := 50 * 1024 // 50MB = 51200KB + + tests := []struct { + sizeBytes int + isLarge bool + }{ + {0, false}, + {1024, false}, // 1KB + {1024 * 1024, false}, // 1MB + {40 * 1024 * 1024, false}, // 40MB + {50 * 1024 * 1024, false}, // 50MB (boundary) + {51 * 1024 * 1024, true}, // 51MB (over threshold) + {100 * 1024 * 1024, true}, // 100MB + } + + for _, tt := range tests { + sizeKB := tt.sizeBytes / 1024 + isLarge := sizeKB > thresholdKB + + if isLarge != tt.isLarge { + t.Errorf("Size %d bytes: expected isLarge=%v, got %v", + tt.sizeBytes, tt.isLarge, isLarge) + } + } +} + +// TestParseGitLsFilesOutput tests parsing ls-files output +func TestParseGitLsFilesOutput(t *testing.T) { + // Format: + tests := []struct { + line string + hasError bool + size string + filename string + }{ + {"100644 a1b2c3d4 0 file.txt", false, "a1b2c3d4", "file.txt"}, + {"100644 abc123 1 dir/file.go", false, "abc123", "dir/file.go"}, + {"invalid", true, "", ""}, + } + + for _, tt := range tests { + parts := parseLsFilesLine(tt.line) + if tt.hasError { + continue // skip error cases for now + } + if len(parts) < 2 { + if !tt.hasError { + t.Errorf("Expected parseable line %q, got empty", tt.line) + } + continue + } + } +} + +// parseLsFilesLine is a helper for testing +func parseLsFilesLine(line string) []string { + if line == "" { + return nil + } + var result []string + current := "" + for _, c := range line { + if c == ' ' { + if current != "" { + result = append(result, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + result = append(result, current) + } + return result +} + +// TestLargeFilePattern tests regex pattern matching +func TestLargeFilePattern(t *testing.T) { + // Test cases for common large file patterns + type patternMatch struct { + pattern string + matches []string + } + patterns := []patternMatch{ + {"*.zip", []string{"data.zip", "archive.zip"}}, + {"*.tar.gz", []string{"backup.tar.gz"}}, + } + + // Verify patterns compile + for _, p := range patterns { + _ = p.pattern // Pattern validation would go here + } +} diff --git a/internal/git/runner.go b/internal/git/runner.go new file mode 100644 index 0000000..74ac003 --- /dev/null +++ b/internal/git/runner.go @@ -0,0 +1,64 @@ +package git + +import ( + "context" + "fmt" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// Runner orchestrates git audit checks. +type Runner struct { + client *Client + cfg appconfig.GitConfig +} + +// NewRunner creates a new git runner. +func NewRunner(repoPath string, cfg appconfig.GitConfig) *Runner { + return &Runner{ + client: NewClient(repoPath), + cfg: cfg, + } +} + +// RunAll executes all git checks and returns aggregated results. +func (r *Runner) RunAll(ctx context.Context) ([]reporter.CheckResult, error) { + var all []reporter.CheckResult + var errs []string + + checks := []struct { + name string + fn func(context.Context) ([]reporter.CheckResult, error) + }{ + {"repo-size", func(ctx context.Context) ([]reporter.CheckResult, error) { + return CheckRepoSize(ctx, r.client, r.cfg) + }}, + {"stale-branches", func(ctx context.Context) ([]reporter.CheckResult, error) { + return CheckStaleBranches(ctx, r.client, r.cfg) + }}, + {"large-files", func(ctx context.Context) ([]reporter.CheckResult, error) { + return CheckLargeFiles(ctx, r.client, r.cfg) + }}, + } + + for _, check := range checks { + results, err := check.fn(ctx) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", check.name, err)) + continue + } + all = append(all, results...) + } + + if len(errs) > 0 { + return all, fmt.Errorf("some checks failed: %v", errs) + } + + return all, nil +} + +// RunAllSimple is a convenience method without context. +func (r *Runner) RunAllSimple() ([]reporter.CheckResult, error) { + return r.RunAll(context.Background()) +} diff --git a/internal/git/runner_test.go b/internal/git/runner_test.go new file mode 100644 index 0000000..da60dd8 --- /dev/null +++ b/internal/git/runner_test.go @@ -0,0 +1,80 @@ +package git + +import ( + "context" + "testing" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" +) + +// mockRunnerClient is a mock implementation of git client for testing +type mockRunnerClient struct { + sizeResults []reporter.CheckResult + sizeErr error + branchResults []reporter.CheckResult + branchErr error + fileResults []reporter.CheckResult + fileErr error +} + +func (m *mockRunnerClient) Run(ctx context.Context, args ...string) (string, error) { + return "", nil +} + +// TestRunnerExecution tests that runner executes all checks +func TestRunnerExecution(t *testing.T) { + cfg := appconfig.GitConfig{ + RepoSizeMB: 500, + BranchAgeDays: 90, + LargeFileMB: 50, + } + + // Test that Runner can be created + _ = NewRunner(".", cfg) +} + +// TestRunnerResultsAggregation tests that results are aggregated correctly +func TestRunnerResultsAggregation(t *testing.T) { + cfg := appconfig.GitConfig{ + RepoSizeMB: 500, + BranchAgeDays: 90, + LargeFileMB: 50, + } + + // Verify config is passed correctly + if cfg.RepoSizeMB != 500 { + t.Errorf("Expected RepoSizeMB 500, got %d", cfg.RepoSizeMB) + } + if cfg.BranchAgeDays != 90 { + t.Errorf("Expected BranchAgeDays 90, got %d", cfg.BranchAgeDays) + } + if cfg.LargeFileMB != 50 { + t.Errorf("Expected LargeFileMB 50, got %d", cfg.LargeFileMB) + } +} + +// TestCheckResultSeverity tests severity levels in results +func TestCheckResultSeverity(t *testing.T) { + tests := []struct { + result reporter.CheckResult + invalid bool + }{ + {reporter.CheckResult{Severity: "LOW"}, false}, + {reporter.CheckResult{Severity: "MEDIUM"}, false}, + {reporter.CheckResult{Severity: "HIGH"}, false}, + {reporter.CheckResult{Severity: "CRITICAL"}, false}, + {reporter.CheckResult{Severity: "INVALID"}, true}, + } + + for _, tt := range tests { + isValid := tt.result.Severity == "LOW" || + tt.result.Severity == "MEDIUM" || + tt.result.Severity == "HIGH" || + tt.result.Severity == "CRITICAL" + + if isValid && tt.invalid { + t.Errorf("Expected invalid severity for %s", tt.result.Severity) + } + } +} diff --git a/internal/git/size.go b/internal/git/size.go new file mode 100644 index 0000000..cccda1e --- /dev/null +++ b/internal/git/size.go @@ -0,0 +1,73 @@ +package git + +import ( + "context" + "regexp" + "strconv" + "strings" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" + "github.com/kaustuvprajapati/devopsctl/internal/reporter" + "github.com/kaustuvprajapati/devopsctl/internal/severity" +) + +// CheckRepoSize checks if the repository size exceeds the configured threshold. +// Returns a MEDIUM severity finding if the repo is too large. +func CheckRepoSize(ctx context.Context, client *Client, cfg appconfig.GitConfig) ([]reporter.CheckResult, error) { + output, err := client.Run(ctx, "count-objects", "-vH") + if err != nil { + return nil, err + } + + // Parse output like: + // count: 1234 + // size: 123.45MiB + // in-pack: 5678 + re := regexp.MustCompile(`size:\s+(\d+\.?\d*)([KMGT]i?B)`) + matches := re.FindStringSubmatch(output) + + if len(matches) < 3 { + return nil, nil // Cannot determine size, skip + } + + value, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return nil, err + } + + unit := matches[2] + sizeMB := convertToMB(value, unit) + + if sizeMB > float64(cfg.RepoSizeMB) { + return []reporter.CheckResult{ + { + CheckName: "git-repo-size", + Severity: string(severity.Medium), + ResourceID: client.repoPath, + Message: "Repository size exceeds threshold", + Recommendation: "Consider using Git LFS for large files or cleaning up unnecessary objects", + }, + }, nil + } + + return nil, nil +} + +// convertToMB converts a size value to megabytes. +func convertToMB(value float64, unit string) float64 { + unitUpper := strings.ToUpper(unit) + switch unitUpper { + case "B": + return value / (1024 * 1024) + case "KB", "KIB": + return value / 1024 + case "MB", "MIB": + return value + case "GB", "GIB": + return value * 1024 + case "TB", "TIB": + return value * 1024 * 1024 + default: + return value + } +} diff --git a/internal/git/size_test.go b/internal/git/size_test.go new file mode 100644 index 0000000..778412c --- /dev/null +++ b/internal/git/size_test.go @@ -0,0 +1,60 @@ +package git + +import ( + "testing" + + appconfig "github.com/kaustuvprajapati/devopsctl/internal/config" +) + +// TestConvertToMB tests all unit conversions +func TestConvertToMB(t *testing.T) { + tests := []struct { + value float64 + unit string + expected float64 + }{ + {100, "B", 100.0 / (1024 * 1024)}, + {100, "KB", 100.0 / 1024}, + {100, "MB", 100.0}, + {1, "GB", 1024.0}, + {1, "TB", 1024.0 * 1024}, + {100, "MiB", 100.0}, + {1, "GiB", 1024.0}, + } + + for _, tt := range tests { + result := convertToMB(tt.value, tt.unit) + if result != tt.expected { + t.Errorf("convertToMB(%v, %s) = %v; want %v", tt.value, tt.unit, result, tt.expected) + } + } +} + +// TestRepoSizeThreshold tests threshold comparison logic +func TestRepoSizeThreshold(t *testing.T) { + cfg := appconfig.GitConfig{ + RepoSizeMB: 500, + BranchAgeDays: 90, + LargeFileMB: 50, + } + + tests := []struct { + size float64 + unit string + above bool + }{ + {10, "MiB", false}, + {100, "MiB", false}, + {500, "MiB", false}, + {501, "MiB", true}, + {1, "GiB", true}, + } + + for _, tt := range tests { + sizeMB := convertToMB(tt.size, tt.unit) + isAbove := sizeMB > float64(cfg.RepoSizeMB) + if isAbove != tt.above { + t.Errorf("convertToMB(%v, %s) = %v MB, expected above=%v", tt.size, tt.unit, sizeMB, tt.above) + } + } +} diff --git a/internal/reporter/markdown.go b/internal/reporter/markdown.go new file mode 100644 index 0000000..282b1ff --- /dev/null +++ b/internal/reporter/markdown.go @@ -0,0 +1,61 @@ +package reporter + +import ( + "fmt" + "io" + "text/tabwriter" +) + +// MarkdownReporter renders reports in Markdown format. +type MarkdownReporter struct{} + +// NewMarkdownReporter creates a new MarkdownReporter. +func NewMarkdownReporter() *MarkdownReporter { + return &MarkdownReporter{} +} + +// Render outputs the report in Markdown format. +func (r *MarkdownReporter) Render(w io.Writer, report *Report) error { + fmt.Fprintf(w, "# %s Audit Report\n\n", titleCase(report.Module)) + + if len(report.Results) == 0 { + fmt.Fprintf(w, "No findings.\n\n") + return nil + } + + // Create a tabwriter for alignment + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "| %s | %s | %s | %s |\n", "Severity", "Check", "Resource", "Message") + fmt.Fprintf(tw, "| --- | --- | --- | --- |\n") + + for _, result := range report.Results { + fmt.Fprintf(tw, "| %s | %s | %s | %s |\n", + result.Severity, + result.CheckName, + result.ResourceID, + result.Message, + ) + } + + tw.Flush() + fmt.Fprintf(w, "\n") + + // Add recommendations section + fmt.Fprintf(w, "## Recommendations\n\n") + for _, result := range report.Results { + if result.Recommendation != "" { + fmt.Fprintf(w, "- **%s**: %s\n", result.CheckName, result.Recommendation) + } + } + + fmt.Fprintf(w, "\n") + return nil +} + +// titleCase converts a string to title case. +func titleCase(s string) string { + if len(s) == 0 { + return s + } + return string(s[0]-'a'+'A') + s[1:] +} diff --git a/internal/reporter/table.go b/internal/reporter/table.go index fe2c192..3e168a9 100644 --- a/internal/reporter/table.go +++ b/internal/reporter/table.go @@ -3,7 +3,10 @@ package reporter import ( "fmt" "io" + "os" "text/tabwriter" + + "github.com/kaustuvprajapati/devopsctl/internal/severity" ) // TableReporter renders check results as a formatted text table. @@ -12,8 +15,34 @@ type TableReporter struct{} // NewTableReporter returns a TableReporter. func NewTableReporter() *TableReporter { return &TableReporter{} } +// ANSI color codes +const ( + red = "\033[31m" + yellow = "\033[33m" + green = "\033[32m" + reset = "\033[0m" +) + +// colorize returns the severity with ANSI color codes +func colorize(s string) string { + level := severity.Level(s) + switch level { + case severity.Critical: + return red + s + reset + case severity.High: + return yellow + s + reset + case severity.Low, severity.Medium: + return green + s + reset + default: + return s + } +} + // Render writes a formatted table of check results to w. func (r *TableReporter) Render(w io.Writer, report *Report) error { + // Check if terminal supports colors + isTerminal := isTerminal(w) + fmt.Fprintf(w, "=== %s Audit Results ===\n\n", report.Module) if len(report.Results) == 0 { fmt.Fprintln(w, "No issues found.") @@ -23,8 +52,25 @@ func (r *TableReporter) Render(w io.Writer, report *Report) error { fmt.Fprintln(tw, "SEVERITY\tCHECK NAME\tRESOURCE\tMESSAGE") fmt.Fprintln(tw, "--------\t----------\t--------\t-------") for _, result := range report.Results { + sev := result.Severity + if isTerminal { + sev = colorize(sev) + } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", - result.Severity, result.CheckName, result.ResourceID, result.Message) + sev, result.CheckName, result.ResourceID, result.Message) } return tw.Flush() } + +// isTerminal checks if the writer is a terminal +func isTerminal(w io.Writer) bool { + if f, ok := w.(*os.File); ok { + return isatty(f.Fd()) + } + return false +} + +// isatty checks if the file descriptor is a terminal +func isatty(fd uintptr) bool { + return false // Simplified - always returns false for non-terminal +}