diff --git a/.gitignore b/.gitignore index dea46fc..0e2db86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ .vscode/ .idea/ .venv/ +.codefox/ .*_cache/ -.env + +__pycache__/ +build/ +codefox.egg-info/ + .codefoxenv .codefoxignore .codefox.yml +.env venv*/ -build/ -__pycache__/ -*.pyc -.codefox/ -codefox.egg-info/ \ No newline at end of file + +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index 1b0d929..4de8ff3 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,53 @@

CodeFox-CLI

- Intelligent automated code review system + Diff-aware AI code review for terminal and CI workflows

CI License Python 3.11+ - Wiki + Wiki PyPI Downloads

📚 Documentation • 🚀 Quick Start • - 🐛 Report Issue + 🐛 Report Issue • + 📝 Demo PRs

+--- ## 🦊 Overview -**CodeFox-CLI** is an intelligent automated code review system that takes over routine security and code quality checks, allowing senior developers to focus on architecture and complex tasks. +**CodeFox-CLI** is a CLI-first AI code review tool for **git diffs, pull requests, and CI workflows**. + +It analyzes code changes, retrieves relevant project context, and produces review feedback directly in the terminal or inside automated review pipelines. + +CodeFox supports both: +- **local reviews with Ollama** for self-hosted workflows +- **cloud LLM providers** such as Gemini and OpenRouter when remote inference is preferred -Unlike traditional linters, CodeFox understands the context of the entire project and its business logic, delivering not just review comments but **ready-to-apply fixes** (Auto-Fix). Works with **Gemini**, **Ollama**, and **OpenRouter** - use your preferred AI backend. +It is designed for developers and teams who want a **CLI-first review workflow** for local checks, pull requests, and CI/CD pipelines. + +--- -| vs Linters | vs AI code review (e.g. CodeRabbit) | -|------------|-------------------------------------| -| Understands full project context & business logic | Self-hosted / local (Ollama), no vendor lock-in | -| Suggests fixes, not only rules | Configurable models, security/performance/style rules | -| RAG over your codebase for relevant hints | CLI-first: `git diff` → review in seconds | +## Why CodeFox? + +- Reviews **git changes**, not just isolated files +- Uses **relevant codebase context** to improve review quality +- Works with **local or cloud models** +- Fits naturally into **terminal-based and CI workflows** +- Supports configurable review focus such as **security**, **performance**, and **style** + +| Compared to linters | Compared to hosted AI reviewers | +|---|---| +| Reviews diffs with codebase context, not only static rules | Can run locally with Ollama | +| Can suggest fixes, not only flag issues | No hard vendor lock-in | +| Flexible review focus: security, performance, style | CLI-first workflow for local and CI usage |

CodeFox scan demo @@ -40,65 +58,83 @@ Unlike traditional linters, CodeFox understands the context of the entire projec --- -## 📥 Installation +## What CodeFox is and is not -Choose the installation method that fits your workflow. +CodeFox is a **CLI for automated AI review of git changes**. -### 🔹 Install dependencies (local setup) +It is **not** an IDE coding assistant like Cursor or Claude Code. +It is built for **diff review workflows**, terminal usage, and CI/CD automation. -```bash -pip install -r requirements.txt -``` -### 🔹 Development mode (editable install) +--- -Provides the local codefox CLI command and enables live code changes. +## Integrations -```bash -python3 -m pip install -e . -``` +Current: +- GitHub Actions -### 🔹 Install from GitHub +Planned: +- GitLab +- Bitbucket -🐍 Using pip +--- + +## Privacy + +- With **Ollama**, reviews can run fully locally on your machine +- With **cloud providers**, code and context may be sent to external APIs depending on your configuration +- Use `.codefoxignore` to exclude files from analysis + +--- +## 📥 Installation + +### For users + +**uv** ```bash -python3 -m pip install codefox -# or python3 -m pip install git+https://github.com/URLbug/CodeFox-CLI.git@main +uv tool install codefox ``` -⚡ Using uv (recommended for CLI usage) +**pip** ```bash -uv tool install codefox -# or uv tool install git+https://github.com/URLbug/CodeFox-CLI.git@main +python3 -m pip install codefox ``` --- -✅ Verify installation +## Verify installation + ```bash codefox version ``` -Or -```bash -python3 -m codefox version -``` -## 🚀 Quick Start +--- -### Initialize (stores your API key) +## 🚀 Quick Start +1. Initialize CodeFox ```bash codefox init ``` -### Run a scan (uses the current git diff) +This stores your provider token locally and creates the initial config files. +2. Review your current git changes ```bash codefox scan ``` -### Show version +What happens during `scan`: + +- collects the current git diff + +- loads relevant project context based on your configuration + +- sends the review request to the configured model + +- returns review comments and optional fix suggestions +3. Show version ```bash codefox version ``` diff --git a/action.yml b/action.yml index 84dae80..8994282 100644 --- a/action.yml +++ b/action.yml @@ -11,12 +11,12 @@ inputs: required: true codefox-branch: - description: 'Git branch of the CodeFox repository to install and run (e.g., main, dev, or a specific release branch).' + description: 'Git branch of the CodeFox repository to install and run.' default: 'main' required: false github-token: - description: 'GitHub token used to authenticate with the GitHub API for posting review comments and interacting with pull requests.' + description: 'GitHub token used to authenticate with the GitHub API.' default: ${{ github.token }} required: false @@ -28,6 +28,16 @@ runs: with: python-version: '3.12' + - name: Restore CodeFox cache + uses: actions/cache@v4 + with: + path: ${{ pwd }}/.codefox/ + key: ${{ runner.os }}-codefox-${{ github.repository }}-${{ github.base_ref }}-${{ inputs.codefox-branch }} + restore-keys: | + ${{ runner.os }}-codefox-${{ github.repository }}-${{ github.base_ref }}- + ${{ runner.os }}-codefox-${{ github.repository }}- + ${{ runner.os }}-codefox- + - name: Install CodeFox shell: bash run: | diff --git a/codefox-template.yml b/codefox-template.yml new file mode 100644 index 0000000..1e13eee --- /dev/null +++ b/codefox-template.yml @@ -0,0 +1,35 @@ +codefox_review: + stage: review + image: python:3.12-slim + + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + CODEFOX_BRANCH: "dev-docs" + + cache: + key: codefox-$CI_PROJECT_ID + paths: + - ".codefox/" + - ".cache/pip/" + + before_script: + - apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates && rm -rf /var/lib/apt/lists/* + - python -m pip install --upgrade pip + - pip install "git+https://github.com/URLbug/CodeFox-CLI.git@${CODEFOX_BRANCH}" + - | + umask 077 + { + echo "CODEFOX_API_KEY=$CODEFOX_API_KEY" + echo "GITLAB_TOKEN=${GITLAB_TOKEN}" + echo "GITLAB_URL=${CI_SERVER_URL}" + echo "GITLAB_REPOSITORY=${CI_PROJECT_ID}" + echo "PR_NUMBER=${CI_MERGE_REQUEST_IID}" + } > "$CI_PROJECT_DIR/.codefoxenv" + + script: + - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME":"$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" || true + - git fetch origin "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME":"$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" || true + - codefox scan --target-branch "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --source-branch "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --ci + + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' \ No newline at end of file diff --git a/codefox/bots/gitlab_bot.py b/codefox/bots/gitlab_bot.py new file mode 100644 index 0000000..8a3a01d --- /dev/null +++ b/codefox/bots/gitlab_bot.py @@ -0,0 +1,56 @@ +import os + +from gitlab import Gitlab +from gitlab.exceptions import GitlabCreateError, GitlabGetError + + +class GitLabBot: + def __init__(self) -> None: + self.gitlab_token = os.getenv("GITLAB_TOKEN") + self.gitlab_url = os.getenv("GITLAB_URL", "https://gitlab.com") + self.repository = os.getenv("GITLAB_REPOSITORY") + self.mr_iid = os.getenv("PR_NUMBER") + + if not self.gitlab_token: + raise ValueError( + "GITLAB_TOKEN environment variable is not set. " + "This token is required to authenticate with the GitLab API." + ) + + if not self.repository or not self.repository.isdigit(): + raise ValueError(f"Invalid GITLAB_REPOSITORY: {self.repository!r}") + + if not self.mr_iid or not self.mr_iid.isdigit(): + raise ValueError( + f"Invalid PR_NUMBER value: {self.mr_iid!r}. " + "Expected a numeric merge request IID." + ) + + self.gitlab = Gitlab( + url=self.gitlab_url, + private_token=self.gitlab_token, + ) + + def send(self, message: str) -> None: + if not message or not message.strip(): + raise ValueError("Message must not be empty.") + + repository = self.repository + mr_iid = self.mr_iid + + if repository is None or mr_iid is None: + raise RuntimeError( + "GitLab project or merge request is not configured." + ) + + try: + project = self.gitlab.projects.get(int(repository)) + mr = project.mergerequests.get(int(mr_iid)) + mr.notes.create({"body": message}) + except GitlabGetError as exc: + raise RuntimeError( + f"Failed to find project '{repository}' " + f"or merge request IID {mr_iid}." + ) from exc + except GitlabCreateError as exc: + raise RuntimeError("Failed to create merge request note.") from exc diff --git a/codefox/cli/index.py b/codefox/cli/index.py new file mode 100644 index 0000000..325c7aa --- /dev/null +++ b/codefox/cli/index.py @@ -0,0 +1,22 @@ +import os + +from rich import print +from rich.markup import escape + +from codefox.api.base_api import BaseAPI +from codefox.cli.base_cli import BaseCLI + + +class Index(BaseCLI): + def __init__(self, model: type[BaseAPI]): + self.model = model() + + def execute(self): + is_upload, error = self.model.upload_files(os.getcwd()) + if not is_upload: + print( + "[red]Failed to index files: " + escape(str(error)) + "[/red]" + ) + return + + print("[green]Successful to index files[/green]") diff --git a/codefox/cli/init.py b/codefox/cli/init.py index 0d9b6b5..118ba79 100644 --- a/codefox/cli/init.py +++ b/codefox/cli/init.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any import yaml from dotenv import load_dotenv, set_key @@ -11,9 +12,18 @@ class Init(BaseCLI): - def __init__(self, model_enum: ModelEnum | None = None): - self.model_enum = model_enum or self._ask_model() + def __init__(self, args: dict[str, Any] | None = None): + resolved_args = args if args is not None else {} + + if not resolved_args.get("provider"): + self.model_enum = self._ask_model() + else: + self.model_enum = ModelEnum.by_name(resolved_args["provider"]) + self.api_class: type[BaseAPI] = self.model_enum.api_class + + self.args: dict[str, Any] = resolved_args + self.config_path = Path(".codefoxenv") self.ignore_path = Path(".codefoxignore") self.yaml_config_path = Path(".codefox.yml") @@ -28,7 +38,10 @@ def _ask_model(self) -> ModelEnum: return ModelEnum.by_name(choice) def execute(self) -> None: - api_key = self._ask_api_key() or "" + if not self.args.get("token"): + api_key = self._ask_api_key() or "" + else: + api_key = self.args["token"] if not self._write_config(api_key): return diff --git a/codefox/cli/scan.py b/codefox/cli/scan.py index a34d791..127c26a 100644 --- a/codefox/cli/scan.py +++ b/codefox/cli/scan.py @@ -9,6 +9,7 @@ from codefox.api.base_api import BaseAPI from codefox.bots.github_bot import GitHubBot +from codefox.bots.gitlab_bot import GitLabBot from codefox.cli.base_cli import BaseCLI from codefox.cli.list import List from codefox.utils.helper import Helper @@ -20,8 +21,11 @@ def __init__(self, model: type[BaseAPI], args: dict[str, Any]): self.args = args self.github_bot = None - if self.args.get("ci", False): + self.gitlab_bot = None + if self.args.get("ci", False) and os.getenv("GITHUB_BOT"): self.github_bot = GitHubBot() + elif self.args.get("ci", False) and os.getenv("GITLAB_TOKEN"): + self.gitlab_bot = GitLabBot() def execute(self) -> None: source_branch, target_branch = self._get_branchs() @@ -79,6 +83,10 @@ def _ci_response_answer(self, diff_text: str) -> None: response = self.model.execute(diff_text) if self.github_bot is not None: self.github_bot.send(response.text) + + if self.gitlab_bot is not None: + self.gitlab_bot.send(response.text) + self.model.remove_files() def _classic_response_answer(self, diff_text: str) -> None: diff --git a/codefox/cli_manager.py b/codefox/cli_manager.py index 935cebb..946d28e 100644 --- a/codefox/cli_manager.py +++ b/codefox/cli_manager.py @@ -8,6 +8,7 @@ from codefox.api.base_api import BaseAPI from codefox.api.model_enum import ModelEnum from codefox.cli.clean import Clean +from codefox.cli.index import Index from codefox.cli.init import Init from codefox.cli.list import List from codefox.cli.scan import Scan @@ -35,6 +36,12 @@ def run(self) -> None: print(f"[green]CodeFox CLI version {version}[/green]") return + if self.command == "index": + api_class = self._get_api_class() + index = Index(api_class) + index.execute() + return + if self.command == "list": api_class = self._get_api_class() list_model = List(api_class, self.args) @@ -54,7 +61,7 @@ def run(self) -> None: return if self.command == "init": - init = Init() + init = Init(self.args or {}) init.execute() return diff --git a/codefox/main.py b/codefox/main.py index 2344945..e7c1fa5 100755 --- a/codefox/main.py +++ b/codefox/main.py @@ -8,9 +8,9 @@ @app.command("scan") def scan( - ci: bool = typer.Option(False, "--ci", help="CI mode"), - source_branch: str = typer.Option(None, help="Source branch"), - target_branch: str = typer.Option(None, help="Target branch"), + ci: bool = typer.Option(False, "--ci", help="CI mode."), + source_branch: str | None = typer.Option(None, help="Source branch."), + target_branch: str | None = typer.Option(None, help="Target branch."), ): """Run AI code review.""" manager = CLIManager( @@ -24,14 +24,32 @@ def scan( manager.run() +@app.command("index") +def index(): + """Index files""" + CLIManager(command="index", args={}).run() + + @app.command("init") -def init(): +def init( + provider: str | None = typer.Option(None, help="Provider."), + token: str | None = typer.Option(None, help="Token provider."), +): """Initialize CodeFox.""" - CLIManager(command="init", args={}).run() + manager = CLIManager( + command="init", + args={ + "provider": provider, + "token": token, + }, + ) + manager.run() @app.command("list") -def list_models(type_model: str = typer.Argument("models", help="Model type")): +def list_models( + type_model: str = typer.Argument("models", help="Model type."), +): """List available models.""" manager = CLIManager( command="list", @@ -43,7 +61,7 @@ def list_models(type_model: str = typer.Argument("models", help="Model type")): @app.command("clean") -def clean(type_cache: str = typer.Argument("all", help="Cache type")): +def clean(type_cache: str = typer.Argument("all", help="Cache type.")): """Clean cache""" manager = CLIManager( command="clean", diff --git a/codefox/utils/helper.py b/codefox/utils/helper.py index 6e4422c..2f4e96b 100644 --- a/codefox/utils/helper.py +++ b/codefox/utils/helper.py @@ -65,7 +65,7 @@ def get_diff( source_branch: str | None = None, target_branch: str | None = None ) -> str | None: try: - repo = git.Repo(".") + repo = git.Repo(".", search_parent_directories=True) if source_branch and target_branch: diff_text = repo.git.diff( @@ -75,7 +75,11 @@ def get_diff( diff_text = repo.git.diff() return cast(str | None, diff_text) - except git.exc.InvalidGitRepositoryError: + except ( + git.exc.InvalidGitRepositoryError, + git.exc.NoSuchPathError, + git.exc.GitCommandError, + ): return None # ------------------------------------------------------------------ diff --git a/codefox/utils/local_rag.py b/codefox/utils/local_rag.py index 27942ce..d6ca44f 100644 --- a/codefox/utils/local_rag.py +++ b/codefox/utils/local_rag.py @@ -191,39 +191,40 @@ def build(self) -> None: if self.client.collection_exists(self.collection_name): self.client.delete_collection(self.collection_name) - with self.console.status( + self.console.print( "[magenta]Building Qdrant vector index...[/magenta]" + ) + + dim: int | None = None + for i in track( + range(0, len(texts), batch_size), + total=(len(texts) + batch_size - 1) // batch_size, + description="[blue]Generating embeddings...[/blue]", ): - dim: int | None = None - for i in track( - range(0, len(texts), batch_size), - total=(len(texts) + batch_size - 1) // batch_size, - description="[blue]Generating embeddings...[/blue]", - ): - batch = texts[i : i + batch_size] - emb = np.array(list(self.model.embed(batch)), dtype="float32") - - if dim is None: - dim = emb.shape[1] - self.client.create_collection( - collection_name=self.collection_name, - vectors_config=VectorParams( - size=dim, distance=Distance.COSINE - ), - ) - - points = [ - PointStruct( - id=j, - vector=vec.tolist(), - payload={"path": self.files[j]["path"]}, - ) - for j, vec in enumerate(emb, start=i) - ] - self.client.upsert( + batch = texts[i : i + batch_size] + emb = np.array(list(self.model.embed(batch)), dtype="float32") + + if dim is None: + dim = emb.shape[1] + self.client.create_collection( collection_name=self.collection_name, - points=points, + vectors_config=VectorParams( + size=dim, distance=Distance.COSINE + ), + ) + + points = [ + PointStruct( + id=j, + vector=vec.tolist(), + payload={"path": self.files[j]["path"]}, ) + for j, vec in enumerate(emb, start=i) + ] + self.client.upsert( + collection_name=self.collection_name, + points=points, + ) self.console.print("[green]✓[/green] Qdrant semantic index built.") self.console.print( diff --git a/pyproject.toml b/pyproject.toml index af2c7da..ab9b12f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,36 @@ [project] name = "codefox" -version = "0.4.0" +version = "0.4.1" description = "CodeFox CLI - code auditing and code review tool" -readme = "README.md" +readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "CodeFox" }, +] +keywords = [ + "ai", + "code review", + "cli", + "static analysis", + "ollama", + "openai", + "gemini", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Software Development", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Utilities", +] dependencies = [ "bm25s==0.3.0", "qdrant-client>=1.7.0", @@ -23,6 +50,7 @@ dependencies = [ "psutil==7.2.2", "PyGithub==2.8.1", "pygments==2.19.2", + "python-gitlab==8.1.0", ] [project.optional-dependencies] @@ -38,6 +66,13 @@ dev = [ [project.scripts] codefox = "codefox.main:cli" +[project.urls] +Documentation = "https://github.com/codefox-lab/CodeFox-CLI/wiki" +Homepage = "https://github.com/URLbug/CodeFox-CLI" +Issues = "https://github.com/URLbug/CodeFox-CLI/issues" +Security = "https://github.com/URLbug/CodeFox-CLI/blob/main/SECURITY.md" +Source = "https://github.com/URLbug/CodeFox-CLI" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/requirements.in b/requirements.in index df054fd..00355ca 100644 --- a/requirements.in +++ b/requirements.in @@ -15,4 +15,5 @@ typer==0.23.1 tree-sitter-language-pack==0.13.0 psutil==7.2.2 PyGithub==2.8.1 -pygments==2.19.2 \ No newline at end of file +pygments==2.19.2 +python-gitlab==8.1.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index df054fd..00355ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,5 @@ typer==0.23.1 tree-sitter-language-pack==0.13.0 psutil==7.2.2 PyGithub==2.8.1 -pygments==2.19.2 \ No newline at end of file +pygments==2.19.2 +python-gitlab==8.1.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 3186c9a..3e18fcb 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,32 @@ from setuptools import find_packages, setup HERE = pathlib.Path(__file__).parent -README = (HERE / "README.txt").read_text(encoding="utf-8") if (HERE / "README.txt").exists() else "" +README = (HERE / "README.md").read_text(encoding="utf-8") if (HERE / "README.md").exists() else "" setup( name="codefox", - version="0.4.0", + version="0.4.1", description="CodeFox CLI - code auditing and code review tool", long_description=README, - long_description_content_type="text/plain", + long_description_content_type="text/markdown", author="CodeFox", + license="MIT", + url="https://github.com/URLbug/CodeFox-CLI", + project_urls={ + "Documentation": "https://github.com/codefox-lab/CodeFox-CLI/wiki", + "Source": "https://github.com/URLbug/CodeFox-CLI", + "Issues": "https://github.com/URLbug/CodeFox-CLI/issues", + "Security": "https://github.com/URLbug/CodeFox-CLI/blob/main/SECURITY.md", + }, + keywords=[ + "ai", + "code review", + "cli", + "static analysis", + "ollama", + "openrouter", + "gemini", + ], packages=find_packages(), include_package_data=True, install_requires=[ @@ -33,6 +50,7 @@ "psutil==7.2.2", "PyGithub==2.8.1", "pygments==2.19.2", + "python-gitlab==8.1.0", ], entry_points={ "console_scripts": [ @@ -40,10 +58,18 @@ ], }, classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Topic :: Software Development", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Utilities", ], python_requires=">=3.11", ) - diff --git a/tests/test_gitlab_bot.py b/tests/test_gitlab_bot.py new file mode 100644 index 0000000..c12de5c --- /dev/null +++ b/tests/test_gitlab_bot.py @@ -0,0 +1,65 @@ +"""Tests for GitLab bot.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from codefox.bots.gitlab_bot import GitLabBot + + +def test_send_creates_merge_request_note( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITLAB_TOKEN", "token") + monkeypatch.setenv("GITLAB_REPOSITORY", "123") + monkeypatch.setenv("PR_NUMBER", "456") + + with patch("codefox.bots.gitlab_bot.Gitlab") as mock_gitlab_class: + mock_project = MagicMock() + mock_mr = MagicMock() + mock_gitlab = mock_gitlab_class.return_value + mock_gitlab.projects.get.return_value = mock_project + mock_project.mergerequests.get.return_value = mock_mr + + bot = GitLabBot() + bot.send("hello") + + mock_gitlab_class.assert_called_once_with( + url="https://gitlab.com", + private_token="token", + ) + mock_gitlab.projects.get.assert_called_once_with(123) + mock_project.mergerequests.get.assert_called_once_with(456) + mock_mr.notes.create.assert_called_once_with({"body": "hello"}) + + +def test_send_rejects_empty_message(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITLAB_TOKEN", "token") + monkeypatch.setenv("GITLAB_REPOSITORY", "123") + monkeypatch.setenv("PR_NUMBER", "456") + + with patch("codefox.bots.gitlab_bot.Gitlab"): + bot = GitLabBot() + + with pytest.raises(ValueError, match="Message must not be empty"): + bot.send(" ") + + +def test_send_raises_when_identifiers_are_not_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITLAB_TOKEN", "token") + monkeypatch.setenv("GITLAB_REPOSITORY", "123") + monkeypatch.setenv("PR_NUMBER", "456") + + with patch("codefox.bots.gitlab_bot.Gitlab") as mock_gitlab_class: + bot = GitLabBot() + bot.repository = None + + with pytest.raises( + RuntimeError, + match="GitLab project or merge request is not configured", + ): + bot.send("hello") + + mock_gitlab_class.return_value.projects.get.assert_not_called() diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..2b3cb78 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,38 @@ +"""Tests for Init command.""" + +from unittest.mock import patch + +from codefox.api.model_enum import ModelEnum +from codefox.cli.init import Init + + +def test_init_normalizes_none_args() -> None: + with patch.object(Init, "_ask_model", return_value=ModelEnum.GEMINI): + init = Init() + + assert init.model_enum is ModelEnum.GEMINI + assert init.api_class is ModelEnum.GEMINI.api_class + assert init.args == {} + + +def test_execute_uses_provider_and_token_from_args() -> None: + init = Init({"provider": "gemini", "token": "test-token"}) + + with ( + patch.object(init, "_ask_api_key") as mock_ask_api_key, + patch.object(init, "_write_config", return_value=True) as mock_write, + patch.object(init, "_ensure_ignore_file") as mock_ignore, + patch.object(init, "_ensure_yaml_config") as mock_yaml, + patch.object(init, "_ensure_gitignore") as mock_gitignore, + patch.object( + init, "_check_connection", return_value=True + ) as mock_check, + ): + init.execute() + + mock_ask_api_key.assert_not_called() + mock_write.assert_called_once_with("test-token") + mock_ignore.assert_called_once() + mock_yaml.assert_called_once() + mock_gitignore.assert_called_once() + mock_check.assert_called_once() diff --git a/uv.lock b/uv.lock index 0d83e73..7c6871b 100644 --- a/uv.lock +++ b/uv.lock @@ -217,7 +217,7 @@ wheels = [ [[package]] name = "codefox" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "bm25s" }, @@ -232,6 +232,7 @@ dependencies = [ { name = "pygithub" }, { name = "pygments" }, { name = "python-dotenv" }, + { name = "python-gitlab" }, { name = "pyyaml" }, { name = "qdrant-client" }, { name = "requests" }, @@ -267,6 +268,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-gitlab", specifier = "==8.1.0" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "qdrant-client", specifier = ">=1.7.0" }, { name = "requests", specifier = ">=2.28.0" }, @@ -1742,6 +1744,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-gitlab" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/1d/a62fea1f3312fd9e58af41466ae072796a09684dd0cd825cc042ba39488c/python_gitlab-8.1.0.tar.gz", hash = "sha256:660f15e3f889ec430797d260322bc61d90f8d90accfc10ba37593b11aed371bd", size = 401576, upload-time = "2026-02-28T01:26:32.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d4/9848be62ef23fcac203f4386faf43a2cc13a4888447b3f5fbf7346f31374/python_gitlab-8.1.0-py3-none-any.whl", hash = "sha256:b1a59e81e5e0363185b446a707dc92c27ee8bf1fc14ce75ed8eafa58cbdce63a", size = 144498, upload-time = "2026-02-28T01:26:31.14Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -1953,6 +1968,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "rich" version = "14.3.2"