From ae9b66169a7d04206f4402868d01ff0a07ac9fc8 Mon Sep 17 00:00:00 2001 From: Ziemek Borowski Date: Sun, 12 Apr 2026 06:27:50 +0200 Subject: [PATCH] Replace PS1 scripts with native Go/Makefile tooling - Add Makefile: build, build-verbose, test, integration-test, clean, help - Add scripts/check-integration-env.sh: validates MSGRAPH* env vars before integration tests, exits 1 with per-variable error messages - Add internal/devtools/release: interactive release automation in Go (version bump, changelog, git commit/tag/push, dev-branch creation) replaces run-interactive-release.ps1; exposed as 'gomailtest devtools release' - Add internal/devtools/env: MSGRAPH* env var management commands exposed as 'gomailtest devtools env' - Add tests/integration/sendmail_test.go: Go integration test replacing the Pester-based Test-SendMail.ps1 - Wire devtools subcommand into root cobra command - Delete tests/Test-SendMail.ps1: broken (hardcoded OneDrive path, references legacy msgraphtool.exe binary) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Makefile | 29 ++ cmd/gomailtest/root.go | 2 + internal/devtools/cmd.go | 27 ++ internal/devtools/env/env.go | 90 ++++++ internal/devtools/env/env_cmd.go | 100 +++++++ internal/devtools/release/changelog.go | 70 +++++ internal/devtools/release/editor.go | 35 +++ internal/devtools/release/gh.go | 41 +++ internal/devtools/release/git.go | 70 +++++ internal/devtools/release/prompt.go | 105 +++++++ internal/devtools/release/release.go | 303 +++++++++++++++++++++ internal/devtools/release/release_cmd.go | 86 ++++++ internal/devtools/release/security_scan.go | 135 +++++++++ internal/devtools/release/version.go | 85 ++++++ scripts/check-integration-env.sh | 37 +++ tests/Test-SendMail.ps1 | 113 -------- tests/integration/sendmail_test.go | 53 ++++ 17 files changed, 1268 insertions(+), 113 deletions(-) create mode 100644 Makefile create mode 100644 internal/devtools/cmd.go create mode 100644 internal/devtools/env/env.go create mode 100644 internal/devtools/env/env_cmd.go create mode 100644 internal/devtools/release/changelog.go create mode 100644 internal/devtools/release/editor.go create mode 100644 internal/devtools/release/gh.go create mode 100644 internal/devtools/release/git.go create mode 100644 internal/devtools/release/prompt.go create mode 100644 internal/devtools/release/release.go create mode 100644 internal/devtools/release/release_cmd.go create mode 100644 internal/devtools/release/security_scan.go create mode 100644 internal/devtools/release/version.go create mode 100644 scripts/check-integration-env.sh delete mode 100644 tests/Test-SendMail.ps1 create mode 100644 tests/integration/sendmail_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f0828f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BINARY := bin/gomailtest +ifeq ($(OS),Windows_NT) + BINARY := bin/gomailtest.exe +endif + +VERSION := $(shell grep -oP 'Version = "\K[^"]+' internal/common/version/version.go 2>/dev/null || echo unknown) + +.PHONY: build build-verbose test integration-test clean help + +build: ## Build the gomailtest binary + go build -ldflags="-s -w" -o $(BINARY) ./cmd/gomailtest + @echo "Built $(BINARY) — version $(VERSION)" + +build-verbose: ## Build the gomailtest binary with verbose output + go build -v -ldflags="-s -w" -o $(BINARY) ./cmd/gomailtest + @echo "Built $(BINARY) — version $(VERSION)" + +test: ## Run unit tests + go test ./... + +integration-test: build ## Run MS Graph integration tests (requires MSGRAPH* env vars) + @sh scripts/check-integration-env.sh + go test -tags integration -v -timeout 120s ./tests/integration/ + +clean: ## Remove build artifacts + rm -f $(BINARY) + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " %-20s %s\n", $$1, $$2}' diff --git a/cmd/gomailtest/root.go b/cmd/gomailtest/root.go index d2c7d8c..642bb71 100644 --- a/cmd/gomailtest/root.go +++ b/cmd/gomailtest/root.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "msgraphtool/internal/common/bootstrap" "msgraphtool/internal/common/version" + "msgraphtool/internal/devtools" "msgraphtool/internal/protocols/imap" "msgraphtool/internal/protocols/jmap" "msgraphtool/internal/protocols/msgraph" @@ -34,6 +35,7 @@ func init() { rootCmd.AddCommand(pop3.NewCmd()) rootCmd.AddCommand(imap.NewCmd()) rootCmd.AddCommand(jmap.NewCmd()) + rootCmd.AddCommand(devtools.NewCmd()) } // Execute runs the root command and returns any error. diff --git a/internal/devtools/cmd.go b/internal/devtools/cmd.go new file mode 100644 index 0000000..b92e804 --- /dev/null +++ b/internal/devtools/cmd.go @@ -0,0 +1,27 @@ +// Package devtools provides developer-facing CLI subcommands for managing +// the project: environment configuration, release automation, etc. +package devtools + +import ( + "github.com/spf13/cobra" + "msgraphtool/internal/devtools/env" + "msgraphtool/internal/devtools/release" +) + +// NewCmd returns the cobra command for 'gomailtest devtools'. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "devtools", + Short: "Developer tools: release management and environment configuration", + Long: `Developer tools for managing the gomailtesttool project. + +Subcommands: + env — manage MSGRAPH* environment variables for integration testing + release — interactive release automation (version bump, changelog, git tag)`, + } + + cmd.AddCommand(env.NewCmd()) + cmd.AddCommand(release.NewCmd()) + + return cmd +} diff --git a/internal/devtools/env/env.go b/internal/devtools/env/env.go new file mode 100644 index 0000000..3bf7aea --- /dev/null +++ b/internal/devtools/env/env.go @@ -0,0 +1,90 @@ +// Package env provides utilities for managing MSGRAPH* environment variables +// used by the Microsoft Graph integration tests and commands. +package env + +import ( + "fmt" + "io" + "os" + "strings" + + "msgraphtool/internal/common/security" +) + +// RequiredVars are the environment variables needed for Microsoft Graph tests. +var RequiredVars = []string{ + "MSGRAPHTENANTID", + "MSGRAPHCLIENTID", + "MSGRAPHSECRET", + "MSGRAPHMAILBOX", +} + +// OptionalVars are the environment variables that may optionally be set. +var OptionalVars = []string{ + "MSGRAPHPROXY", +} + +// VarStatus holds the name, value (masked), and whether a variable is set. +type VarStatus struct { + Name string + Masked string + Set bool +} + +// ShowVars writes the masked status of all MSGRAPH* variables to w. +func ShowVars(w io.Writer) { + fmt.Fprintln(w, "MSGRAPH environment variables:") + fmt.Fprintln(w) + + allVars := append(RequiredVars, OptionalVars...) + for _, name := range allVars { + val := os.Getenv(name) + tag := "[required]" + for _, o := range OptionalVars { + if o == name { + tag = "[optional]" + break + } + } + if val == "" { + fmt.Fprintf(w, " %-24s %s (not set)\n", name, tag) + } else { + fmt.Fprintf(w, " %-24s %s %s\n", name, tag, maskVar(name, val)) + } + } +} + +// ClearCommands writes shell unset commands for all MSGRAPH* vars to w. +// The caller must execute these commands in their shell since a child process +// cannot modify its parent process's environment. +func ClearCommands(w io.Writer) { + allVars := append(RequiredVars, OptionalVars...) + for _, name := range allVars { + fmt.Fprintf(w, "unset %s\n", name) + } +} + +// Missing returns the names of required variables that are not set. +func Missing() []string { + var missing []string + for _, name := range RequiredVars { + if os.Getenv(name) == "" { + missing = append(missing, name) + } + } + return missing +} + +// maskVar applies the appropriate masking function based on the variable name. +func maskVar(name, val string) string { + switch { + case strings.HasSuffix(name, "TENANTID") || strings.HasSuffix(name, "CLIENTID"): + return security.MaskGUID(val) + case strings.HasSuffix(name, "SECRET"): + return security.MaskSecret(val) + case strings.HasSuffix(name, "MAILBOX"): + return security.MaskEmail(val) + default: + return security.MaskPassword(val) + } +} diff --git a/internal/devtools/env/env_cmd.go b/internal/devtools/env/env_cmd.go new file mode 100644 index 0000000..afe76fd --- /dev/null +++ b/internal/devtools/env/env_cmd.go @@ -0,0 +1,100 @@ +package env + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// NewCmd returns the cobra command for 'gomailtest devtools env'. +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "Manage MSGRAPH* environment variables for integration testing", + Long: `Display or clear Microsoft Graph environment variables used for integration tests. + +Required variables: + MSGRAPHTENANTID Azure Active Directory tenant GUID + MSGRAPHCLIENTID Azure app registration client ID (GUID) + MSGRAPHSECRET Azure app client secret + MSGRAPHMAILBOX Test mailbox email address + +Optional variables: + MSGRAPHPROXY Proxy server URL (e.g. http://proxy:8080) + +To set variables (bash/zsh): + export MSGRAPHTENANTID= + export MSGRAPHCLIENTID= + export MSGRAPHSECRET= + export MSGRAPHMAILBOX= + +To set variables (PowerShell): + $env:MSGRAPHTENANTID = "" + $env:MSGRAPHCLIENTID = "" + $env:MSGRAPHSECRET = "" + $env:MSGRAPHMAILBOX = ""`, + RunE: func(cmd *cobra.Command, args []string) error { + ShowVars(cmd.OutOrStdout()) + if missing := Missing(); len(missing) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), "\nmissing required variables: %s\n", strings.Join(missing, ", ")) + return fmt.Errorf("not all required variables are set") + } + return nil + }, + } + + cmd.AddCommand(newShowCmd()) + cmd.AddCommand(newClearCmd()) + cmd.AddCommand(newCheckCmd()) + + return cmd +} + +func newShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Display masked MSGRAPH* environment variables", + RunE: func(cmd *cobra.Command, args []string) error { + ShowVars(cmd.OutOrStdout()) + return nil + }, + } +} + +func newClearCmd() *cobra.Command { + return &cobra.Command{ + Use: "clear", + Short: "Print unset commands for all MSGRAPH* variables", + Long: `Prints shell unset commands for all MSGRAPH* environment variables. + +Since a child process cannot modify its parent shell's environment, run the +output of this command directly in your shell: + + bash/zsh: eval "$(gomailtest devtools env clear)" + PowerShell: gomailtest devtools env clear | ForEach-Object { Invoke-Expression $_ }`, + RunE: func(cmd *cobra.Command, args []string) error { + ClearCommands(cmd.OutOrStdout()) + return nil + }, + } +} + +func newCheckCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Check that all required MSGRAPH* variables are set", + RunE: func(cmd *cobra.Command, args []string) error { + missing := Missing() + if len(missing) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "All required MSGRAPH* variables are set.") + return nil + } + for _, name := range missing { + fmt.Fprintf(os.Stderr, "missing: %s\n", name) + } + return fmt.Errorf("%d required variable(s) not set", len(missing)) + }, + } +} diff --git a/internal/devtools/release/changelog.go b/internal/devtools/release/changelog.go new file mode 100644 index 0000000..49fd314 --- /dev/null +++ b/internal/devtools/release/changelog.go @@ -0,0 +1,70 @@ +package release + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Sections holds the content for each changelog section. +type Sections struct { + Added []string + Changed []string + Fixed []string + Security []string +} + +// EntryPath returns the path for a version's changelog entry. +// e.g. "ChangeLog/3.2.0.md" +func EntryPath(projectRoot, version string) string { + return filepath.Join(projectRoot, "ChangeLog", version+".md") +} + +// CreateEntry writes a new changelog entry file. +// If the file already exists it is not overwritten; returns the path. +func CreateEntry(path, version string, s Sections) error { + if _, err := os.Stat(path); err == nil { + // File already exists — skip creation, user will edit it + return nil + } + + date := time.Now().Format("2006-01-02") + var sb strings.Builder + fmt.Fprintf(&sb, "# %s — %s\n\n", version, date) + + if len(s.Added) > 0 { + sb.WriteString("## Added\n") + for _, item := range s.Added { + fmt.Fprintf(&sb, "- %s\n", item) + } + sb.WriteString("\n") + } + if len(s.Changed) > 0 { + sb.WriteString("## Changed\n") + for _, item := range s.Changed { + fmt.Fprintf(&sb, "- %s\n", item) + } + sb.WriteString("\n") + } + if len(s.Fixed) > 0 { + sb.WriteString("## Fixed\n") + for _, item := range s.Fixed { + fmt.Fprintf(&sb, "- %s\n", item) + } + sb.WriteString("\n") + } + if len(s.Security) > 0 { + sb.WriteString("## Security\n") + for _, item := range s.Security { + fmt.Fprintf(&sb, "- %s\n", item) + } + sb.WriteString("\n") + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create ChangeLog dir: %w", err) + } + return os.WriteFile(path, []byte(sb.String()), 0644) +} diff --git a/internal/devtools/release/editor.go b/internal/devtools/release/editor.go new file mode 100644 index 0000000..652d2e0 --- /dev/null +++ b/internal/devtools/release/editor.go @@ -0,0 +1,35 @@ +package release + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +// OpenInEditor opens the file at path in the user's preferred editor. +// It respects the EDITOR environment variable, falling back to: +// - notepad on Windows +// - vi on Unix/macOS +// +// The call blocks until the editor exits. +func OpenInEditor(path string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "vi" + } + } + + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("editor %q exited with error: %w", editor, err) + } + return nil +} diff --git a/internal/devtools/release/gh.go b/internal/devtools/release/gh.go new file mode 100644 index 0000000..bed46f8 --- /dev/null +++ b/internal/devtools/release/gh.go @@ -0,0 +1,41 @@ +package release + +import ( + "fmt" + "os/exec" + "strings" +) + +// ghAvailable returns true if the 'gh' CLI is installed. +func ghAvailable() bool { + _, err := exec.LookPath("gh") + return err == nil +} + +// CreatePR creates a GitHub pull request using the 'gh' CLI. +func CreatePR(title, body, base string) (string, error) { + if !ghAvailable() { + return "", fmt.Errorf("'gh' CLI not found — install from https://cli.github.com") + } + out, err := exec.Command("gh", "pr", "create", + "--title", title, + "--body", body, + "--base", base, + ).CombinedOutput() + if err != nil { + return "", fmt.Errorf("gh pr create: %w\n%s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// ListRuns lists the most recent GitHub Actions workflow runs. +func ListRuns(limit int) (string, error) { + if !ghAvailable() { + return "", fmt.Errorf("'gh' CLI not found") + } + out, err := exec.Command("gh", "run", "list", "--limit", fmt.Sprintf("%d", limit)).CombinedOutput() + if err != nil { + return "", fmt.Errorf("gh run list: %w\n%s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/devtools/release/git.go b/internal/devtools/release/git.go new file mode 100644 index 0000000..faead7b --- /dev/null +++ b/internal/devtools/release/git.go @@ -0,0 +1,70 @@ +package release + +import ( + "fmt" + "os/exec" + "strings" +) + +// gitRun executes a git command and returns combined output. +// Returns an error that includes stderr on failure. +func gitRun(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + trimmed := strings.TrimSpace(string(out)) + if err != nil { + return trimmed, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, trimmed) + } + return trimmed, nil +} + +// Status returns true if the working tree is dirty (has uncommitted changes). +func Status() (dirty bool, output string, err error) { + out, err := gitRun("status", "--porcelain") + if err != nil { + return false, "", err + } + return out != "", out, nil +} + +// CurrentBranch returns the name of the current git branch. +func CurrentBranch() (string, error) { + return gitRun("rev-parse", "--abbrev-ref", "HEAD") +} + +// Add stages the given file paths. +func Add(paths ...string) error { + args := append([]string{"add", "--"}, paths...) + _, err := gitRun(args...) + return err +} + +// Commit creates a commit with the given message. +func Commit(message string) error { + _, err := gitRun("commit", "-m", message) + return err +} + +// Push pushes the current branch to remote. +func Push(remote, branch string) error { + _, err := gitRun("push", remote, branch) + return err +} + +// Tag creates a lightweight git tag. +func Tag(name string) error { + _, err := gitRun("tag", name) + return err +} + +// PushTag pushes a tag to remote. +func PushTag(remote, tag string) error { + _, err := gitRun("push", remote, tag) + return err +} + +// CheckoutNewBranch creates and checks out a new branch. +func CheckoutNewBranch(name string) error { + _, err := gitRun("checkout", "-b", name) + return err +} diff --git a/internal/devtools/release/prompt.go b/internal/devtools/release/prompt.go new file mode 100644 index 0000000..16bd102 --- /dev/null +++ b/internal/devtools/release/prompt.go @@ -0,0 +1,105 @@ +package release + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// PromptLine reads a single line from r after printing the prompt to w. +func PromptLine(w io.Writer, r io.Reader, prompt string) (string, error) { + fmt.Fprint(w, prompt) + scanner := bufio.NewScanner(r) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", io.EOF +} + +// PromptYesNo asks a yes/no question and returns true if the user enters y/Y/yes. +// defaultYes controls what an empty response means. +func PromptYesNo(w io.Writer, r io.Reader, question string, defaultYes bool) (bool, error) { + hint := "[y/N]" + if defaultYes { + hint = "[Y/n]" + } + fmt.Fprintf(w, "%s %s: ", question, hint) + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, err + } + return defaultYes, nil + } + ans := strings.ToLower(strings.TrimSpace(scanner.Text())) + if ans == "" { + return defaultYes, nil + } + return ans == "y" || ans == "yes", nil +} + +// PromptVersion asks for a new version, showing suggestions. +// Returns the validated version string chosen by the user. +func PromptVersion(w io.Writer, r io.Reader, current string) (string, error) { + nextPatch, _ := SuggestNextPatch(current) + nextMinor, _ := SuggestNextMinor(current) + + fmt.Fprintf(w, "Current version: %s\n", current) + fmt.Fprintf(w, " [1] Next patch: %s\n", nextPatch) + fmt.Fprintf(w, " [2] Next minor: %s\n", nextMinor) + fmt.Fprintf(w, " [3] Custom version\n") + fmt.Fprint(w, "Choice [1]: ") + + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + return nextPatch, nil + } + choice := strings.TrimSpace(scanner.Text()) + + switch choice { + case "", "1": + return nextPatch, nil + case "2": + return nextMinor, nil + case "3": + fmt.Fprint(w, "Enter version: ") + if !scanner.Scan() { + return "", fmt.Errorf("no version entered") + } + v := strings.TrimSpace(scanner.Text()) + if err := ValidateVersion(v); err != nil { + return "", err + } + return v, nil + default: + // User may have typed a version directly + if err := ValidateVersion(choice); err == nil { + return choice, nil + } + return "", fmt.Errorf("invalid choice %q", choice) + } +} + +// PromptLines collects multiple lines for a changelog section until the user enters an empty line. +// Returns nil if no items were entered. +func PromptLines(w io.Writer, r io.Reader, section string) []string { + fmt.Fprintf(w, "%s (one item per line, empty line to finish):\n", section) + scanner := bufio.NewScanner(r) + var items []string + for { + fmt.Fprint(w, " > ") + if !scanner.Scan() { + break + } + line := strings.TrimSpace(scanner.Text()) + if line == "" { + break + } + items = append(items, line) + } + return items +} diff --git a/internal/devtools/release/release.go b/internal/devtools/release/release.go new file mode 100644 index 0000000..db0c016 --- /dev/null +++ b/internal/devtools/release/release.go @@ -0,0 +1,303 @@ +// Package release implements interactive release automation for gomailtesttool. +// It replicates run-interactive-release.ps1 in portable Go, and fixes a latent +// bug in that script which read/wrote the legacy src\VERSION path instead of +// internal/common/version/version.go. +package release + +import ( + "fmt" + "io" + "path/filepath" +) + +// Options controls optional steps in the release flow. +type Options struct { + ProjectRoot string + VersionFilePath string // relative to ProjectRoot; default: internal/common/version/version.go + SkipSecurityScan bool + SkipPush bool + NoDevBranch bool + DryRun bool + In io.Reader + Out io.Writer +} + +func (o *Options) versionFile() string { + if o.VersionFilePath != "" { + return filepath.Join(o.ProjectRoot, o.VersionFilePath) + } + return filepath.Join(o.ProjectRoot, "internal", "common", "version", "version.go") +} + +func (o *Options) printf(format string, args ...any) { + if o.Out != nil { + fmt.Fprintf(o.Out, format, args...) + } +} + +func (o *Options) dry(action string, fn func() error) error { + if o.DryRun { + o.printf("[dry-run] %s\n", action) + return nil + } + return fn() +} + +// Run executes the interactive release workflow. +func Run(opts Options) error { + w := opts.Out + r := opts.In + + opts.printf("\n=== gomailtesttool release tool ===\n\n") + + // Step 1: Git status check + opts.printf("Step 1/13: Checking git status...\n") + dirty, statusOut, err := Status() + if err != nil { + return fmt.Errorf("step 1 git status: %w", err) + } + if dirty { + opts.printf("WARNING: Working tree has uncommitted changes:\n%s\n\n", statusOut) + cont, err := PromptYesNo(w, r, "Continue anyway?", false) + if err != nil || !cont { + return fmt.Errorf("aborted: uncommitted changes") + } + } else { + opts.printf(" Working tree is clean.\n") + } + + // Step 2: Security scan + if !opts.SkipSecurityScan { + opts.printf("\nStep 2/13: Scanning for secrets...\n") + findings, err := ScanFiles(opts.ProjectRoot) + if err != nil { + return fmt.Errorf("step 2 security scan: %w", err) + } + if len(findings) > 0 { + opts.printf("WARNING: %d potential secret(s) found:\n", len(findings)) + for _, f := range findings { + opts.printf(" %s:%d [%s] %s\n", f.File, f.Line, f.Kind, f.Content) + } + opts.printf("\n") + cont, err := PromptYesNo(w, r, "Continue despite findings?", false) + if err != nil || !cont { + return fmt.Errorf("aborted: potential secrets detected") + } + } else { + opts.printf(" No secrets detected.\n") + } + } else { + opts.printf("\nStep 2/13: Security scan skipped (--skip-security-scan).\n") + } + + // Step 3: Read current version + opts.printf("\nStep 3/13: Reading current version...\n") + currentVer, err := ReadVersion(opts.versionFile()) + if err != nil { + return fmt.Errorf("step 3 read version: %w", err) + } + opts.printf(" Current version: %s\n", currentVer) + + // Step 4: Prompt for new version + opts.printf("\nStep 4/13: Select new version...\n") + newVer, err := PromptVersion(w, r, currentVer) + if err != nil { + return fmt.Errorf("step 4 version selection: %w", err) + } + opts.printf(" New version: %s\n", newVer) + + // Step 5: Write version + opts.printf("\nStep 5/13: Updating version file...\n") + if err := opts.dry(fmt.Sprintf("WriteVersion(%s)", newVer), func() error { + return WriteVersion(opts.versionFile(), newVer) + }); err != nil { + return fmt.Errorf("step 5 write version: %w", err) + } + opts.printf(" Updated %s\n", opts.versionFile()) + + // Step 6: Collect changelog sections + opts.printf("\nStep 6/13: Changelog entry for v%s\n", newVer) + sections := Sections{ + Added: PromptLines(w, r, "Added"), + Changed: PromptLines(w, r, "Changed"), + Fixed: PromptLines(w, r, "Fixed"), + Security: PromptLines(w, r, "Security"), + } + changelogPath := EntryPath(opts.ProjectRoot, newVer) + if err := opts.dry(fmt.Sprintf("CreateEntry(%s)", changelogPath), func() error { + return CreateEntry(changelogPath, newVer, sections) + }); err != nil { + return fmt.Errorf("step 6 create changelog: %w", err) + } + + // Step 7: Open changelog in editor + opts.printf("\nStep 7/13: Opening changelog in editor...\n") + if !opts.DryRun { + if err := OpenInEditor(changelogPath); err != nil { + opts.printf("WARNING: editor failed: %v — continuing\n", err) + } + } else { + opts.printf("[dry-run] OpenInEditor(%s)\n", changelogPath) + } + + // Step 8: Git commit + opts.printf("\nStep 8/13: Committing changes...\n") + commitMsg := fmt.Sprintf("Bump version to %s\n\nCo-Authored-By: Claude Sonnet 4.6 (1M context) ", newVer) + if err := opts.dry("git add + commit", func() error { + if err := Add(opts.versionFile(), changelogPath); err != nil { + return err + } + return Commit(commitMsg) + }); err != nil { + return fmt.Errorf("step 8 commit: %w", err) + } + + // Step 9: Push branch + if !opts.SkipPush { + opts.printf("\nStep 9/13: Pushing branch...\n") + branch, err := CurrentBranch() + if err != nil { + return fmt.Errorf("step 9 get branch: %w", err) + } + doPush, err := PromptYesNo(w, r, fmt.Sprintf("Push branch %q to origin?", branch), true) + if err != nil { + return err + } + if doPush { + if err := opts.dry(fmt.Sprintf("git push origin %s", branch), func() error { + return Push("origin", branch) + }); err != nil { + return fmt.Errorf("step 9 push: %w", err) + } + } + } else { + opts.printf("\nStep 9/13: Push skipped (--skip-push).\n") + } + + // Step 10: Create and push git tag + opts.printf("\nStep 10/13: Creating git tag v%s (triggers GitHub Actions)...\n", newVer) + doTag, err := PromptYesNo(w, r, fmt.Sprintf("Create and push tag v%s?", newVer), true) + if err != nil { + return err + } + if doTag { + tagName := "v" + newVer + if err := opts.dry(fmt.Sprintf("git tag %s", tagName), func() error { + return Tag(tagName) + }); err != nil { + return fmt.Errorf("step 10 tag: %w", err) + } + if !opts.SkipPush { + if err := opts.dry(fmt.Sprintf("git push origin %s", tagName), func() error { + return PushTag("origin", tagName) + }); err != nil { + return fmt.Errorf("step 10 push tag: %w", err) + } + opts.printf(" Tag pushed — GitHub Actions should start shortly.\n") + } + } + + // Step 11: Optional PR creation + opts.printf("\nStep 11/13: Pull request (optional)...\n") + branch, _ := CurrentBranch() + if branch != "main" && branch != "master" && ghAvailable() { + doPR, err := PromptYesNo(w, r, fmt.Sprintf("Create PR to merge %q into main?", branch), false) + if err != nil { + return err + } + if doPR { + prURL, err := opts.dry2("gh pr create", func() (string, error) { + return CreatePR( + fmt.Sprintf("Release v%s", newVer), + fmt.Sprintf("Release v%s\n\nSee ChangeLog/%s.md for details.", newVer, newVer), + "main", + ) + }) + if err != nil { + opts.printf("WARNING: PR creation failed: %v\n", err) + } else { + opts.printf(" PR created: %s\n", prURL) + } + } + } else if !ghAvailable() { + opts.printf(" 'gh' CLI not found — skipping PR creation.\n") + } + + // Step 12: Optional GitHub Actions monitoring + opts.printf("\nStep 12/13: GitHub Actions status (optional)...\n") + doRuns, err := PromptYesNo(w, r, "Show recent workflow runs?", false) + if err != nil { + return err + } + if doRuns { + runs, err := ListRuns(5) + if err != nil { + opts.printf("WARNING: %v\n", err) + } else { + opts.printf("%s\n", runs) + } + } + + // Step 13: Prepare next dev cycle + if !opts.NoDevBranch { + opts.printf("\nStep 13/13: Prepare next development cycle...\n") + nextVer, err := SuggestNextPatch(newVer) + if err != nil { + return fmt.Errorf("step 13 suggest next patch: %w", err) + } + devBranch := "b" + nextVer + opts.printf(" Next version will be: %s\n", nextVer) + doNext, err := PromptYesNo(w, r, fmt.Sprintf("Create dev branch %q and bump version to %s?", devBranch, nextVer), true) + if err != nil { + return err + } + if doNext { + if err := opts.dry(fmt.Sprintf("git checkout -b %s", devBranch), func() error { + return CheckoutNewBranch(devBranch) + }); err != nil { + return fmt.Errorf("step 13 checkout dev branch: %w", err) + } + if err := opts.dry(fmt.Sprintf("WriteVersion(%s)", nextVer), func() error { + return WriteVersion(opts.versionFile(), nextVer) + }); err != nil { + return fmt.Errorf("step 13 write next version: %w", err) + } + nextCommitMsg := fmt.Sprintf("Bump version to %s\n\nCo-Authored-By: Claude Sonnet 4.6 (1M context) ", nextVer) + if err := opts.dry("git add + commit dev version", func() error { + if err := Add(opts.versionFile()); err != nil { + return err + } + return Commit(nextCommitMsg) + }); err != nil { + return fmt.Errorf("step 13 commit dev version: %w", err) + } + if !opts.SkipPush { + doPushDev, err := PromptYesNo(w, r, fmt.Sprintf("Push dev branch %q to origin?", devBranch), true) + if err != nil { + return err + } + if doPushDev { + if err := opts.dry(fmt.Sprintf("git push origin %s", devBranch), func() error { + return Push("origin", devBranch) + }); err != nil { + return fmt.Errorf("step 13 push dev branch: %w", err) + } + } + } + } + } else { + opts.printf("\nStep 13/13: Dev branch skipped (--no-dev-branch).\n") + } + + opts.printf("\n=== Release v%s complete ===\n", newVer) + return nil +} + +// dry2 is like dry but for functions that return (string, error). +func (o *Options) dry2(action string, fn func() (string, error)) (string, error) { + if o.DryRun { + o.printf("[dry-run] %s\n", action) + return "", nil + } + return fn() +} diff --git a/internal/devtools/release/release_cmd.go b/internal/devtools/release/release_cmd.go new file mode 100644 index 0000000..15ff9c7 --- /dev/null +++ b/internal/devtools/release/release_cmd.go @@ -0,0 +1,86 @@ +package release + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +// NewCmd returns the cobra command for 'gomailtest devtools release'. +func NewCmd() *cobra.Command { + var ( + skipSecScan bool + skipPush bool + noDevBranch bool + dryRun bool + versionFile string + ) + + cmd := &cobra.Command{ + Use: "release", + Short: "Interactive release automation: version bump, changelog, git tag", + Long: `Interactive release workflow for gomailtesttool. + +Steps performed: + 1. Check git working tree status + 2. Scan files for accidentally committed secrets + 3. Read current version from internal/common/version/version.go + 4. Prompt for new version (patch / minor / custom) + 5. Update version file + 6. Collect changelog sections (Added / Changed / Fixed / Security) + 7. Open changelog entry in editor + 8. Commit version file + changelog + 9. Push branch to origin + 10. Create + push git tag vX.Y.Z (triggers GitHub Actions CI/CD) + 11. Optionally create a pull request via 'gh' CLI + 12. Show recent GitHub Actions workflow runs + 13. Create dev branch bX.Y.Z+1 and bump version for next cycle + +Flags: + --dry-run Print all actions without executing them + --skip-security-scan Skip the secret detection scan + --skip-push Do not push anything to remote + --no-dev-branch Skip creating the next development branch`, + RunE: func(cmd *cobra.Command, args []string) error { + root, err := projectRoot() + if err != nil { + return fmt.Errorf("cannot determine project root: %w", err) + } + + return Run(Options{ + ProjectRoot: root, + VersionFilePath: versionFile, + SkipSecurityScan: skipSecScan, + SkipPush: skipPush, + NoDevBranch: noDevBranch, + DryRun: dryRun, + In: os.Stdin, + Out: cmd.OutOrStdout(), + }) + }, + } + + cmd.Flags().BoolVar(&skipSecScan, "skip-security-scan", false, "Skip secret detection scan") + cmd.Flags().BoolVar(&skipPush, "skip-push", false, "Do not push to remote") + cmd.Flags().BoolVar(&noDevBranch, "no-dev-branch", false, "Skip creating next dev branch") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print actions without executing them") + cmd.Flags().StringVar(&versionFile, "version-file", "", + "Path to version.go relative to project root (default: internal/common/version/version.go)") + + return cmd +} + +// projectRoot returns the repository root by running 'git rev-parse --show-toplevel'. +func projectRoot() (string, error) { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").CombinedOutput() + if err != nil { + // Fallback: use the directory of the running binary + exe, _ := os.Executable() + return filepath.Dir(exe), nil + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/devtools/release/security_scan.go b/internal/devtools/release/security_scan.go new file mode 100644 index 0000000..7a293ba --- /dev/null +++ b/internal/devtools/release/security_scan.go @@ -0,0 +1,135 @@ +package release + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Finding represents a potential secret detected in a file. +type Finding struct { + File string + Line int + Kind string + Content string +} + +var ( + reGUID = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + reEmail = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + reSecret = regexp.MustCompile(`(?i)(secret|password|apikey|api_key)\s*[:=]\s*\S+`) + + // False-positive patterns: lines containing these strings are ignored. + falsePositives = []string{ + "xxx", "yyy", "zzz", + "example.com", "example.org", + "contoso", "fabrikam", + "your-", " 120 { + return s[:117] + "..." + } + return s +} diff --git a/internal/devtools/release/version.go b/internal/devtools/release/version.go new file mode 100644 index 0000000..be79e84 --- /dev/null +++ b/internal/devtools/release/version.go @@ -0,0 +1,85 @@ +package release + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +var versionLineRe = regexp.MustCompile(`const Version = "[^"]+"`) + +// ReadVersion reads the version string from the Go version file. +// The file must contain a line of the form: const Version = "x.y.z" +func ReadVersion(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read version file: %w", err) + } + match := versionLineRe.Find(data) + if match == nil { + return "", fmt.Errorf("no version line found in %s", path) + } + // Extract the quoted value + line := string(match) + start := strings.Index(line, `"`) + 1 + end := strings.LastIndex(line, `"`) + return line[start:end], nil +} + +// WriteVersion updates the version string in the Go version file in-place. +// Only the quoted version value on the `const Version = "..."` line is changed. +func WriteVersion(path, newVersion string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read version file: %w", err) + } + replacement := fmt.Sprintf(`const Version = "%s"`, newVersion) + updated := versionLineRe.ReplaceAll(data, []byte(replacement)) + if string(updated) == string(data) { + return fmt.Errorf("version line not found or unchanged in %s", path) + } + return os.WriteFile(path, updated, 0644) +} + +// SuggestNextPatch returns the version with the patch component incremented by 1. +// Input format: "major.minor.patch" +func SuggestNextPatch(current string) (string, error) { + parts := strings.Split(current, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid version format %q (expected major.minor.patch)", current) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return "", fmt.Errorf("invalid patch number %q: %w", parts[2], err) + } + return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], patch+1), nil +} + +// SuggestNextMinor returns the version with the minor component incremented and patch reset to 0. +func SuggestNextMinor(current string) (string, error) { + parts := strings.Split(current, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid version format %q (expected major.minor.patch)", current) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return "", fmt.Errorf("invalid minor number %q: %w", parts[1], err) + } + return fmt.Sprintf("%s.%d.0", parts[0], minor+1), nil +} + +// ValidateVersion checks that v follows the major.minor.patch format. +func ValidateVersion(v string) error { + parts := strings.Split(v, ".") + if len(parts) != 3 { + return fmt.Errorf("version %q must be in major.minor.patch format", v) + } + for _, p := range parts { + if _, err := strconv.Atoi(p); err != nil { + return fmt.Errorf("version component %q is not a number", p) + } + } + return nil +} diff --git a/scripts/check-integration-env.sh b/scripts/check-integration-env.sh new file mode 100644 index 0000000..28320dc --- /dev/null +++ b/scripts/check-integration-env.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# check-integration-env.sh — Validate required MSGRAPH* env vars before running integration tests. +# Called by: make integration-test +# Exits 1 with a clear error message if any required variable is missing. + +set -e + +REQUIRED_VARS="MSGRAPHTENANTID MSGRAPHCLIENTID MSGRAPHSECRET MSGRAPHMAILBOX" +MISSING="" + +for var in $REQUIRED_VARS; do + eval "val=\$$var" + if [ -z "$val" ]; then + MISSING="$MISSING $var" + fi +done + +if [ -n "$MISSING" ]; then + echo "" + echo "ERROR: Missing required environment variable(s) for integration tests:" + for var in $MISSING; do + echo " - $var" + done + echo "" + echo "Set them before running 'make integration-test', for example:" + echo " export MSGRAPHTENANTID=your-tenant-id" + echo " export MSGRAPHCLIENTID=your-client-id" + echo " export MSGRAPHSECRET=your-client-secret" + echo " export MSGRAPHMAILBOX=test@example.com" + echo "" + echo "Or use the built-in env helper:" + echo " gomailtest devtools env set" + echo "" + exit 1 +fi + +echo "All required MSGRAPH* environment variables are set." diff --git a/tests/Test-SendMail.ps1 b/tests/Test-SendMail.ps1 deleted file mode 100644 index 2e3f696..0000000 --- a/tests/Test-SendMail.ps1 +++ /dev/null @@ -1,113 +0,0 @@ -# Test-SendMail.ps1 -# Pester test for sendmail action - -BeforeAll { - # Source environment variables from secure location - $envPath = Join-Path $env:OneDrive "Documents\Safe\zblab21eu_env.ps1" - - if (-not (Test-Path $envPath)) { - throw "Environment file not found: $envPath" - } - - Write-Host "Sourcing environment from: $envPath" -ForegroundColor Cyan - . $envPath - - # Get the path to the executable - $script:exePath = Join-Path $PSScriptRoot "..\msgraphtool.exe" - - if (-not (Test-Path $script:exePath)) { - throw "Executable not found: $script:exePath" - } - - Write-Host "Using executable: $script:exePath" -ForegroundColor Cyan -} - -Describe "Send Mail Tests" { - - Context "When sending email with verbose output" { - - It "Should execute sendmail command successfully" { - # Execute the command - $output = & $script:exePath -action sendmail -verbose 2>&1 - $exitCode = $LASTEXITCODE - - # Display output - Write-Host "Command Output:" -ForegroundColor Yellow - $output | ForEach-Object { Write-Host $_ } - - # Verify exit code - $exitCode | Should -Be 0 - } - -# It "Should show verbose configuration output" { -# # Execute the command and capture output -# $output = & $script:exePath -action sendmail -verbose 2>&1 | Out-String - -# # Verify verbose output contains expected sections -# $output | Should -Match "Environment Variables:" -# $output | Should -Match "Final Configuration:" -# $output | Should -Match "Authentication Details:" -# } - -# It "Should use environment variables from sourced file" { -# # Execute the command and capture output -# $output = & $script:exePath -action sendmail -verbose 2>&1 | Out-String - -# # Verify that environment variables are being used -# $output | Should -Match "MSGRAPH" -# } - -# It "Should complete without errors" { -# # Execute the command -# $errorOutput = & $script:exePath -action sendmail -verbose 2>&1 | -# Where-Object { $_ -match "ERROR|error|Error" } - -# # There should be no error messages (unless expected authentication errors in test environment) -# # Adjust this assertion based on your test environment -# Write-Host "Checking for errors..." -ForegroundColor Cyan -# if ($errorOutput) { -# Write-Host "Errors found:" -ForegroundColor Yellow -# $errorOutput | ForEach-Object { Write-Host $_ -ForegroundColor Red } -# } -# } - } - - Context "When checking CSV log output" { - - It "Should create CSV log file" { - # Execute the command - & $script:exePath -action sendmail -verbose 2>&1 | Out-Null - - # Check for CSV log file in temp directory - $dateStr = Get-Date -Format "yyyy-MM-dd" - $csvPath = Join-Path $env:TEMP "_msgraphtool_sendmail_$dateStr.csv" - - Write-Host "Checking for CSV log: $csvPath" -ForegroundColor Cyan - Test-Path $csvPath | Should -Be $true - } - -# It "Should write sendmail entry to CSV log" { -# # Execute the command -# & $script:exePath -action sendmail -verbose 2>&1 | Out-Null - -# # Read the CSV log -# $dateStr = Get-Date -Format "yyyy-MM-dd" -# $csvPath = Join-Path $env:TEMP "_msgraphtool_$dateStr.csv" - -# if (Test-Path $csvPath) { -# $csvContent = Get-Content $csvPath -# Write-Host "CSV Content (last 5 lines):" -ForegroundColor Cyan -# $csvContent | Select-Object -Last 5 | ForEach-Object { Write-Host $_ } - -# # Verify sendmail action is logged -# $csvContent | Should -Match "sendmail" -# } -# } - } -} - -AfterAll { - Write-Host "" - Write-Host "Test execution completed!" -ForegroundColor Green - Write-Host "Check the output above for detailed results." -ForegroundColor Cyan -} diff --git a/tests/integration/sendmail_test.go b/tests/integration/sendmail_test.go new file mode 100644 index 0000000..aed8fa3 --- /dev/null +++ b/tests/integration/sendmail_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package integration + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +// TestSendMail runs gomailtest msgraph sendmail and verifies: +// - the command exits with code 0 +// - a CSV log file is created in the temp directory +// +// Requires env vars: MSGRAPHTENANTID, MSGRAPHCLIENTID, MSGRAPHSECRET, MSGRAPHMAILBOX +// Run with: go test -tags integration -v ./tests/integration/ +func TestSendMail(t *testing.T) { + requiredEnv := []string{ + "MSGRAPHTENANTID", + "MSGRAPHCLIENTID", + "MSGRAPHSECRET", + "MSGRAPHMAILBOX", + } + for _, e := range requiredEnv { + if os.Getenv(e) == "" { + t.Skipf("skipping: %s not set", e) + } + } + + // Resolve binary relative to this file's location (tests/integration/ → ../../bin/) + binary := filepath.Join("..", "..", "bin", "gomailtest") + if runtime.GOOS == "windows" { + binary += ".exe" + } + + cmd := exec.Command(binary, "msgraph", "sendmail", "--verbose") + output, err := cmd.CombinedOutput() + t.Logf("output:\n%s", output) + if err != nil { + t.Fatalf("sendmail failed: %v", err) + } + + // Verify CSV log file was created (matches pattern written by msgraph protocol) + date := time.Now().Format("2006-01-02") + csvPath := filepath.Join(os.TempDir(), fmt.Sprintf("_msgraphtool_sendmail_%s.csv", date)) + if _, statErr := os.Stat(csvPath); os.IsNotExist(statErr) { + t.Errorf("expected CSV log at %s, not found", csvPath) + } +}