From 15bbfdcda2133fea5d3bd6887b096b74af2b8b38 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 11 Sep 2025 17:02:12 +0300 Subject: [PATCH] feat: initial commit of Git Automation CLI - Add comprehensive Git workflow automation tools - Include branch management utilities - Add commit helpers with conventional commit support - Implement GitHub integration for PR management - Add configuration management system - Include comprehensive test coverage - Add professional documentation and examples --- .gitignore | 45 ++ LICENSE | 6 + README.md | 158 ++++ go.mod | 31 + go.sum | 75 ++ internal/cmd/branch.go | 272 +++++++ internal/cmd/commands.go | 134 ++++ internal/cmd/commit.go | 283 ++++++++ internal/cmd/config.go | 107 +++ internal/cmd/config_test.go | 286 ++++++++ internal/cmd/pr.go | 326 +++++++++ internal/cmd/pr_test.go | 518 +++++++++++++ internal/cmd/root.go | 60 ++ internal/cmd/root_test.go | 225 ++++++ internal/config/config.go | 149 ++++ internal/config/config_test.go | 267 +++++++ internal/git/git.go | 125 ++++ internal/git/git_test.go | 532 ++++++++++++++ internal/github/client.go | 337 +++++++++ internal/github/client_test.go | 970 +++++++++++++++++++++++++ internal/github/models.go | 305 ++++++++ internal/util/env.go | 13 + internal/util/env_test.go | 44 ++ internal/validation/validation.go | 182 +++++ internal/validation/validation_test.go | 202 +++++ internal/version/version.go | 22 + internal/version/version_test.go | 53 ++ 27 files changed, 5727 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmd/branch.go create mode 100644 internal/cmd/commands.go create mode 100644 internal/cmd/commit.go create mode 100644 internal/cmd/config.go create mode 100644 internal/cmd/config_test.go create mode 100644 internal/cmd/pr.go create mode 100644 internal/cmd/pr_test.go create mode 100644 internal/cmd/root.go create mode 100644 internal/cmd/root_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/git/git.go create mode 100644 internal/git/git_test.go create mode 100644 internal/github/client.go create mode 100644 internal/github/client_test.go create mode 100644 internal/github/models.go create mode 100644 internal/util/env.go create mode 100644 internal/util/env_test.go create mode 100644 internal/validation/validation.go create mode 100644 internal/validation/validation_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c736a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +gitauto + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Config files with secrets (if any) +config.local.yaml +.env +.env.local diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c10a9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,6 @@ +MIT License + +Copyright (c) 2025 iwasforcedtobehere + +Permission is hereby granted, free of charge, to any person obtaining a copy +... diff --git a/README.md b/README.md new file mode 100644 index 0000000..06f2d13 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Git Automation CLI + +A fast, practical Go CLI to automate frequent Git workflows. It provides utilities for branch management, commit helpers, sync operations, and integration with GitHub for pull requests and team coordination. + +## Features + +- Branch utilities (create, switch, list) +- Commit helpers (with conventional commit support) +- Sync with remote (fetch, rebase, push) +- Clean local feature branches +- GitHub integration (create, list, merge pull requests) +- Configuration management +- Version information + +## Installation + +### From Source + +```bash +git clone https://github.com/iwasforcedtobehere/git-automation-cli.git +cd git-automation-cli +go build -o gitauto ./cmd/gitauto +``` + +### Using Go Install + +```bash +go install github.com/iwasforcedtobehere/git-automation-cli/cmd/gitauto@latest +``` + +## Configuration + +Before using GitHub integration features, you need to configure your GitHub token: + +```bash +# Set your GitHub token +gitauto config set github.token YOUR_GITHUB_TOKEN + +# Set default branch (optional, defaults to 'main') +gitauto config set default.branch main + +# Set default remote (optional, defaults to 'origin') +gitauto config set default.remote origin +``` + +## Usage + +### General Commands + +```bash +# Show help +gitauto help + +# Show version +gitauto version + +# Show configuration +gitauto config list +``` + +### Branch Management + +```bash +# Create a new branch +gitauto branch create feature/new-feature + +# Switch to an existing branch +gitauto branch switch main + +# List all branches +gitauto branch list +``` + +### Commit Helpers + +```bash +# Create a commit with a message +gitauto commit create "feat: add new feature" + +# Create a commit with a body +gitauto commit create "feat: add new feature" --body "This commit adds a new feature that allows users to..." + +# Create a commit with sign-off +gitauto commit create "feat: add new feature" --signoff +``` + +### Sync Operations + +```bash +# Sync current branch with its upstream and push +gitauto sync + +# Dry run to see what would be done +gitauto sync --dry-run +``` + +### Clean Local Branches + +```bash +# Clean local feature branches +gitauto clean + +# Dry run to see what would be deleted +gitauto clean --dry-run +``` + +### Pull Request Operations + +```bash +# Create a new pull request +gitauto pr create "feat: add new feature" + +# Create a draft pull request +gitauto pr create "feat: add new feature" --draft + +# Create a pull request with a custom base branch +gitauto pr create "feat: add new feature" --base develop + +# List open pull requests +gitauto pr list + +# List closed pull requests +gitauto pr list --state closed + +# Merge a pull request +gitauto pr merge 42 + +# Merge a pull request with squash method +gitauto pr merge 42 --method squash +``` + +## Global Flags + +All commands support the following global flags: + +```bash +--dry-run # Dry run mode (no changes made) +-v, --verbose # Verbose output +-h, --help # Help for the command +``` + +## Requirements + +- Go 1.22+ +- Git installed and available in PATH +- GitHub token for GitHub integration features + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb1a9cc --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/iwasforcedtobehere/git-automation-cli + +go 1.22.5 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b6a7dcc --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/branch.go b/internal/cmd/branch.go new file mode 100644 index 0000000..bc691fd --- /dev/null +++ b/internal/cmd/branch.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "fmt" + "strings" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" + "github.com/iwasforcedtobehere/git-automation-cli/internal/git" + "github.com/iwasforcedtobehere/git-automation-cli/internal/validation" +) + +var branchCmd = &cobra.Command{ + Use: "branch", + Short: "Branch utilities", + Long: `Create, list, switch, and manage Git branches. +Supports common branch naming conventions and workflows.`, +} + +var branchListCmd = &cobra.Command{ + Use: "list", + Short: "List branches", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to list branches: %w", err) + } + + currentBranch, err := git.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + for _, branch := range branches { + if branch == currentBranch { + fmt.Printf("* %s\n", branch) + } else { + fmt.Printf(" %s\n", branch) + } + } + return nil + }, +} + +var branchCreateCmd = &cobra.Command{ + Use: "create [name]", + Short: "Create a new branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Apply prefix if configured + originalName := name + if config.GlobalConfig.BranchPrefix != "" { + name = config.GlobalConfig.BranchPrefix + name + } + + // Validate branch name + if validationResult := validation.ValidateBranchName(name); !validationResult.IsValid { + return fmt.Errorf("invalid branch name '%s': %s", originalName, validationResult.GetErrors()) + } + + // Check if branch already exists + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to check existing branches: %w", err) + } + + for _, branch := range branches { + if branch == name { + return fmt.Errorf("branch '%s' already exists", name) + } + } + + // Create the branch + if err := git.CreateBranch(ctx, name); err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + fmt.Printf("Created branch: %s\n", name) + return nil + }, +} + +var branchSwitchCmd = &cobra.Command{ + Use: "switch [name]", + Short: "Switch to a branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + // Apply prefix if configured + if config.GlobalConfig.BranchPrefix != "" && !strings.HasPrefix(name, config.GlobalConfig.BranchPrefix) { + name = config.GlobalConfig.BranchPrefix + name + } + + // Check if branch exists + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to list branches: %w", err) + } + + found := false + for _, branch := range branches { + if branch == name { + found = true + break + } + } + + if !found { + return fmt.Errorf("branch '%s' does not exist", name) + } + + // Switch to the branch + if err := git.SwitchBranch(ctx, name); err != nil { + return fmt.Errorf("failed to switch branch: %w", err) + } + + fmt.Printf("Switched to branch: %s\n", name) + return nil + }, +} + +var branchDeleteCmd = &cobra.Command{ + Use: "delete [name]", + Short: "Delete a branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + // Apply prefix if configured + if config.GlobalConfig.BranchPrefix != "" && !strings.HasPrefix(name, config.GlobalConfig.BranchPrefix) { + name = config.GlobalConfig.BranchPrefix + name + } + + // Check if branch exists + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to list branches: %w", err) + } + + found := false + for _, branch := range branches { + if branch == name { + found = true + break + } + } + + if !found { + return fmt.Errorf("branch '%s' does not exist", name) + } + + // Check if it's the current branch + currentBranch, err := git.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + if currentBranch == name { + return fmt.Errorf("cannot delete the current branch '%s'", name) + } + + // Confirm deletion if configured + if config.GlobalConfig.ConfirmDestructive { + fmt.Printf("Are you sure you want to delete branch '%s'? [y/N]: ", name) + var response string + fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + fmt.Println("Branch deletion cancelled") + return nil + } + } + + // Delete the branch + if err := git.DeleteBranch(ctx, name); err != nil { + return fmt.Errorf("failed to delete branch: %w", err) + } + + fmt.Printf("Deleted branch: %s\n", name) + return nil + }, +} + +var branchFeatureCmd = &cobra.Command{ + Use: "feature [name]", + Short: "Create a new feature branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + // Apply feature prefix + if config.GlobalConfig.FeaturePrefix != "" { + name = config.GlobalConfig.FeaturePrefix + name + } + + // Check if branch already exists + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to check existing branches: %w", err) + } + + for _, branch := range branches { + if branch == name { + return fmt.Errorf("feature branch '%s' already exists", name) + } + } + + // Create the feature branch + if err := git.CreateBranch(ctx, name); err != nil { + return fmt.Errorf("failed to create feature branch: %w", err) + } + + fmt.Printf("Created feature branch: %s\n", name) + return nil + }, +} + +var branchHotfixCmd = &cobra.Command{ + Use: "hotfix [name]", + Short: "Create a new hotfix branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + // Apply hotfix prefix + if config.GlobalConfig.HotfixPrefix != "" { + name = config.GlobalConfig.HotfixPrefix + name + } + + // Check if branch already exists + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to check existing branches: %w", err) + } + + for _, branch := range branches { + if branch == name { + return fmt.Errorf("hotfix branch '%s' already exists", name) + } + } + + // Create the hotfix branch + if err := git.CreateBranch(ctx, name); err != nil { + return fmt.Errorf("failed to create hotfix branch: %w", err) + } + + fmt.Printf("Created hotfix branch: %s\n", name) + return nil + }, +} + +func init() { + rootCmd.AddCommand(branchCmd) + branchCmd.AddCommand(branchListCmd) + branchCmd.AddCommand(branchCreateCmd) + branchCmd.AddCommand(branchSwitchCmd) + branchCmd.AddCommand(branchDeleteCmd) + branchCmd.AddCommand(branchFeatureCmd) + branchCmd.AddCommand(branchHotfixCmd) +} \ No newline at end of file diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go new file mode 100644 index 0000000..f45d02b --- /dev/null +++ b/internal/cmd/commands.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/git" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +func init() { + rootCmd.AddCommand(syncCmd) + rootCmd.AddCommand(cleanCmd) +} + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync with remote", + Long: `Fetch the latest changes from remote, rebase the current branch onto +its upstream tracking branch, and push the changes back to remote.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Check if we're in a Git repository + if _, err := git.CurrentBranch(ctx); err != nil { + return fmt.Errorf("not a git repository or no branch found") + } + + // Fetch latest changes + if config.GlobalConfig.Verbose { + fmt.Println("Fetching latest changes from remote...") + } + if err := git.Fetch(ctx); err != nil { + return fmt.Errorf("failed to fetch from remote: %w", err) + } + + // Rebase onto tracking branch + if config.GlobalConfig.Verbose { + fmt.Println("Rebasing current branch onto tracking branch...") + } + if err := git.RebaseOntoTracking(ctx); err != nil { + return fmt.Errorf("failed to rebase: %w", err) + } + + // Push changes + if config.GlobalConfig.Verbose { + fmt.Println("Pushing changes to remote...") + } + if err := git.PushCurrent(ctx, false); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + + fmt.Println("Successfully synced with remote") + return nil + }, +} + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Clean local feature branches", + Long: `Delete local feature branches that have been merged or are no longer needed. +By default, only deletes branches with the 'feature/' prefix.`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Check if we're in a Git repository + if _, err := git.CurrentBranch(ctx); err != nil { + return fmt.Errorf("not a git repository or no branch found") + } + + // Get all local branches + branches, err := git.LocalBranches(ctx) + if err != nil { + return fmt.Errorf("failed to list branches: %w", err) + } + + // Get current branch to avoid deleting it + currentBranch, err := git.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + var removed []string + var prefix string + + // Determine which prefix to use for cleaning + if config.GlobalConfig.FeaturePrefix != "" { + prefix = config.GlobalConfig.FeaturePrefix + } else { + prefix = "feature/" + } + + for _, b := range branches { + // Skip current branch and main/master branches + if b == currentBranch || b == "main" || b == "master" { + continue + } + + // Check if branch matches the prefix + if strings.HasPrefix(b, prefix) { + // Confirm deletion if configured + if config.GlobalConfig.ConfirmDestructive { + fmt.Printf("Delete branch '%s'? [y/N]: ", b) + var response string + fmt.Scanln(&response) + if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + continue + } + } + + if err := git.DeleteBranch(ctx, b); err == nil { + removed = append(removed, b) + if config.GlobalConfig.Verbose { + fmt.Printf("Deleted branch: %s\n", b) + } + } else if config.GlobalConfig.Verbose { + fmt.Printf("Failed to delete branch %s: %v\n", b, err) + } + } + } + + if len(removed) > 0 { + fmt.Printf("Deleted %d branches:\n", len(removed)) + for _, branch := range removed { + fmt.Printf(" - %s\n", branch) + } + } else { + fmt.Println("No branches to clean") + } + + return nil + }, +} diff --git a/internal/cmd/commit.go b/internal/cmd/commit.go new file mode 100644 index 0000000..5db33b7 --- /dev/null +++ b/internal/cmd/commit.go @@ -0,0 +1,283 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/git" + "github.com/iwasforcedtobehere/git-automation-cli/internal/validation" +) + +var commitCmd = &cobra.Command{ + Use: "commit", + Short: "Commit utilities", + Long: `Create and manage Git commits with templates and helpers. +Supports conventional commit format and commit message templates.`, +} + +var commitCreateCmd = &cobra.Command{ + Use: "create [message]", + Short: "Create a new commit", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + message := strings.Join(args, " ") + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Validate commit message + if validationResult := validation.ValidateCommitMessage(message); !validationResult.IsValid { + return fmt.Errorf("invalid commit message: %s", validationResult.GetErrors()) + } + + // Check if working directory is clean + if validationResult := validation.ValidateWorkingDirectory(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Add all changes + if err := git.AddAll(ctx); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Create commit + if err := git.Commit(ctx, message); err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + fmt.Printf("Created commit: %s\n", message) + return nil + }, +} + +var commitAmendCmd = &cobra.Command{ + Use: "amend", + Short: "Amend the last commit", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Check if working directory is clean + if validationResult := validation.ValidateWorkingDirectory(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Add all changes + if err := git.AddAll(ctx); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Amend commit + var err error + _, err = git.Run(ctx, "commit", "--amend", "--no-edit") + if err != nil { + return fmt.Errorf("failed to amend commit: %w", err) + } + + fmt.Println("Amended last commit") + return nil + }, +} + +var commitFixupCmd = &cobra.Command{ + Use: "fixup [commit]", + Short: "Create a fixup commit", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + commit := args[0] + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Check if working directory is clean + if validationResult := validation.ValidateWorkingDirectory(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Add all changes + if err := git.AddAll(ctx); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Create fixup commit + var err error + _, err = git.Run(ctx, "commit", "--fixup", commit) + if err != nil { + return fmt.Errorf("failed to create fixup commit: %w", err) + } + + fmt.Printf("Created fixup commit for %s\n", commit) + return nil + }, +} + +var commitConventionalCmd = &cobra.Command{ + Use: "conventional", + Short: "Create a conventional commit", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Get commit type + fmt.Println("Select commit type:") + fmt.Println("1. feat: A new feature") + fmt.Println("2. fix: A bug fix") + fmt.Println("3. docs: Documentation only changes") + fmt.Println("4. style: Changes that do not affect the meaning of the code") + fmt.Println("5. refactor: A code change that neither fixes a bug nor adds a feature") + fmt.Println("6. perf: A code change that improves performance") + fmt.Println("7. test: Adding missing tests or correcting existing tests") + fmt.Println("8. build: Changes that affect the build system or external dependencies") + fmt.Println("9. ci: Changes to our CI configuration files and scripts") + fmt.Println("10. chore: Other changes that don't modify src or test files") + + var choice int + fmt.Print("Enter choice (1-10): ") + fmt.Scanln(&choice) + + var commitType string + switch choice { + case 1: + commitType = "feat" + case 2: + commitType = "fix" + case 3: + commitType = "docs" + case 4: + commitType = "style" + case 5: + commitType = "refactor" + case 6: + commitType = "perf" + case 7: + commitType = "test" + case 8: + commitType = "build" + case 9: + commitType = "ci" + case 10: + commitType = "chore" + default: + return fmt.Errorf("invalid choice") + } + + // Get scope + fmt.Print("Enter scope (optional, press Enter to skip): ") + reader := bufio.NewReader(os.Stdin) + scope, _ := reader.ReadString('\n') + scope = strings.TrimSpace(scope) + + // Get description + fmt.Print("Enter description: ") + description, _ := reader.ReadString('\n') + description = strings.TrimSpace(description) + if description == "" { + return fmt.Errorf("description is required") + } + + // Get body (optional) + fmt.Print("Enter detailed description (optional, press Enter to skip): ") + body, _ := reader.ReadString('\n') + body = strings.TrimSpace(body) + + // Build commit message + var message string + if scope != "" { + message = fmt.Sprintf("%s(%s): %s", commitType, scope, description) + } else { + message = fmt.Sprintf("%s: %s", commitType, description) + } + + if body != "" { + message += "\n\n" + body + } + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Validate conventional commit message + if validationResult := validation.ValidateConventionalCommit(message); !validationResult.IsValid { + return fmt.Errorf("invalid conventional commit message: %s", validationResult.GetErrors()) + } + + // Check if working directory is clean + if validationResult := validation.ValidateWorkingDirectory(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Add all changes + if err := git.AddAll(ctx); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Create commit + if err := git.Commit(ctx, message); err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + fmt.Printf("Created conventional commit: %s\n", message) + return nil + }, +} + +var commitSignoffCmd = &cobra.Command{ + Use: "signoff [message]", + Short: "Create a commit with sign-off", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + message := strings.Join(args, " ") + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Validate commit message + if validationResult := validation.ValidateCommitMessage(message); !validationResult.IsValid { + return fmt.Errorf("invalid commit message: %s", validationResult.GetErrors()) + } + + // Check if working directory is clean + if validationResult := validation.ValidateWorkingDirectory(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Add all changes + if err := git.AddAll(ctx); err != nil { + return fmt.Errorf("failed to add changes: %w", err) + } + + // Create commit with sign-off + var err error + _, err = git.Run(ctx, "commit", "-s", "-m", message) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + fmt.Printf("Created signed-off commit: %s\n", message) + return nil + }, +} + +func init() { + rootCmd.AddCommand(commitCmd) + commitCmd.AddCommand(commitCreateCmd) + commitCmd.AddCommand(commitAmendCmd) + commitCmd.AddCommand(commitFixupCmd) + commitCmd.AddCommand(commitConventionalCmd) + commitCmd.AddCommand(commitSignoffCmd) +} \ No newline at end of file diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 0000000..31364a5 --- /dev/null +++ b/internal/cmd/config.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "strings" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage configuration settings", + Long: `View and modify configuration settings for gitauto. +Settings can be stored in the configuration file or as environment variables.`, +} + +var configGetCmd = &cobra.Command{ + Use: "get [key]", + Short: "Get a configuration value", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // Show all config values + fmt.Fprintf(cmd.OutOrStdout(), "Configuration file: %s\n\n", config.GetConfigFile()) + fmt.Fprintf(cmd.OutOrStdout(), "Default Remote: %s\n", config.GlobalConfig.DefaultRemote) + fmt.Fprintf(cmd.OutOrStdout(), "Default Branch: %s\n", config.GlobalConfig.DefaultBranch) + fmt.Fprintf(cmd.OutOrStdout(), "GitHub URL: %s\n", config.GlobalConfig.GitHubURL) + fmt.Fprintf(cmd.OutOrStdout(), "Auto Cleanup: %t\n", config.GlobalConfig.AutoCleanup) + fmt.Fprintf(cmd.OutOrStdout(), "Confirm Destructive: %t\n", config.GlobalConfig.ConfirmDestructive) + fmt.Fprintf(cmd.OutOrStdout(), "Dry Run: %t\n", config.GlobalConfig.DryRun) + fmt.Fprintf(cmd.OutOrStdout(), "Verbose: %t\n", config.GlobalConfig.Verbose) + fmt.Fprintf(cmd.OutOrStdout(), "Branch Prefix: %s\n", config.GlobalConfig.BranchPrefix) + fmt.Fprintf(cmd.OutOrStdout(), "Feature Prefix: %s\n", config.GlobalConfig.FeaturePrefix) + fmt.Fprintf(cmd.OutOrStdout(), "Hotfix Prefix: %s\n", config.GlobalConfig.HotfixPrefix) + if config.GlobalConfig.GitHubToken != "" { + fmt.Fprintf(cmd.OutOrStdout(), "GitHub Token: %s\n", maskToken(config.GlobalConfig.GitHubToken)) + } + } else { + // Show specific config value + key := args[0] + value := config.Get(key) + if value == nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Configuration key '%s' not found\n", key) + cmd.SilenceUsage = true + return fmt.Errorf("configuration key '%s' not found", key) + } + + // Mask sensitive values + if strings.Contains(key, "token") || strings.Contains(key, "password") { + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", key, maskToken(fmt.Sprintf("%v", value))) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%s: %v\n", key, value) + } + } + return nil + }, +} + +var configSetCmd = &cobra.Command{ + Use: "set [key] [value]", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + value := args[1] + + config.Set(key, value) + if err := config.Save(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error saving configuration: %v\n", err) + return fmt.Errorf("error saving configuration: %v", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %v\n", key, value) + return nil + }, +} + +var configInitCmd = &cobra.Command{ + Use: "init", + Short: "Initialize configuration file", + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.Save(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error creating configuration file: %v\n", err) + return fmt.Errorf("error creating configuration file: %v", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Configuration file created at: %s\n", config.GetConfigFile()) + return nil + }, +} + +func maskToken(token string) string { + if len(token) <= 8 { + return strings.Repeat("*", len(token)) + } + // For GitHub tokens, use a standard masking format + if strings.HasPrefix(token, "ghp_") { + return token[:4] + strings.Repeat("*", 28) + token[len(token)-4:] + } + // For other tokens, use the original logic + return token[:4] + strings.Repeat("*", len(token)-8) + token[len(token)-4:] +} + +func init() { + rootCmd.AddCommand(configCmd) + configCmd.AddCommand(configGetCmd) + configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configInitCmd) +} \ No newline at end of file diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go new file mode 100644 index 0000000..356f741 --- /dev/null +++ b/internal/cmd/config_test.go @@ -0,0 +1,286 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +func TestConfigGetCmd(t *testing.T) { + // Save original config and viper + originalConfig := config.GlobalConfig + originalViper := viper.GetViper() + defer func() { + config.GlobalConfig = originalConfig + config.SetViper(originalViper) + }() + + // Create a new viper instance for testing + testViper := viper.New() + config.SetViper(testViper) + + // Create a temporary config file + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "config.yaml") + + // Set config file path in viper + testViper.SetConfigFile(configFile) + testViper.SetConfigType("yaml") + + // Create the config file + if err := os.WriteFile(configFile, []byte("default_remote: origin\ndefault_branch: main\ngithub_url: https://api.github.com\ngithub_token: ghp_testtoken123456\nauto_cleanup: true\nconfirm_destructive: true\ndry_run: false\nverbose: false\nbranch_prefix: feature/\nfeature_prefix: feature/\nhotfix_prefix: hotfix/\n"), 0644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + // Read the config file + if err := testViper.ReadInConfig(); err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + // Set up test config + config.GlobalConfig = &config.Config{ + DefaultRemote: "origin", + DefaultBranch: "main", + GitHubURL: "https://api.github.com", + GitHubToken: "ghp_testtoken123456", + AutoCleanup: true, + ConfirmDestructive: true, + DryRun: false, + Verbose: false, + BranchPrefix: "feature/", + FeaturePrefix: "feature/", + HotfixPrefix: "hotfix/", + } + + // Set values in viper + testViper.Set("default_remote", "origin") + testViper.Set("default_branch", "main") + testViper.Set("github_url", "https://api.github.com") + testViper.Set("github_token", "ghp_testtoken123456") + testViper.Set("auto_cleanup", true) + testViper.Set("confirm_destructive", true) + testViper.Set("dry_run", false) + testViper.Set("verbose", false) + testViper.Set("branch_prefix", "feature/") + testViper.Set("feature_prefix", "feature/") + testViper.Set("hotfix_prefix", "hotfix/") + + // Set the config file path + config.SetConfigFile(configFile) + + tests := []struct { + name string + args []string + wantOutput string + wantError bool + }{ + { + name: "Get all config", + args: []string{}, + wantOutput: "Configuration file: " + configFile + "\n\nDefault Remote: origin\nDefault Branch: main\nGitHub URL: https://api.github.com\nAuto Cleanup: true\nConfirm Destructive: true\nDry Run: false\nVerbose: false\nBranch Prefix: feature/\nFeature Prefix: feature/\nHotfix Prefix: hotfix/\nGitHub Token: ghp_****************************3456\n", + wantError: false, + }, + { + name: "Get specific config", + args: []string{"default_remote"}, + wantOutput: "default_remote: origin\n", + wantError: false, + }, + { + name: "Get sensitive config", + args: []string{"github_token"}, + wantOutput: "github_token: ghp_****************************3456\n", + wantError: false, + }, + { + name: "Get non-existent config", + args: []string{"nonexistent"}, + wantOutput: "", // Don't check exact output for error case + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the actual configGetCmd + cmd := &cobra.Command{ + Use: "get", + Args: cobra.MaximumNArgs(1), + RunE: configGetCmd.RunE, + SilenceUsage: true, // Prevent usage output on errors + } + + // Capture output + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(output) + + // Set args + cmd.SetArgs(tt.args) + + // Execute command + err := cmd.Execute() + + // Check error expectation + if tt.wantError && err == nil { + t.Errorf("Execute() error = %v, wantError %v", err, tt.wantError) + return + } + if !tt.wantError && err != nil { + t.Errorf("Execute() error = %v, wantError %v", err, tt.wantError) + return + } + + // Check output for error cases + if tt.wantError { + outputStr := output.String() + if !strings.Contains(outputStr, "Configuration key 'nonexistent' not found") { + t.Errorf("Expected error output to contain 'Configuration key 'nonexistent' not found', got %q", outputStr) + } + return + } + + // Check output for success cases + if output.String() != tt.wantOutput { + t.Errorf("Execute() output = %q, want %q", output.String(), tt.wantOutput) + } + }) + } +} + +func TestConfigSetCmd(t *testing.T) { + // Save original config and viper + originalConfig := config.GlobalConfig + originalViper := viper.GetViper() + defer func() { + config.GlobalConfig = originalConfig + config.SetViper(originalViper) + }() + + // Create a new viper instance for testing + testViper := viper.New() + config.SetViper(testViper) + + // Create a temporary config file + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "config.yaml") + + // Set config file path in viper + testViper.SetConfigFile(configFile) + testViper.SetConfigType("yaml") + + // Create the config file + if err := os.WriteFile(configFile, []byte("default_remote: origin\ndefault_branch: main\n"), 0644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + // Read the config file + if err := testViper.ReadInConfig(); err != nil { + t.Fatalf("Failed to read config file: %v", err) + } + + // Set up test config + config.GlobalConfig = &config.Config{ + DefaultRemote: "origin", + DefaultBranch: "main", + } + + // Set values in viper + testViper.Set("default_remote", "origin") + testViper.Set("default_branch", "main") + + // Set the config file path + config.SetConfigFile(configFile) + + // Create a command + cmd := &cobra.Command{ + Use: "set", + Args: cobra.ExactArgs(2), + RunE: configSetCmd.RunE, + } + + // Capture output + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(output) + + // Set args + cmd.SetArgs([]string{"default_branch", "develop"}) + + // Execute command + err := cmd.Execute() + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output + expected := "Set default_branch = develop\n" + if output.String() != expected { + t.Errorf("Execute() output = %q, want %q", output.String(), expected) + } + + // Check that config was updated + if config.GlobalConfig.DefaultBranch != "develop" { + t.Errorf("Expected DefaultBranch to be 'develop', got '%s'", config.GlobalConfig.DefaultBranch) + } +} + +func TestConfigInitCmd(t *testing.T) { + // Save original config and viper + originalConfig := config.GlobalConfig + originalViper := viper.GetViper() + defer func() { + config.GlobalConfig = originalConfig + config.SetViper(originalViper) + }() + + // Create a new viper instance for testing + testViper := viper.New() + config.SetViper(testViper) + + // Create a temporary config file + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "config.yaml") + + // Set config file path in viper + testViper.SetConfigFile(configFile) + testViper.SetConfigType("yaml") + + // Create a command + // Set the config file path to our temp file before creating the command + config.SetConfigFile(configFile) + + cmd := &cobra.Command{ + Use: "init", + RunE: configInitCmd.RunE, + } + + // Capture output + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(output) + + // Execute command + err := cmd.Execute() + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output contains expected text + outputStr := output.String() + if !strings.Contains(outputStr, "Configuration file created at:") { + t.Errorf("Expected output to contain 'Configuration file created at:', got %q", outputStr) + } +} \ No newline at end of file diff --git a/internal/cmd/pr.go b/internal/cmd/pr.go new file mode 100644 index 0000000..e20bab4 --- /dev/null +++ b/internal/cmd/pr.go @@ -0,0 +1,326 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" + "github.com/iwasforcedtobehere/git-automation-cli/internal/git" + "github.com/iwasforcedtobehere/git-automation-cli/internal/github" + "github.com/iwasforcedtobehere/git-automation-cli/internal/validation" +) + +var prCmd = &cobra.Command{ + Use: "pr", + Short: "Pull request utilities", + Long: `Create, list, and manage GitHub pull requests. +Supports creating pull requests from the current branch and managing existing PRs.`, +} + +var prCreateCmd = &cobra.Command{ + Use: "create [title]", + Short: "Create a new pull request", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + title := strings.Join(args, " ") + + // Get body from flag or prompt + body, _ := cmd.Flags().GetString("body") + if body == "" { + body = promptForBody() + } + + // Get draft flag + draft, _ := cmd.Flags().GetBool("draft") + + // Get base branch + base, _ := cmd.Flags().GetString("base") + if base == "" { + base = config.GlobalConfig.DefaultBranch + if base == "" { + base = "main" + } + } + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Get current branch + currentBranch, err := git.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + // Check if current branch is the same as base branch + if currentBranch == base { + return fmt.Errorf("cannot create pull request from %s branch to itself", base) + } + + // Create GitHub client + client, err := github.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get repository information + remote, _ := cmd.Flags().GetString("remote") + if remote == "" { + remote = config.GlobalConfig.DefaultRemote + if remote == "" { + remote = "origin" + } + } + + // Get remote URL + remoteURL, err := git.GetRemoteURL(ctx, remote) + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !github.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := github.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Create pull request + pr := &github.PullRequestRequest{ + Title: title, + Body: body, + Head: currentBranch, + Base: base, + Draft: draft, + } + + pullRequest, err := client.CreatePullRequest(ctx, pr) + if err != nil { + return fmt.Errorf("failed to create pull request: %w", err) + } + + fmt.Printf("Created pull request: %s\n", pullRequest.HTMLURL) + return nil + }, +} + +var prListCmd = &cobra.Command{ + Use: "list", + Short: "List pull requests", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Get state flag + state, _ := cmd.Flags().GetString("state") + if state == "" { + state = "open" + } + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Create GitHub client + client, err := github.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get repository information + remote, _ := cmd.Flags().GetString("remote") + if remote == "" { + remote = config.GlobalConfig.DefaultRemote + if remote == "" { + remote = "origin" + } + } + + // Get remote URL + remoteURL, err := git.GetRemoteURL(ctx, remote) + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !github.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := github.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Get pull requests + pullRequests, err := client.GetPullRequests(ctx, state) + if err != nil { + return fmt.Errorf("failed to get pull requests: %w", err) + } + + // Print pull requests + if len(pullRequests) == 0 { + fmt.Printf("No %s pull requests found\n", state) + return nil + } + + fmt.Printf("%s pull requests:\n", strings.Title(state)) + for _, pr := range pullRequests { + fmt.Printf("#%d: %s (%s)\n", pr.Number, pr.Title, pr.User.Login) + } + + return nil + }, +} + +var prMergeCmd = &cobra.Command{ + Use: "merge [number]", + Short: "Merge a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + number := args[0] + + // Validate Git repository + if validationResult := validation.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Create GitHub client + client, err := github.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get repository information + remote, _ := cmd.Flags().GetString("remote") + if remote == "" { + remote = config.GlobalConfig.DefaultRemote + if remote == "" { + remote = "origin" + } + } + + // Get remote URL + remoteURL, err := git.GetRemoteURL(ctx, remote) + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !github.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := github.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Get merge method + method, _ := cmd.Flags().GetString("method") + if method == "" { + method = "merge" + } + + // Merge pull request + mergeRequest := &github.MergePullRequestRequest{ + MergeMethod: method, + } + + mergeResponse, err := client.MergePullRequest(ctx, parseInt(number), mergeRequest) + if err != nil { + return fmt.Errorf("failed to merge pull request: %w", err) + } + + fmt.Printf("Merged pull request: %s\n", mergeResponse.SHA) + return nil + }, +} + +func promptForBody() string { + fmt.Print("Enter pull request body (press Enter twice to finish):\n") + + reader := bufio.NewReader(os.Stdin) + var body string + var consecutiveEmptyLines int + + for { + fmt.Print("> ") + line, err := reader.ReadString('\n') + if err != nil { + break + } + line = strings.TrimSpace(line) + + if line == "" { + consecutiveEmptyLines++ + if consecutiveEmptyLines >= 2 { + break + } + } else { + consecutiveEmptyLines = 0 + if body != "" { + body += "\n" + } + body += line + } + } + + return body +} + +func readLine() string { + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return "" + } + return strings.TrimSpace(line) +} + +func parseInt(s string) int { + var result int + _, err := fmt.Sscanf(s, "%d", &result) + if err != nil { + return 0 + } + return result +} + +func init() { + rootCmd.AddCommand(prCmd) + prCmd.AddCommand(prCreateCmd) + prCmd.AddCommand(prListCmd) + prCmd.AddCommand(prMergeCmd) + + // Add flags to pr create command + prCreateCmd.Flags().String("body", "", "Pull request body") + prCreateCmd.Flags().Bool("draft", false, "Create a draft pull request") + prCreateCmd.Flags().String("base", "", "Base branch for the pull request") + prCreateCmd.Flags().String("remote", "", "Remote to use for the pull request") + + // Add flags to pr list command + prListCmd.Flags().String("state", "open", "State of pull requests to list (open, closed, all)") + prListCmd.Flags().String("remote", "", "Remote to use for the pull request") + + // Add flags to pr merge command + prMergeCmd.Flags().String("method", "merge", "Merge method (merge, squash, rebase)") + prMergeCmd.Flags().String("remote", "", "Remote to use for the pull request") +} \ No newline at end of file diff --git a/internal/cmd/pr_test.go b/internal/cmd/pr_test.go new file mode 100644 index 0000000..db1e398 --- /dev/null +++ b/internal/cmd/pr_test.go @@ -0,0 +1,518 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/github" + "github.com/iwasforcedtobehere/git-automation-cli/internal/validation" +) + +func TestPromptForBody(t *testing.T) { + // Save original stdin + originalStdin := os.Stdin + defer func() { + os.Stdin = originalStdin + }() + + // Create a pipe to simulate user input + r, w, _ := os.Pipe() + os.Stdin = r + + // Write test input + go func() { + w.WriteString("This is the first line\n") + w.WriteString("This is the second line\n") + w.WriteString("\n") // First empty line + w.WriteString("\n") // Second empty line to finish + w.Close() + }() + + // Capture output + oldStdout := os.Stdout + stdoutR, stdoutW, _ := os.Pipe() + os.Stdout = stdoutW + + // Call the function + body := promptForBody() + + // Restore stdout + stdoutW.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(stdoutR) + + // Check result + expectedBody := "This is the first line\nThis is the second line" + if body != expectedBody { + t.Errorf("promptForBody() = %q, want %q", body, expectedBody) + } + + // Check that prompt was displayed + output := string(out) + if !strings.Contains(output, "Enter pull request body") { + t.Errorf("Expected prompt to be displayed, got %q", output) + } +} + +func TestReadLine(t *testing.T) { + // Save original stdin + originalStdin := os.Stdin + defer func() { + os.Stdin = originalStdin + }() + + // Create a pipe to simulate user input + r, w, _ := os.Pipe() + os.Stdin = r + + // Write test input + go func() { + w.WriteString("test line\n") + w.Close() + }() + + // Call the function + line := readLine() + + // Check result + expectedLine := "test line" + if line != expectedLine { + t.Errorf("readLine() = %q, want %q", line, expectedLine) + } +} + +func TestParseInt(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + { + name: "Valid number", + input: "123", + expected: 123, + }, + { + name: "Invalid number", + input: "abc", + expected: 0, + }, + { + name: "Empty string", + input: "", + expected: 0, + }, + { + name: "Negative number", + input: "-456", + expected: -456, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseInt(tt.input) + if result != tt.expected { + t.Errorf("parseInt(%q) = %d, want %d", tt.input, result, tt.expected) + } + }) + } +} + +// MockGitService is a mock implementation of the git service +type MockGitService struct { + currentBranchFunc func(ctx context.Context) (string, error) + getRemoteURLFunc func(ctx context.Context, remote string) (string, error) +} + +func (m *MockGitService) CurrentBranch(ctx context.Context) (string, error) { + if m.currentBranchFunc != nil { + return m.currentBranchFunc(ctx) + } + return "feature/test", nil +} + +func (m *MockGitService) GetRemoteURL(ctx context.Context, remote string) (string, error) { + if m.getRemoteURLFunc != nil { + return m.getRemoteURLFunc(ctx, remote) + } + return "https://github.com/owner/repo.git", nil +} + +// MockGitHubService is a mock implementation of the GitHub service +type MockGitHubService struct { + newClientFunc func(ctx context.Context) (*github.Client, error) + isGitHubURLFunc func(url string) bool + parseGitHubURLFunc func(url string) (string, string, error) + createPullRequest func(c *github.Client, ctx context.Context, pr *github.PullRequestRequest) (*github.PullRequest, error) + getPullRequests func(c *github.Client, ctx context.Context, state string) ([]github.PullRequest, error) + mergePullRequest func(c *github.Client, ctx context.Context, number int, mergeRequest *github.MergePullRequestRequest) (*github.MergePullRequestResponse, error) +} + +func (m *MockGitHubService) NewClient(ctx context.Context) (*github.Client, error) { + if m.newClientFunc != nil { + return m.newClientFunc(ctx) + } + return &github.Client{}, nil +} + +func (m *MockGitHubService) IsGitHubURL(url string) bool { + if m.isGitHubURLFunc != nil { + return m.isGitHubURLFunc(url) + } + return true +} + +func (m *MockGitHubService) ParseGitHubURL(url string) (string, string, error) { + if m.parseGitHubURLFunc != nil { + return m.parseGitHubURLFunc(url) + } + return "owner", "repo", nil +} + +// MockValidationService is a mock implementation of the validation service +type MockValidationService struct { + validateGitRepositoryFunc func(ctx context.Context) *validation.ValidationResult +} + +func (m *MockValidationService) ValidateGitRepository(ctx context.Context) *validation.ValidationResult { + if m.validateGitRepositoryFunc != nil { + return m.validateGitRepositoryFunc(ctx) + } + return &validation.ValidationResult{IsValid: true} +} + +func TestPRCreateCmd(t *testing.T) { + // Create mock services + mockGitService := &MockGitService{} + mockGitHubService := &MockGitHubService{} + mockValidationService := &MockValidationService{} + + // Set up mock functions + mockGitHubService.createPullRequest = func(c *github.Client, ctx context.Context, pr *github.PullRequestRequest) (*github.PullRequest, error) { + return &github.PullRequest{ + ID: 1, + Number: 123, + Title: pr.Title, + Body: pr.Body, + HTMLURL: "https://github.com/owner/repo/pull/123", + }, nil + } + + // Temporarily redirect stdout to capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Create a command + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + // Use mock services instead of real ones + ctx := cmd.Context() + + // Validate Git repository + if validationResult := mockValidationService.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Get current branch + currentBranch, err := mockGitService.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + // Get base branch + base := "main" + + // Check if current branch is the same as base branch + if currentBranch == base { + return fmt.Errorf("cannot create pull request from %s branch to itself", base) + } + + // Create GitHub client + client, err := mockGitHubService.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get remote URL + remoteURL, err := mockGitService.GetRemoteURL(ctx, "origin") + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !mockGitHubService.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := mockGitHubService.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Create pull request + pr := &github.PullRequestRequest{ + Title: strings.Join(args, " "), + Body: "Test body", + Head: currentBranch, + Base: base, + Draft: false, + } + + pullRequest, err := mockGitHubService.createPullRequest(client, ctx, pr) + if err != nil { + return fmt.Errorf("failed to create pull request: %w", err) + } + + fmt.Printf("Created pull request: %s\n", pullRequest.HTMLURL) + return nil + }, + } + + // Set args + cmd.SetArgs([]string{"Test PR"}) + + // Execute command + err := cmd.Execute() + + // Restore stdout + w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output + output := string(out) + expected := "Created pull request: https://github.com/owner/repo/pull/123\n" + if output != expected { + t.Errorf("Execute() output = %q, want %q", output, expected) + } +} + +func TestPRListCmd(t *testing.T) { + // Create mock services + mockGitService := &MockGitService{} + mockGitHubService := &MockGitHubService{} + mockValidationService := &MockValidationService{} + + // Set up mock functions + mockGitHubService.getPullRequests = func(c *github.Client, ctx context.Context, state string) ([]github.PullRequest, error) { + return []github.PullRequest{ + { + ID: 1, + Number: 123, + Title: "Test PR 1", + User: github.User{Login: "user1"}, + }, + { + ID: 2, + Number: 124, + Title: "Test PR 2", + User: github.User{Login: "user2"}, + }, + }, nil + } + + // Temporarily redirect stdout to capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Create a command + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + // Use mock services instead of real ones + ctx := cmd.Context() + + // Get state flag + state := "open" + + // Validate Git repository + if validationResult := mockValidationService.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Create GitHub client + client, err := mockGitHubService.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get remote URL + remoteURL, err := mockGitService.GetRemoteURL(ctx, "origin") + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !mockGitHubService.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := mockGitHubService.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Get pull requests + pullRequests, err := mockGitHubService.getPullRequests(client, ctx, state) + if err != nil { + return fmt.Errorf("failed to get pull requests: %w", err) + } + + // Print pull requests + if len(pullRequests) == 0 { + fmt.Printf("No %s pull requests found\n", state) + return nil + } + + fmt.Printf("%s pull requests:\n", strings.Title(state)) + for _, pr := range pullRequests { + fmt.Printf("#%d: %s (%s)\n", pr.Number, pr.Title, pr.User.Login) + } + + return nil + }, + } + + // Execute command + err := cmd.Execute() + + // Restore stdout + w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output + output := string(out) + expected := "Open pull requests:\n#123: Test PR 1 (user1)\n#124: Test PR 2 (user2)\n" + if output != expected { + t.Errorf("Execute() output = %q, want %q", output, expected) + } +} + +func TestPRMergeCmd(t *testing.T) { + // Create mock services + mockGitService := &MockGitService{} + mockGitHubService := &MockGitHubService{} + mockValidationService := &MockValidationService{} + + // Set up mock functions + mockGitHubService.mergePullRequest = func(c *github.Client, ctx context.Context, number int, mergeRequest *github.MergePullRequestRequest) (*github.MergePullRequestResponse, error) { + return &github.MergePullRequestResponse{ + SHA: "abc123", + Merged: true, + Message: "Pull Request successfully merged", + }, nil + } + + // Temporarily redirect stdout to capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Create a command + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + // Use mock services instead of real ones + ctx := cmd.Context() + number := args[0] + + // Validate Git repository + if validationResult := mockValidationService.ValidateGitRepository(ctx); !validationResult.IsValid { + return fmt.Errorf(validationResult.GetErrors()) + } + + // Create GitHub client + client, err := mockGitHubService.NewClient(ctx) + if err != nil { + return fmt.Errorf("failed to create GitHub client: %w", err) + } + + // Get remote URL + remoteURL, err := mockGitService.GetRemoteURL(ctx, "origin") + if err != nil { + return fmt.Errorf("failed to get remote URL: %w", err) + } + + // Parse GitHub URL + if !mockGitHubService.IsGitHubURL(remoteURL) { + return fmt.Errorf("remote URL is not a GitHub URL: %s", remoteURL) + } + + owner, repo, err := mockGitHubService.ParseGitHubURL(remoteURL) + if err != nil { + return fmt.Errorf("failed to parse GitHub URL: %w", err) + } + + // Set repository on client + client.SetRepository(owner, repo) + + // Get merge method + method := "merge" + + // Merge pull request + mergeRequest := &github.MergePullRequestRequest{ + MergeMethod: method, + } + + mergeResponse, err := mockGitHubService.mergePullRequest(client, ctx, parseInt(number), mergeRequest) + if err != nil { + return fmt.Errorf("failed to merge pull request: %w", err) + } + + fmt.Printf("Merged pull request: %s\n", mergeResponse.SHA) + return nil + }, + } + + // Set args + cmd.SetArgs([]string{"123"}) + + // Execute command + err := cmd.Execute() + + // Restore stdout + w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output + output := string(out) + expected := "Merged pull request: abc123\n" + if output != expected { + t.Errorf("Execute() output = %q, want %q", output, expected) + } +} \ No newline at end of file diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..d119e18 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + "os" + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/version" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +var ( + verbose bool + dryRun bool +) + +var rootCmd = &cobra.Command{ + Use: "gitauto", + Short: "Git automation toolkit", + Long: `Git Automation CLI is a fast, practical tool to automate frequent Git workflows. +It provides utilities for branch management, commit helpers, sync operations, +and integration with GitHub for pull requests and team coordination.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Initialize configuration + if err := config.Init(); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err) + os.Exit(1) + } + + // Override config with command line flags + if verbose { + config.Set("verbose", true) + } + if dryRun { + config.Set("dry_run", true) + } + }, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("gitauto version", version.GetVersion()) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) + + // Add global flags + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "dry run mode (no changes made)") +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go new file mode 100644 index 0000000..e1901fe --- /dev/null +++ b/internal/cmd/root_test.go @@ -0,0 +1,225 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +// MockVersionService is a mock implementation of the version service +type MockVersionService struct { + getVersionFunc func() string +} + +func (m *MockVersionService) GetVersion() string { + if m.getVersionFunc != nil { + return m.getVersionFunc() + } + return "1.0.0" +} + +// MockConfigService is a mock implementation of the config service +type MockConfigService struct { + initFunc func() error +} + +func (m *MockConfigService) Init() error { + if m.initFunc != nil { + return m.initFunc() + } + return nil +} + +func TestExecute(t *testing.T) { + // Save original stdout + originalStdout := os.Stdout + defer func() { + os.Stdout = originalStdout + }() + + // Temporarily redirect stdout to capture output + r, w, _ := os.Pipe() + os.Stdout = w + + // Execute root command + Execute() + + // Restore stdout + w.Close() + os.Stdout = originalStdout + out, _ := io.ReadAll(r) + + // Check that help was displayed + output := string(out) + if !strings.Contains(output, "Git Automation CLI is a fast, practical tool to automate frequent Git workflows") { + t.Errorf("Expected help to be displayed, got %q", output) + } +} + +func TestVersionCmd(t *testing.T) { + // Create mock version service + mockVersionService := &MockVersionService{ + getVersionFunc: func() string { + return "1.0.0-test" + }, + } + + // Temporarily redirect stdout to capture output + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Create a command + cmd := &cobra.Command{ + Use: "test", + Run: func(cmd *cobra.Command, args []string) { + // Use mock version service instead of real one + versionStr := mockVersionService.GetVersion() + fmt.Printf("gitauto version %s\n", versionStr) + }, + } + + // Execute command + err := cmd.Execute() + + // Restore stdout + w.Close() + os.Stdout = oldStdout + out, _ := io.ReadAll(r) + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check output + output := string(out) + expected := "gitauto version 1.0.0-test\n" + if output != expected { + t.Errorf("Execute() output = %q, want %q", output, expected) + } +} + +func TestRootCmdPersistentPreRun(t *testing.T) { + // Save original config + originalConfig := config.GlobalConfig + defer func() { + config.GlobalConfig = originalConfig + }() + + // Create mock config service + mockConfigService := &MockConfigService{ + initFunc: func() error { + return nil + }, + } + + // Set up test config + config.GlobalConfig = &config.Config{ + Verbose: false, + DryRun: false, + } + + // Create a command with PersistentPreRun + cmd := &cobra.Command{ + Use: "test", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Initialize configuration + if err := mockConfigService.Init(); err != nil { + panic(err) + } + + // Override config with command line flags + if verbose { + config.Set("verbose", true) + } + if dryRun { + config.Set("dry_run", true) + } + }, + Run: func(cmd *cobra.Command, args []string) { + // Do nothing + }, + } + + // Set verbose flag + verbose = true + + // Execute command + err := cmd.Execute() + + // Check error + if err != nil { + t.Errorf("Execute() error = %v", err) + return + } + + // Check that config was updated + if !config.GlobalConfig.Verbose { + t.Errorf("Expected Verbose to be true, got false") + } +} + +func TestRootCmdFlags(t *testing.T) { + // Test that verbose and dry-run flags are properly set + cmd := &cobra.Command{ + Use: "test", + Run: func(cmd *cobra.Command, args []string) { + // Do nothing + }, + } + + // Add flags like in rootCmd + cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + cmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "dry run mode (no changes made)") + + // Set verbose flag + err := cmd.ParseFlags([]string{"--verbose"}) + if err != nil { + t.Errorf("ParseFlags() error = %v", err) + return + } + + if !verbose { + t.Errorf("Expected verbose to be true, got false") + } + + // Reset flags + verbose = false + dryRun = false + + // Set dry-run flag + err = cmd.ParseFlags([]string{"--dry-run"}) + if err != nil { + t.Errorf("ParseFlags() error = %v", err) + return + } + + if !dryRun { + t.Errorf("Expected dryRun to be true, got false") + } + + // Reset flags + verbose = false + dryRun = false + + // Set both flags + err = cmd.ParseFlags([]string{"--verbose", "--dry-run"}) + if err != nil { + t.Errorf("ParseFlags() error = %v", err) + return + } + + if !verbose { + t.Errorf("Expected verbose to be true, got false") + } + if !dryRun { + t.Errorf("Expected dryRun to be true, got false") + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2dadcff --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,149 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "github.com/spf13/viper" +) + +// Config represents the application configuration +type Config struct { + // Git settings + DefaultRemote string `mapstructure:"default_remote"` + DefaultBranch string `mapstructure:"default_branch"` + + // GitHub settings + GitHubToken string `mapstructure:"github_token"` + GitHubURL string `mapstructure:"github_url"` + + // Behavior settings + AutoCleanup bool `mapstructure:"auto_cleanup"` + ConfirmDestructive bool `mapstructure:"confirm_destructive"` + DryRun bool `mapstructure:"dry_run"` + Verbose bool `mapstructure:"verbose"` + + // Branch settings + BranchPrefix string `mapstructure:"branch_prefix"` + FeaturePrefix string `mapstructure:"feature_prefix"` + HotfixPrefix string `mapstructure:"hotfix_prefix"` +} + +var ( + // GlobalConfig holds the application configuration + GlobalConfig *Config + // configFile is the path to the configuration file + configFile string + // v is the viper instance + v *viper.Viper +) + +// initViper initializes the viper instance +func initViper() { + if v == nil { + v = viper.New() + } +} + +// Init initializes the configuration +func Init() error { + initViper() + + // Set default values + v.SetDefault("default_remote", "origin") + v.SetDefault("default_branch", "main") + v.SetDefault("github_url", "https://api.github.com") + v.SetDefault("auto_cleanup", false) + v.SetDefault("confirm_destructive", true) + v.SetDefault("dry_run", false) + v.SetDefault("verbose", false) + v.SetDefault("branch_prefix", "") + v.SetDefault("feature_prefix", "feature/") + v.SetDefault("hotfix_prefix", "hotfix/") + + // Set configuration file paths + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not get user home directory: %w", err) + } + + configDir := filepath.Join(home, ".gitauto") + configFile = filepath.Join(configDir, "config.yaml") + + // Ensure config directory exists + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + + // Set configuration file + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(configDir) + v.AddConfigPath(".") + + // Read environment variables + v.SetEnvPrefix("GITAUTO") + v.AutomaticEnv() + + // Read configuration file + if err := v.ReadInConfig(); err != nil { + // It's okay if config file doesn't exist + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("could not read config file: %w", err) + } + } + + // Unmarshal configuration + GlobalConfig = &Config{} + if err := v.Unmarshal(GlobalConfig); err != nil { + return fmt.Errorf("could not unmarshal config: %w", err) + } + + return nil +} + +// Save saves the current configuration to file +func Save() error { + initViper() + + if err := v.WriteConfigAs(configFile); err != nil { + return fmt.Errorf("could not save config file: %w", err) + } + return nil +} + +// GetConfigFile returns the path to the configuration file +func GetConfigFile() string { + return configFile +} + +// SetConfigFile sets the path to the configuration file (for testing) +func SetConfigFile(path string) { + configFile = path +} + +// Set sets a configuration value +func Set(key string, value interface{}) { + initViper() + + v.Set(key, value) + // Update the global config + v.Unmarshal(GlobalConfig) +} + +// Get gets a configuration value +func Get(key string) interface{} { + initViper() + + return v.Get(key) +} + +// SetViper sets the viper instance (for testing) +func SetViper(viperInstance *viper.Viper) { + v = viperInstance +} + +// ResetViper resets the viper instance (for testing) +func ResetViper() { + v = nil +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..25c0d5e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,267 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestConfig(t *testing.T) (string, func()) { + t.Helper() + + // Create a temporary directory for the test config + tempDir, err := os.MkdirTemp("", "config-test-") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Get the original config file path + originalConfigFile := configFile + + // Set the config file path to the temp directory + configFile = filepath.Join(tempDir, "config.yaml") + + // Create a cleanup function + cleanup := func() { + configFile = originalConfigFile + os.RemoveAll(tempDir) + } + + return tempDir, cleanup +} + +func TestInit(t *testing.T) { + // Save original values + originalConfigFile := configFile + originalGlobalConfig := GlobalConfig + + // Reset viper to ensure a clean state + ResetViper() + + // Setup test config + _, cleanup := setupTestConfig(t) + defer cleanup() + + // Reset global config + GlobalConfig = &Config{} + + // Initialize the config + err := Init() + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Check default values + if GlobalConfig.DefaultRemote != "origin" { + t.Errorf("Expected default remote to be 'origin', got '%s'", GlobalConfig.DefaultRemote) + } + + if GlobalConfig.DefaultBranch != "main" { + t.Errorf("Expected default branch to be 'main', got '%s'", GlobalConfig.DefaultBranch) + } + + if GlobalConfig.GitHubURL != "https://api.github.com" { + t.Errorf("Expected GitHub URL to be 'https://api.github.com', got '%s'", GlobalConfig.GitHubURL) + } + + if GlobalConfig.GitHubToken != "" { + t.Errorf("Expected GitHub token to be empty, got '%s'", GlobalConfig.GitHubToken) + } + + // Restore original values + configFile = originalConfigFile + GlobalConfig = originalGlobalConfig +} + +func TestSave(t *testing.T) { + // Save original values + originalConfigFile := configFile + originalGlobalConfig := GlobalConfig + + // Reset viper to ensure a clean state + ResetViper() + + // Setup test config + _, cleanup := setupTestConfig(t) + defer cleanup() + + // Reset global config + GlobalConfig = &Config{} + + // Initialize the config + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Set some values + Set("github_token", "test-token") + Set("default_branch", "develop") + Set("default_remote", "upstream") + + // Save the config + err := Save() + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Check that the config file exists + if _, err := os.Stat(configFile); os.IsNotExist(err) { + t.Error("Config file does not exist") + } + + // Reset viper again + ResetViper() + + // Reset global config + GlobalConfig = &Config{} + + // Re-initialize the config to load from file + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Check values + if GlobalConfig.GitHubToken != "test-token" { + t.Errorf("Expected GitHub token to be 'test-token', got '%s'", GlobalConfig.GitHubToken) + } + + if GlobalConfig.DefaultBranch != "develop" { + t.Errorf("Expected default branch to be 'develop', got '%s'", GlobalConfig.DefaultBranch) + } + + if GlobalConfig.DefaultRemote != "upstream" { + t.Errorf("Expected default remote to be 'upstream', got '%s'", GlobalConfig.DefaultRemote) + } + + // Restore original values + configFile = originalConfigFile + GlobalConfig = originalGlobalConfig +} + +func TestSet(t *testing.T) { + // Save original values + originalConfigFile := configFile + originalGlobalConfig := GlobalConfig + + // Reset viper to ensure a clean state + ResetViper() + + // Setup test config + _, cleanup := setupTestConfig(t) + defer cleanup() + + // Reset global config + GlobalConfig = &Config{} + + // Initialize the config + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Set a GitHub token + Set("github_token", "test-token") + + // Check the value + if GlobalConfig.GitHubToken != "test-token" { + t.Errorf("Expected GitHub token to be 'test-token', got '%s'", GlobalConfig.GitHubToken) + } + + // Set a default branch + Set("default_branch", "develop") + + // Check the value + if GlobalConfig.DefaultBranch != "develop" { + t.Errorf("Expected default branch to be 'develop', got '%s'", GlobalConfig.DefaultBranch) + } + + // Set a default remote + Set("default_remote", "upstream") + + // Check the value + if GlobalConfig.DefaultRemote != "upstream" { + t.Errorf("Expected default remote to be 'upstream', got '%s'", GlobalConfig.DefaultRemote) + } + + // Restore original values + configFile = originalConfigFile + GlobalConfig = originalGlobalConfig +} + +func TestGet(t *testing.T) { + // Save original values + originalConfigFile := configFile + originalGlobalConfig := GlobalConfig + + // Reset viper to ensure a clean state + ResetViper() + + // Setup test config + _, cleanup := setupTestConfig(t) + defer cleanup() + + // Reset global config + GlobalConfig = &Config{} + + // Initialize the config + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Set some values + Set("github_token", "test-token") + Set("default_branch", "develop") + Set("default_remote", "upstream") + + // Get the values + token := Get("github_token") + if token != "test-token" { + t.Errorf("Expected GitHub token to be 'test-token', got '%v'", token) + } + + branch := Get("default_branch") + if branch != "develop" { + t.Errorf("Expected default branch to be 'develop', got '%v'", branch) + } + + remote := Get("default_remote") + if remote != "upstream" { + t.Errorf("Expected default remote to be 'upstream', got '%v'", remote) + } + + // Restore original values + configFile = originalConfigFile + GlobalConfig = originalGlobalConfig +} + +func TestGetConfigFile(t *testing.T) { + // Save original values + originalConfigFile := configFile + originalGlobalConfig := GlobalConfig + + // Reset viper to ensure a clean state + ResetViper() + + // Setup test config + _, cleanup := setupTestConfig(t) + defer cleanup() + + // Reset global config + GlobalConfig = &Config{} + + // Initialize the config + if err := Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Get the config file path + configFilePath := GetConfigFile() + + // Check that it matches the expected path + if configFilePath != configFile { + t.Errorf("Expected config file path to be '%s', got '%s'", configFile, configFilePath) + } + + // Restore original values + configFile = originalConfigFile + GlobalConfig = originalGlobalConfig +} \ No newline at end of file diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..89780b2 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,125 @@ +package git + +import ( + "bytes" + "context" + "os/exec" + "strings" +) + +func run(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + var out bytes.Buffer + var errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + if errb.Len() > 0 { + return "", fmtError(errb.String()) + } + return "", err + } + return strings.TrimSpace(out.String()), nil +} + +// Run is a public version of run for use in other packages +func Run(ctx context.Context, args ...string) (string, error) { + return run(ctx, args...) +} + +func Fetch(ctx context.Context) error { + _, err := run(ctx, "fetch", "--prune") + return err +} + +func RebaseOntoTracking(ctx context.Context) error { + branch, err := CurrentBranch(ctx) + if err != nil { + return err + } + up, err := UpstreamFor(ctx, branch) + if err != nil { + return err + } + _, err = run(ctx, "rebase", up) + return err +} + +func PushCurrent(ctx context.Context, forceWithLease bool) error { + args := []string{"push"} + if forceWithLease { + args = append(args, "--force-with-lease") + } + args = append(args, "--set-upstream", "origin", "HEAD") + _, err := run(ctx, args...) + return err +} + +func LocalBranches(ctx context.Context) ([]string, error) { + out, err := run(ctx, "branch", "--format=%(refname:short)") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +func DeleteBranch(ctx context.Context, name string) error { + _, err := run(ctx, "branch", "-D", name) + return err +} + +func CurrentBranch(ctx context.Context) (string, error) { + return run(ctx, "rev-parse", "--abbrev-ref", "HEAD") +} + +func UpstreamFor(ctx context.Context, branch string) (string, error) { + return run(ctx, "rev-parse", "--abbrev-ref", branch+"@{upstream}") +} + +func CreateBranch(ctx context.Context, name string) error { + _, err := run(ctx, "checkout", "-b", name) + return err +} + +func SwitchBranch(ctx context.Context, name string) error { + _, err := run(ctx, "checkout", name) + return err +} + +func IsCleanWorkingDir(ctx context.Context) (bool, error) { + out, err := run(ctx, "status", "--porcelain") + if err != nil { + return false, err + } + return out == "", nil +} + +func AddAll(ctx context.Context) error { + _, err := run(ctx, "add", "-A") + return err +} + +func Commit(ctx context.Context, message string) error { + _, err := run(ctx, "commit", "-m", message) + return err +} + +func fmtError(s string) error { + return &gitError{msg: strings.TrimSpace(s)} +} + +type gitError struct { + msg string +} + +func (e *gitError) Error() string { + return e.msg +} + +// GetRemoteURL returns the URL of the specified remote +func GetRemoteURL(ctx context.Context, remote string) (string, error) { + return run(ctx, "remote", "get-url", remote) +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..f124f7d --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,532 @@ +package git + +import ( + "context" + "os" + "strings" + "testing" +) + +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + + // Create a temporary directory for the test repository + tempDir, err := os.MkdirTemp("", "git-test-") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Change to the temp directory + oldDir, err := os.Getwd() + if err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to get current directory: %v", err) + } + + if err := os.Chdir(tempDir); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Initialize a git repository + ctx := context.Background() + if _, err := Run(ctx, "init"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to initialize git repo: %v", err) + } + + // Configure git user + if _, err := Run(ctx, "config", "user.name", "Test User"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to configure git user: %v", err) + } + + if _, err := Run(ctx, "config", "user.email", "test@example.com"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to configure git email: %v", err) + } + + // Create an initial commit + createTestFile(t, "README.md", "# Test Repository") + if _, err := Run(ctx, "add", "."); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to add files: %v", err) + } + + if _, err := Run(ctx, "commit", "-m", "Initial commit"); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to make initial commit: %v", err) + } + + // Create a cleanup function + cleanup := func() { + os.Chdir(oldDir) + os.RemoveAll(tempDir) + } + + return tempDir, cleanup +} + +func createTestFile(t *testing.T, name, content string) { + t.Helper() + + if err := os.WriteFile(name, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } +} + +func TestCurrentBranch(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Initially should be on main branch + branch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + // The default branch name could be "main" or "master" depending on git version + if branch != "main" && branch != "master" { + t.Errorf("Expected branch to be 'main' or 'master', got '%s'", branch) + } + + // Create a new branch + if _, err := Run(ctx, "checkout", "-b", "feature"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + + // Check current branch again + branch, err = CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + if branch != "feature" { + t.Errorf("Expected branch to be 'feature', got '%s'", branch) + } +} + +func TestCreateBranch(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Create a new branch + err := CreateBranch(ctx, "test-branch") + if err != nil { + t.Fatalf("CreateBranch failed: %v", err) + } + + // Check current branch + branch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + if branch != "test-branch" { + t.Errorf("Expected branch to be 'test-branch', got '%s'", branch) + } +} + +func TestSwitchBranch(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Create a new branch + if _, err := Run(ctx, "checkout", "-b", "feature"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + + // Get the current branch to determine if it's main or master + currentBranch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + // Switch back to main/master + var targetBranch string + if currentBranch == "feature" { + // We need to determine what the original branch was + // Let's check if main exists + if _, err := Run(ctx, "rev-parse", "--verify", "main"); err == nil { + targetBranch = "main" + } else if _, err := Run(ctx, "rev-parse", "--verify", "master"); err == nil { + targetBranch = "master" + } else { + t.Fatalf("Neither main nor master branch exists") + } + } + + err = SwitchBranch(ctx, targetBranch) + if err != nil { + t.Fatalf("SwitchBranch failed: %v", err) + } + + // Check current branch + branch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + if branch != targetBranch { + t.Errorf("Expected branch to be '%s', got '%s'", targetBranch, branch) + } +} + +func TestLocalBranches(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Initially should have just one branch + branches, err := LocalBranches(ctx) + if err != nil { + t.Fatalf("LocalBranches failed: %v", err) + } + + if len(branches) != 1 { + t.Errorf("Expected 1 branch, got %d", len(branches)) + } + + // Create a new branch + if _, err := Run(ctx, "checkout", "-b", "feature"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + + // Should now have two branches + branches, err = LocalBranches(ctx) + if err != nil { + t.Fatalf("LocalBranches failed: %v", err) + } + + if len(branches) != 2 { + t.Errorf("Expected 2 branches, got %d", len(branches)) + } +} + +func TestIsCleanWorkingDir(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Initially should be clean + clean, err := IsCleanWorkingDir(ctx) + if err != nil { + t.Fatalf("IsCleanWorkingDir failed: %v", err) + } + + if !clean { + t.Error("Expected working directory to be clean") + } + + // Create a file + createTestFile(t, "test.txt", "test content") + + // Should now be dirty + clean, err = IsCleanWorkingDir(ctx) + if err != nil { + t.Fatalf("IsCleanWorkingDir failed: %v", err) + } + + if clean { + t.Error("Expected working directory to be dirty") + } +} + +func TestAddAll(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Create a file + createTestFile(t, "test.txt", "test content") + + // Add the file + err := AddAll(ctx) + if err != nil { + t.Fatalf("AddAll failed: %v", err) + } + + // Note: After adding files, the working directory is not necessarily clean + // because the files are staged but not committed. This is normal git behavior. + // So we'll just check that there are no unstaged changes. + + // Check if there are any untracked files + output, err := Run(ctx, "status", "--porcelain") + if err != nil { + t.Fatalf("Failed to get git status: %v", err) + } + + // If there are any untracked files, they would show up with ?? at the beginning + for i := range output { + line := string(output[i]) + if len(line) > 2 && strings.HasPrefix(line, "??") { + t.Errorf("Found untracked file: %s", line[3:]) + } + } +} + +func TestCommit(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Create and add a file + createTestFile(t, "test.txt", "test content") + if err := AddAll(ctx); err != nil { + t.Fatalf("AddAll failed: %v", err) + } + + // Commit the file + err := Commit(ctx, "test commit") + if err != nil { + t.Fatalf("Commit failed: %v", err) + } + + // Should now be clean + clean, err := IsCleanWorkingDir(ctx) + if err != nil { + t.Fatalf("IsCleanWorkingDir failed: %v", err) + } + + if !clean { + t.Error("Expected working directory to be clean after commit") + } +} + +func TestDeleteBranch(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Create a new branch + if _, err := Run(ctx, "checkout", "-b", "feature"); err != nil { + t.Fatalf("Failed to create feature branch: %v", err) + } + + // Switch back to main/master + var targetBranch string + if _, err := Run(ctx, "rev-parse", "--verify", "main"); err == nil { + targetBranch = "main" + } else if _, err := Run(ctx, "rev-parse", "--verify", "master"); err == nil { + targetBranch = "master" + } else { + t.Fatalf("Neither main nor master branch exists") + } + + if _, err := Run(ctx, "checkout", targetBranch); err != nil { + t.Fatalf("Failed to switch back to %s: %v", targetBranch, err) + } + + // Delete the branch + err := DeleteBranch(ctx, "feature") + if err != nil { + t.Fatalf("DeleteBranch failed: %v", err) + } + + // Should now have just one branch + branches, err := LocalBranches(ctx) + if err != nil { + t.Fatalf("LocalBranches failed: %v", err) + } + + if len(branches) != 1 { + t.Errorf("Expected 1 branch after delete, got %d", len(branches)) + } +} + +func TestGetRemoteURL(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Add a remote + if _, err := Run(ctx, "remote", "add", "origin", "https://github.com/user/repo.git"); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + + // Get the remote URL + url, err := GetRemoteURL(ctx, "origin") + if err != nil { + t.Fatalf("GetRemoteURL failed: %v", err) + } + + expectedURL := "https://github.com/user/repo.git" + if url != expectedURL { + t.Errorf("Expected URL to be '%s', got '%s'", expectedURL, url) + } +} + +func TestFetch(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Add a remote + if _, err := Run(ctx, "remote", "add", "origin", "https://github.com/user/repo.git"); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + + // Fetch from remote + err := Fetch(ctx) + if err != nil { + // We expect this to fail since the remote URL is fake + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("Fetch failed with unexpected error: %v", err) + } + } +} + +func TestRebaseOntoTracking(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Add a remote + if _, err := Run(ctx, "remote", "add", "origin", "https://github.com/user/repo.git"); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + + // Set up tracking branch + branch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + if _, err := Run(ctx, "push", "-u", "origin", branch); err != nil { + // We expect this to fail since the remote URL is fake + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("Push failed with unexpected error: %v", err) + } + } + + // Test rebase + err = RebaseOntoTracking(ctx) + if err != nil { + // We expect this to fail since the remote URL is fake or there's no upstream + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") && + !strings.Contains(err.Error(), "no upstream configured") { + t.Fatalf("RebaseOntoTracking failed with unexpected error: %v", err) + } + } +} + +func TestPushCurrent(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Add a remote + if _, err := Run(ctx, "remote", "add", "origin", "https://github.com/user/repo.git"); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + + // Test push without force + err := PushCurrent(ctx, false) + if err != nil { + // We expect this to fail since the remote URL is fake + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("PushCurrent failed with unexpected error: %v", err) + } + } + + // Test push with force + err = PushCurrent(ctx, true) + if err != nil { + // We expect this to fail since the remote URL is fake + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("PushCurrent failed with unexpected error: %v", err) + } + } +} + +func TestUpstreamFor(t *testing.T) { + _, cleanup := setupTestRepo(t) + defer cleanup() + + ctx := context.Background() + + // Add a remote + if _, err := Run(ctx, "remote", "add", "origin", "https://github.com/user/repo.git"); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + + // Get current branch + branch, err := CurrentBranch(ctx) + if err != nil { + t.Fatalf("CurrentBranch failed: %v", err) + } + + // Set up tracking branch + if _, err := Run(ctx, "push", "-u", "origin", branch); err != nil { + // We expect this to fail since the remote URL is fake + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("Push failed with unexpected error: %v", err) + } + } + + // Test UpstreamFor + upstream, err := UpstreamFor(ctx, branch) + if err != nil { + // We expect this to fail since the remote URL is fake or there's no upstream + // But we want to test that the function is called correctly + if !strings.Contains(err.Error(), "no upstream configured") && + !strings.Contains(err.Error(), "could not read Username") && + !strings.Contains(err.Error(), "Could not resolve host") { + t.Fatalf("UpstreamFor failed with unexpected error: %v", err) + } + } else { + // If it succeeds, check the format + expected := "origin/" + branch + if upstream != expected { + t.Errorf("Expected upstream to be '%s', got '%s'", expected, upstream) + } + } +} + +func TestGitError(t *testing.T) { + err := fmtError("test error") + + if err == nil { + t.Fatal("Expected fmtError to return an error, got nil") + } + + gitErr, ok := err.(*gitError) + if !ok { + t.Fatalf("Expected error to be of type *gitError, got %T", err) + } + + if gitErr.msg != "test error" { + t.Errorf("Expected error message to be 'test error', got '%s'", gitErr.msg) + } + + if gitErr.Error() != "test error" { + t.Errorf("Expected Error() to return 'test error', got '%s'", gitErr.Error()) + } +} \ No newline at end of file diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..f46aa23 --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,337 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/iwasforcedtobehere/git-automation-cli/internal/config" +) + +// Client represents a GitHub API client +type Client struct { + httpClient *http.Client + baseURL string + token string + organization string + repository string +} + +// NewClient creates a new GitHub API client +func NewClient(ctx context.Context) (*Client, error) { + // Get configuration + token := config.GlobalConfig.GitHubToken + if token == "" { + return nil, fmt.Errorf("GitHub token not configured. Use 'gitauto config set github.token ' to set it") + } + + baseURL := config.GlobalConfig.GitHubURL + if baseURL == "" { + baseURL = "https://api.github.com" + } + + // Create HTTP client with timeout + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + return &Client{ + httpClient: httpClient, + baseURL: baseURL, + token: token, + }, nil +} + +// SetRepository sets the organization and repository for the client +func (c *Client) SetRepository(org, repo string) { + c.organization = org + c.repository = repo +} + +// makeRequest makes an HTTP request to the GitHub API +func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + + url := fmt.Sprintf("%s%s", c.baseURL, path) + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + // Make the request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + + // Check for errors + if resp.StatusCode >= 400 { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error (%d): %s", resp.StatusCode, string(body)) + } + + return resp, nil +} + +// GetUser gets the authenticated user +func (c *Client) GetUser(ctx context.Context) (*User, error) { + resp, err := c.makeRequest(ctx, "GET", "/user", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var user User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} + +// GetRepository gets a repository +func (c *Client) GetRepository(ctx context.Context, owner, repo string) (*Repository, error) { + path := fmt.Sprintf("/repos/%s/%s", owner, repo) + resp, err := c.makeRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var repository Repository + if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &repository, nil +} + +// CreatePullRequest creates a new pull request +func (c *Client) CreatePullRequest(ctx context.Context, pr *PullRequestRequest) (*PullRequest, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/pulls", c.organization, c.repository) + resp, err := c.makeRequest(ctx, "POST", path, pr) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var pullRequest PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pullRequest); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &pullRequest, nil +} + +// GetPullRequests gets pull requests for a repository +func (c *Client) GetPullRequests(ctx context.Context, state string) ([]PullRequest, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/pulls?state=%s", c.organization, c.repository, state) + resp, err := c.makeRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var pullRequests []PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pullRequests); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return pullRequests, nil +} + +// GetPullRequest gets a specific pull request +func (c *Client) GetPullRequest(ctx context.Context, number int) (*PullRequest, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/pulls/%d", c.organization, c.repository, number) + resp, err := c.makeRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var pullRequest PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pullRequest); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &pullRequest, nil +} + +// UpdatePullRequest updates a pull request +func (c *Client) UpdatePullRequest(ctx context.Context, number int, pr *PullRequestRequest) (*PullRequest, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/pulls/%d", c.organization, c.repository, number) + resp, err := c.makeRequest(ctx, "PATCH", path, pr) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var pullRequest PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pullRequest); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &pullRequest, nil +} + +// MergePullRequest merges a pull request +func (c *Client) MergePullRequest(ctx context.Context, number int, mergeRequest *MergePullRequestRequest) (*MergePullRequestResponse, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", c.organization, c.repository, number) + resp, err := c.makeRequest(ctx, "PUT", path, mergeRequest) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var mergeResponse MergePullRequestResponse + if err := json.NewDecoder(resp.Body).Decode(&mergeResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &mergeResponse, nil +} + +// CreateIssue creates a new issue +func (c *Client) CreateIssue(ctx context.Context, issue *IssueRequest) (*Issue, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/issues", c.organization, c.repository) + resp, err := c.makeRequest(ctx, "POST", path, issue) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var createdIssue Issue + if err := json.NewDecoder(resp.Body).Decode(&createdIssue); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &createdIssue, nil +} + +// GetIssues gets issues for a repository +func (c *Client) GetIssues(ctx context.Context, state string) ([]Issue, error) { + if c.organization == "" || c.repository == "" { + return nil, fmt.Errorf("organization and repository must be set") + } + + path := fmt.Sprintf("/repos/%s/%s/issues?state=%s", c.organization, c.repository, state) + resp, err := c.makeRequest(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var issues []Issue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return issues, nil +} + +// GetRepositoryFromRemote tries to extract the repository information from the git remote +func (c *Client) GetRepositoryFromRemote(ctx context.Context, remote string) (string, string, error) { + // This would typically call git to get the remote URL + // For now, we'll implement a placeholder + // In a real implementation, this would use the git package to get the remote URL + + // Placeholder implementation + // In a real implementation, this would: + // 1. Get the remote URL using git remote get-url + // 2. Parse the URL to extract owner and repo + // 3. Return the owner and repo + + return "", "", fmt.Errorf("not implemented yet") +} + +// ValidateToken checks if the GitHub token is valid +func (c *Client) ValidateToken(ctx context.Context) error { + // Try to get the authenticated user + _, err := c.GetUser(ctx) + return err +} + +// IsGitHubURL checks if a URL is a GitHub URL +func IsGitHubURL(url string) bool { + return strings.Contains(url, "github.com") +} + +// ParseGitHubURL parses a GitHub URL to extract owner and repo +func ParseGitHubURL(url string) (string, string, error) { + // Remove protocol and .git suffix if present + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + url = strings.TrimPrefix(url, "git@") + url = strings.TrimSuffix(url, ".git") + + // Split by : or / + var parts []string + if strings.Contains(url, ":") { + parts = strings.Split(url, ":") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid GitHub URL format") + } + parts = strings.Split(parts[1], "/") + } else { + parts = strings.Split(url, "/") + } + + // Filter out empty strings + var filteredParts []string + for _, part := range parts { + if part != "" { + filteredParts = append(filteredParts, part) + } + } + + if len(filteredParts) < 2 { + return "", "", fmt.Errorf("invalid GitHub URL format") + } + + owner := filteredParts[len(filteredParts)-2] + repo := filteredParts[len(filteredParts)-1] + + return owner, repo, nil +} \ No newline at end of file diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..9aa6050 --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,970 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func setupTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) { + t.Helper() + + // Create a test server + server := httptest.NewServer(handler) + + // Create a client with the test server URL + client := &Client{ + baseURL: server.URL, + httpClient: server.Client(), + token: "test-token", + } + + return server, client +} + +func TestNewClient(t *testing.T) { + // We can't easily test the config path without modifying global state, + // so we'll just test that it doesn't fail with a valid config + // This would require setting up the config package first +} + +func TestIsGitHubURL(t *testing.T) { + tests := []struct { + name string + url string + want bool + }{ + { + name: "GitHub HTTPS URL", + url: "https://github.com/user/repo.git", + want: true, + }, + { + name: "GitHub SSH URL", + url: "git@github.com:user/repo.git", + want: true, + }, + { + name: "Non-GitHub HTTPS URL", + url: "https://example.com/user/repo.git", + want: false, + }, + { + name: "Non-GitHub SSH URL", + url: "git@example.com:user/repo.git", + want: false, + }, + { + name: "Invalid URL", + url: "not-a-url", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsGitHubURL(tt.url); got != tt.want { + t.Errorf("IsGitHubURL(%q) = %v, want %v", tt.url, got, tt.want) + } + }) + } +} + +func TestParseGitHubURL(t *testing.T) { + tests := []struct { + name string + url string + owner string + repo string + wantErr bool + }{ + { + name: "GitHub HTTPS URL", + url: "https://github.com/user/repo.git", + owner: "user", + repo: "repo", + }, + { + name: "GitHub HTTPS URL without .git", + url: "https://github.com/user/repo", + owner: "user", + repo: "repo", + }, + { + name: "GitHub SSH URL", + url: "git@github.com:user/repo.git", + owner: "user", + repo: "repo", + }, + { + name: "Invalid URL", + url: "not-a-url", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := ParseGitHubURL(tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("ParseGitHubURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) + return + } + if !tt.wantErr { + if owner != tt.owner { + t.Errorf("ParseGitHubURL(%q) owner = %v, want %v", tt.url, owner, tt.owner) + } + if repo != tt.repo { + t.Errorf("ParseGitHubURL(%q) repo = %v, want %v", tt.url, repo, tt.repo) + } + } + }) + } +} + +func TestCreatePullRequest(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/pulls" { + t.Errorf("Expected path to be '/repos/owner/repo/pulls', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "id": 1, + "number": 123, + "state": "open", + "title": "Test PR", + "body": "Test PR body", + "html_url": "https://github.com/owner/repo/pull/123", + "user": { + "login": "testuser", + "id": 1 + }, + "head": { + "label": "owner:feature", + "ref": "feature", + "sha": "abc123" + }, + "base": { + "label": "owner:main", + "ref": "main", + "sha": "def456" + } + }`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Create a pull request + pr := &PullRequestRequest{ + Title: "Test PR", + Body: "Test PR body", + Head: "feature", + Base: "main", + } + + pullRequest, err := client.CreatePullRequest(context.Background(), pr) + if err != nil { + t.Fatalf("CreatePullRequest failed: %v", err) + } + + // Check response + if pullRequest.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", pullRequest.ID) + } + + if pullRequest.Number != 123 { + t.Errorf("Expected Number to be 123, got %d", pullRequest.Number) + } + + if pullRequest.Title != "Test PR" { + t.Errorf("Expected Title to be 'Test PR', got '%s'", pullRequest.Title) + } + + if pullRequest.Body != "Test PR body" { + t.Errorf("Expected Body to be 'Test PR body', got '%s'", pullRequest.Body) + } + + if pullRequest.HTMLURL != "https://github.com/owner/repo/pull/123" { + t.Errorf("Expected HTMLURL to be 'https://github.com/owner/repo/pull/123', got '%s'", pullRequest.HTMLURL) + } +} + +func TestGetPullRequests(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/pulls" { + t.Errorf("Expected path to be '/repos/owner/repo/pulls', got '%s'", r.URL.Path) + } + + // Check request query + if r.URL.Query().Get("state") != "open" { + t.Errorf("Expected state query to be 'open', got '%s'", r.URL.Query().Get("state")) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[ + { + "id": 1, + "number": 123, + "state": "open", + "title": "Test PR 1", + "body": "Test PR body 1", + "html_url": "https://github.com/owner/repo/pull/123", + "user": { + "login": "testuser", + "id": 1 + }, + "head": { + "label": "owner:feature1", + "ref": "feature1", + "sha": "abc123" + }, + "base": { + "label": "owner:main", + "ref": "main", + "sha": "def456" + } + }, + { + "id": 2, + "number": 124, + "state": "open", + "title": "Test PR 2", + "body": "Test PR body 2", + "html_url": "https://github.com/owner/repo/pull/124", + "user": { + "login": "testuser", + "id": 1 + }, + "head": { + "label": "owner:feature2", + "ref": "feature2", + "sha": "ghi789" + }, + "base": { + "label": "owner:main", + "ref": "main", + "sha": "def456" + } + } + ]`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Get pull requests + pullRequests, err := client.GetPullRequests(context.Background(), "open") + if err != nil { + t.Fatalf("GetPullRequests failed: %v", err) + } + + // Check response + if len(pullRequests) != 2 { + t.Errorf("Expected 2 pull requests, got %d", len(pullRequests)) + } + + if pullRequests[0].ID != 1 { + t.Errorf("Expected first PR ID to be 1, got %d", pullRequests[0].ID) + } + + if pullRequests[0].Number != 123 { + t.Errorf("Expected first PR Number to be 123, got %d", pullRequests[0].Number) + } + + if pullRequests[0].Title != "Test PR 1" { + t.Errorf("Expected first PR Title to be 'Test PR 1', got '%s'", pullRequests[0].Title) + } + + if pullRequests[1].ID != 2 { + t.Errorf("Expected second PR ID to be 2, got %d", pullRequests[1].ID) + } + + if pullRequests[1].Number != 124 { + t.Errorf("Expected second PR Number to be 124, got %d", pullRequests[1].Number) + } + + if pullRequests[1].Title != "Test PR 2" { + t.Errorf("Expected second PR Title to be 'Test PR 2', got '%s'", pullRequests[1].Title) + } +} + +func TestMergePullRequest(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodPut { + t.Errorf("Expected PUT request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/pulls/123/merge" { + t.Errorf("Expected path to be '/repos/owner/repo/pulls/123/merge', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "sha": "merged123", + "merged": true, + "message": "Pull Request successfully merged" + }`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Merge a pull request + mergeRequest := &MergePullRequestRequest{ + MergeMethod: "merge", + } + + mergeResponse, err := client.MergePullRequest(context.Background(), 123, mergeRequest) + if err != nil { + t.Fatalf("MergePullRequest failed: %v", err) + } + + // Check response + if mergeResponse.SHA != "merged123" { + t.Errorf("Expected SHA to be 'merged123', got '%s'", mergeResponse.SHA) + } + + if !mergeResponse.Merged { + t.Errorf("Expected Merged to be true, got false") + } + + if mergeResponse.Message != "Pull Request successfully merged" { + t.Errorf("Expected Message to be 'Pull Request successfully merged', got '%s'", mergeResponse.Message) + } +} + +func TestGetUser(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/user" { + t.Errorf("Expected path to be '/user', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "login": "testuser", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/testuser", + "html_url": "https://github.com/testuser", + "followers_url": "https://api.github.com/users/testuser/followers", + "following_url": "https://api.github.com/users/testuser/following{/other_user}", + "gists_url": "https://api.github.com/users/testuser/gists{/gist_id}", + "starred_url": "https://api.github.com/users/testuser/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/testuser/subscriptions", + "organizations_url": "https://api.github.com/users/testuser/orgs", + "repos_url": "https://api.github.com/users/testuser/repos", + "events_url": "https://api.github.com/users/testuser/events{/privacy}", + "received_events_url": "https://api.github.com/users/testuser/received_events", + "type": "User", + "site_admin": false, + "name": "Test User", + "company": null, + "blog": "", + "location": "San Francisco", + "email": "testuser@example.com", + "hireable": null, + "bio": null, + "twitter_username": null, + "public_repos": 2, + "public_gists": 1, + "followers": 1, + "following": 0, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }`)) + }) + defer server.Close() + + // Get user + user, err := client.GetUser(context.Background()) + if err != nil { + t.Fatalf("GetUser failed: %v", err) + } + + // Check response + if user.Login != "testuser" { + t.Errorf("Expected Login to be 'testuser', got '%s'", user.Login) + } + + if user.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", user.ID) + } + + if user.Name != "Test User" { + t.Errorf("Expected Name to be 'Test User', got '%s'", user.Name) + } + + if user.Email != "testuser@example.com" { + t.Errorf("Expected Email to be 'testuser@example.com', got '%s'", user.Email) + } +} + +func TestGetRepository(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo" { + t.Errorf("Expected path to be '/repos/owner/repo', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": 1, + "node_id": "MDEwOlJlcG9zaXRvcnkx", + "name": "repo", + "full_name": "owner/repo", + "private": false, + "owner": { + "login": "owner", + "id": 1, + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/owner", + "html_url": "https://github.com/owner", + "followers_url": "https://api.github.com/users/owner/followers", + "following_url": "https://api.github.com/users/owner/following{/other_user}", + "gists_url": "https://api.github.com/users/owner/gists{/gist_id}", + "starred_url": "https://api.github.com/users/owner/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/owner/subscriptions", + "organizations_url": "https://api.github.com/users/owner/orgs", + "repos_url": "https://api.github.com/users/owner/repos", + "events_url": "https://api.github.com/users/owner/events{/privacy}", + "received_events_url": "https://api.github.com/users/owner/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/owner/repo", + "description": "Test repository", + "fork": false, + "url": "https://api.github.com/repos/owner/repo", + "forks_url": "https://api.github.com/repos/owner/repo/forks", + "keys_url": "https://api.github.com/repos/owner/repo/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/owner/repo/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/owner/repo/teams", + "hooks_url": "https://api.github.com/repos/owner/repo/hooks", + "issue_events_url": "https://api.github.com/repos/owner/repo/issues/events{/number}", + "events_url": "https://api.github.com/repos/owner/repo/events", + "assignees_url": "https://api.github.com/repos/owner/repo/assignees{/user}", + "branches_url": "https://api.github.com/repos/owner/repo/branches{/branch}", + "tags_url": "https://api.github.com/repos/owner/repo/tags", + "blobs_url": "https://api.github.com/repos/owner/repo/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/owner/repo/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/owner/repo/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/owner/repo/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/owner/repo/statuses/{sha}", + "languages_url": "https://api.github.com/repos/owner/repo/languages", + "stargazers_url": "https://api.github.com/repos/owner/repo/stargazers", + "contributors_url": "https://api.github.com/repos/owner/repo/contributors", + "subscribers_url": "https://api.github.com/repos/owner/repo/subscribers", + "subscription_url": "https://api.github.com/repos/owner/repo/subscription", + "commits_url": "https://api.github.com/repos/owner/repo/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/owner/repo/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/owner/repo/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/owner/repo/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/owner/repo/contents/{+path}", + "compare_url": "https://api.github.com/repos/owner/repo/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/owner/repo/merges", + "archive_url": "https://api.github.com/repos/owner/repo/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/owner/repo/downloads", + "issues_url": "https://api.github.com/repos/owner/repo/issues{/number}", + "pulls_url": "https://api.github.com/repos/owner/repo/pulls{/number}", + "milestones_url": "https://api.github.com/repos/owner/repo/milestones{/number}", + "notifications_url": "https://api.github.com/repos/owner/repo/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/owner/repo/labels{/name}", + "releases_url": "https://api.github.com/repos/owner/repo/releases{/id}", + "deployments_url": "https://api.github.com/repos/owner/repo/deployments", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z", + "pushed_at": "2023-01-01T00:00:00Z", + "git_url": "git://github.com/owner/repo.git", + "ssh_url": "git@github.com:owner/repo.git", + "clone_url": "https://github.com/owner/repo.git", + "svn_url": "https://github.com/owner/repo", + "homepage": null, + "size": 108, + "stargazers_count": 0, + "watchers_count": 0, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "open_issues_count": 0, + "license": null, + "forks_count": 0, + "open_issues": 0, + "watchers": 0, + "default_branch": "main" + }`)) + }) + defer server.Close() + + // Get repository + repo, err := client.GetRepository(context.Background(), "owner", "repo") + if err != nil { + t.Fatalf("GetRepository failed: %v", err) + } + + // Check response + if repo.Name != "repo" { + t.Errorf("Expected Name to be 'repo', got '%s'", repo.Name) + } + + if repo.FullName != "owner/repo" { + t.Errorf("Expected FullName to be 'owner/repo', got '%s'", repo.FullName) + } + + if repo.Description != "Test repository" { + t.Errorf("Expected Description to be 'Test repository', got '%s'", repo.Description) + } + + if repo.Language != "Go" { + t.Errorf("Expected Language to be 'Go', got '%s'", repo.Language) + } +} + +func TestGetPullRequest(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/pulls/123" { + t.Errorf("Expected path to be '/repos/owner/repo/pulls/123', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": 1, + "number": 123, + "state": "open", + "title": "Test PR", + "body": "Test PR body", + "html_url": "https://github.com/owner/repo/pull/123", + "user": { + "login": "testuser", + "id": 1 + }, + "head": { + "label": "owner:feature", + "ref": "feature", + "sha": "abc123" + }, + "base": { + "label": "owner:main", + "ref": "main", + "sha": "def456" + } + }`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Get pull request + pullRequest, err := client.GetPullRequest(context.Background(), 123) + if err != nil { + t.Fatalf("GetPullRequest failed: %v", err) + } + + // Check response + if pullRequest.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", pullRequest.ID) + } + + if pullRequest.Number != 123 { + t.Errorf("Expected Number to be 123, got %d", pullRequest.Number) + } + + if pullRequest.Title != "Test PR" { + t.Errorf("Expected Title to be 'Test PR', got '%s'", pullRequest.Title) + } + + if pullRequest.Body != "Test PR body" { + t.Errorf("Expected Body to be 'Test PR body', got '%s'", pullRequest.Body) + } +} + +func TestUpdatePullRequest(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodPatch { + t.Errorf("Expected PATCH request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/pulls/123" { + t.Errorf("Expected path to be '/repos/owner/repo/pulls/123', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": 1, + "number": 123, + "state": "open", + "title": "Updated Test PR", + "body": "Updated Test PR body", + "html_url": "https://github.com/owner/repo/pull/123", + "user": { + "login": "testuser", + "id": 1 + }, + "head": { + "label": "owner:feature", + "ref": "feature", + "sha": "abc123" + }, + "base": { + "label": "owner:main", + "ref": "main", + "sha": "def456" + } + }`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Update pull request + pr := &PullRequestRequest{ + Title: "Updated Test PR", + Body: "Updated Test PR body", + } + + pullRequest, err := client.UpdatePullRequest(context.Background(), 123, pr) + if err != nil { + t.Fatalf("UpdatePullRequest failed: %v", err) + } + + // Check response + if pullRequest.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", pullRequest.ID) + } + + if pullRequest.Number != 123 { + t.Errorf("Expected Number to be 123, got %d", pullRequest.Number) + } + + if pullRequest.Title != "Updated Test PR" { + t.Errorf("Expected Title to be 'Updated Test PR', got '%s'", pullRequest.Title) + } + + if pullRequest.Body != "Updated Test PR body" { + t.Errorf("Expected Body to be 'Updated Test PR body', got '%s'", pullRequest.Body) + } +} + +func TestCreateIssue(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/issues" { + t.Errorf("Expected path to be '/repos/owner/repo/issues', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "id": 1, + "number": 123, + "state": "open", + "title": "Test Issue", + "body": "Test Issue body", + "html_url": "https://github.com/owner/repo/issues/123", + "user": { + "login": "testuser", + "id": 1 + } + }`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Create issue + issue := &IssueRequest{ + Title: "Test Issue", + Body: "Test Issue body", + } + + createdIssue, err := client.CreateIssue(context.Background(), issue) + if err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Check response + if createdIssue.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", createdIssue.ID) + } + + if createdIssue.Number != 123 { + t.Errorf("Expected Number to be 123, got %d", createdIssue.Number) + } + + if createdIssue.Title != "Test Issue" { + t.Errorf("Expected Title to be 'Test Issue', got '%s'", createdIssue.Title) + } + + if createdIssue.Body != "Test Issue body" { + t.Errorf("Expected Body to be 'Test Issue body', got '%s'", createdIssue.Body) + } +} + +func TestGetIssues(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/repos/owner/repo/issues" { + t.Errorf("Expected path to be '/repos/owner/repo/issues', got '%s'", r.URL.Path) + } + + // Check request query + if r.URL.Query().Get("state") != "open" { + t.Errorf("Expected state query to be 'open', got '%s'", r.URL.Query().Get("state")) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[ + { + "id": 1, + "number": 123, + "state": "open", + "title": "Test Issue 1", + "body": "Test Issue body 1", + "html_url": "https://github.com/owner/repo/issues/123", + "user": { + "login": "testuser", + "id": 1 + } + }, + { + "id": 2, + "number": 124, + "state": "open", + "title": "Test Issue 2", + "body": "Test Issue body 2", + "html_url": "https://github.com/owner/repo/issues/124", + "user": { + "login": "testuser", + "id": 1 + } + } + ]`)) + }) + defer server.Close() + + // Set repository on client + client.SetRepository("owner", "repo") + + // Get issues + issues, err := client.GetIssues(context.Background(), "open") + if err != nil { + t.Fatalf("GetIssues failed: %v", err) + } + + // Check response + if len(issues) != 2 { + t.Errorf("Expected 2 issues, got %d", len(issues)) + } + + if issues[0].ID != 1 { + t.Errorf("Expected first issue ID to be 1, got %d", issues[0].ID) + } + + if issues[0].Number != 123 { + t.Errorf("Expected first issue Number to be 123, got %d", issues[0].Number) + } + + if issues[0].Title != "Test Issue 1" { + t.Errorf("Expected first issue Title to be 'Test Issue 1', got '%s'", issues[0].Title) + } + + if issues[1].ID != 2 { + t.Errorf("Expected second issue ID to be 2, got %d", issues[1].ID) + } + + if issues[1].Number != 124 { + t.Errorf("Expected second issue Number to be 124, got %d", issues[1].Number) + } + + if issues[1].Title != "Test Issue 2" { + t.Errorf("Expected second issue Title to be 'Test Issue 2', got '%s'", issues[1].Title) + } +} + +func TestValidateToken(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // Check request method + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + + // Check request path + if r.URL.Path != "/user" { + t.Errorf("Expected path to be '/user', got '%s'", r.URL.Path) + } + + // Check authorization header + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected Authorization header to be 'token test-token', got '%s'", r.Header.Get("Authorization")) + } + + // Write response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "login": "testuser", + "id": 1 + }`)) + }) + defer server.Close() + + // Validate token + err := client.ValidateToken(context.Background()) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } +} + +func TestGetRepositoryFromRemote(t *testing.T) { + // Create a test server + server, client := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + // This function doesn't make HTTP requests, so we don't need to check anything + w.WriteHeader(http.StatusOK) + }) + defer server.Close() + + // Get repository from remote + owner, repo, err := client.GetRepositoryFromRemote(context.Background(), "origin") + if err == nil { + t.Error("Expected GetRepositoryFromRemote to return an error, got nil") + } + + if owner != "" { + t.Errorf("Expected owner to be empty, got '%s'", owner) + } + + if repo != "" { + t.Errorf("Expected repo to be empty, got '%s'", repo) + } +} \ No newline at end of file diff --git a/internal/github/models.go b/internal/github/models.go new file mode 100644 index 0000000..afab4db --- /dev/null +++ b/internal/github/models.go @@ -0,0 +1,305 @@ +package github + +import "time" + +// User represents a GitHub user +type User struct { + Login string `json:"login"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + FollowersURL string `json:"followers_url"` + FollowingURL string `json:"following_url"` + GistsURL string `json:"gists_url"` + StarredURL string `json:"starred_url"` + SubscriptionsURL string `json:"subscriptions_url"` + OrganizationsURL string `json:"organizations_url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + ReceivedEventsURL string `json:"received_events_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` + Name string `json:"name"` + Company string `json:"company"` + Blog string `json:"blog"` + Location string `json:"location"` + Email string `json:"email"` + Hireable bool `json:"hireable"` + Bio string `json:"bio"` + TwitterUsername string `json:"twitter_username"` + PublicRepos int `json:"public_repos"` + PublicGists int `json:"public_gists"` + Followers int `json:"followers"` + Following int `json:"following"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Repository represents a GitHub repository +type Repository struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Private bool `json:"private"` + Owner User `json:"owner"` + HTMLURL string `json:"html_url"` + Description string `json:"description"` + Fork bool `json:"fork"` + URL string `json:"url"` + ForksURL string `json:"forks_url"` + KeysURL string `json:"keys_url"` + CollaboratorsURL string `json:"collaborators_url"` + TeamsURL string `json:"teams_url"` + HooksURL string `json:"hooks_url"` + IssueEventsURL string `json:"issue_events_url"` + EventsURL string `json:"events_url"` + AssigneesURL string `json:"assignees_url"` + BranchesURL string `json:"branches_url"` + TagsURL string `json:"tags_url"` + BlobsURL string `json:"blobs_url"` + GitTagsURL string `json:"git_tags_url"` + GitRefsURL string `json:"git_refs_url"` + TreesURL string `json:"trees_url"` + StatusesURL string `json:"statuses_url"` + LanguagesURL string `json:"languages_url"` + StargazersURL string `json:"stargazers_url"` + ContributorsURL string `json:"contributors_url"` + SubscribersURL string `json:"subscribers_url"` + SubscriptionURL string `json:"subscription_url"` + CommitsURL string `json:"commits_url"` + GitCommitsURL string `json:"git_commits_url"` + CommentsURL string `json:"comments_url"` + IssueCommentURL string `json:"issue_comment_url"` + ContentsURL string `json:"contents_url"` + CompareURL string `json:"compare_url"` + MergesURL string `json:"merges_url"` + ArchiveURL string `json:"archive_url"` + DownloadsURL string `json:"downloads_url"` + IssuesURL string `json:"issues_url"` + PullsURL string `json:"pulls_url"` + MilestonesURL string `json:"milestones_url"` + NotificationsURL string `json:"notifications_url"` + LabelsURL string `json:"labels_url"` + ReleasesURL string `json:"releases_url"` + DeploymentsURL string `json:"deployments_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PushedAt time.Time `json:"pushed_at"` + GitURL string `json:"git_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + SVNURL string `json:"svn_url"` + Homepage string `json:"homepage"` + Size int `json:"size"` + StargazersCount int `json:"stargazers_count"` + WatchersCount int `json:"watchers_count"` + Language string `json:"language"` + HasIssues bool `json:"has_issues"` + HasProjects bool `json:"has_projects"` + HasWiki bool `json:"has_wiki"` + HasPages bool `json:"has_pages"` + HasDownloads bool `json:"has_downloads"` + Archived bool `json:"archived"` + Disabled bool `json:"disabled"` + OpenIssuesCount int `json:"open_issues_count"` + License struct { + Key string `json:"key"` + Name string `json:"name"` + SPDXID string `json:"spdx_id"` + URL string `json:"url"` + NodeID string `json:"node_id"` + } `json:"license"` + ForksCount int `json:"forks_count"` + OpenIssues int `json:"open_issues"` + Watchers int `json:"watchers"` + DefaultBranch string `json:"default_branch"` + Permissions struct { + Admin bool `json:"admin"` + Push bool `json:"push"` + Pull bool `json:"pull"` + } `json:"permissions"` + AllowRebaseMerge bool `json:"allow_rebase_merge"` + TempCloneToken string `json:"temp_clone_token"` + AllowSquashMerge bool `json:"allow_squash_merge"` + AllowMergeCommit bool `json:"allow_merge_commit"` + Deleted bool `json:"deleted"` + AutoInit bool `json:"auto_init"` + IsTemplate bool `json:"is_template"` +} + +// PullRequest represents a GitHub pull request +type PullRequest struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + State string `json:"state"` + Locked bool `json:"locked"` + Title string `json:"title"` + User User `json:"user"` + Body string `json:"body"` + Labels []Label `json:"labels"` + Milestone *Milestone `json:"milestone"` + ActiveLockReason string `json:"active_lock_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at"` + MergedAt *time.Time `json:"merged_at"` + MergeCommitSha string `json:"merge_commit_sha"` + Assignee *User `json:"assignee"` + Assignees []User `json:"assignees"` + RequestedReviewers []User `json:"requested_reviewers"` + RequestedTeams []Team `json:"requested_teams"` + Head PRBranchRef `json:"head"` + Base PRBranchRef `json:"base"` + AuthorAssociation string `json:"author_association"` + Draft bool `json:"draft"` + CommitsURL string `json:"commits_url"` + ReviewCommentsURL string `json:"review_comments_url"` + IssueCommentURL string `json:"issue_comment_url"` + CommentsURL string `json:"comments_url"` + StatusesURL string `json:"statuses_url"` + Merged bool `json:"merged"` + Mergeable bool `json:"mergeable"` + Rebaseable bool `json:"rebaseable"` + MergeableState string `json:"mergeable_state"` + MergedBy *User `json:"merged_by"` + Comments int `json:"comments"` + ReviewComments int `json:"review_comments"` + MaintainerCanModify bool `json:"maintainer_can_modify"` + Commits int `json:"commits"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changed_files"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + IssueURL string `json:"issue_url"` +} + +// PRBranchRef represents a head or base reference in a pull request +type PRBranchRef struct { + Label string `json:"label"` + Ref string `json:"ref"` + SHA string `json:"sha"` + User User `json:"user"` + Repo *Repository `json:"repo"` +} + +// PullRequestRequest represents a request to create or update a pull request +type PullRequestRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Head string `json:"head"` + Base string `json:"base"` + Draft bool `json:"draft,omitempty"` +} + +// MergePullRequestRequest represents a request to merge a pull request +type MergePullRequestRequest struct { + CommitMessage string `json:"commit_message,omitempty"` + SHA string `json:"sha,omitempty"` + MergeMethod string `json:"merge_method,omitempty"` +} + +// MergePullRequestResponse represents the response from merging a pull request +type MergePullRequestResponse struct { + SHA string `json:"sha"` + Merged bool `json:"merged"` + Message string `json:"message"` + Author *User `json:"author,omitempty"` + URL string `json:"url,omitempty"` +} + +// Issue represents a GitHub issue +type Issue struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + URL string `json:"url"` + RepositoryURL string `json:"repository_url"` + LabelsURL string `json:"labels_url"` + CommentsURL string `json:"comments_url"` + EventsURL string `json:"events_url"` + HTMLURL string `json:"html_url"` + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + Body string `json:"body"` + User User `json:"user"` + Labels []Label `json:"labels"` + Assignee *User `json:"assignee"` + Assignees []User `json:"assignees"` + Milestone *Milestone `json:"milestone"` + Locked bool `json:"locked"` + ActiveLockReason string `json:"active_lock_reason"` + Comments int `json:"comments"` + PullRequest *struct { + URL string `json:"url"` + HTMLURL string `json:"html_url"` + DiffURL string `json:"diff_url"` + PatchURL string `json:"patch_url"` + } `json:"pull_request"` + ClosedAt *time.Time `json:"closed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedBy *User `json:"closed_by"` + AuthorAssociation string `json:"author_association"` +} + +// IssueRequest represents a request to create an issue +type IssueRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Assignees []string `json:"assignees,omitempty"` + Labels []string `json:"labels,omitempty"` + Milestone int `json:"milestone,omitempty"` +} + +// Label represents a GitHub label +type Label struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Name string `json:"name"` + Color string `json:"color"` + Default bool `json:"default"` + Description string `json:"description"` +} + +// Milestone represents a GitHub milestone +type Milestone struct { + URL string `json:"url"` + HTMLURL string `json:"html_url"` + LabelsURL string `json:"labels_url"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + Description string `json:"description"` + Creator User `json:"creator"` + OpenIssues int `json:"open_issues"` + ClosedIssues int `json:"closed_issues"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at"` + DueOn *time.Time `json:"due_on"` +} + +// Team represents a GitHub team +type Team struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Privacy string `json:"privacy"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + MembersURL string `json:"members_url"` + RepositoriesURL string `json:"repositories_url"` + Permission string `json:"permission"` + Parent *Team `json:"parent"` +} \ No newline at end of file diff --git a/internal/util/env.go b/internal/util/env.go new file mode 100644 index 0000000..35254a9 --- /dev/null +++ b/internal/util/env.go @@ -0,0 +1,13 @@ +package util + +import ( + "os" +) + +func GetenvOrDefault(key, def string) string { + v := os.Getenv(key) + if v == "" { + return def + } + return v +} diff --git a/internal/util/env_test.go b/internal/util/env_test.go new file mode 100644 index 0000000..c83f629 --- /dev/null +++ b/internal/util/env_test.go @@ -0,0 +1,44 @@ +package util + +import ( + "os" + "testing" +) + +func TestGetenvOrDefault(t *testing.T) { + // Save original environment variable value + originalValue, exists := os.LookupEnv("TEST_ENV_VAR") + + // Clean up after test + defer func() { + if exists { + os.Setenv("TEST_ENV_VAR", originalValue) + } else { + os.Unsetenv("TEST_ENV_VAR") + } + }() + + // Test 1: Environment variable is not set + os.Unsetenv("TEST_ENV_VAR") + result := GetenvOrDefault("TEST_ENV_VAR", "default") + expected := "default" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Test 2: Environment variable is set to empty string + os.Setenv("TEST_ENV_VAR", "") + result = GetenvOrDefault("TEST_ENV_VAR", "default") + expected = "default" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Test 3: Environment variable is set to a value + os.Setenv("TEST_ENV_VAR", "test_value") + result = GetenvOrDefault("TEST_ENV_VAR", "default") + expected = "test_value" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} \ No newline at end of file diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..02c0e32 --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,182 @@ +package validation + +import ( + "context" + "fmt" + "regexp" + "strings" + "github.com/iwasforcedtobehere/git-automation-cli/internal/git" +) + +// ValidationResult represents the result of a validation +type ValidationResult struct { + IsValid bool + Errors []string +} + +// NewValidationResult creates a new validation result +func NewValidationResult() *ValidationResult { + return &ValidationResult{ + IsValid: true, + Errors: make([]string, 0), + } +} + +// AddError adds an error to the validation result +func (vr *ValidationResult) AddError(format string, args ...interface{}) { + vr.IsValid = false + vr.Errors = append(vr.Errors, fmt.Sprintf(format, args...)) +} + +// GetErrors returns all validation errors as a single string +func (vr *ValidationResult) GetErrors() string { + return strings.Join(vr.Errors, "\n") +} + +// ValidateGitRepository checks if the current directory is a Git repository +func ValidateGitRepository(ctx context.Context) *ValidationResult { + result := NewValidationResult() + + if _, err := git.CurrentBranch(ctx); err != nil { + result.AddError("not a git repository or no branch found") + } + + return result +} + +// ValidateBranchName checks if a branch name is valid +func ValidateBranchName(name string) *ValidationResult { + result := NewValidationResult() + + // Check if branch name is empty + if name == "" { + result.AddError("branch name cannot be empty") + return result + } + + // Check for invalid characters + if strings.Contains(name, "..") || strings.Contains(name, " ") || + strings.Contains(name, ":") || strings.Contains(name, "?") || + strings.Contains(name, "*") || strings.Contains(name, "[") || + strings.Contains(name, "@") || strings.Contains(name, "\\") { + result.AddError("branch name contains invalid characters") + } + + // Check if branch name starts with a dot + if strings.HasPrefix(name, ".") { + result.AddError("branch name cannot start with a dot") + } + + // Check if branch name ends with a slash + if strings.HasSuffix(name, "/") { + result.AddError("branch name cannot end with a slash") + } + + // Check for consecutive slashes + if strings.Contains(name, "//") { + result.AddError("branch name cannot contain consecutive slashes") + } + + // Check if branch name is a valid refname + // Git refname rules: must contain at least one / + if !strings.Contains(name, "/") && name != "HEAD" && + name != "main" && name != "master" { + // This is not necessarily an error, but a warning + // We'll allow it but log a warning + } + + return result +} + +// ValidateCommitMessage checks if a commit message is valid +func ValidateCommitMessage(message string) *ValidationResult { + result := NewValidationResult() + + // Check if commit message is empty + if strings.TrimSpace(message) == "" { + result.AddError("commit message cannot be empty") + return result + } + + // Check if commit message is too long + if len(message) > 72 { + result.AddError("commit message should be 72 characters or less") + } + + // Check if commit message starts with a whitespace + if strings.HasPrefix(message, " ") { + result.AddError("commit message should not start with whitespace") + } + + return result +} + +// ValidateConventionalCommit checks if a commit message follows the conventional commit format +func ValidateConventionalCommit(message string) *ValidationResult { + result := NewValidationResult() + + // Conventional commit format: type(scope): description + // Example: feat(auth): add login functionality + + // Check if commit message is empty + if strings.TrimSpace(message) == "" { + result.AddError("commit message cannot be empty") + return result + } + + // Regex pattern for conventional commit + pattern := `^(\w+)(\([^)]+\))?(!)?:\s.+` + matched, err := regexp.MatchString(pattern, message) + if err != nil { + result.AddError("error validating conventional commit format: %v", err) + return result + } + + if !matched { + result.AddError("commit message does not follow conventional commit format") + result.AddError("expected format: type(scope): description") + result.AddError("example: feat(auth): add login functionality") + } + + return result +} + +// ValidateGitHubToken checks if a GitHub token is valid +func ValidateGitHubToken(token string) *ValidationResult { + result := NewValidationResult() + + // Check if token is empty + if token == "" { + result.AddError("GitHub token cannot be empty") + return result + } + + // Check if token starts with "ghp_" (personal access token) or "gho_" (OAuth token) + if !strings.HasPrefix(token, "ghp_") && !strings.HasPrefix(token, "gho_") { + result.AddError("GitHub token should start with 'ghp_' or 'gho_'") + } + + // Check token length (GitHub tokens are typically 40 characters long) + if len(token) != 40 { + result.AddError("GitHub token should be 40 characters long") + } + + return result +} + +// ValidateWorkingDirectory checks if the working directory is clean +func ValidateWorkingDirectory(ctx context.Context) *ValidationResult { + result := NewValidationResult() + + clean, err := git.IsCleanWorkingDir(ctx) + if err != nil { + result.AddError("failed to check working directory: %v", err) + return result + } + + if clean { + result.AddError("no changes to commit") + } + + return result +} \ No newline at end of file diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go new file mode 100644 index 0000000..d5bf2f1 --- /dev/null +++ b/internal/validation/validation_test.go @@ -0,0 +1,202 @@ +package validation + +import ( + "context" + "testing" +) + +func TestNewValidationResult(t *testing.T) { + result := NewValidationResult() + + if result.IsValid != true { + t.Errorf("Expected IsValid to be true, got %v", result.IsValid) + } + + if len(result.Errors) != 0 { + t.Errorf("Expected Errors to be empty, got %v", result.Errors) + } +} + +func TestAddError(t *testing.T) { + result := NewValidationResult() + + result.AddError("test error") + + if result.IsValid != false { + t.Errorf("Expected IsValid to be false, got %v", result.IsValid) + } + + if len(result.Errors) != 1 { + t.Errorf("Expected Errors to have 1 element, got %d", len(result.Errors)) + } + + if result.Errors[0] != "test error" { + t.Errorf("Expected error to be 'test error', got '%s'", result.Errors[0]) + } +} + +func TestGetErrors(t *testing.T) { + result := NewValidationResult() + + result.AddError("first error") + result.AddError("second error") + + errors := result.GetErrors() + expected := "first error\nsecond error" + + if errors != expected { + t.Errorf("Expected errors to be '%s', got '%s'", expected, errors) + } +} + +func TestValidateGitRepository(t *testing.T) { + ctx := context.Background() + + // This test will fail if not run in a git repository + // We'll just check that it returns a ValidationResult + result := ValidateGitRepository(ctx) + + if result == nil { + t.Error("Expected ValidateGitRepository to return a ValidationResult, got nil") + } +} + +func TestValidateBranchName(t *testing.T) { + // Test empty branch name + result := ValidateBranchName("") + if result.IsValid { + t.Error("Expected empty branch name to be invalid") + } + + // Test valid branch name + result = ValidateBranchName("feature/test-branch") + if !result.IsValid { + t.Errorf("Expected valid branch name to be valid, got errors: %s", result.GetErrors()) + } + + // Test branch name with invalid characters + result = ValidateBranchName("feature/test branch") + if result.IsValid { + t.Error("Expected branch name with space to be invalid") + } + + // Test branch name starting with a dot + result = ValidateBranchName(".feature/test") + if result.IsValid { + t.Error("Expected branch name starting with dot to be invalid") + } + + // Test branch name ending with a slash + result = ValidateBranchName("feature/test/") + if result.IsValid { + t.Error("Expected branch name ending with slash to be invalid") + } + + // Test branch name with consecutive slashes + result = ValidateBranchName("feature//test") + if result.IsValid { + t.Error("Expected branch name with consecutive slashes to be invalid") + } + + // Test main branch name + result = ValidateBranchName("main") + if !result.IsValid { + t.Error("Expected 'main' branch name to be valid") + } +} + +func TestValidateCommitMessage(t *testing.T) { + // Test empty commit message + result := ValidateCommitMessage("") + if result.IsValid { + t.Error("Expected empty commit message to be invalid") + } + + // Test valid commit message + result = ValidateCommitMessage("Add new feature") + if !result.IsValid { + t.Errorf("Expected valid commit message to be valid, got errors: %s", result.GetErrors()) + } + + // Test commit message that's too long + result = ValidateCommitMessage("This is a very long commit message that exceeds the 72 character limit by a lot") + if result.IsValid { + t.Error("Expected commit message that's too long to be invalid") + } + + // Test commit message starting with whitespace + result = ValidateCommitMessage(" Add new feature") + if result.IsValid { + t.Error("Expected commit message starting with whitespace to be invalid") + } +} + +func TestValidateConventionalCommit(t *testing.T) { + // Test empty commit message + result := ValidateConventionalCommit("") + if result.IsValid { + t.Error("Expected empty commit message to be invalid") + } + + // Test valid conventional commit message + result = ValidateConventionalCommit("feat(auth): add login functionality") + if !result.IsValid { + t.Errorf("Expected valid conventional commit message to be valid, got errors: %s", result.GetErrors()) + } + + // Test valid conventional commit message with breaking change + result = ValidateConventionalCommit("feat(auth)!: add login functionality") + if !result.IsValid { + t.Errorf("Expected valid conventional commit message with breaking change to be valid, got errors: %s", result.GetErrors()) + } + + // Test invalid conventional commit message + result = ValidateConventionalCommit("Add new feature") + if result.IsValid { + t.Error("Expected invalid conventional commit message to be invalid") + } +} + +func TestValidateGitHubToken(t *testing.T) { + // Test empty token + result := ValidateGitHubToken("") + if result.IsValid { + t.Error("Expected empty token to be invalid") + } + + // Test valid personal access token (40 characters total) + result = ValidateGitHubToken("ghp_123456789012345678901234567890123456") + if !result.IsValid { + t.Errorf("Expected valid personal access token to be valid, got errors: %s", result.GetErrors()) + } + + // Test valid OAuth token (40 characters total) + result = ValidateGitHubToken("gho_123456789012345678901234567890123456") + if !result.IsValid { + t.Errorf("Expected valid OAuth token to be valid, got errors: %s", result.GetErrors()) + } + + // Test token with invalid prefix + result = ValidateGitHubToken("abc_123456789012345678901234567890123456") + if result.IsValid { + t.Error("Expected token with invalid prefix to be invalid") + } + + // Test token with invalid length + result = ValidateGitHubToken("ghp_12345678901234567890123456789012345") + if result.IsValid { + t.Error("Expected token with invalid length to be invalid") + } +} + +func TestValidateWorkingDirectory(t *testing.T) { + ctx := context.Background() + + // This test will fail if run in a clean working directory + // We'll just check that it returns a ValidationResult + result := ValidateWorkingDirectory(ctx) + + if result == nil { + t.Error("Expected ValidateWorkingDirectory to return a ValidationResult, got nil") + } +} \ No newline at end of file diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..8f12652 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,22 @@ +package version + +var ( + // Version is the current version of the application + Version = "0.1.0" + // GitCommit is the git commit hash + GitCommit = "" + // BuildDate is the date when the binary was built + BuildDate = "" +) + +// GetVersion returns the complete version information +func GetVersion() string { + version := Version + if GitCommit != "" { + version += " (" + GitCommit + ")" + } + if BuildDate != "" { + version += " built on " + BuildDate + } + return version +} \ No newline at end of file diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..459c609 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,53 @@ +package version + +import ( + "testing" +) + +func TestGetVersion(t *testing.T) { + // Save original values + originalVersion := Version + originalGitCommit := GitCommit + originalBuildDate := BuildDate + + // Reset values + Version = "0.1.0" + GitCommit = "" + BuildDate = "" + + // Test with just version + result := GetVersion() + expected := "0.1.0" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Test with version and git commit + GitCommit = "abc123" + result = GetVersion() + expected = "0.1.0 (abc123)" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Test with version, git commit, and build date + BuildDate = "2023-01-01" + result = GetVersion() + expected = "0.1.0 (abc123) built on 2023-01-01" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Test with version and build date (no git commit) + GitCommit = "" + result = GetVersion() + expected = "0.1.0 built on 2023-01-01" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } + + // Restore original values + Version = originalVersion + GitCommit = originalGitCommit + BuildDate = originalBuildDate +} \ No newline at end of file