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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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}'
2 changes: 2 additions & 0 deletions cmd/gomailtest/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions internal/devtools/cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
90 changes: 90 additions & 0 deletions internal/devtools/env/env.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
100 changes: 100 additions & 0 deletions internal/devtools/env/env_cmd.go
Original file line number Diff line number Diff line change
@@ -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=<tenant-id>
export MSGRAPHCLIENTID=<client-id>
export MSGRAPHSECRET=<secret>
export MSGRAPHMAILBOX=<email>

To set variables (PowerShell):
$env:MSGRAPHTENANTID = "<tenant-id>"
$env:MSGRAPHCLIENTID = "<client-id>"
$env:MSGRAPHSECRET = "<secret>"
$env:MSGRAPHMAILBOX = "<email>"`,
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))
},
}
}
70 changes: 70 additions & 0 deletions internal/devtools/release/changelog.go
Original file line number Diff line number Diff line change
@@ -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)
}
35 changes: 35 additions & 0 deletions internal/devtools/release/editor.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading