diff --git a/.github/workflows/completions.yaml b/.github/workflows/completions.yaml new file mode 100644 index 0000000..dc423f2 --- /dev/null +++ b/.github/workflows/completions.yaml @@ -0,0 +1,28 @@ +name: Validate completions + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + validate-completions: + timeout-minutes: 5 + runs-on: ubuntu-24.04 + name: validate-shell-completions + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install shells + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends zsh fish + + - name: Run completion tests + run: bash tests/completions/test_completions.sh diff --git a/Makefile b/Makefile index 2acda31..cb19a5d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help activate test lint install clean +.PHONY: help activate test test-completions lint install clean # Default target help: @@ -6,7 +6,8 @@ help: @echo "" @echo "Available targets:" @echo " make activate - Create venv and install dev dependencies" - @echo " make test - Run unit tests" + @echo " make test - Run unit tests" + @echo " make test-completions - Run shell completion tests in Docker" @echo " make lint - Run linters/formatters" @echo " make install - Install acp with pipx from current branch" @echo " make clean - Clean up test artifacts" @@ -22,6 +23,11 @@ activate: test: venv/bin/pytest test_acp.py -v +# Shell completion tests (Docker) +test-completions: + docker build -t acp-completions-test -f tests/completions/Dockerfile . + docker run --rm acp-completions-test + # Format and lint code lint: venv/bin/black . diff --git a/acp.py b/acp.py index eca749a..10033af 100755 --- a/acp.py +++ b/acp.py @@ -12,6 +12,155 @@ __version__ = "1.1.0" +def get_bash_completion(): + """Generate bash completion script.""" + return """\ +_acp() { + local cur prev commands opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + commands="pr checkout completions" + opts="-v --verbose -b --body -i --interactive --merge --auto-merge --merge-method -s --sync -a --add -r --reviewers -h --help --version" + + if [[ ${COMP_CWORD} -eq 1 ]]; then + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + else + COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) + fi + return 0 + fi + + case "${prev}" in + --merge-method) + COMPREPLY=( $(compgen -W "merge squash rebase" -- "${cur}") ) + return 0 + ;; + completions) + COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") ) + return 0 + ;; + -b|--body|-r|--reviewers) + return 0 + ;; + esac + + local subcmd="${COMP_WORDS[1]}" + if [[ ${cur} == -* ]] || [[ "${subcmd}" == "pr" && -z "${cur}" ]]; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + fi +} + +complete -F _acp acp +""" + + +def get_zsh_completion(): + """Generate zsh completion script.""" + return """\ +#compdef acp + +_acp() { + local curcontext="$curcontext" ret=1 + + _arguments -C \\ + '1:command:->command' \\ + '*::arg:->args' && ret=0 + + case $state in + command) + local -a commands + commands=( + 'pr:Create a pull request from staged changes' + 'checkout:Checkout a branch' + 'completions:Output shell completion script' + ) + _describe -t commands 'acp commands' commands && ret=0 + ;; + args) + case ${words[1]} in + pr) + _arguments \\ + '1:commit message:' \\ + '-v[Show detailed output]' \\ + '--verbose[Show detailed output]' \\ + '-b[Custom PR body message]:body:' \\ + '--body[Custom PR body message]:body:' \\ + '-i[Show PR creation URL]' \\ + '--interactive[Show PR creation URL]' \\ + '--merge[Merge PR immediately after creation]' \\ + '--auto-merge[Enable auto-merge when checks pass]' \\ + '--merge-method[Merge method]:method:(merge squash rebase)' \\ + '-s[Sync current branch with remote after merge]' \\ + '--sync[Sync current branch with remote after merge]' \\ + '-a[Run git add . before committing]' \\ + '--add[Run git add . before committing]' \\ + '-r[Request reviewers]:reviewers:' \\ + '--reviewers[Request reviewers]:reviewers:' \\ + '-h[Show help]' \\ + '--help[Show help]' && ret=0 + ;; + checkout) + _arguments '1:branch name:' && ret=0 + ;; + completions) + _arguments '1:shell:(bash zsh fish)' && ret=0 + ;; + esac + ;; + esac + + return ret +} + +_acp "$@" +""" + + +def get_fish_completion(): + """Generate fish completion script.""" + return """\ +# Fish completion for acp + +# Disable file completion by default +complete -c acp -f + +# Commands +complete -c acp -n "__fish_use_subcommand" -a "pr" -d "Create a pull request from staged changes" +complete -c acp -n "__fish_use_subcommand" -a "checkout" -d "Checkout a branch" +complete -c acp -n "__fish_use_subcommand" -a "completions" -d "Output shell completion script" +complete -c acp -n "__fish_seen_subcommand_from completions" -a "bash zsh fish" + +# Options +complete -c acp -s v -l verbose -d "Show detailed output" +complete -c acp -s b -l body -d "Custom PR body message" -r +complete -c acp -s i -l interactive -d "Show PR creation URL" +complete -c acp -l merge -d "Merge PR immediately after creation" +complete -c acp -l auto-merge -d "Enable auto-merge when checks pass" +complete -c acp -l merge-method -d "Merge method" -r -a "merge squash rebase" +complete -c acp -s s -l sync -d "Sync current branch with remote after merge" +complete -c acp -s a -l add -d "Run git add . before committing" +complete -c acp -s r -l reviewers -d "Comma-separated list of reviewers" -r +complete -c acp -s h -l help -d "Show help" +complete -c acp -l version -d "Show version" +""" + + +def print_completion(shell): + """Print completion script for the specified shell.""" + completions = { + "bash": get_bash_completion, + "zsh": get_zsh_completion, + "fish": get_fish_completion, + } + if shell not in completions: + print(f"Error: Unknown shell '{shell}'", file=sys.stderr) + print("Supported shells: bash, zsh, fish", file=sys.stderr) + sys.exit(1) + print(completions[shell]()) + + def run(cmd, quiet=False): """Run a command and return output.""" result = subprocess.run(cmd, capture_output=True, text=True) @@ -493,12 +642,16 @@ def show_help(): """Show help message.""" print("usage: acp pr [pr options]") print(" acp checkout ") + print(" acp completions ") print() print("Commands:") print(" pr Create a PR with staged changes") print( " checkout Checkout a branch, stripping 'user:' prefix if present" ) + print( + " completions Output shell completion script (bash, zsh, fish)" + ) print() print("PR Options:") print(" -a, --add Run 'git add .' before committing changes") @@ -515,6 +668,12 @@ def show_help(): print(" --version Show version number") print(" -h, --help Show this help message") print() + print("Shell Completions:") + print(" Bash: echo 'eval \"$(acp completions bash)\"' >> ~/.bashrc") + print(" Zsh: echo 'eval \"$(acp completions zsh)\"' >> ~/.zshrc") + print(" Fish: acp completions fish | source") + print(" Or save to: ~/.config/fish/completions/acp.fish") + print() print("Examples:") print(' acp pr "fix: some typo" -i') print(' acp pr "fix: urgent" -b "Closes issue #123" --merge --merge-method rebase') @@ -788,6 +947,15 @@ def main(): args = parser.parse_args() + if args.command == "completions": + # args.message is the second positional arg (commit message for pr, branch for checkout) + shell = args.message + if not shell: + print("Error: Shell name required (bash, zsh, fish)", file=sys.stderr) + sys.exit(1) + print_completion(shell) + sys.exit(0) + if args.version: print(f"acp version {__version__}") sys.exit(0) diff --git a/test_acp.py b/test_acp.py index 5d672dd..b6bd46a 100644 --- a/test_acp.py +++ b/test_acp.py @@ -1487,5 +1487,68 @@ def test_checkout_no_branch(self, capsys): assert "Branch name required" in captured.err +class TestCompletion: + """Test shell completion scripts.""" + + def test_completion_command_bash(self, capsys): + """Test 'acp completions bash' outputs script.""" + with mock.patch.object(sys, "argv", ["acp", "completions", "bash"]): + with pytest.raises(SystemExit) as exc: + acp.main() + assert exc.value.code == 0 + + captured = capsys.readouterr() + assert "_acp()" in captured.out + assert "complete -F _acp acp" in captured.out + + def test_completion_command_zsh(self, capsys): + """Test 'acp completions zsh' outputs script.""" + with mock.patch.object(sys, "argv", ["acp", "completions", "zsh"]): + with pytest.raises(SystemExit) as exc: + acp.main() + assert exc.value.code == 0 + + captured = capsys.readouterr() + assert "#compdef acp" in captured.out + assert "_arguments" in captured.out + + def test_completion_command_fish(self, capsys): + """Test 'acp completions fish' outputs script.""" + with mock.patch.object(sys, "argv", ["acp", "completions", "fish"]): + with pytest.raises(SystemExit) as exc: + acp.main() + assert exc.value.code == 0 + + captured = capsys.readouterr() + assert "complete -c acp" in captured.out + + def test_completion_command_invalid(self, capsys): + """Test 'acp completions invalid' with invalid shell.""" + with mock.patch.object(sys, "argv", ["acp", "completions", "invalid"]): + with pytest.raises(SystemExit) as exc: + acp.main() + assert exc.value.code != 0 + + def test_completion_command_no_shell(self, capsys): + """Test 'acp completions' without shell name.""" + with mock.patch.object(sys, "argv", ["acp", "completions"]): + with pytest.raises(SystemExit) as exc: + acp.main() + assert exc.value.code != 0 + + captured = capsys.readouterr() + assert "Shell name required" in captured.err + + def test_print_completion_invalid_shell(self, capsys): + """Test print_completion() directly with invalid shell.""" + with pytest.raises(SystemExit) as exc: + acp.print_completion("powershell") + assert exc.value.code == 1 + + captured = capsys.readouterr() + assert "Unknown shell" in captured.err + assert "Supported shells" in captured.err + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/completions/Dockerfile b/tests/completions/Dockerfile new file mode 100644 index 0000000..0db6ad9 --- /dev/null +++ b/tests/completions/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + zsh \ + fish \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY acp.py . +COPY tests/completions/test_completions.sh . + +RUN chmod +x test_completions.sh + +CMD ["./test_completions.sh"] diff --git a/tests/completions/test_completions.sh b/tests/completions/test_completions.sh new file mode 100644 index 0000000..cfd068e --- /dev/null +++ b/tests/completions/test_completions.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +pass() { echo -e "${GREEN}pass $1${NC}"; } +fail() { echo -e "${RED}fail $1${NC}"; exit 1; } +info() { echo -e "${YELLOW}info $1${NC}"; } + +# Syntax validation +python acp.py completions bash | bash -n && pass "Bash syntax valid" || fail "Bash syntax invalid" +python acp.py completions zsh | zsh -n && pass "Zsh syntax valid" || fail "Zsh syntax invalid" +python acp.py completions fish | fish -n && pass "Fish syntax valid" || fail "Fish syntax invalid" + +# Bash functional tests +eval "$(python acp.py completions bash)" + +# Command completion +COMP_WORDS=(acp ""); COMP_CWORD=1; _acp +[[ " ${COMPREPLY[*]} " =~ " pr " ]] || fail "Bash: 'pr' not in command completions" +[[ " ${COMPREPLY[*]} " =~ " checkout " ]] || fail "Bash: 'checkout' not in command completions" +[[ " ${COMPREPLY[*]} " =~ " completions " ]] || fail "Bash: 'completions' not in command completions" +pass "Bash: command completion (pr, checkout, completions)" + +# Option completion with -- prefix +COMP_WORDS=(acp pr --); COMP_CWORD=2; _acp +[[ " ${COMPREPLY[*]} " =~ " --merge " ]] || fail "Bash: '--merge' not in option completions" +[[ " ${COMPREPLY[*]} " =~ " --verbose " ]] || fail "Bash: '--verbose' not in option completions" +[[ " ${COMPREPLY[*]} " =~ " --add " ]] || fail "Bash: '--add' not in option completions" +[[ " ${COMPREPLY[*]} " =~ " --reviewers " ]] || fail "Bash: '--reviewers' not in option completions" +pass "Bash: option completion (--merge, --verbose, --add, --reviewers)" + +# 'acp pr ' shows options without -- prefix +COMP_WORDS=(acp pr ""); COMP_CWORD=2; _acp +[[ " ${COMPREPLY[*]} " =~ " --merge " ]] || fail "Bash: '--merge' not in 'acp pr ' completions" +[[ " ${COMPREPLY[*]} " =~ " --verbose " ]] || fail "Bash: '--verbose' not in 'acp pr ' completions" +[[ " ${COMPREPLY[*]} " =~ " -v " ]] || fail "Bash: '-v' not in 'acp pr ' completions" +pass "Bash: 'acp pr ' shows options without -- prefix" + +# Short option completion +COMP_WORDS=(acp pr -); COMP_CWORD=2; _acp +[[ " ${COMPREPLY[*]} " =~ " -v " ]] || fail "Bash: '-v' not in short option completions" +[[ " ${COMPREPLY[*]} " =~ " -b " ]] || fail "Bash: '-b' not in short option completions" +[[ " ${COMPREPLY[*]} " =~ " -a " ]] || fail "Bash: '-a' not in short option completions" +[[ " ${COMPREPLY[*]} " =~ " -r " ]] || fail "Bash: '-r' not in short option completions" +[[ " ${COMPREPLY[*]} " =~ " -s " ]] || fail "Bash: '-s' not in short option completions" +pass "Bash: short option completion (-v, -b, -a, -r, -s)" + +# --merge-method values +COMP_WORDS=(acp pr --merge-method ""); COMP_CWORD=3; _acp +[[ " ${COMPREPLY[*]} " =~ " squash " ]] || fail "Bash: 'squash' not in --merge-method completions" +[[ " ${COMPREPLY[*]} " =~ " merge " ]] || fail "Bash: 'merge' not in --merge-method completions" +[[ " ${COMPREPLY[*]} " =~ " rebase " ]] || fail "Bash: 'rebase' not in --merge-method completions" +pass "Bash: --merge-method values (squash, merge, rebase)" + +# completions subcommand values +COMP_WORDS=(acp completions ""); COMP_CWORD=2; _acp +[[ " ${COMPREPLY[*]} " =~ " bash " ]] || fail "Bash: 'bash' not in completions values" +[[ " ${COMPREPLY[*]} " =~ " zsh " ]] || fail "Bash: 'zsh' not in completions values" +[[ " ${COMPREPLY[*]} " =~ " fish " ]] || fail "Bash: 'fish' not in completions values" +pass "Bash: completions values (bash, zsh, fish)" + +# Partial completion +COMP_WORDS=(acp pr --mer); COMP_CWORD=2; _acp +[[ " ${COMPREPLY[*]} " =~ " --merge " ]] || fail "Bash: '--merge' not in partial completions" +[[ " ${COMPREPLY[*]} " =~ " --merge-method " ]] || fail "Bash: '--merge-method' not in partial completions" +pass "Bash: partial completion (--mer -> --merge, --merge-method)" + +echo "" +echo -e "${GREEN}All completion tests passed!${NC}"