diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml new file mode 100644 index 0000000..68ef218 --- /dev/null +++ b/.github/workflows/test-and-build.yml @@ -0,0 +1,88 @@ +name: Test and Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.12] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + PYTHONPATH=$PYTHONPATH:$(pwd) pytest tests/ --cov=. --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: true + + build: + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ghcr.io/mnofresno/convcommitgpt:latest + ghcr.io/mnofresno/convcommitgpt:${{ github.sha }} + platforms: linux/amd64,linux/arm64 + + test-install: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Test installation + run: | + chmod +x test_install.sh + ./test_install.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 62545a8..23d6e0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,10 @@ ENV OLLAMA_HOST=host.docker.internal ENV OLLAMA_PORT=11434 # Copy application files -COPY *.py ./ -COPY *.md ./ +COPY . . + +# Install the package +RUN pip install -e . # Set the entrypoint -ENTRYPOINT ["python", "convcommit.py"] +ENTRYPOINT ["python", "-m", "convcommitgpt.convcommit"] diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f195549 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +""" +ConvCommitGPT - A tool to generate commit messages using AI +""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/build_and_push.sh b/build_and_push.sh index 03a4563..1fb6130 100755 --- a/build_and_push.sh +++ b/build_and_push.sh @@ -54,17 +54,14 @@ prepare_build_dir() { BUILD_DIR=$(mktemp -d) print_message "Created build directory: $BUILD_DIR" "$GREEN" - # Copy all Python files, requirements.txt, and markdown files - cp *.py "$BUILD_DIR/" 2>/dev/null || true - cp requirements.txt "$BUILD_DIR/" 2>/dev/null || true - cp *.md "$BUILD_DIR/" 2>/dev/null || true - cp Dockerfile "$BUILD_DIR/" + # Copy all files to build directory + cp -r . "$BUILD_DIR/" # Change to build directory cd "$BUILD_DIR" # Verify essential files exist - if [ ! -f "requirements.txt" ] || [ ! -f "convcommit.py" ] || [ ! -f "Dockerfile" ]; then + if [ ! -f "setup.py" ] || [ ! -f "requirements.txt" ] || [ ! -f "Dockerfile" ]; then print_message "Error: Essential files are missing" "$RED" return 1 fi diff --git a/assistant.py b/convcommitgpt/assistant.py similarity index 100% rename from assistant.py rename to convcommitgpt/assistant.py diff --git a/convcommit.py b/convcommitgpt/convcommit.py similarity index 100% rename from convcommit.py rename to convcommitgpt/convcommit.py diff --git a/diff_generator.py b/convcommitgpt/diff_generator.py similarity index 100% rename from diff_generator.py rename to convcommitgpt/diff_generator.py diff --git a/runner.py b/convcommitgpt/runner.py similarity index 100% rename from runner.py rename to convcommitgpt/runner.py diff --git a/install.sh b/install.sh index 461ddf6..a649f96 100755 --- a/install.sh +++ b/install.sh @@ -121,11 +121,7 @@ download_files() { print_message "Downloading files..." "$YELLOW" cd /tmp local files=( - "convcommit.py" - "assistant.py" - "diff_generator.py" - "runner.py" - "spinner.py" + "setup.py" "requirements.txt" "instructions_prompt.md" "convcommit.sh" @@ -138,8 +134,19 @@ download_files() { fi done + # Download the package directory + if ! curl -sSL "https://github.com/mnofresno/convcommitgpt/archive/refs/heads/main.zip" -o "main.zip"; then + print_message "Error: Failed to download package" "$RED" + exit 1 + fi + + # Extract the package + unzip -q main.zip + rm main.zip + print_message "Copying files to installation directory..." "$YELLOW" - cp -r /tmp/*.py /tmp/*.txt /tmp/*.md /tmp/convcommit.sh ~/.local/lib/convcommitgpt/ + cp -r convcommitgpt-main/convcommitgpt ~/.local/lib/convcommitgpt/ + cp /tmp/*.py /tmp/*.txt /tmp/*.md /tmp/convcommit.sh ~/.local/lib/convcommitgpt/ cd - } diff --git a/requirements.txt b/requirements.txt index 5fc415e..9571cef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ click==8.1.7 openai>=1.12.0 gitpython==3.1.43 -python-dotenv==1.0.1 \ No newline at end of file +python-dotenv==1.0.1 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..78a9607 --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup, find_packages + +setup( + name="convcommitgpt", + version="1.0.0", + packages=find_packages(), + install_requires=[ + "click==8.1.7", + "openai>=1.12.0", + "gitpython==3.1.43", + "python-dotenv==1.0.1", + ], + extras_require={ + "dev": [ + "pytest==8.0.0", + "pytest-cov==4.1.0", + "pytest-mock==3.12.0", + ], + }, + entry_points={ + "console_scripts": [ + "convcommit=convcommitgpt.convcommit:main", + ], + }, +) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c13dbda --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Test suite for ConvCommitGPT +""" \ No newline at end of file diff --git a/tests/test_assistant.py b/tests/test_assistant.py new file mode 100644 index 0000000..758bfee --- /dev/null +++ b/tests/test_assistant.py @@ -0,0 +1,44 @@ +import pytest +from unittest.mock import Mock, patch +from convcommitgpt.assistant import Assistant + +@pytest.fixture +def mock_response(): + return { + "choices": [ + { + "message": { + "content": "Test commit message" + } + } + ] + } + +@pytest.fixture +def assistant(): + return Assistant(api_url="http://test.com", api_key="test-key") + +def test_assistant_initialization(): + assistant = Assistant(api_url="http://test.com", api_key="test-key") + assert assistant.api_url == "http://test.com" + assert assistant.api_key == "test-key" + +@patch('assistant.httpx.post') +def test_assist_success(mock_post, assistant, mock_response): + mock_post.return_value.json.return_value = mock_response + mock_post.return_value.status_code = 200 + + result = assistant.assist("test diff", "test prompt", "test model") + + assert result == "Test commit message" + mock_post.assert_called_once() + +@patch('assistant.httpx.post') +def test_assist_error(mock_post, assistant): + mock_post.return_value.status_code = 400 + mock_post.return_value.text = "Error message" + + with pytest.raises(Exception) as exc_info: + assistant.assist("test diff", "test prompt", "test model") + + assert "Error message" in str(exc_info.value) \ No newline at end of file diff --git a/tests/test_convcommit.py b/tests/test_convcommit.py new file mode 100644 index 0000000..041a6fc --- /dev/null +++ b/tests/test_convcommit.py @@ -0,0 +1,75 @@ +import pytest +from unittest.mock import Mock, patch +from io import StringIO +from convcommitgpt.convcommit import main + +@pytest.fixture +def mock_diff_generator(): + with patch('convcommit.DiffGenerator') as mock: + mock.return_value.generate.return_value = "test diff" + yield mock + +@pytest.fixture +def mock_assistant(): + with patch('convcommit.Assistant') as mock: + instance = Mock() + instance.assist.return_value = "Test commit message" + mock.return_value = instance + yield mock + +@pytest.fixture +def mock_runner(): + with patch('convcommit.Runner') as mock: + instance = Mock() + instance.generate.return_value = "Test commit message" + mock.return_value = instance + yield mock + +def test_main_with_repository_path(mock_diff_generator, mock_assistant, mock_runner): + with patch('convcommit.click.echo') as mock_echo: + main( + repository_path="/test/repo", + prompt_file="test_prompt.md", + model="test-model", + openai_api_key="test-key", + base_url="http://test.com", + diff_from_stdin=None, + debug_diff=False, + max_bytes_in_diff=1024, + verbose=False + ) + + mock_echo.assert_called_with("Test commit message") + +def test_main_with_stdin(mock_assistant, mock_runner): + with patch('convcommit.click.echo') as mock_echo: + stdin = StringIO("test diff") + main( + repository_path=None, + prompt_file="test_prompt.md", + model="test-model", + openai_api_key="test-key", + base_url="http://test.com", + diff_from_stdin=stdin, + debug_diff=False, + max_bytes_in_diff=1024, + verbose=False + ) + + mock_echo.assert_called_with("Test commit message") + +def test_main_no_input(): + with pytest.raises(SystemExit) as exc_info: + main( + repository_path=None, + prompt_file="test_prompt.md", + model="test-model", + openai_api_key="test-key", + base_url="http://test.com", + diff_from_stdin=None, + debug_diff=False, + max_bytes_in_diff=1024, + verbose=False + ) + + assert exc_info.value.code == 1 \ No newline at end of file diff --git a/tests/test_diff_generator.py b/tests/test_diff_generator.py new file mode 100644 index 0000000..3b467de --- /dev/null +++ b/tests/test_diff_generator.py @@ -0,0 +1,51 @@ +import pytest +from pathlib import Path +from unittest.mock import Mock, patch +from convcommitgpt.diff_generator import DiffGenerator + +@pytest.fixture +def mock_git_diff(): + return """diff --git a/test.py b/test.py +index 1234567..89abcde 100644 +--- a/test.py ++++ b/test.py +@@ -1,2 +1,3 @@ + def test(): +- return True ++ return False +""" + +@pytest.fixture +def diff_generator(): + return DiffGenerator(max_bytes=1024) + +@patch('diff_generator.subprocess.run') +def test_generate_diff_success(mock_run, diff_generator, mock_git_diff): + mock_run.return_value = Mock( + returncode=0, + stdout=mock_git_diff.encode(), + stderr=b'' + ) + + result = diff_generator.generate(Path("/test/repo")) + + assert result == mock_git_diff + mock_run.assert_called_once() + +@patch('diff_generator.subprocess.run') +def test_generate_diff_error(mock_run, diff_generator): + mock_run.return_value = Mock( + returncode=1, + stdout=b'', + stderr=b'Error: not a git repository' + ) + + with pytest.raises(Exception) as exc_info: + diff_generator.generate(Path("/test/repo")) + + assert "Error: not a git repository" in str(exc_info.value) + +def test_max_bytes_limit(diff_generator): + long_diff = "x" * 2000 + result = diff_generator._limit_diff_size(long_diff) + assert len(result) <= 1024 \ No newline at end of file diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..43394e8 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,41 @@ +import pytest +from unittest.mock import Mock, patch +from convcommitgpt.runner import Runner +from convcommitgpt.assistant import Assistant + +@pytest.fixture +def mock_assistant(): + return Mock(spec=Assistant) + +@pytest.fixture +def runner(mock_assistant): + return Runner(mock_assistant) + +def test_runner_initialization(): + assistant = Mock(spec=Assistant) + runner = Runner(assistant) + assert runner.assistant == assistant + +@patch('runner.open') +def test_generate_commit_message(mock_open, runner, mock_assistant): + # Mock file reading + mock_open.return_value.__enter__.return_value.read.return_value = "Test prompt" + + # Mock assistant response + mock_assistant.assist.return_value = "Test commit message" + + result = runner.generate("test diff", "test_prompt.md", "test-model") + + assert result == "Test commit message" + mock_assistant.assist.assert_called_once_with( + "test diff", + "Test prompt", + "test-model" + ) + +@patch('runner.open') +def test_generate_commit_message_file_not_found(mock_open, runner): + mock_open.side_effect = FileNotFoundError + + with pytest.raises(FileNotFoundError): + runner.generate("test diff", "nonexistent.md", "test-model") \ No newline at end of file