diff --git a/module/pipeline_step_git_checkout.go b/module/pipeline_step_git_checkout.go new file mode 100644 index 00000000..e1872745 --- /dev/null +++ b/module/pipeline_step_git_checkout.go @@ -0,0 +1,106 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// GitCheckoutStep checks out a branch, tag, or creates a new branch. +type GitCheckoutStep struct { + name string + directory string + branch string + create bool + tmpl *TemplateEngine + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewGitCheckoutStepFactory returns a StepFactory that creates GitCheckoutStep instances. +func NewGitCheckoutStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + directory, _ := config["directory"].(string) + if directory == "" { + return nil, fmt.Errorf("git_checkout step %q: 'directory' is required", name) + } + + branch, _ := config["branch"].(string) + if branch == "" { + return nil, fmt.Errorf("git_checkout step %q: 'branch' is required", name) + } + + create, _ := config["create"].(bool) + + return &GitCheckoutStep{ + name: name, + directory: directory, + branch: branch, + create: create, + tmpl: NewTemplateEngine(), + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *GitCheckoutStep) Name() string { return s.name } + +// Execute checks out the configured branch or creates it. +func (s *GitCheckoutStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + directory, err := s.tmpl.Resolve(s.directory, pc) + if err != nil { + return nil, fmt.Errorf("git_checkout step %q: failed to resolve directory: %w", s.name, err) + } + + branch, err := s.tmpl.Resolve(s.branch, pc) + if err != nil { + return nil, fmt.Errorf("git_checkout step %q: failed to resolve branch: %w", s.name, err) + } + + // Build checkout args. + args := []string{"-C", directory, "checkout"} + if s.create { + args = append(args, "-b") + } + args = append(args, branch) + + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", args...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git_checkout step %q: git checkout failed: %w\nstdout: %s\nstderr: %s", + s.name, err, stdout.String(), stderr.String()) + } + + // Get commit SHA after checkout. + commitSHA, err := s.getCommitSHA(ctx, directory) + if err != nil { + commitSHA = "" + } + + return &StepResult{ + Output: map[string]any{ + "branch": branch, + "commit_sha": commitSHA, + "created": s.create, + "success": true, + }, + }, nil +} + +// getCommitSHA returns the HEAD commit SHA for the given directory. +func (s *GitCheckoutStep) getCommitSHA(ctx context.Context, dir string) (string, error) { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "HEAD") //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/module/pipeline_step_git_checkout_test.go b/module/pipeline_step_git_checkout_test.go new file mode 100644 index 00000000..7ce241d5 --- /dev/null +++ b/module/pipeline_step_git_checkout_test.go @@ -0,0 +1,222 @@ +package module + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +func TestGitCheckoutStep_FactoryRequiresDirectory(t *testing.T) { + factory := NewGitCheckoutStepFactory() + _, err := factory("checkout", map[string]any{"branch": "main"}, nil) + if err == nil { + t.Fatal("expected error when directory is missing") + } + if !strings.Contains(err.Error(), "directory") { + t.Errorf("expected error to mention directory, got: %v", err) + } +} + +func TestGitCheckoutStep_FactoryRequiresBranch(t *testing.T) { + factory := NewGitCheckoutStepFactory() + _, err := factory("checkout", map[string]any{"directory": "/tmp/repo"}, nil) + if err == nil { + t.Fatal("expected error when branch is missing") + } + if !strings.Contains(err.Error(), "branch") { + t.Errorf("expected error to mention branch, got: %v", err) + } +} + +func TestGitCheckoutStep_Name(t *testing.T) { + factory := NewGitCheckoutStepFactory() + step, err := factory("my-checkout", map[string]any{ + "directory": "/tmp/repo", + "branch": "main", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-checkout" { + t.Errorf("expected name %q, got %q", "my-checkout", step.Name()) + } +} + +func TestGitCheckoutStep_FactoryWithCreate(t *testing.T) { + factory := NewGitCheckoutStepFactory() + raw, err := factory("checkout", map[string]any{ + "directory": "/tmp/repo", + "branch": "feature/new", + "create": true, + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitCheckoutStep) + if !s.create { + t.Error("expected create=true") + } +} + +func TestGitCheckoutStep_Execute_CheckoutExistingBranch(t *testing.T) { + var checkoutArgs []string + callCount := 0 + + s := &GitCheckoutStep{ + name: "checkout", + directory: "/tmp/repo", + branch: "main", + create: false, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + checkoutArgs = append([]string{name}, args...) + return exec.Command("true") // git checkout + } + return exec.Command("echo", "abc123") // git rev-parse HEAD + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["branch"] != "main" { + t.Errorf("expected branch=main, got %v", result.Output["branch"]) + } + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + if result.Output["created"] != false { + t.Errorf("expected created=false, got %v", result.Output["created"]) + } + + argsStr := strings.Join(checkoutArgs, " ") + if !strings.Contains(argsStr, "checkout") { + t.Errorf("expected checkout in args, got: %v", checkoutArgs) + } + if strings.Contains(argsStr, "-b") { + t.Errorf("expected no -b flag for existing branch checkout, got: %v", checkoutArgs) + } +} + +func TestGitCheckoutStep_Execute_CreateNewBranch(t *testing.T) { + var checkoutArgs []string + callCount := 0 + + s := &GitCheckoutStep{ + name: "checkout", + directory: "/tmp/repo", + branch: "feature/new", + create: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + checkoutArgs = append([]string{name}, args...) + return exec.Command("true") // git checkout -b + } + return exec.Command("echo", "abc123") // git rev-parse HEAD + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["created"] != true { + t.Errorf("expected created=true, got %v", result.Output["created"]) + } + + argsStr := strings.Join(checkoutArgs, " ") + if !strings.Contains(argsStr, "-b") { + t.Errorf("expected -b flag for new branch, got: %v", checkoutArgs) + } + if !strings.Contains(argsStr, "feature/new") { + t.Errorf("expected branch name in args, got: %v", checkoutArgs) + } +} + +func TestGitCheckoutStep_Execute_CheckoutFailure(t *testing.T) { + s := &GitCheckoutStep{ + name: "checkout", + directory: "/tmp/repo", + branch: "nonexistent", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("false") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when git checkout fails") + } + if !strings.Contains(err.Error(), "git checkout") { + t.Errorf("expected error to mention git checkout, got: %v", err) + } +} + +func TestGitCheckoutStep_Execute_TemplateResolution(t *testing.T) { + var checkoutArgs []string + callCount := 0 + + s := &GitCheckoutStep{ + name: "checkout", + directory: "/tmp/workspace/{{ .repo }}", + branch: "feature/{{ .feature_name }}", + create: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + checkoutArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "abc123") + }, + } + + pc := &PipelineContext{ + Current: map[string]any{"repo": "my-repo", "feature_name": "add-logging"}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{"repo": "my-repo", "feature_name": "add-logging"}, + Metadata: map[string]any{}, + } + + result, err := s.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["branch"] != "feature/add-logging" { + t.Errorf("expected branch=feature/add-logging, got %v", result.Output["branch"]) + } + + argsStr := strings.Join(checkoutArgs, " ") + if !strings.Contains(argsStr, "/tmp/workspace/my-repo") { + t.Errorf("expected resolved directory in args, got: %v", checkoutArgs) + } + if !strings.Contains(argsStr, "feature/add-logging") { + t.Errorf("expected resolved branch in args, got: %v", checkoutArgs) + } +} diff --git a/module/pipeline_step_git_clone.go b/module/pipeline_step_git_clone.go new file mode 100644 index 00000000..b4b6d41b --- /dev/null +++ b/module/pipeline_step_git_clone.go @@ -0,0 +1,185 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// GitCloneStep clones a git repository to a local directory. +type GitCloneStep struct { + name string + repository string + branch string + depth int + directory string + token string + sshKey string + tmpl *TemplateEngine + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewGitCloneStepFactory returns a StepFactory that creates GitCloneStep instances. +func NewGitCloneStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + repository, _ := config["repository"].(string) + if repository == "" { + return nil, fmt.Errorf("git_clone step %q: 'repository' is required", name) + } + + directory, _ := config["directory"].(string) + if directory == "" { + return nil, fmt.Errorf("git_clone step %q: 'directory' is required", name) + } + + branch, _ := config["branch"].(string) + token, _ := config["token"].(string) + sshKey, _ := config["ssh_key"].(string) + + depth := 0 + if d, ok := config["depth"].(int); ok { + depth = d + } + + return &GitCloneStep{ + name: name, + repository: repository, + branch: branch, + depth: depth, + directory: directory, + token: token, + sshKey: sshKey, + tmpl: NewTemplateEngine(), + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *GitCloneStep) Name() string { return s.name } + +// Execute clones the repository to the configured directory. +func (s *GitCloneStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + repository, err := s.tmpl.Resolve(s.repository, pc) + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to resolve repository: %w", s.name, err) + } + + directory, err := s.tmpl.Resolve(s.directory, pc) + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to resolve directory: %w", s.name, err) + } + + branch, err := s.tmpl.Resolve(s.branch, pc) + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to resolve branch: %w", s.name, err) + } + + token, err := s.tmpl.Resolve(s.token, pc) + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to resolve token: %w", s.name, err) + } + + sshKey, err := s.tmpl.Resolve(s.sshKey, pc) + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to resolve ssh_key: %w", s.name, err) + } + + // Inject token into HTTPS URL if provided. + cloneURL := repository + if token != "" && strings.HasPrefix(repository, "https://") { + cloneURL = strings.Replace(repository, "https://", "https://"+token+"@", 1) + } + + // Build git clone args. + args := []string{"clone"} + if branch != "" { + args = append(args, "--branch", branch) + } + if s.depth > 0 { + args = append(args, "--depth", fmt.Sprintf("%d", s.depth)) + } + args = append(args, cloneURL, directory) + + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", args...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Set up SSH key if provided. + if sshKey != "" { + keyFile, err := os.CreateTemp("", "git-ssh-key-*") + if err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to create SSH key temp file: %w", s.name, err) + } + defer os.Remove(keyFile.Name()) + + if _, err := keyFile.WriteString(sshKey); err != nil { + keyFile.Close() + return nil, fmt.Errorf("git_clone step %q: failed to write SSH key: %w", s.name, err) + } + keyFile.Close() + + if err := os.Chmod(keyFile.Name(), 0600); err != nil { + return nil, fmt.Errorf("git_clone step %q: failed to chmod SSH key: %w", s.name, err) + } + + cmd.Env = append(os.Environ(), + fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no", keyFile.Name()), + ) + } + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git_clone step %q: git clone failed: %w\nstdout: %s\nstderr: %s", + s.name, err, stdout.String(), stderr.String()) + } + + // Get commit SHA and branch from cloned repo. + commitSHA, err := s.getCommitSHA(ctx, directory) + if err != nil { + // Non-fatal: return success without SHA. + commitSHA = "" + } + + resolvedBranch := branch + if resolvedBranch == "" { + resolvedBranch, _ = s.getCurrentBranch(ctx, directory) + } + + return &StepResult{ + Output: map[string]any{ + "clone_dir": directory, + "commit_sha": commitSHA, + "branch": resolvedBranch, + "success": true, + }, + }, nil +} + +// getCommitSHA returns the HEAD commit SHA in the given directory. +func (s *GitCloneStep) getCommitSHA(ctx context.Context, dir string) (string, error) { + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "HEAD") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("rev-parse HEAD failed: %w\nstderr: %s", err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + +// getCurrentBranch returns the current branch name in the given directory. +func (s *GitCloneStep) getCurrentBranch(ctx context.Context, dir string) (string, error) { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/module/pipeline_step_git_clone_test.go b/module/pipeline_step_git_clone_test.go new file mode 100644 index 00000000..3057e84c --- /dev/null +++ b/module/pipeline_step_git_clone_test.go @@ -0,0 +1,301 @@ +package module + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +func TestGitCloneStep_FactoryRequiresRepository(t *testing.T) { + factory := NewGitCloneStepFactory() + _, err := factory("clone", map[string]any{"directory": "/tmp/repo"}, nil) + if err == nil { + t.Fatal("expected error when repository is missing") + } + if !strings.Contains(err.Error(), "repository") { + t.Errorf("expected error to mention repository, got: %v", err) + } +} + +func TestGitCloneStep_FactoryRequiresDirectory(t *testing.T) { + factory := NewGitCloneStepFactory() + _, err := factory("clone", map[string]any{"repository": "https://github.com/org/repo.git"}, nil) + if err == nil { + t.Fatal("expected error when directory is missing") + } + if !strings.Contains(err.Error(), "directory") { + t.Errorf("expected error to mention directory, got: %v", err) + } +} + +func TestGitCloneStep_Name(t *testing.T) { + factory := NewGitCloneStepFactory() + step, err := factory("my-clone", map[string]any{ + "repository": "https://github.com/org/repo.git", + "directory": "/tmp/repo", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-clone" { + t.Errorf("expected name %q, got %q", "my-clone", step.Name()) + } +} + +func TestGitCloneStep_FactoryDefaults(t *testing.T) { + factory := NewGitCloneStepFactory() + raw, err := factory("clone", map[string]any{ + "repository": "https://github.com/org/repo.git", + "directory": "/tmp/repo", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitCloneStep) + if s.branch != "" { + t.Errorf("expected empty default branch, got %q", s.branch) + } + if s.depth != 0 { + t.Errorf("expected depth 0, got %d", s.depth) + } +} + +func TestGitCloneStep_FactoryWithAllOptions(t *testing.T) { + factory := NewGitCloneStepFactory() + raw, err := factory("clone", map[string]any{ + "repository": "https://github.com/org/repo.git", + "directory": "/tmp/repo", + "branch": "main", + "depth": 1, + "token": "mytoken", + "ssh_key": "mykeydata", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitCloneStep) + if s.branch != "main" { + t.Errorf("expected branch main, got %q", s.branch) + } + if s.depth != 1 { + t.Errorf("expected depth 1, got %d", s.depth) + } + if s.token != "mytoken" { + t.Errorf("expected token mytoken, got %q", s.token) + } +} + +func TestGitCloneStep_Execute_Success(t *testing.T) { + var capturedArgs [][]string + callCount := 0 + + s := &GitCloneStep{ + name: "clone", + repository: "https://github.com/org/repo.git", + directory: "/tmp/repo", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + capturedArgs = append(capturedArgs, append([]string{name}, args...)) + if callCount == 1 { + // git clone — succeed + return exec.Command("true") + } + // git rev-parse HEAD and --abbrev-ref HEAD — echo a fake SHA/branch + if len(args) > 0 && args[len(args)-1] == "HEAD" { + return exec.Command("echo", "abc123def456") + } + return exec.Command("echo", "main") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + if result.Output["clone_dir"] != "/tmp/repo" { + t.Errorf("expected clone_dir=/tmp/repo, got %v", result.Output["clone_dir"]) + } + + // Verify git clone was called. + if callCount < 1 { + t.Error("expected at least one exec call") + } + if len(capturedArgs) < 1 || capturedArgs[0][0] != "git" { + t.Error("expected first call to be git") + } + cloneArgs := capturedArgs[0] + foundClone := false + for _, a := range cloneArgs { + if a == "clone" { + foundClone = true + break + } + } + if !foundClone { + t.Errorf("expected git clone in first call args, got %v", cloneArgs) + } +} + +func TestGitCloneStep_Execute_CloneFailure(t *testing.T) { + s := &GitCloneStep{ + name: "clone", + repository: "https://github.com/org/repo.git", + directory: "/tmp/repo", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("false") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when git clone fails") + } + if !strings.Contains(err.Error(), "git clone") { + t.Errorf("expected error to mention git clone, got: %v", err) + } +} + +func TestGitCloneStep_Execute_TokenInjectedIntoURL(t *testing.T) { + var capturedArgs []string + callCount := 0 + + s := &GitCloneStep{ + name: "clone", + repository: "https://github.com/org/repo.git", + directory: "/tmp/repo", + token: "secret-token", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + capturedArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "abc123") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Check that the token-injected URL appears in the clone args. + foundToken := false + for _, a := range capturedArgs { + if strings.Contains(a, "secret-token@") { + foundToken = true + break + } + } + if !foundToken { + t.Errorf("expected token to be injected into URL, args: %v", capturedArgs) + } +} + +func TestGitCloneStep_Execute_WithBranchAndDepth(t *testing.T) { + var cloneArgs []string + callCount := 0 + + s := &GitCloneStep{ + name: "clone", + repository: "https://github.com/org/repo.git", + directory: "/tmp/repo", + branch: "develop", + depth: 1, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + cloneArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "abc123") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(cloneArgs, " ") + if !strings.Contains(argsStr, "--branch") || !strings.Contains(argsStr, "develop") { + t.Errorf("expected --branch develop in args, got: %v", cloneArgs) + } + if !strings.Contains(argsStr, "--depth") || !strings.Contains(argsStr, "1") { + t.Errorf("expected --depth 1 in args, got: %v", cloneArgs) + } +} + +func TestGitCloneStep_Execute_TemplateResolution(t *testing.T) { + var capturedDir string + callCount := 0 + + s := &GitCloneStep{ + name: "clone", + repository: "https://github.com/org/{{ .repo }}.git", + directory: "/tmp/workspace/{{ .repo }}", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + // Last arg before the URL should be the directory. + for i, a := range args { + if a == "/tmp/workspace/my-repo" { + _ = i + capturedDir = a + } + } + return exec.Command("true") + } + return exec.Command("echo", "abc123") + }, + } + + pc := &PipelineContext{ + Current: map[string]any{"repo": "my-repo"}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{"repo": "my-repo"}, + Metadata: map[string]any{}, + } + + result, err := s.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["clone_dir"] != "/tmp/workspace/my-repo" { + t.Errorf("expected clone_dir=/tmp/workspace/my-repo, got %v", result.Output["clone_dir"]) + } + if capturedDir != "/tmp/workspace/my-repo" { + t.Errorf("expected directory /tmp/workspace/my-repo in git args, got %q", capturedDir) + } +} diff --git a/module/pipeline_step_git_commit.go b/module/pipeline_step_git_commit.go new file mode 100644 index 00000000..3375be14 --- /dev/null +++ b/module/pipeline_step_git_commit.go @@ -0,0 +1,183 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// GitCommitStep creates a git commit in a local repository. +type GitCommitStep struct { + name string + directory string + message string + authorName string + authorEmail string + addAll bool + addFiles []string + tmpl *TemplateEngine + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewGitCommitStepFactory returns a StepFactory that creates GitCommitStep instances. +func NewGitCommitStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + directory, _ := config["directory"].(string) + if directory == "" { + return nil, fmt.Errorf("git_commit step %q: 'directory' is required", name) + } + + message, _ := config["message"].(string) + if message == "" { + return nil, fmt.Errorf("git_commit step %q: 'message' is required", name) + } + + authorName, _ := config["author_name"].(string) + authorEmail, _ := config["author_email"].(string) + addAll, _ := config["add_all"].(bool) + + var addFiles []string + if filesRaw, ok := config["add_files"].([]any); ok { + for i, f := range filesRaw { + s, ok := f.(string) + if !ok { + return nil, fmt.Errorf("git_commit step %q: add_files[%d] must be a string", name, i) + } + addFiles = append(addFiles, s) + } + } + + return &GitCommitStep{ + name: name, + directory: directory, + message: message, + authorName: authorName, + authorEmail: authorEmail, + addAll: addAll, + addFiles: addFiles, + tmpl: NewTemplateEngine(), + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *GitCommitStep) Name() string { return s.name } + +// Execute stages files and creates a commit. +func (s *GitCommitStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + directory, err := s.tmpl.Resolve(s.directory, pc) + if err != nil { + return nil, fmt.Errorf("git_commit step %q: failed to resolve directory: %w", s.name, err) + } + + message, err := s.tmpl.Resolve(s.message, pc) + if err != nil { + return nil, fmt.Errorf("git_commit step %q: failed to resolve message: %w", s.name, err) + } + + // Stage files. + if s.addAll { + if err := s.runGit(ctx, directory, "add", "-A"); err != nil { + return nil, fmt.Errorf("git_commit step %q: git add -A failed: %w", s.name, err) + } + } else if len(s.addFiles) > 0 { + addArgs := append([]string{"add", "--"}, s.addFiles...) + if err := s.runGit(ctx, directory, addArgs...); err != nil { + return nil, fmt.Errorf("git_commit step %q: git add failed: %w", s.name, err) + } + } + + // Build commit args. + commitArgs := []string{"commit", "-m", message} + if s.authorName != "" && s.authorEmail != "" { + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", s.authorName, s.authorEmail)) + } + + // Run commit. + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", append([]string{"-C", directory}, commitArgs...)...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Check if "nothing to commit" — not an error. + combined := stdout.String() + stderr.String() + if strings.Contains(combined, "nothing to commit") || + strings.Contains(combined, "nothing added to commit") { + return &StepResult{ + Output: map[string]any{ + "commit_sha": "", + "message": message, + "files_changed": 0, + "success": true, + }, + }, nil + } + return nil, fmt.Errorf("git_commit step %q: git commit failed: %w\nstdout: %s\nstderr: %s", + s.name, err, stdout.String(), stderr.String()) + } + + // Parse commit SHA. + commitSHA, err := s.getCommitSHA(ctx, directory) + if err != nil { + commitSHA = "" + } + + // Count files changed from commit output. + filesChanged := parseFilesChanged(stdout.String()) + + return &StepResult{ + Output: map[string]any{ + "commit_sha": commitSHA, + "message": message, + "files_changed": filesChanged, + "success": true, + }, + }, nil +} + +// runGit runs a git subcommand in the given directory. +func (s *GitCommitStep) runGit(ctx context.Context, dir string, args ...string) error { + fullArgs := append([]string{"-C", dir}, args...) + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", fullArgs...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + return nil +} + +// getCommitSHA returns the HEAD commit SHA for the given directory. +func (s *GitCommitStep) getCommitSHA(ctx context.Context, dir string) (string, error) { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "HEAD") //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} + +// parseFilesChanged extracts the number of files changed from git commit output. +// e.g. " 3 files changed, 10 insertions(+)" +func parseFilesChanged(output string) int { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "file") && strings.Contains(line, "changed") { + fields := strings.Fields(line) + if len(fields) > 0 { + if n, err := strconv.Atoi(fields[0]); err == nil { + return n + } + } + } + } + return 0 +} diff --git a/module/pipeline_step_git_commit_test.go b/module/pipeline_step_git_commit_test.go new file mode 100644 index 00000000..bcf13574 --- /dev/null +++ b/module/pipeline_step_git_commit_test.go @@ -0,0 +1,331 @@ +package module + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +func TestGitCommitStep_FactoryRequiresDirectory(t *testing.T) { + factory := NewGitCommitStepFactory() + _, err := factory("commit", map[string]any{"message": "update config"}, nil) + if err == nil { + t.Fatal("expected error when directory is missing") + } + if !strings.Contains(err.Error(), "directory") { + t.Errorf("expected error to mention directory, got: %v", err) + } +} + +func TestGitCommitStep_FactoryRequiresMessage(t *testing.T) { + factory := NewGitCommitStepFactory() + _, err := factory("commit", map[string]any{"directory": "/tmp/repo"}, nil) + if err == nil { + t.Fatal("expected error when message is missing") + } + if !strings.Contains(err.Error(), "message") { + t.Errorf("expected error to mention message, got: %v", err) + } +} + +func TestGitCommitStep_FactoryAddFilesInvalidEntry(t *testing.T) { + factory := NewGitCommitStepFactory() + _, err := factory("commit", map[string]any{ + "directory": "/tmp/repo", + "message": "update config", + "add_files": []any{42}, + }, nil) + if err == nil { + t.Fatal("expected error for non-string add_files entry") + } +} + +func TestGitCommitStep_Name(t *testing.T) { + factory := NewGitCommitStepFactory() + step, err := factory("my-commit", map[string]any{ + "directory": "/tmp/repo", + "message": "update config", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-commit" { + t.Errorf("expected name %q, got %q", "my-commit", step.Name()) + } +} + +func TestGitCommitStep_Execute_AddAllAndCommit(t *testing.T) { + var gitCalls [][]string + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/repo", + message: "update config", + addAll: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + gitCalls = append(gitCalls, append([]string{name}, args...)) + // First call: git add -A — succeed. + // Second call: git commit — return output with "1 file changed". + // Third call: git rev-parse HEAD. + switch len(gitCalls) { + case 1: + return exec.Command("true") // git add -A + case 2: + return exec.Command("echo", "1 file changed, 5 insertions(+)") // git commit + default: + return exec.Command("echo", "abc123def456") // rev-parse HEAD + } + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + + // Verify git add -A was called. + foundAddAll := false + for _, call := range gitCalls { + argsStr := strings.Join(call, " ") + if strings.Contains(argsStr, "add") && strings.Contains(argsStr, "-A") { + foundAddAll = true + break + } + } + if !foundAddAll { + t.Errorf("expected git add -A call, got: %v", gitCalls) + } +} + +func TestGitCommitStep_Execute_AddSpecificFiles(t *testing.T) { + var gitCalls [][]string + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/repo", + message: "update config", + addAll: false, + addFiles: []string{"config/app.yaml", "generated/"}, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + gitCalls = append(gitCalls, append([]string{name}, args...)) + switch len(gitCalls) { + case 1: + return exec.Command("true") // git add -- files + case 2: + return exec.Command("echo", "2 files changed") // git commit + default: + return exec.Command("echo", "abc123") // rev-parse HEAD + } + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Verify specific files were passed to git add. + foundSpecificAdd := false + for _, call := range gitCalls { + argsStr := strings.Join(call, " ") + if strings.Contains(argsStr, "add") && strings.Contains(argsStr, "config/app.yaml") { + foundSpecificAdd = true + break + } + } + if !foundSpecificAdd { + t.Errorf("expected git add with specific files, got: %v", gitCalls) + } +} + +func TestGitCommitStep_Execute_NothingToCommit(t *testing.T) { + callCount := 0 + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/repo", + message: "update config", + addAll: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + return exec.Command("true") // git add -A + } + // git commit outputs "nothing to commit" and exits non-zero. + return exec.Command("bash", "-c", "echo 'nothing to commit, working tree clean'; exit 1") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("expected no error for nothing to commit, got: %v", err) + } + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + if result.Output["files_changed"] != 0 { + t.Errorf("expected files_changed=0, got %v", result.Output["files_changed"]) + } +} + +func TestGitCommitStep_Execute_CommitFailure(t *testing.T) { + callCount := 0 + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/repo", + message: "update config", + addAll: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + return exec.Command("true") // git add -A + } + return exec.Command("false") // git commit fails + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when git commit fails") + } + if !strings.Contains(err.Error(), "git commit") { + t.Errorf("expected error to mention git commit, got: %v", err) + } +} + +func TestGitCommitStep_Execute_WithAuthor(t *testing.T) { + var commitArgs []string + callCount := 0 + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/repo", + message: "update config", + authorName: "Workflow Bot", + authorEmail: "bot@workflow.dev", + addAll: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + return exec.Command("true") // git add -A + } + if callCount == 2 { + commitArgs = append([]string{name}, args...) + return exec.Command("echo", "1 file changed") + } + return exec.Command("echo", "abc123") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(commitArgs, " ") + if !strings.Contains(argsStr, "--author") { + t.Errorf("expected --author in commit args, got: %v", commitArgs) + } + if !strings.Contains(argsStr, "Workflow Bot") { + t.Errorf("expected author name in commit args, got: %v", commitArgs) + } +} + +func TestGitCommitStep_Execute_TemplateResolution(t *testing.T) { + var commitArgs []string + callCount := 0 + + s := &GitCommitStep{ + name: "commit", + directory: "/tmp/workspace/{{ .repo }}", + message: "Update config for {{ .version }}", + addAll: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + return exec.Command("true") // git add -A + } + if callCount == 2 { + commitArgs = append([]string{name}, args...) + return exec.Command("echo", "1 file changed") + } + return exec.Command("echo", "abc123") + }, + } + + pc := &PipelineContext{ + Current: map[string]any{"repo": "my-repo", "version": "v1.2.0"}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{"repo": "my-repo", "version": "v1.2.0"}, + Metadata: map[string]any{}, + } + + _, err := s.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(commitArgs, " ") + if !strings.Contains(argsStr, "/tmp/workspace/my-repo") { + t.Errorf("expected resolved directory in args, got: %v", commitArgs) + } + if !strings.Contains(argsStr, "Update config for v1.2.0") { + t.Errorf("expected resolved message in commit args, got: %v", commitArgs) + } +} + +func TestParseFilesChanged(t *testing.T) { + tests := []struct { + input string + expected int + }{ + {" 3 files changed, 10 insertions(+)", 3}, + {" 1 file changed, 5 insertions(+), 2 deletions(-)", 1}, + {"nothing to commit", 0}, + {"", 0}, + } + + for _, tc := range tests { + got := parseFilesChanged(tc.input) + if got != tc.expected { + t.Errorf("parseFilesChanged(%q) = %d, want %d", tc.input, got, tc.expected) + } + } +} diff --git a/module/pipeline_step_git_push.go b/module/pipeline_step_git_push.go new file mode 100644 index 00000000..6adbf655 --- /dev/null +++ b/module/pipeline_step_git_push.go @@ -0,0 +1,160 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// GitPushStep pushes commits to a remote repository. +type GitPushStep struct { + name string + directory string + remote string + branch string + force bool + tags bool + token string + tmpl *TemplateEngine + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewGitPushStepFactory returns a StepFactory that creates GitPushStep instances. +func NewGitPushStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + directory, _ := config["directory"].(string) + if directory == "" { + return nil, fmt.Errorf("git_push step %q: 'directory' is required", name) + } + + remote, _ := config["remote"].(string) + if remote == "" { + remote = "origin" + } + + branch, _ := config["branch"].(string) + force, _ := config["force"].(bool) + tags, _ := config["tags"].(bool) + token, _ := config["token"].(string) + + return &GitPushStep{ + name: name, + directory: directory, + remote: remote, + branch: branch, + force: force, + tags: tags, + token: token, + tmpl: NewTemplateEngine(), + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *GitPushStep) Name() string { return s.name } + +// Execute pushes commits to the configured remote. +func (s *GitPushStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + directory, err := s.tmpl.Resolve(s.directory, pc) + if err != nil { + return nil, fmt.Errorf("git_push step %q: failed to resolve directory: %w", s.name, err) + } + + remote, err := s.tmpl.Resolve(s.remote, pc) + if err != nil { + return nil, fmt.Errorf("git_push step %q: failed to resolve remote: %w", s.name, err) + } + + branch, err := s.tmpl.Resolve(s.branch, pc) + if err != nil { + return nil, fmt.Errorf("git_push step %q: failed to resolve branch: %w", s.name, err) + } + + token, err := s.tmpl.Resolve(s.token, pc) + if err != nil { + return nil, fmt.Errorf("git_push step %q: failed to resolve token: %w", s.name, err) + } + + // If a token is provided, rewrite the remote URL to embed the token. + if token != "" { + if err := s.injectTokenIntoRemote(ctx, directory, remote, token); err != nil { + return nil, fmt.Errorf("git_push step %q: failed to inject token into remote: %w", s.name, err) + } + } + + // Resolve the current branch if not specified. + resolvedBranch := branch + if resolvedBranch == "" { + resolvedBranch, err = s.getCurrentBranch(ctx, directory) + if err != nil { + return nil, fmt.Errorf("git_push step %q: failed to get current branch: %w", s.name, err) + } + } + + // Build push args. + args := []string{"-C", directory, "push", remote, resolvedBranch} + if s.force { + args = append(args, "--force") + } + if s.tags { + args = append(args, "--tags") + } + + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", args...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git_push step %q: git push failed: %w\nstdout: %s\nstderr: %s", + s.name, err, stdout.String(), stderr.String()) + } + + return &StepResult{ + Output: map[string]any{ + "remote": remote, + "branch": resolvedBranch, + "success": true, + }, + }, nil +} + +// injectTokenIntoRemote rewrites the remote URL to embed the token. +func (s *GitPushStep) injectTokenIntoRemote(ctx context.Context, dir, remote, token string) error { + // Get current remote URL. + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "remote", "get-url", remote) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + remoteURL := strings.TrimSpace(stdout.String()) + + // Inject token into HTTPS URL. + if strings.HasPrefix(remoteURL, "https://") { + newURL := strings.Replace(remoteURL, "https://", "https://"+token+"@", 1) + var stderr bytes.Buffer + setCmd := s.execCommand(ctx, "git", "-C", dir, "remote", "set-url", remote, newURL) //nolint:gosec // G204: args from trusted pipeline config + setCmd.Stderr = &stderr + if err := setCmd.Run(); err != nil { + return fmt.Errorf("failed to set remote URL: %w\nstderr: %s", err, stderr.String()) + } + } + return nil +} + +// getCurrentBranch returns the current branch name for the given directory. +func (s *GitPushStep) getCurrentBranch(ctx context.Context, dir string) (string, error) { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/module/pipeline_step_git_push_test.go b/module/pipeline_step_git_push_test.go new file mode 100644 index 00000000..ef57832e --- /dev/null +++ b/module/pipeline_step_git_push_test.go @@ -0,0 +1,260 @@ +package module + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +func TestGitPushStep_FactoryRequiresDirectory(t *testing.T) { + factory := NewGitPushStepFactory() + _, err := factory("push", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error when directory is missing") + } + if !strings.Contains(err.Error(), "directory") { + t.Errorf("expected error to mention directory, got: %v", err) + } +} + +func TestGitPushStep_FactoryDefaultRemote(t *testing.T) { + factory := NewGitPushStepFactory() + raw, err := factory("push", map[string]any{"directory": "/tmp/repo"}, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitPushStep) + if s.remote != "origin" { + t.Errorf("expected default remote origin, got %q", s.remote) + } +} + +func TestGitPushStep_Name(t *testing.T) { + factory := NewGitPushStepFactory() + step, err := factory("my-push", map[string]any{"directory": "/tmp/repo"}, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-push" { + t.Errorf("expected name %q, got %q", "my-push", step.Name()) + } +} + +func TestGitPushStep_FactoryWithOptions(t *testing.T) { + factory := NewGitPushStepFactory() + raw, err := factory("push", map[string]any{ + "directory": "/tmp/repo", + "remote": "upstream", + "branch": "main", + "force": true, + "tags": true, + "token": "mytoken", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitPushStep) + if s.remote != "upstream" { + t.Errorf("expected remote upstream, got %q", s.remote) + } + if s.branch != "main" { + t.Errorf("expected branch main, got %q", s.branch) + } + if !s.force { + t.Error("expected force=true") + } + if !s.tags { + t.Error("expected tags=true") + } +} + +func TestGitPushStep_Execute_Success(t *testing.T) { + var pushArgs []string + callCount := 0 + + s := &GitPushStep{ + name: "push", + directory: "/tmp/repo", + remote: "origin", + branch: "main", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + // git push + pushArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "main") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + if result.Output["remote"] != "origin" { + t.Errorf("expected remote=origin, got %v", result.Output["remote"]) + } + if result.Output["branch"] != "main" { + t.Errorf("expected branch=main, got %v", result.Output["branch"]) + } + + argsStr := strings.Join(pushArgs, " ") + if !strings.Contains(argsStr, "push") { + t.Errorf("expected push in args, got: %v", pushArgs) + } +} + +func TestGitPushStep_Execute_PushFailure(t *testing.T) { + s := &GitPushStep{ + name: "push", + directory: "/tmp/repo", + remote: "origin", + branch: "main", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("false") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when git push fails") + } + if !strings.Contains(err.Error(), "git push") { + t.Errorf("expected error to mention git push, got: %v", err) + } +} + +func TestGitPushStep_Execute_ForceAndTags(t *testing.T) { + var pushArgs []string + callCount := 0 + + s := &GitPushStep{ + name: "push", + directory: "/tmp/repo", + remote: "origin", + branch: "main", + force: true, + tags: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + pushArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "main") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(pushArgs, " ") + if !strings.Contains(argsStr, "--force") { + t.Errorf("expected --force in push args, got: %v", pushArgs) + } + if !strings.Contains(argsStr, "--tags") { + t.Errorf("expected --tags in push args, got: %v", pushArgs) + } +} + +func TestGitPushStep_Execute_InfersBranch(t *testing.T) { + callCount := 0 + + s := &GitPushStep{ + name: "push", + directory: "/tmp/repo", + remote: "origin", + branch: "", // not set — should infer from current branch + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + // rev-parse --abbrev-ref HEAD — return "feature-branch" + return exec.Command("echo", "feature-branch") + } + // git push + return exec.Command("true") + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["branch"] != "feature-branch" { + t.Errorf("expected branch=feature-branch, got %v", result.Output["branch"]) + } +} + +func TestGitPushStep_Execute_TemplateResolution(t *testing.T) { + var pushArgs []string + callCount := 0 + + s := &GitPushStep{ + name: "push", + directory: "/tmp/workspace/{{ .repo }}", + remote: "origin", + branch: "{{ .branch }}", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + pushArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "main") + }, + } + + pc := &PipelineContext{ + Current: map[string]any{"repo": "my-repo", "branch": "release/1.0"}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{"repo": "my-repo", "branch": "release/1.0"}, + Metadata: map[string]any{}, + } + + result, err := s.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(pushArgs, " ") + if !strings.Contains(argsStr, "/tmp/workspace/my-repo") { + t.Errorf("expected resolved directory in args, got: %v", pushArgs) + } + if result.Output["branch"] != "release/1.0" { + t.Errorf("expected branch=release/1.0, got %v", result.Output["branch"]) + } +} diff --git a/module/pipeline_step_git_tag.go b/module/pipeline_step_git_tag.go new file mode 100644 index 00000000..0204b4ad --- /dev/null +++ b/module/pipeline_step_git_tag.go @@ -0,0 +1,175 @@ +package module + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/CrisisTextLine/modular" +) + +// GitTagStep creates and optionally pushes a git tag. +type GitTagStep struct { + name string + directory string + tag string + message string + push bool + token string + tmpl *TemplateEngine + execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// NewGitTagStepFactory returns a StepFactory that creates GitTagStep instances. +func NewGitTagStepFactory() StepFactory { + return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + directory, _ := config["directory"].(string) + if directory == "" { + return nil, fmt.Errorf("git_tag step %q: 'directory' is required", name) + } + + tag, _ := config["tag"].(string) + if tag == "" { + return nil, fmt.Errorf("git_tag step %q: 'tag' is required", name) + } + + message, _ := config["message"].(string) + push, _ := config["push"].(bool) + token, _ := config["token"].(string) + + return &GitTagStep{ + name: name, + directory: directory, + tag: tag, + message: message, + push: push, + token: token, + tmpl: NewTemplateEngine(), + execCommand: exec.CommandContext, + }, nil + } +} + +// Name returns the step name. +func (s *GitTagStep) Name() string { return s.name } + +// Execute creates the tag and optionally pushes it. +func (s *GitTagStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + directory, err := s.tmpl.Resolve(s.directory, pc) + if err != nil { + return nil, fmt.Errorf("git_tag step %q: failed to resolve directory: %w", s.name, err) + } + + tag, err := s.tmpl.Resolve(s.tag, pc) + if err != nil { + return nil, fmt.Errorf("git_tag step %q: failed to resolve tag: %w", s.name, err) + } + + message, err := s.tmpl.Resolve(s.message, pc) + if err != nil { + return nil, fmt.Errorf("git_tag step %q: failed to resolve message: %w", s.name, err) + } + + token, err := s.tmpl.Resolve(s.token, pc) + if err != nil { + return nil, fmt.Errorf("git_tag step %q: failed to resolve token: %w", s.name, err) + } + + // Build tag args. + tagArgs := []string{"-C", directory, "tag"} + if message != "" { + // Annotated tag. + tagArgs = append(tagArgs, "-a", tag, "-m", message) + } else { + // Lightweight tag. + tagArgs = append(tagArgs, tag) + } + + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", tagArgs...) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git_tag step %q: git tag failed: %w\nstdout: %s\nstderr: %s", + s.name, err, stdout.String(), stderr.String()) + } + + // Get the commit SHA for the tag. + commitSHA, err := s.getTagCommitSHA(ctx, directory, tag) + if err != nil { + commitSHA = "" + } + + pushed := false + if s.push { + if err := s.pushTag(ctx, directory, tag, token); err != nil { + return nil, fmt.Errorf("git_tag step %q: failed to push tag: %w", s.name, err) + } + pushed = true + } + + return &StepResult{ + Output: map[string]any{ + "tag": tag, + "commit_sha": commitSHA, + "pushed": pushed, + "success": true, + }, + }, nil +} + +// pushTag pushes the tag to the remote origin. +func (s *GitTagStep) pushTag(ctx context.Context, dir, tag, token string) error { + // If token is provided, inject into remote URL first. + if token != "" { + if err := s.injectTokenIntoRemote(ctx, dir, "origin", token); err != nil { + return fmt.Errorf("failed to inject token: %w", err) + } + } + + var stdout, stderr bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "push", "origin", tag) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git push tag failed: %w\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + return nil +} + +// injectTokenIntoRemote rewrites the origin remote URL to embed the token. +func (s *GitTagStep) injectTokenIntoRemote(ctx context.Context, dir, remote, token string) error { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "remote", "get-url", remote) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + remoteURL := strings.TrimSpace(stdout.String()) + + if strings.HasPrefix(remoteURL, "https://") { + newURL := strings.Replace(remoteURL, "https://", "https://"+token+"@", 1) + var stderr bytes.Buffer + setCmd := s.execCommand(ctx, "git", "-C", dir, "remote", "set-url", remote, newURL) //nolint:gosec // G204: args from trusted pipeline config + setCmd.Stderr = &stderr + if err := setCmd.Run(); err != nil { + return fmt.Errorf("failed to set remote URL: %w\nstderr: %s", err, stderr.String()) + } + } + return nil +} + +// getTagCommitSHA returns the commit SHA that the tag points to. +func (s *GitTagStep) getTagCommitSHA(ctx context.Context, dir, tag string) (string, error) { + var stdout bytes.Buffer + cmd := s.execCommand(ctx, "git", "-C", dir, "rev-list", "-n", "1", tag) //nolint:gosec // G204: args from trusted pipeline config + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/module/pipeline_step_git_tag_test.go b/module/pipeline_step_git_tag_test.go new file mode 100644 index 00000000..15e7c061 --- /dev/null +++ b/module/pipeline_step_git_tag_test.go @@ -0,0 +1,302 @@ +package module + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +func TestGitTagStep_FactoryRequiresDirectory(t *testing.T) { + factory := NewGitTagStepFactory() + _, err := factory("tag", map[string]any{"tag": "v1.0.0"}, nil) + if err == nil { + t.Fatal("expected error when directory is missing") + } + if !strings.Contains(err.Error(), "directory") { + t.Errorf("expected error to mention directory, got: %v", err) + } +} + +func TestGitTagStep_FactoryRequiresTag(t *testing.T) { + factory := NewGitTagStepFactory() + _, err := factory("tag", map[string]any{"directory": "/tmp/repo"}, nil) + if err == nil { + t.Fatal("expected error when tag is missing") + } + if !strings.Contains(err.Error(), "tag") { + t.Errorf("expected error to mention tag, got: %v", err) + } +} + +func TestGitTagStep_Name(t *testing.T) { + factory := NewGitTagStepFactory() + step, err := factory("my-tag", map[string]any{ + "directory": "/tmp/repo", + "tag": "v1.0.0", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + if step.Name() != "my-tag" { + t.Errorf("expected name %q, got %q", "my-tag", step.Name()) + } +} + +func TestGitTagStep_FactoryWithOptions(t *testing.T) { + factory := NewGitTagStepFactory() + raw, err := factory("tag", map[string]any{ + "directory": "/tmp/repo", + "tag": "v1.0.0", + "message": "Release v1.0.0", + "push": true, + "token": "mytoken", + }, nil) + if err != nil { + t.Fatalf("unexpected factory error: %v", err) + } + s := raw.(*GitTagStep) + if s.message != "Release v1.0.0" { + t.Errorf("expected message, got %q", s.message) + } + if !s.push { + t.Error("expected push=true") + } +} + +func TestGitTagStep_Execute_LightweightTag(t *testing.T) { + var tagArgs []string + callCount := 0 + + s := &GitTagStep{ + name: "tag", + directory: "/tmp/repo", + tag: "v1.0.0", + message: "", // lightweight tag + push: false, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + tagArgs = append([]string{name}, args...) + return exec.Command("true") // git tag + } + return exec.Command("echo", "abc123") // git rev-list + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["tag"] != "v1.0.0" { + t.Errorf("expected tag=v1.0.0, got %v", result.Output["tag"]) + } + if result.Output["pushed"] != false { + t.Errorf("expected pushed=false, got %v", result.Output["pushed"]) + } + if result.Output["success"] != true { + t.Errorf("expected success=true, got %v", result.Output["success"]) + } + + // Verify no -a flag (lightweight tag). + argsStr := strings.Join(tagArgs, " ") + if strings.Contains(argsStr, " -a ") { + t.Errorf("expected no -a flag for lightweight tag, got: %v", tagArgs) + } +} + +func TestGitTagStep_Execute_AnnotatedTag(t *testing.T) { + var tagArgs []string + callCount := 0 + + s := &GitTagStep{ + name: "tag", + directory: "/tmp/repo", + tag: "v1.0.0", + message: "Release v1.0.0", + push: false, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + tagArgs = append([]string{name}, args...) + return exec.Command("true") // git tag + } + return exec.Command("echo", "abc123") // git rev-list + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + argsStr := strings.Join(tagArgs, " ") + if !strings.Contains(argsStr, "-a") { + t.Errorf("expected -a flag for annotated tag, got: %v", tagArgs) + } + if !strings.Contains(argsStr, "-m") { + t.Errorf("expected -m flag for annotated tag, got: %v", tagArgs) + } + if !strings.Contains(argsStr, "Release v1.0.0") { + t.Errorf("expected message in tag args, got: %v", tagArgs) + } +} + +func TestGitTagStep_Execute_TagFailure(t *testing.T) { + s := &GitTagStep{ + name: "tag", + directory: "/tmp/repo", + tag: "v1.0.0", + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, _ ...string) *exec.Cmd { + return exec.Command("false") + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when git tag fails") + } + if !strings.Contains(err.Error(), "git tag") { + t.Errorf("expected error to mention git tag, got: %v", err) + } +} + +func TestGitTagStep_Execute_WithPush(t *testing.T) { + var gitCalls [][]string + callCount := 0 + + s := &GitTagStep{ + name: "tag", + directory: "/tmp/repo", + tag: "v1.0.0", + message: "Release", + push: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + gitCalls = append(gitCalls, append([]string{name}, args...)) + return exec.Command("true") // all succeed + }, + } + + result, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["pushed"] != true { + t.Errorf("expected pushed=true, got %v", result.Output["pushed"]) + } + + // Expect calls: git tag, git rev-list, git push origin v1.0.0 + foundPush := false + for _, call := range gitCalls { + argsStr := strings.Join(call, " ") + if strings.Contains(argsStr, "push") && strings.Contains(argsStr, "v1.0.0") { + foundPush = true + break + } + } + if !foundPush { + t.Errorf("expected git push with tag, got calls: %v", gitCalls) + } +} + +func TestGitTagStep_Execute_PushFailure(t *testing.T) { + callCount := 0 + + s := &GitTagStep{ + name: "tag", + directory: "/tmp/repo", + tag: "v1.0.0", + push: true, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, _ string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + return exec.Command("true") // git tag succeeds + } + return exec.Command("false") // rev-list and push fail + }, + } + + _, err := s.Execute(context.Background(), &PipelineContext{ + Current: map[string]any{}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{}, + Metadata: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error when push fails") + } +} + +func TestGitTagStep_Execute_TemplateResolution(t *testing.T) { + var tagArgs []string + callCount := 0 + + s := &GitTagStep{ + name: "tag", + directory: "/tmp/workspace/{{ .repo }}", + tag: "v{{ .version }}", + message: "Release {{ .version }}", + push: false, + tmpl: NewTemplateEngine(), + execCommand: func(_ context.Context, name string, args ...string) *exec.Cmd { + callCount++ + if callCount == 1 { + tagArgs = append([]string{name}, args...) + return exec.Command("true") + } + return exec.Command("echo", "abc123") + }, + } + + pc := &PipelineContext{ + Current: map[string]any{"repo": "my-repo", "version": "1.2.0"}, + StepOutputs: map[string]map[string]any{}, + TriggerData: map[string]any{"repo": "my-repo", "version": "1.2.0"}, + Metadata: map[string]any{}, + } + + result, err := s.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + if result.Output["tag"] != "v1.2.0" { + t.Errorf("expected tag=v1.2.0, got %v", result.Output["tag"]) + } + + argsStr := strings.Join(tagArgs, " ") + if !strings.Contains(argsStr, "v1.2.0") { + t.Errorf("expected resolved tag in args, got: %v", tagArgs) + } + if !strings.Contains(argsStr, "/tmp/workspace/my-repo") { + t.Errorf("expected resolved directory in args, got: %v", tagArgs) + } +} diff --git a/plugins/cicd/plugin.go b/plugins/cicd/plugin.go index 145c695d..7aa64a16 100644 --- a/plugins/cicd/plugin.go +++ b/plugins/cicd/plugin.go @@ -1,7 +1,7 @@ // Package cicd provides a plugin that registers CI/CD pipeline step types: // shell_exec, artifact_pull, artifact_push, docker_build, docker_push, // docker_run, scan_sast, scan_container, scan_deps, deploy, gate, build_ui, -// build_from_config. +// build_from_config, git_clone, git_commit, git_push, git_tag, git_checkout. package cicd import ( @@ -23,13 +23,13 @@ func New() *Plugin { BaseNativePlugin: plugin.BaseNativePlugin{ PluginName: "cicd", PluginVersion: "1.0.0", - PluginDescription: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config)", + PluginDescription: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config, git operations)", }, Manifest: plugin.PluginManifest{ Name: "cicd", Version: "1.0.0", Author: "GoCodeAlone", - Description: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config)", + Description: "CI/CD pipeline step types (shell exec, Docker, artifact management, security scanning, deploy, gate, build from config, git operations)", Tier: plugin.TierCore, StepTypes: []string{ "step.shell_exec", @@ -45,6 +45,11 @@ func New() *Plugin { "step.gate", "step.build_ui", "step.build_from_config", + "step.git_clone", + "step.git_commit", + "step.git_push", + "step.git_tag", + "step.git_checkout", }, Capabilities: []plugin.CapabilityDecl{ {Name: "cicd-pipeline", Role: "provider", Priority: 50}, @@ -59,7 +64,7 @@ func (p *Plugin) Capabilities() []capability.Contract { return []capability.Contract{ { Name: "cicd-pipeline", - Description: "CI/CD pipeline operations: shell exec, Docker, artifact management, security scanning, deploy, gate, build from config", + Description: "CI/CD pipeline operations: shell exec, Docker, artifact management, security scanning, deploy, gate, build from config, git operations", }, } } @@ -80,6 +85,11 @@ func (p *Plugin) StepFactories() map[string]plugin.StepFactory { "step.gate": wrapStepFactory(module.NewGateStepFactory()), "step.build_ui": wrapStepFactory(module.NewBuildUIStepFactory()), "step.build_from_config": wrapStepFactory(module.NewBuildFromConfigStepFactory()), + "step.git_clone": wrapStepFactory(module.NewGitCloneStepFactory()), + "step.git_commit": wrapStepFactory(module.NewGitCommitStepFactory()), + "step.git_push": wrapStepFactory(module.NewGitPushStepFactory()), + "step.git_tag": wrapStepFactory(module.NewGitTagStepFactory()), + "step.git_checkout": wrapStepFactory(module.NewGitCheckoutStepFactory()), } } diff --git a/plugins/cicd/plugin_test.go b/plugins/cicd/plugin_test.go index 0f598a05..0e476447 100644 --- a/plugins/cicd/plugin_test.go +++ b/plugins/cicd/plugin_test.go @@ -44,6 +44,11 @@ func TestStepFactories(t *testing.T) { "step.gate", "step.build_ui", "step.build_from_config", + "step.git_clone", + "step.git_commit", + "step.git_push", + "step.git_tag", + "step.git_checkout", } for _, stepType := range expectedSteps { @@ -66,7 +71,7 @@ func TestPluginLoads(t *testing.T) { } steps := loader.StepFactories() - if len(steps) != 13 { - t.Fatalf("expected 13 step factories after load, got %d", len(steps)) + if len(steps) != 18 { + t.Fatalf("expected 18 step factories after load, got %d", len(steps)) } }