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
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -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
|
6
LICENSE
Normal file
6
LICENSE
Normal file
@@ -0,0 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 iwasforcedtobehere
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
...
|
158
README.md
Normal file
158
README.md
Normal file
@@ -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
|
31
go.mod
Normal file
31
go.mod
Normal file
@@ -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
|
||||
)
|
75
go.sum
Normal file
75
go.sum
Normal file
@@ -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=
|
272
internal/cmd/branch.go
Normal file
272
internal/cmd/branch.go
Normal file
@@ -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)
|
||||
}
|
134
internal/cmd/commands.go
Normal file
134
internal/cmd/commands.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
283
internal/cmd/commit.go
Normal file
283
internal/cmd/commit.go
Normal file
@@ -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)
|
||||
}
|
107
internal/cmd/config.go
Normal file
107
internal/cmd/config.go
Normal file
@@ -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)
|
||||
}
|
286
internal/cmd/config_test.go
Normal file
286
internal/cmd/config_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
326
internal/cmd/pr.go
Normal file
326
internal/cmd/pr.go
Normal file
@@ -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")
|
||||
}
|
518
internal/cmd/pr_test.go
Normal file
518
internal/cmd/pr_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
60
internal/cmd/root.go
Normal file
60
internal/cmd/root.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
225
internal/cmd/root_test.go
Normal file
225
internal/cmd/root_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
149
internal/config/config.go
Normal file
149
internal/config/config.go
Normal file
@@ -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
|
||||
}
|
267
internal/config/config_test.go
Normal file
267
internal/config/config_test.go
Normal file
@@ -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
|
||||
}
|
125
internal/git/git.go
Normal file
125
internal/git/git.go
Normal file
@@ -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)
|
||||
}
|
532
internal/git/git_test.go
Normal file
532
internal/git/git_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
337
internal/github/client.go
Normal file
337
internal/github/client.go
Normal file
@@ -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 <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 <remote>
|
||||
// 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
|
||||
}
|
970
internal/github/client_test.go
Normal file
970
internal/github/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
305
internal/github/models.go
Normal file
305
internal/github/models.go
Normal file
@@ -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"`
|
||||
}
|
13
internal/util/env.go
Normal file
13
internal/util/env.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetenvOrDefault(key, def string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
44
internal/util/env_test.go
Normal file
44
internal/util/env_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
182
internal/validation/validation.go
Normal file
182
internal/validation/validation.go
Normal file
@@ -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
|
||||
}
|
202
internal/validation/validation_test.go
Normal file
202
internal/validation/validation_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
22
internal/version/version.go
Normal file
22
internal/version/version.go
Normal file
@@ -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
|
||||
}
|
53
internal/version/version_test.go
Normal file
53
internal/version/version_test.go
Normal file
@@ -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
|
||||
}
|
Reference in New Issue
Block a user