Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ MR Reviewer automatise la revue de code sur GitLab en s'appuyant sur un moteur d
## Fonctionnalités principales

- Interrogation périodique de GitLab pour récupérer les MRs ouvertes
- Possibilité de ne traiter qu'un projet spécifique via `PROJECT_ID`
- Analyse du diff via Mistral et publication automatisée des commentaires
- Gestion du nombre maximal de tokens et filtrage des MRs trop volumineuses
- Système de cache pour éviter les doublons
Expand All @@ -33,6 +34,7 @@ GITLAB_URL=https://gitlab.com/api/v4
MISTRAL_API_KEY=sk-xxxx
CACHE_FILE=cache.json
POLLING_INTERVAL=300
PROJECT_ID=123 # Laisser vide pour traiter toutes vos MR
MAX_TOKENS=8000
LOG_LEVEL=INFO
# Optionnel : fichier où enregistrer les logs
Expand All @@ -47,7 +49,7 @@ python -m app.main

## Comportement général

- Le scheduler déclenche `review_merge_requests()` à l'intervalle défini
- Le scheduler déclenche immédiatement `review_merge_requests()` puis à l'intervalle défini
- Pour chaque MR détectée :
- si elle a déjà été traitée, elle est ignorée
- si le diff dépasse la limite autorisée, un message explicatif est envoyé
Expand Down
13 changes: 7 additions & 6 deletions app/adapters/gitlab_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,15 @@ def _request(self, method: str, url: str, **kwargs) -> tuple[any, dict]:
raise
return resp.json(), resp.headers

def get_open_merge_requests(self) -> Iterable[MergeRequest]:
"""
Retrieve all open merge requests from GitLab.
def get_open_merge_requests(self, project_id: int | None = None) -> Iterable[MergeRequest]:
"""Retrieve open merge requests.

Returns:
Iterable[MergeRequest]: An iterable of MergeRequest objects representing open merge requests.
When ``project_id`` is provided, only MRs of that project are returned.
"""
url = f"{self.base_url}/merge_requests"
if project_id is not None:
url = f"{self.base_url}/projects/{project_id}/merge_requests"
else:
url = f"{self.base_url}/merge_requests"
params = {"state": "opened", "per_page": 100, "page": 1}
results: List[MergeRequest] = []
while True:
Expand Down
13 changes: 6 additions & 7 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import time

from app.adapters.gitlab_adapter import GitLabAdapter
from app.adapters.mistral_adapter import MistralAdapter
from app.adapters.cache_adapter import JSONCacheAdapter
Expand All @@ -9,25 +10,23 @@


def main():
"""
Main entry point for the application.

Initializes the logging, adapters, and starts the scheduler to review merge requests.
This function is responsible for setting up the environment and starting the review process.
"""
"""Main entry point for the application."""
configure_logging()
gitlab = GitLabAdapter(token=settings.gitlab_token, base_url=settings.gitlab_url)
ai = MistralAdapter(api_key=settings.mistral_api_key, model=settings.mistral_model)
cache = JSONCacheAdapter(cache_file=settings.cache_file)

start_scheduler(
lambda: run_merge_request_review(gitlab, ai, cache), settings.polling_interval
lambda: run_merge_request_review(gitlab, ai, cache, project_id=settings.project_id),
settings.polling_interval,
)

try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
stop_scheduler()


Expand Down
8 changes: 6 additions & 2 deletions app/ports/output/gitlab_port.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ class GitLabPort(ABC):
"""Interface used to interact with GitLab."""

@abstractmethod
def get_open_merge_requests(self) -> Iterable[MergeRequest]:
"""Return currently opened merge requests."""
def get_open_merge_requests(self, project_id: int | None = None) -> Iterable[MergeRequest]:
"""Return currently opened merge requests.

Args:
project_id: restrict search to this project when provided.
"""

raise NotImplementedError

Expand Down
1 change: 1 addition & 0 deletions app/scheduler/polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def start_scheduler(task: Callable[[], None], interval: int) -> None:
global _scheduler
scheduler = BackgroundScheduler()
scheduler.add_job(task, "interval", seconds=interval)
task()
scheduler.start()
_scheduler = scheduler
logger.info("Scheduler démarré (intervalle=%ss)", interval)
Expand Down
6 changes: 4 additions & 2 deletions app/usecases/review_merge_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
from config.settings import settings


def run_merge_request_review(gitlab: GitLabPort, ai: AIPort, cache: CachePort) -> None:
def run_merge_request_review(
gitlab: GitLabPort, ai: AIPort, cache: CachePort, project_id: int | None = None
) -> None:
"""Fetch opened MRs and post an automated review when possible."""
logger = logging.getLogger("Review")
engine = ReviewEngine(ai, settings.max_tokens)

for mr in gitlab.get_open_merge_requests():
for mr in gitlab.get_open_merge_requests(project_id=project_id):
if cache.is_up_to_date(mr.id, mr.sha):
logger.debug("MR %s déjà analysée", mr.id)
continue
Expand Down
1 change: 1 addition & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Settings(BaseSettings):

cache_file: str = Field(default="cache.json", env="CACHE_FILE")
polling_interval: int = Field(default=300, env="POLLING_INTERVAL")
project_id: int | None = Field(default=None, env="PROJECT_ID")
max_tokens: int = Field(default=8000, env="MAX_TOKENS")
log_level: str = Field(default="INFO", env="LOG_LEVEL")
log_file: str | None = Field(default=None, env="LOG_FILE")
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Définit les interfaces abstraites que les adaptateurs techniques doivent implé
Interface : `review_diff(diff: str) -> str`

- **gitlab_port.py**
Interface : `get_open_merge_requests()`, `get_diff(mr_id)`, `post_comment(mr_id, text)`
Interface : `get_open_merge_requests(project_id=None)`, `get_diff(mr_id)`, `post_comment(mr_id, text)`

- **cache_port.py**
Interface : `is_up_to_date(mr_id, sha)`, `update_reviewed(mr_id, sha)`
Expand Down
7 changes: 4 additions & 3 deletions tests/integration/test_usecase_run_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
class DummyGitLab:
def __init__(self):
self.comments = []
def get_open_merge_requests(self):

def get_open_merge_requests(self, project_id=None):
return [MergeRequest(id=1, project_id=1, sha="s1")]
def get_diff(self, project_id, mr_id):
return "good diff"
Expand Down Expand Up @@ -33,7 +34,7 @@ def test_run_merge_request_review(monkeypatch):
ai = DummyAI()
cache = DummyCache()
monkeypatch.setattr(usecase, "settings", SimpleNamespace(max_tokens=100))
usecase.run_merge_request_review(gitlab, ai, cache)
usecase.run_merge_request_review(gitlab, ai, cache, project_id=None)
assert ai.called == "good diff"
assert gitlab.comments == ["reviewed"]
assert cache.data[1] == "s1"
Expand All @@ -44,5 +45,5 @@ def test_skip_when_diff_too_large(monkeypatch):
ai = DummyAI()
cache = DummyCache()
monkeypatch.setattr(usecase, "settings", SimpleNamespace(max_tokens=1))
usecase.run_merge_request_review(gitlab, ai, cache)
usecase.run_merge_request_review(gitlab, ai, cache, project_id=None)
assert gitlab.comments == ["Diff trop volumineux pour analyse automatique."]
16 changes: 16 additions & 0 deletions tests/unit/test_gitlab_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ def fake_request(method, url, **kwargs):
assert calls[-1][2]["params"]["page"] == "2"


def test_get_open_merge_requests_for_project(monkeypatch):
resp = DummyResponse([], headers={})
captured = {}

def fake_request(method, url, **kwargs):
captured["url"] = url
captured["params"] = kwargs["params"]
return resp

monkeypatch.setattr(requests, "request", fake_request)
adapter = GitLabAdapter(token="t", base_url="http://x")
list(adapter.get_open_merge_requests(project_id=5))
assert captured["url"].endswith("/projects/5/merge_requests")
assert captured["params"]["state"] == "opened"


def test_get_diff_and_post_comment(monkeypatch):
def fake_request(method, url, **kwargs):
if method == "GET":
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def test_main_calls_dependencies(
mock_settings.mistral_model = "model"
mock_settings.cache_file = "cache.json"
mock_settings.polling_interval = 42
mock_settings.project_id = None

with (
patch("app.main.time.sleep", side_effect=KeyboardInterrupt),
Expand Down Expand Up @@ -66,10 +67,13 @@ def test_main_runs_lambda(mock_start_scheduler):
mock_settings.mistral_model = "model"
mock_settings.cache_file = "cache.json"
mock_settings.polling_interval = 42
mock_settings.project_id = 123

app.main.main()

# Extract the lambda and call it
lambda_func = mock_start_scheduler.call_args[0][0]
lambda_func()
mock_review.assert_called_once_with(fake_gitlab, fake_ai, fake_cache)
mock_review.assert_called_once_with(
fake_gitlab, fake_ai, fake_cache, project_id=123
)
1 change: 1 addition & 0 deletions tests/unit/test_polling_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def task():
called.append(True)

polling.start_scheduler(task, interval=5)
assert called == [True]
assert dummy.started
assert dummy.jobs[0][1] == "interval" and dummy.jobs[0][2] == 5
assert polling._scheduler is dummy
Expand Down