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 @@
- Intelligent automated code review system + Diff-aware AI code review for terminal and CI workflows
📚 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 |
@@ -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"