Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0689dcc6c8 | |||
| ccfe1361a4 | |||
| d7b515a94d | |||
| 3ed405bd68 | |||
| ff71b8d089 | |||
| 7f3ca14fc3 | |||
| d1c8de3c24 | |||
| 4f0bfec2fb | |||
| fdd1bbfe1d | |||
| 74a21dbf1d | |||
| c11ebf3606 | |||
| daf0c28c6c | |||
| 8b374707b6 | |||
| dd71c5131f | |||
| 1831b37072 | |||
| 20f049ee17 | |||
| 4efa7ea14f | |||
| 71717c5469 | |||
| 8cf89bb4b8 | |||
| 63891aa816 | |||
| 125cafcb8d | |||
| 342508ef73 | |||
| 9fd1a125f0 | |||
| 611b0fe94a | |||
| 43da32a8d3 | |||
| d7eaadd87a | |||
| 751e5d6683 | |||
| cd514908c1 | |||
| 7066424d09 | |||
| eda03802fa | |||
| ff7d3539f8 | |||
| 10fdc32617 | |||
| c0bdc1c3bd | |||
| e74328e644 | |||
| 0f1aa01814 | |||
| a9689d0369 | |||
| e5c8f435ff | |||
| 8f52024956 | |||
| b9b2d237e2 |
+2
-2
@@ -185,7 +185,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
|
||||
|
||||
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
@@ -193,7 +193,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
|
||||
@@ -64,3 +64,4 @@ tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
/gitlab-integration-tests/
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
GitLab Test Fixtures
|
||||
====================
|
||||
|
||||
Mock data and fixtures for GitLab integration tests.
|
||||
"""
|
||||
|
||||
# Sample GitLab MR data
|
||||
SAMPLE_MR_DATA = {
|
||||
"iid": 123,
|
||||
"id": 12345,
|
||||
"title": "Add user authentication feature",
|
||||
"description": "Implement OAuth2 login with Google and GitHub providers",
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "john_doe",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
},
|
||||
"source_branch": "feature/oauth-auth",
|
||||
"target_branch": "main",
|
||||
"state": "opened",
|
||||
"draft": False,
|
||||
"merge_status": "can_be_merged",
|
||||
"web_url": "https://gitlab.com/group/project/-/merge_requests/123",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
"updated_at": "2025-01-14T12:00:00.000Z",
|
||||
"labels": ["feature", "authentication"],
|
||||
"assignees": [],
|
||||
}
|
||||
|
||||
SAMPLE_MR_CHANGES = {
|
||||
"id": 12345,
|
||||
"iid": 123,
|
||||
"project_id": 1,
|
||||
"title": "Add user authentication feature",
|
||||
"description": "Implement OAuth2 login",
|
||||
"state": "opened",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
"updated_at": "2025-01-14T12:00:00.000Z",
|
||||
"merge_status": "can_be_merged",
|
||||
"additions": 150,
|
||||
"deletions": 20,
|
||||
"changed_files_count": 5,
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "src/auth/__init__.py",
|
||||
"new_path": "src/auth/__init__.py",
|
||||
"diff": "@@ -0,0 +1,5 @@\n+from .oauth import OAuthHandler\n+from .providers import GoogleProvider, GitHubProvider",
|
||||
"new_file": False,
|
||||
"renamed_file": False,
|
||||
"deleted_file": False,
|
||||
},
|
||||
{
|
||||
"old_path": "src/auth/oauth.py",
|
||||
"new_path": "src/auth/oauth.py",
|
||||
"diff": "@@ -0,0 +1,50 @@\n+class OAuthHandler:\n+ def handle_callback(self, request):\n+ pass",
|
||||
"new_file": True,
|
||||
"renamed_file": False,
|
||||
"deleted_file": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_MR_COMMITS = [
|
||||
{
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123de",
|
||||
"title": "Add OAuth handler",
|
||||
"message": "Add OAuth handler",
|
||||
"author_name": "John Doe",
|
||||
"author_email": "john@example.com",
|
||||
"authored_date": "2025-01-14T10:00:00.000Z",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
"id": "def456ghi789",
|
||||
"short_id": "def456gh",
|
||||
"title": "Add Google provider",
|
||||
"message": "Add Google provider",
|
||||
"author_name": "John Doe",
|
||||
"author_email": "john@example.com",
|
||||
"authored_date": "2025-01-14T11:00:00.000Z",
|
||||
"created_at": "2025-01-14T11:00:00.000Z",
|
||||
},
|
||||
]
|
||||
|
||||
# Sample GitLab issue data
|
||||
SAMPLE_ISSUE_DATA = {
|
||||
"iid": 42,
|
||||
"id": 42,
|
||||
"title": "Bug: Login button not working",
|
||||
"description": "Clicking the login button does nothing",
|
||||
"author": {
|
||||
"id": 2,
|
||||
"username": "jane_smith",
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
},
|
||||
"state": "opened",
|
||||
"labels": ["bug", "urgent"],
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
"web_url": "https://gitlab.com/group/project/-/issues/42",
|
||||
"created_at": "2025-01-14T09:00:00.000Z",
|
||||
"updated_at": "2025-01-14T09:30:00.000Z",
|
||||
}
|
||||
|
||||
# Sample GitLab pipeline data
|
||||
SAMPLE_PIPELINE_DATA = {
|
||||
"id": 1001,
|
||||
"iid": 1,
|
||||
"project_id": 1,
|
||||
"ref": "feature/oauth-auth",
|
||||
"sha": "abc123def456",
|
||||
"status": "success",
|
||||
"source": "merge_request_event",
|
||||
"created_at": "2025-01-14T10:30:00.000Z",
|
||||
"updated_at": "2025-01-14T10:35:00.000Z",
|
||||
"finished_at": "2025-01-14T10:35:00.000Z",
|
||||
"duration": 300,
|
||||
"web_url": "https://gitlab.com/group/project/-/pipelines/1001",
|
||||
}
|
||||
|
||||
SAMPLE_PIPELINE_JOBS = [
|
||||
{
|
||||
"id": 5001,
|
||||
"name": "test",
|
||||
"stage": "test",
|
||||
"status": "success",
|
||||
"started_at": "2025-01-14T10:31:00.000Z",
|
||||
"finished_at": "2025-01-14T10:34:00.000Z",
|
||||
"duration": 180,
|
||||
"allow_failure": False,
|
||||
},
|
||||
{
|
||||
"id": 5002,
|
||||
"name": "lint",
|
||||
"stage": "test",
|
||||
"status": "success",
|
||||
"started_at": "2025-01-14T10:31:00.000Z",
|
||||
"finished_at": "2025-01-14T10:32:00.000Z",
|
||||
"duration": 60,
|
||||
"allow_failure": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Sample GitLab discussion/note data
|
||||
SAMPLE_MR_DISCUSSIONS = [
|
||||
{
|
||||
"id": "d1",
|
||||
"notes": [
|
||||
{
|
||||
"id": 1001,
|
||||
"type": "DiscussionNote",
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider adding error handling for OAuth failures",
|
||||
"created_at": "2025-01-14T11:00:00.000Z",
|
||||
"system": False,
|
||||
"resolvable": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
SAMPLE_MR_NOTES = [
|
||||
{
|
||||
"id": 2001,
|
||||
"type": "DiscussionNote",
|
||||
"author": {"username": "reviewer_user"},
|
||||
"body": "LGTM, just one comment",
|
||||
"created_at": "2025-01-14T12:00:00.000Z",
|
||||
"system": False,
|
||||
}
|
||||
]
|
||||
|
||||
# Mock GitLab config
|
||||
MOCK_GITLAB_CONFIG = {
|
||||
"token": "glpat-test-token-12345",
|
||||
"project": "group/project",
|
||||
"instance_url": "https://gitlab.example.com",
|
||||
}
|
||||
|
||||
|
||||
def create_mock_client(project_dir=None):
|
||||
"""Create a mock GitLab client for testing.
|
||||
|
||||
Args:
|
||||
project_dir: Optional project directory path (uses temp dir if None)
|
||||
|
||||
Returns:
|
||||
Configured GitLabClient instance
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = Path(tempfile.mkdtemp())
|
||||
else:
|
||||
project_dir = Path(project_dir)
|
||||
|
||||
config = GitLabConfig(**MOCK_GITLAB_CONFIG)
|
||||
return GitLabClient(project_dir=project_dir, config=config)
|
||||
|
||||
|
||||
def mock_mr_data(**overrides):
|
||||
"""Create mock MR data with optional overrides."""
|
||||
import copy
|
||||
|
||||
data = copy.deepcopy(SAMPLE_MR_DATA)
|
||||
|
||||
# Handle special case for author override
|
||||
if "author" in overrides:
|
||||
author_value = overrides.pop("author")
|
||||
if isinstance(author_value, str):
|
||||
# If author is a string, update the username field
|
||||
data["author"]["username"] = author_value
|
||||
else:
|
||||
# Otherwise, merge the author dict
|
||||
data["author"].update(author_value)
|
||||
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_mr_changes(**overrides):
|
||||
"""Create mock MR changes with optional overrides."""
|
||||
data = SAMPLE_MR_CHANGES.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_issue_data(**overrides):
|
||||
"""Create mock issue data with optional overrides."""
|
||||
data = SAMPLE_ISSUE_DATA.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_pipeline_data(**overrides):
|
||||
"""Create mock pipeline data with optional overrides."""
|
||||
data = SAMPLE_PIPELINE_DATA.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_pipeline_jobs(**overrides):
|
||||
"""Create mock pipeline jobs with optional overrides."""
|
||||
data = SAMPLE_PIPELINE_JOBS.copy()
|
||||
if overrides:
|
||||
data[0].update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_mr_commits(**overrides):
|
||||
"""Create mock MR commits with optional overrides."""
|
||||
import copy
|
||||
|
||||
data = copy.deepcopy(SAMPLE_MR_COMMITS)
|
||||
if overrides and data:
|
||||
data[0].update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def get_mock_diff() -> str:
|
||||
"""Get a mock diff string for testing."""
|
||||
return """diff --git a/src/auth/oauth.py b/src/auth/oauth.py
|
||||
new file mode 100644
|
||||
index 0000000..abc1234
|
||||
--- /dev/null
|
||||
+++ b/src/auth/oauth.py
|
||||
@@ -0,0 +1,50 @@
|
||||
+class OAuthHandler:
|
||||
+ def handle_callback(self, request):
|
||||
+ pass
|
||||
diff --git a/src/auth/providers.py b/src/auth/providers.py
|
||||
new file mode 100644
|
||||
index 0000000..def5678
|
||||
--- /dev/null
|
||||
+++ b/src/auth/providers.py
|
||||
@@ -0,0 +1,30 @@
|
||||
+class GoogleProvider:
|
||||
+ pass
|
||||
+
|
||||
+class GitHubProvider:
|
||||
+ pass
|
||||
"""
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Tests for GitLab Auto-fix Processor
|
||||
======================================
|
||||
|
||||
Tests for auto-fix workflow, permission verification, and state management.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.autofix_processor import AutoFixProcessor
|
||||
from runners.gitlab.models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from runners.gitlab.permissions import GitLabPermissionChecker
|
||||
except ImportError:
|
||||
from models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from runners.gitlab.autofix_processor import AutoFixProcessor
|
||||
from runners.gitlab.permissions import GitLabPermissionChecker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
config = MagicMock(spec=GitLabRunnerConfig)
|
||||
config.project = "namespace/test-project"
|
||||
config.instance_url = "https://gitlab.example.com"
|
||||
config.auto_fix_enabled = True
|
||||
config.auto_fix_labels = ["auto-fix", "autofix"]
|
||||
config.token = "test-token"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_permission_checker():
|
||||
"""Create a mock permission checker."""
|
||||
checker = MagicMock(spec=GitLabPermissionChecker)
|
||||
checker.verify_automation_trigger = AsyncMock()
|
||||
return checker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_gitlab_dir(tmp_path):
|
||||
"""Create a temporary GitLab directory."""
|
||||
gitlab_dir = tmp_path / ".auto-claude" / "gitlab"
|
||||
gitlab_dir.mkdir(parents=True, exist_ok=True)
|
||||
return gitlab_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor(mock_config, mock_permission_checker, tmp_path, tmp_gitlab_dir):
|
||||
"""Create an AutoFixProcessor instance."""
|
||||
return AutoFixProcessor(
|
||||
gitlab_dir=tmp_gitlab_dir,
|
||||
config=mock_config,
|
||||
permission_checker=mock_permission_checker,
|
||||
progress_callback=None,
|
||||
)
|
||||
|
||||
|
||||
class TestProcessIssue:
|
||||
"""Tests for issue processing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_success(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test successful issue processing."""
|
||||
issue = {
|
||||
"iid": 123,
|
||||
"title": "Fix this bug",
|
||||
"description": "Please fix",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True,
|
||||
username="developer",
|
||||
role="MAINTAINER",
|
||||
)
|
||||
|
||||
result = await processor.process_issue(
|
||||
issue_iid=123,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
assert result.issue_iid == 123
|
||||
assert result.status == AutoFixStatus.CREATING_SPEC
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_permission_denied(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test issue processing with permission denied."""
|
||||
issue = {
|
||||
"iid": 456,
|
||||
"title": "Unauthorized fix",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=False,
|
||||
username="outsider",
|
||||
role="NONE",
|
||||
reason="Not a maintainer",
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await processor.process_issue(
|
||||
issue_iid=456,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_in_progress(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that in-progress issues are not reprocessed."""
|
||||
issue = {
|
||||
"iid": 789,
|
||||
"title": "Already processing",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
# Create existing state in progress
|
||||
existing_state = AutoFixState(
|
||||
issue_iid=789,
|
||||
issue_url="https://gitlab.example.com/issue/789",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await existing_state.save(tmp_gitlab_dir)
|
||||
|
||||
result = await processor.process_issue(
|
||||
issue_iid=789,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
# Should return the existing state
|
||||
assert result.status == AutoFixStatus.ANALYZING
|
||||
|
||||
|
||||
class TestCheckLabeledIssues:
|
||||
"""Tests for checking labeled issues."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_finds_new(
|
||||
self, processor, mock_permission_checker
|
||||
):
|
||||
"""Test finding new labeled issues."""
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Has auto-fix label",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Has autofix label",
|
||||
"labels": ["autofix"],
|
||||
},
|
||||
{
|
||||
"iid": 3,
|
||||
"title": "No label",
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
|
||||
# Permission checks pass
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["issue_iid"] == 1
|
||||
assert result[1]["issue_iid"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_filters_in_queue(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that issues already in queue are filtered out."""
|
||||
# Create existing state for issue 1
|
||||
existing_state = AutoFixState(
|
||||
issue_iid=1,
|
||||
issue_url="https://gitlab.example.com/issue/1",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await existing_state.save(tmp_gitlab_dir)
|
||||
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Already in queue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "New issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
]
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
# Should only return issue 2 (issue 1 is already in queue)
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iid"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_permission_filtering(
|
||||
self, processor, mock_permission_checker
|
||||
):
|
||||
"""Test that unauthorized issues are filtered out."""
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Authorized issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Unauthorized issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
]
|
||||
|
||||
def make_permission_result(issue_iid, trigger_label):
|
||||
if issue_iid == 1:
|
||||
return MagicMock(allowed=True)
|
||||
else:
|
||||
return MagicMock(allowed=False, reason="Not authorized")
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.side_effect = (
|
||||
make_permission_result
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
# Should only return issue 1
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iid"] == 1
|
||||
|
||||
|
||||
class TestGetQueue:
|
||||
"""Tests for getting auto-fix queue."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_empty(self, processor, tmp_gitlab_dir):
|
||||
"""Test getting queue when empty."""
|
||||
queue = await processor.get_queue()
|
||||
|
||||
assert queue == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_with_items(self, processor, tmp_gitlab_dir):
|
||||
"""Test getting queue with items."""
|
||||
# Create some states
|
||||
for i in [1, 2, 3]:
|
||||
state = AutoFixState(
|
||||
issue_iid=i,
|
||||
issue_url=f"https://gitlab.example.com/issue/{i}",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await state.save(tmp_gitlab_dir)
|
||||
|
||||
queue = await processor.get_queue()
|
||||
|
||||
assert len(queue) == 3
|
||||
|
||||
|
||||
class TestAutoFixState:
|
||||
"""Tests for AutoFixState model."""
|
||||
|
||||
def test_state_creation(self, tmp_gitlab_dir):
|
||||
"""Test creating and saving state."""
|
||||
state = AutoFixState(
|
||||
issue_iid=123,
|
||||
issue_url="https://gitlab.example.com/issue/123",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.PENDING,
|
||||
)
|
||||
|
||||
assert state.issue_iid == 123
|
||||
assert state.status == AutoFixStatus.PENDING
|
||||
|
||||
def test_state_save_and_load(self, tmp_gitlab_dir):
|
||||
"""Test saving and loading state."""
|
||||
state = AutoFixState(
|
||||
issue_iid=456,
|
||||
issue_url="https://gitlab.example.com/issue/456",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.BUILDING,
|
||||
)
|
||||
|
||||
# Save state
|
||||
import asyncio
|
||||
|
||||
asyncio.run(state.save(tmp_gitlab_dir))
|
||||
|
||||
# Load state
|
||||
loaded = AutoFixState.load(tmp_gitlab_dir, 456)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.issue_iid == 456
|
||||
assert loaded.status == AutoFixStatus.BUILDING
|
||||
|
||||
def test_state_transition_validation(self, tmp_gitlab_dir):
|
||||
"""Test that invalid state transitions are rejected."""
|
||||
state = AutoFixState(
|
||||
issue_iid=789,
|
||||
issue_url="https://gitlab.example.com/issue/789",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.PENDING,
|
||||
)
|
||||
|
||||
# Valid transition
|
||||
state.update_status(AutoFixStatus.ANALYZING) # Should work
|
||||
|
||||
# Invalid transition
|
||||
with pytest.raises(ValueError):
|
||||
state.update_status(AutoFixStatus.COMPLETED) # Can't skip to completed
|
||||
|
||||
|
||||
class TestProgressReporting:
|
||||
"""Tests for progress callback handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_reported_during_processing(
|
||||
self, mock_config, tmp_path, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that progress callback is stored on the processor."""
|
||||
progress_calls = []
|
||||
|
||||
def progress_callback(progress):
|
||||
progress_calls.append(progress)
|
||||
|
||||
processor = AutoFixProcessor(
|
||||
gitlab_dir=tmp_gitlab_dir,
|
||||
config=mock_config,
|
||||
permission_checker=MagicMock(),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Verify the callback is stored
|
||||
assert processor.progress_callback is not None
|
||||
assert processor.progress_callback == progress_callback
|
||||
|
||||
# Test that calling the callback works
|
||||
processor.progress_callback({"status": "test"})
|
||||
|
||||
assert len(progress_calls) == 1
|
||||
assert progress_calls[0] == {"status": "test"}
|
||||
|
||||
|
||||
class TestURLConstruction:
|
||||
"""Tests for URL construction."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_url_construction(self, processor, mock_config):
|
||||
"""Test that issue URLs are constructed correctly."""
|
||||
issue = {"iid": 123}
|
||||
|
||||
state = await processor.process_issue(
|
||||
issue_iid=123,
|
||||
issue=issue,
|
||||
trigger_label=None,
|
||||
)
|
||||
|
||||
assert (
|
||||
state.issue_url
|
||||
== "https://gitlab.example.com/namespace/test-project/-/issues/123"
|
||||
)
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Tests for GitLab Batch Issues
|
||||
================================
|
||||
|
||||
Tests for issue batching, similarity detection, and batch processing.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.batch_issues import (
|
||||
ClaudeGitlabBatchAnalyzer,
|
||||
GitlabBatchStatus,
|
||||
GitlabIssueBatch,
|
||||
GitlabIssueBatcher,
|
||||
GitlabIssueBatchItem,
|
||||
format_batch_summary,
|
||||
)
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabConfig
|
||||
from runners.gitlab.batch_issues import (
|
||||
ClaudeGitlabBatchAnalyzer,
|
||||
GitlabBatchStatus,
|
||||
GitlabIssueBatch,
|
||||
GitlabIssueBatcher,
|
||||
GitlabIssueBatchItem,
|
||||
format_batch_summary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
config = MagicMock(spec=GitLabConfig)
|
||||
config.project = "namespace/test-project"
|
||||
config.instance_url = "https://gitlab.example.com"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issues():
|
||||
"""Sample issues for batching."""
|
||||
return [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Login bug",
|
||||
"description": "Cannot login with special characters",
|
||||
"labels": ["bug", "auth"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Signup bug",
|
||||
"description": "Cannot signup with special characters",
|
||||
"labels": ["bug", "auth"],
|
||||
},
|
||||
{
|
||||
"iid": 3,
|
||||
"title": "UI bug",
|
||||
"description": "Button alignment issue",
|
||||
"labels": ["bug", "ui"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestBatchAnalyzer:
|
||||
"""Tests for Claude-based batch analyzer."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_single_issue(self, mock_config, tmp_path):
|
||||
"""Test analyzing a single issue."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
issues = [{"iid": 1, "title": "Single issue"}]
|
||||
|
||||
with patch.object(analyzer, "_fallback_batches") as mock_fallback:
|
||||
mock_fallback.return_value = [
|
||||
{
|
||||
"issue_iids": [1],
|
||||
"theme": "Single issue",
|
||||
"reasoning": "Single issue in group",
|
||||
"confidence": 1.0,
|
||||
}
|
||||
]
|
||||
|
||||
result = await analyzer.analyze_and_batch_issues(issues)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iids"] == [1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_empty_list(self, mock_config, tmp_path):
|
||||
"""Test analyzing empty issue list."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
result = await analyzer.analyze_and_batch_issues([])
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_json_response(self, mock_config, tmp_path):
|
||||
"""Test JSON parsing from Claude response."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
# Valid JSON
|
||||
json_str = '{"batches": [{"issue_iids": [1, 2]}]}'
|
||||
result = analyzer._parse_json_response(json_str)
|
||||
|
||||
assert "batches" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_json_from_markdown(self, mock_config, tmp_path):
|
||||
"""Test extracting JSON from markdown code blocks."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
# JSON in markdown code block
|
||||
response = '```json\n{"batches": [{"issue_iids": [1, 2]}]}\n```'
|
||||
result = analyzer._parse_json_response(response)
|
||||
|
||||
assert "batches" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_batches(self, mock_config, tmp_path):
|
||||
"""Test fallback batching when Claude is unavailable."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
issues = [
|
||||
{"iid": 1, "title": "Issue 1"},
|
||||
{"iid": 2, "title": "Issue 2"},
|
||||
]
|
||||
|
||||
result = analyzer._fallback_batches(issues)
|
||||
|
||||
assert len(result) == 2
|
||||
assert all("confidence" in r for r in result)
|
||||
|
||||
|
||||
class TestIssueBatchItem:
|
||||
"""Tests for IssueBatchItem model."""
|
||||
|
||||
def test_batch_item_to_dict(self):
|
||||
"""Test converting batch item to dict."""
|
||||
item = GitlabIssueBatchItem(
|
||||
issue_iid=123,
|
||||
title="Test Issue",
|
||||
body="Description",
|
||||
labels=["bug"],
|
||||
similarity_to_primary=0.8,
|
||||
)
|
||||
|
||||
result = item.to_dict()
|
||||
|
||||
assert result["issue_iid"] == 123
|
||||
assert result["similarity_to_primary"] == 0.8
|
||||
|
||||
def test_batch_item_from_dict(self):
|
||||
"""Test creating batch item from dict."""
|
||||
data = {
|
||||
"issue_iid": 456,
|
||||
"title": "Test",
|
||||
"body": "Desc",
|
||||
"labels": ["feature"],
|
||||
"similarity_to_primary": 1.0,
|
||||
}
|
||||
|
||||
result = GitlabIssueBatchItem.from_dict(data)
|
||||
|
||||
assert result.issue_iid == 456
|
||||
|
||||
|
||||
class TestIssueBatch:
|
||||
"""Tests for IssueBatch model."""
|
||||
|
||||
def test_batch_creation(self):
|
||||
"""Test creating a batch."""
|
||||
issues = [
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=1,
|
||||
title="Issue 1",
|
||||
body="",
|
||||
),
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=2,
|
||||
title="Issue 2",
|
||||
body="",
|
||||
),
|
||||
]
|
||||
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-1-2",
|
||||
project="namespace/test-project",
|
||||
primary_issue=1,
|
||||
issues=issues,
|
||||
theme="Authentication issues",
|
||||
)
|
||||
|
||||
assert batch.batch_id == "batch-1-2"
|
||||
assert batch.primary_issue == 1
|
||||
assert len(batch.issues) == 2
|
||||
|
||||
def test_batch_to_dict(self):
|
||||
"""Test converting batch to dict."""
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-1",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
|
||||
result = batch.to_dict()
|
||||
|
||||
assert result["batch_id"] == "batch-1"
|
||||
assert result["status"] == "pending"
|
||||
|
||||
def test_batch_from_dict(self):
|
||||
"""Test creating batch from dict."""
|
||||
data = {
|
||||
"batch_id": "batch-1",
|
||||
"project": "namespace/project",
|
||||
"primary_issue": 1,
|
||||
"issues": [],
|
||||
"status": "pending",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = GitlabIssueBatch.from_dict(data)
|
||||
|
||||
assert result.batch_id == "batch-1"
|
||||
assert result.status == GitlabBatchStatus.PENDING
|
||||
|
||||
|
||||
class TestIssueBatcher:
|
||||
"""Tests for IssueBatcher class."""
|
||||
|
||||
def test_batcher_initialization(self, mock_config, tmp_path):
|
||||
"""Test batcher initialization."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert batcher.project == "namespace/project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_batches(self, mock_config, tmp_path, sample_issues):
|
||||
"""Test creating batches from issues."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
# Patch the analyzer's analyze_and_batch_issues method
|
||||
with patch.object(batcher.analyzer, "analyze_and_batch_issues") as mock_analyze:
|
||||
mock_analyze.return_value = [
|
||||
{
|
||||
"issue_iids": [1, 2],
|
||||
"theme": "Auth issues",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
{
|
||||
"issue_iids": [3],
|
||||
"theme": "UI bug",
|
||||
"confidence": 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
batches = await batcher.create_batches(sample_issues)
|
||||
|
||||
assert len(batches) == 2
|
||||
assert batches[0].theme == "Auth issues"
|
||||
assert batches[1].theme == "UI bug"
|
||||
|
||||
def test_generate_batch_id(self, mock_config, tmp_path):
|
||||
"""Test batch ID generation."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
batch_id = batcher._generate_batch_id([1, 2, 3])
|
||||
|
||||
assert batch_id == "batch-1-2-3"
|
||||
|
||||
def test_save_and_load_batch(self, mock_config, tmp_path):
|
||||
"""Test saving and loading batches."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-123",
|
||||
project="namespace/project",
|
||||
primary_issue=123,
|
||||
issues=[],
|
||||
)
|
||||
|
||||
# Save
|
||||
batcher.save_batch(batch)
|
||||
|
||||
# Load
|
||||
loaded = batcher.load_batch(tmp_path / ".auto-claude" / "gitlab", "batch-123")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.batch_id == "batch-123"
|
||||
|
||||
def test_list_batches(self, mock_config, tmp_path):
|
||||
"""Test listing all batches."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
# Create a couple of batches
|
||||
batch1 = GitlabIssueBatch(
|
||||
batch_id="batch-1",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
batch2 = GitlabIssueBatch(
|
||||
batch_id="batch-2",
|
||||
project="namespace/project",
|
||||
primary_issue=2,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.COMPLETED,
|
||||
)
|
||||
|
||||
batcher.save_batch(batch1)
|
||||
batcher.save_batch(batch2)
|
||||
|
||||
# List
|
||||
batches = batcher.list_batches()
|
||||
|
||||
assert len(batches) == 2
|
||||
# Should be sorted by created_at descending
|
||||
assert batches[0].batch_id == "batch-2"
|
||||
assert batches[1].batch_id == "batch-1"
|
||||
|
||||
|
||||
class TestBatchStatus:
|
||||
"""Tests for BatchStatus enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Test all status values exist."""
|
||||
expected_statuses = [
|
||||
GitlabBatchStatus.PENDING,
|
||||
GitlabBatchStatus.ANALYZING,
|
||||
GitlabBatchStatus.CREATING_SPEC,
|
||||
GitlabBatchStatus.BUILDING,
|
||||
GitlabBatchStatus.QA_REVIEW,
|
||||
GitlabBatchStatus.MR_CREATED,
|
||||
GitlabBatchStatus.COMPLETED,
|
||||
GitlabBatchStatus.FAILED,
|
||||
]
|
||||
|
||||
for status in expected_statuses:
|
||||
assert status.value in [
|
||||
"pending",
|
||||
"analyzing",
|
||||
"creating_spec",
|
||||
"building",
|
||||
"qa_review",
|
||||
"mr_created",
|
||||
"completed",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
class TestBatchSummaryFormatting:
|
||||
"""Tests for batch summary formatting."""
|
||||
|
||||
def test_format_batch_summary(self):
|
||||
"""Test formatting a batch summary."""
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-auth-issues",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=1,
|
||||
title="Login bug",
|
||||
body="",
|
||||
),
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=2,
|
||||
title="Signup bug",
|
||||
body="",
|
||||
),
|
||||
],
|
||||
common_themes=["Authentication issues"],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
|
||||
summary = format_batch_summary(batch)
|
||||
|
||||
assert "batch-auth-issues" in summary
|
||||
assert "!1" in summary
|
||||
assert "!2" in summary
|
||||
assert "Authentication issues" in summary
|
||||
|
||||
|
||||
class TestSimilarityThreshold:
|
||||
"""Tests for similarity threshold handling."""
|
||||
|
||||
def test_threshold_filtering(self, mock_config, tmp_path):
|
||||
"""Test that similarity threshold is respected."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
similarity_threshold=0.8, # High threshold
|
||||
)
|
||||
|
||||
assert batcher.similarity_threshold == 0.8
|
||||
|
||||
|
||||
class TestBatchSizeLimits:
|
||||
"""Tests for batch size limits."""
|
||||
|
||||
def test_max_batch_size(self, mock_config, tmp_path):
|
||||
"""Test that max batch size is enforced."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
max_batch_size=3,
|
||||
)
|
||||
|
||||
assert batcher.max_batch_size == 3
|
||||
|
||||
def test_min_batch_size(self, mock_config, tmp_path):
|
||||
"""Test min batch size setting."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
min_batch_size=2,
|
||||
)
|
||||
|
||||
assert batcher.min_batch_size == 2
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
GitLab Bot Detection Tests
|
||||
==========================
|
||||
|
||||
Tests for bot detection to prevent infinite review loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_data,
|
||||
)
|
||||
|
||||
|
||||
class TestBotDetector:
|
||||
"""Test bot detection prevents infinite loops."""
|
||||
|
||||
@pytest.fixture
|
||||
def detector(self, tmp_path):
|
||||
"""Create a BotDetector instance for testing."""
|
||||
from runners.gitlab.bot_detection import BotDetector
|
||||
|
||||
return BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="auto-claude-bot",
|
||||
review_own_mrs=False,
|
||||
)
|
||||
|
||||
def test_bot_detection_init(self, detector):
|
||||
"""Test detector initializes correctly."""
|
||||
assert detector.bot_username == "auto-claude-bot"
|
||||
assert detector.review_own_mrs is False
|
||||
assert detector.state.reviewed_commits == {}
|
||||
|
||||
def test_is_bot_mr_self_authored(self, detector):
|
||||
"""Test MR authored by bot is detected."""
|
||||
mr_data = mock_mr_data(author="auto-claude-bot")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is True
|
||||
|
||||
def test_is_bot_mr_pattern_match(self, detector):
|
||||
"""Test MR with bot pattern in username is detected."""
|
||||
mr_data = mock_mr_data(author="coderabbit[bot]")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is True
|
||||
|
||||
def test_is_bot_mr_human_authored(self, detector):
|
||||
"""Test MR authored by human is not detected as bot."""
|
||||
mr_data = mock_mr_data(author="john_doe")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is False
|
||||
|
||||
def test_is_bot_commit_self_authored(self, detector):
|
||||
"""Test commit by bot is detected."""
|
||||
commit = {
|
||||
"author": {"username": "auto-claude-bot"},
|
||||
"message": "Fix issue",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is True
|
||||
|
||||
def test_is_bot_commit_ai_coauthored(self, detector):
|
||||
"""Test commit with AI co-authorship is detected."""
|
||||
commit = {
|
||||
"author": {"username": "human"},
|
||||
"message": "Co-authored-by: claude <no-reply>",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is True
|
||||
|
||||
def test_is_bot_commit_human(self, detector):
|
||||
"""Test human commit is not detected as bot."""
|
||||
commit = {
|
||||
"author": {"username": "john_doe"},
|
||||
"message": "Fix bug",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is False
|
||||
|
||||
def test_should_skip_mr_bot_authored(self, detector):
|
||||
"""Test should skip MR when bot authored."""
|
||||
mr_data = mock_mr_data(author="auto-claude-bot")
|
||||
commits = []
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "auto-claude-bot" in reason.lower()
|
||||
|
||||
def test_should_skip_mr_in_cooling_off(self, detector):
|
||||
"""Test should skip MR when in cooling off period."""
|
||||
# First, mark as reviewed
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Immediately try to review again
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "abc123", "sha": "abc123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "cooling" in reason.lower()
|
||||
|
||||
def test_should_skip_mr_already_reviewed(self, detector):
|
||||
"""Test should skip MR when commit already reviewed."""
|
||||
# Mark as reviewed
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Try to review same commit
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "abc123", "sha": "abc123"}]
|
||||
|
||||
# Wait past cooling off (manually update time)
|
||||
detector.state.last_review_times["123"] = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
).isoformat()
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "already reviewed" in reason.lower()
|
||||
|
||||
def test_should_not_skip_safe_mr(self, detector):
|
||||
"""Test should not skip when MR is safe to review."""
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "new123", "sha": "new123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(456, mr_data, commits)
|
||||
|
||||
assert should_skip is False
|
||||
assert reason == ""
|
||||
|
||||
def test_mark_reviewed(self, detector):
|
||||
"""Test marking MR as reviewed."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
assert "123" in detector.state.reviewed_commits
|
||||
assert "abc123" in detector.state.reviewed_commits["123"]
|
||||
assert "123" in detector.state.last_review_times
|
||||
|
||||
def test_mark_reviewed_multiple_commits(self, detector):
|
||||
"""Test marking multiple commits for same MR."""
|
||||
detector.mark_reviewed(123, "commit1")
|
||||
detector.mark_reviewed(123, "commit2")
|
||||
detector.mark_reviewed(123, "commit3")
|
||||
|
||||
assert len(detector.state.reviewed_commits["123"]) == 3
|
||||
|
||||
def test_clear_mr_state(self, detector):
|
||||
"""Test clearing MR state."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
detector.clear_mr_state(123)
|
||||
|
||||
assert "123" not in detector.state.reviewed_commits
|
||||
assert "123" not in detector.state.last_review_times
|
||||
|
||||
def test_get_stats(self, detector):
|
||||
"""Test getting detector statistics."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
detector.mark_reviewed(124, "def456")
|
||||
|
||||
stats = detector.get_stats()
|
||||
|
||||
assert stats["bot_username"] == "auto-claude-bot"
|
||||
assert stats["total_mrs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 2
|
||||
|
||||
def test_cleanup_stale_mrs(self, detector):
|
||||
"""Test cleanup of old MR state."""
|
||||
# Add an old MR (manually set old timestamp)
|
||||
old_time = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
|
||||
detector.state.last_review_times["999"] = old_time
|
||||
detector.state.reviewed_commits["999"] = ["old123"]
|
||||
|
||||
# Add a recent MR
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
cleaned = detector.cleanup_stale_mrs(max_age_days=30)
|
||||
|
||||
assert cleaned == 1
|
||||
assert "999" not in detector.state.reviewed_commits
|
||||
assert "123" in detector.state.reviewed_commits
|
||||
|
||||
def test_state_persistence(self, tmp_path):
|
||||
"""Test state is saved and loaded correctly."""
|
||||
from runners.gitlab.bot_detection import BotDetector
|
||||
|
||||
# Create detector and mark as reviewed
|
||||
detector1 = BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="test-bot",
|
||||
)
|
||||
detector1.mark_reviewed(123, "abc123")
|
||||
|
||||
# Create new detector instance (should load state)
|
||||
detector2 = BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="test-bot",
|
||||
)
|
||||
|
||||
assert "123" in detector2.state.reviewed_commits
|
||||
assert "abc123" in detector2.state.reviewed_commits["123"]
|
||||
|
||||
|
||||
class TestBotDetectionState:
|
||||
"""Test BotDetectionState model."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting state to dictionary."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={"123": ["abc123", "def456"]},
|
||||
last_review_times={"123": "2025-01-14T10:00:00"},
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
|
||||
assert data["reviewed_commits"]["123"] == ["abc123", "def456"]
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test loading state from dictionary."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
data = {
|
||||
"reviewed_commits": {"123": ["abc123"]},
|
||||
"last_review_times": {"123": "2025-01-14T10:00:00"},
|
||||
}
|
||||
|
||||
state = BotDetectionState.from_dict(data)
|
||||
|
||||
assert state.reviewed_commits["123"] == ["abc123"]
|
||||
assert state.last_review_times["123"] == "2025-01-14T10:00:00"
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
"""Test saving and loading state from disk."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={"123": ["abc123"]},
|
||||
last_review_times={"123": "2025-01-14T10:00:00"},
|
||||
)
|
||||
|
||||
state.save(tmp_path)
|
||||
|
||||
loaded = BotDetectionState.load(tmp_path)
|
||||
|
||||
assert loaded.reviewed_commits["123"] == ["abc123"]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Tests for GitLab Branch Operations
|
||||
====================================
|
||||
|
||||
Tests for branch listing, creation, deletion, and comparison.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_branches():
|
||||
"""Sample branch data."""
|
||||
return [
|
||||
{
|
||||
"name": "main",
|
||||
"merged": False,
|
||||
"protected": True,
|
||||
"default": True,
|
||||
"developers_can_push": False,
|
||||
"developers_can_merge": False,
|
||||
"commit": {
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123d",
|
||||
"title": "Stable branch",
|
||||
},
|
||||
"web_url": "https://gitlab.example.com/namespace/test-project/-/tree/main",
|
||||
},
|
||||
{
|
||||
"name": "develop",
|
||||
"merged": False,
|
||||
"protected": False,
|
||||
"default": False,
|
||||
"developers_can_push": True,
|
||||
"developers_can_merge": True,
|
||||
"commit": {
|
||||
"id": "def456abc123",
|
||||
"short_id": "def456a",
|
||||
"title": "Development branch",
|
||||
},
|
||||
"web_url": "https://gitlab.example.com/namespace/test-project/-/tree/develop",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestListBranches:
|
||||
"""Tests for list_branches method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_branches(self, client, sample_branches):
|
||||
"""Test listing all branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches
|
||||
|
||||
result = client.list_branches()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "main"
|
||||
assert result[1]["name"] == "develop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_with_search(self, client, sample_branches):
|
||||
"""Test listing branches with search filter."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [sample_branches[0]] # Only main
|
||||
|
||||
result = client.list_branches(search="main")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_async(self, client, sample_branches):
|
||||
"""Test async variant of list_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches
|
||||
|
||||
result = await client.list_branches_async()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestGetBranch:
|
||||
"""Tests for get_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_branch(self, client, sample_branches):
|
||||
"""Test getting an existing branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches[0]
|
||||
|
||||
result = client.get_branch("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
assert result["protected"] is True
|
||||
assert result["commit"]["id"] == "abc123def456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch_async(self, client, sample_branches):
|
||||
"""Test async variant of get_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches[0]
|
||||
|
||||
result = await client.get_branch_async("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_branch(self, client):
|
||||
"""Test getting a branch that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_branch("nonexistent")
|
||||
|
||||
|
||||
class TestCreateBranch:
|
||||
"""Tests for create_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_from_ref(self, client):
|
||||
"""Test creating a branch from another branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "feature-branch",
|
||||
"commit": {"id": "new123"},
|
||||
"protected": False,
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="feature-branch",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
assert result["name"] == "feature-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_from_commit(self, client):
|
||||
"""Test creating a branch from a commit SHA."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "fix-branch",
|
||||
"commit": {"id": "fix123"},
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="fix-branch",
|
||||
ref="abc123def",
|
||||
)
|
||||
|
||||
assert result["name"] == "fix-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_async(self, client):
|
||||
"""Test async variant of create_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"name": "feature", "commit": {}}
|
||||
|
||||
result = await client.create_branch_async("feature", "main")
|
||||
|
||||
assert result["name"] == "feature"
|
||||
|
||||
|
||||
class TestDeleteBranch:
|
||||
"""Tests for delete_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_existing_branch(self, client):
|
||||
"""Test deleting an existing branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_branch("feature-branch")
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch_async(self, client):
|
||||
"""Test async variant of delete_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None
|
||||
|
||||
result = await client.delete_branch_async("old-branch")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCompareBranches:
|
||||
"""Tests for compare_branches method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches_basic(self, client):
|
||||
"""Test comparing two branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
"commits": [{"id": "abc123"}],
|
||||
"compare_same_ref": False,
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
assert result["compare_same_ref"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches_async(self, client):
|
||||
"""Test async variant of compare_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
}
|
||||
|
||||
result = await client.compare_branches_async("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_same_branch(self, client):
|
||||
"""Test comparing a branch to itself."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "",
|
||||
"compare_same_ref": True,
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "main")
|
||||
|
||||
assert result["compare_same_ref"] is True
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
GitLab CI Checker Tests
|
||||
========================
|
||||
|
||||
Tests for CI/CD pipeline status checking.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
mock_pipeline_jobs,
|
||||
)
|
||||
|
||||
|
||||
class TestCIChecker:
|
||||
"""Test CI/CD pipeline checking functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def checker(self, tmp_path):
|
||||
"""Create a CIChecker instance for testing."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.ci_checker import CIChecker
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.ci_checker.GitLabClient"):
|
||||
return CIChecker(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_init(self, checker):
|
||||
"""Test checker initializes correctly."""
|
||||
assert checker.client is not None
|
||||
|
||||
def test_check_mr_pipeline_success(self, checker):
|
||||
"""Test checking MR with successful pipeline."""
|
||||
pipeline_data = mock_pipeline_data(status="success")
|
||||
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return [pipeline_data]
|
||||
|
||||
async def mock_get_pipeline_status(pipeline_id):
|
||||
return pipeline_data
|
||||
|
||||
async def mock_get_pipeline_jobs(pipeline_id):
|
||||
return mock_pipeline_jobs()
|
||||
|
||||
# Setup async mocks
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_status_async",
|
||||
mock_get_pipeline_status,
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_jobs_async",
|
||||
mock_get_pipeline_jobs,
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline is not None
|
||||
assert pipeline.pipeline_id == 1001
|
||||
assert pipeline.status.value == "success"
|
||||
assert pipeline.has_failures is False
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_check_mr_pipeline_failed(self, checker):
|
||||
"""Test checking MR with failed pipeline."""
|
||||
pipeline_data = mock_pipeline_data(status="failed")
|
||||
jobs_data = mock_pipeline_jobs()
|
||||
jobs_data[0]["status"] = "failed"
|
||||
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return [pipeline_data]
|
||||
|
||||
async def mock_get_pipeline_status(pipeline_id):
|
||||
return pipeline_data
|
||||
|
||||
async def mock_get_pipeline_jobs(pipeline_id):
|
||||
return jobs_data
|
||||
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_status_async",
|
||||
mock_get_pipeline_status,
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_jobs_async",
|
||||
mock_get_pipeline_jobs,
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline.has_failures is True
|
||||
assert pipeline.is_blocking is True
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_check_mr_pipeline_no_pipeline(self, checker):
|
||||
"""Test checking MR with no pipeline."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return []
|
||||
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline is None
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_get_blocking_reason_success(self, checker):
|
||||
"""Test getting blocking reason for successful pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[],
|
||||
)
|
||||
|
||||
reason = checker.get_blocking_reason(pipeline)
|
||||
|
||||
assert reason == ""
|
||||
|
||||
def test_get_blocking_reason_failed(self, checker):
|
||||
"""Test getting blocking reason for failed pipeline."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="AssertionError",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
reason = checker.get_blocking_reason(pipeline)
|
||||
|
||||
assert "failed" in reason.lower()
|
||||
|
||||
def test_format_pipeline_summary(self, checker):
|
||||
"""Test formatting pipeline summary."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
duration=300,
|
||||
jobs=[
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
),
|
||||
JobStatus(
|
||||
name="lint",
|
||||
status="success",
|
||||
stage="lint",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
summary = checker.format_pipeline_summary(pipeline)
|
||||
|
||||
assert "Pipeline #1001" in summary
|
||||
assert "SUCCESS" in summary
|
||||
assert "2 total" in summary
|
||||
|
||||
def test_security_scan_detection(self, checker):
|
||||
"""Test detection of security scan failures."""
|
||||
from runners.gitlab.services.ci_checker import JobStatus
|
||||
|
||||
jobs = [
|
||||
JobStatus(
|
||||
name="sast",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="Vulnerability found",
|
||||
),
|
||||
JobStatus(
|
||||
name="secret_detection",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="Secret leaked",
|
||||
),
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
),
|
||||
]
|
||||
|
||||
issues = checker._check_security_scans(jobs)
|
||||
|
||||
assert len(issues) == 2
|
||||
assert any(i["type"] == "Static Application Security Testing" for i in issues)
|
||||
assert any(i["type"] == "Secret Detection" for i in issues)
|
||||
|
||||
|
||||
class TestPipelineStatus:
|
||||
"""Test PipelineStatus enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Test all status values exist."""
|
||||
from runners.gitlab.services.ci_checker import PipelineStatus
|
||||
|
||||
assert PipelineStatus.PENDING.value == "pending"
|
||||
assert PipelineStatus.RUNNING.value == "running"
|
||||
assert PipelineStatus.SUCCESS.value == "success"
|
||||
assert PipelineStatus.FAILED.value == "failed"
|
||||
assert PipelineStatus.CANCELED.value == "canceled"
|
||||
|
||||
|
||||
class TestJobStatus:
|
||||
"""Test JobStatus model."""
|
||||
|
||||
def test_job_status_creation(self):
|
||||
"""Test creating JobStatus."""
|
||||
from runners.gitlab.services.ci_checker import JobStatus
|
||||
|
||||
job = JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
started_at="2025-01-14T10:00:00",
|
||||
finished_at="2025-01-14T10:01:00",
|
||||
duration=60,
|
||||
)
|
||||
|
||||
assert job.name == "test"
|
||||
assert job.status == "success"
|
||||
assert job.duration == 60
|
||||
|
||||
|
||||
class TestPipelineInfo:
|
||||
"""Test PipelineInfo model."""
|
||||
|
||||
def test_pipeline_info_creation(self):
|
||||
"""Test creating PipelineInfo."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.pipeline_id == 1001
|
||||
assert pipeline.has_failures is False
|
||||
assert pipeline.is_blocking is False
|
||||
|
||||
def test_has_failures_property(self):
|
||||
"""Test has_failures property."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
JobStatus(name="test", status="failed", stage="test"),
|
||||
],
|
||||
)
|
||||
|
||||
assert pipeline.has_failures is True
|
||||
assert len(pipeline.failed_jobs) == 1
|
||||
|
||||
def test_is_blocking_success(self):
|
||||
"""Test is_blocking for successful pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.is_blocking is False
|
||||
|
||||
def test_is_blocking_failed(self):
|
||||
"""Test is_blocking for failed pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.is_blocking is True
|
||||
|
||||
def test_is_blocking_running(self):
|
||||
"""Test is_blocking for running pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.RUNNING,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
# Running with no failed jobs is not blocking
|
||||
assert pipeline.is_blocking is False
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Tests for GitLab Client Error Handling
|
||||
=======================================
|
||||
|
||||
Tests for enhanced retry logic, rate limiting, and error handling.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
default_timeout=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_response(
|
||||
status=200, content=b'{"id": 123}', content_type="application/json", headers=None
|
||||
):
|
||||
"""Helper to create a mock HTTP response."""
|
||||
mock_resp = Mock()
|
||||
mock_resp.status = status
|
||||
mock_resp.read = lambda: content
|
||||
# Use a real dict for headers to properly support .get() method
|
||||
headers_dict = {"Content-Type": content_type}
|
||||
if headers:
|
||||
headers_dict.update(headers)
|
||||
mock_resp.headers = headers_dict
|
||||
# Support context manager protocol
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
"""Tests for retry logic on transient failures."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_429_rate_limit(self, client):
|
||||
"""Test retry on HTTP 429 rate limit."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# First call: rate limited
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=429,
|
||||
msg="Rate limited",
|
||||
hdrs={"Retry-After": "1"},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
# Second call: success
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2 # Retried once
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_500_server_error(self, client):
|
||||
"""Test retry on HTTP 500 server error."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=500,
|
||||
msg="Internal server error",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_502_bad_gateway(self, client):
|
||||
"""Test retry on HTTP 502 bad gateway."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=502,
|
||||
msg="Bad gateway",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_socket_timeout(self, client):
|
||||
"""Test retry on socket timeout."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise TimeoutError("Connection timed out")
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_connection_reset(self, client):
|
||||
"""Test retry on connection reset."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ConnectionResetError("Connection reset")
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_retry_on_404_not_found(self, client):
|
||||
"""Test that 404 errors are not retried."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=404,
|
||||
msg="Not found",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 1 # No retry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_retries_exceeded(self, client):
|
||||
"""Test that max retries limit is respected."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Always fail
|
||||
raise urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=500,
|
||||
msg="Server error",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception, match="GitLab API error"):
|
||||
client._fetch("/projects/namespace%2Fproject", max_retries=2)
|
||||
|
||||
# With max_retries=2, the loop runs range(2) = [0, 1], so 2 attempts total
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Tests for rate limit handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_after_header_parsing(self, client):
|
||||
"""Test parsing Retry-After header."""
|
||||
import time
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=429,
|
||||
msg="Rate limited",
|
||||
hdrs={"Retry-After": "2"},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with patch("time.sleep") as mock_sleep:
|
||||
# Should fail after retries
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Check that sleep was called with Retry-After value
|
||||
mock_sleep.assert_called_with(2)
|
||||
|
||||
|
||||
class TestErrorMessages:
|
||||
"""Tests for helpful error messages."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_error_message_included(self, client):
|
||||
"""Test that GitLab error messages are included in exceptions."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=400,
|
||||
msg="Bad request",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b'{"message": "Invalid branch name"}'
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Error message should include GitLab's message
|
||||
assert "Invalid branch name" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_endpoint_raises(self, client):
|
||||
"""Test that invalid endpoints are rejected."""
|
||||
with pytest.raises(
|
||||
ValueError, match="does not match known GitLab API patterns"
|
||||
):
|
||||
client._fetch("/invalid/endpoint")
|
||||
|
||||
|
||||
class TestResponseSizeLimits:
|
||||
"""Tests for response size limits."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_response_rejected(self, client):
|
||||
"""Test that overly large responses are rejected."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
# Use application/json to trigger size check (status < 400)
|
||||
return _create_mock_response(
|
||||
content=b"Large response",
|
||||
content_type="application/json",
|
||||
headers={"Content-Length": str(20 * 1024 * 1024)}, # 20MB
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(ValueError, match="Response too large"):
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
|
||||
class TestContentTypeHandling:
|
||||
"""Tests for Content-Type validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_json_response_handling(self, client):
|
||||
"""Test handling of non-JSON responses on success."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
mock_resp = _create_mock_response(
|
||||
content=b"Plain text response", content_type="text/plain"
|
||||
)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Should return raw response for non-JSON on success
|
||||
assert result == "Plain text response"
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Tests for GitLab Client API Extensions
|
||||
=========================================
|
||||
|
||||
Tests for new CRUD endpoints, branch operations, file operations, and webhooks.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Try imports with fallback for different environments
|
||||
try:
|
||||
from runners.gitlab.glab_client import (
|
||||
GitLabClient,
|
||||
GitLabConfig,
|
||||
encode_project_path,
|
||||
)
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig, encode_project_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestMRExtensions:
|
||||
"""Tests for MR CRUD operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mr(self, client):
|
||||
"""Test creating a merge request."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"source_branch": "feature",
|
||||
"target_branch": "main",
|
||||
}
|
||||
|
||||
result = client.create_mr(
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
title="Test MR",
|
||||
description="Test description",
|
||||
)
|
||||
|
||||
assert mock_fetch.called
|
||||
assert result["iid"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mrs_filters(self, client):
|
||||
"""Test listing MRs with filters."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"iid": 1, "title": "MR 1"},
|
||||
{"iid": 2, "title": "MR 2"},
|
||||
]
|
||||
|
||||
result = client.list_mrs(state="opened", labels=["bug"])
|
||||
|
||||
assert mock_fetch.called
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_mr(self, client):
|
||||
"""Test updating a merge request."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"iid": 123, "title": "Updated"}
|
||||
|
||||
result = client.update_mr(
|
||||
mr_iid=123,
|
||||
title="Updated",
|
||||
labels={"bug": True, "feature": False},
|
||||
)
|
||||
|
||||
assert mock_fetch.called
|
||||
|
||||
|
||||
class TestBranchOperations:
|
||||
"""Tests for branch management operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches(self, client):
|
||||
"""Test listing branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"name": "main", "commit": {"id": "abc123"}},
|
||||
{"name": "develop", "commit": {"id": "def456"}},
|
||||
]
|
||||
|
||||
result = client.list_branches()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch(self, client):
|
||||
"""Test getting a specific branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "main",
|
||||
"commit": {"id": "abc123"},
|
||||
"protected": True,
|
||||
}
|
||||
|
||||
result = client.get_branch("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch(self, client):
|
||||
"""Test creating a new branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "feature-branch",
|
||||
"commit": {"id": "abc123"},
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="feature-branch",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
assert result["name"] == "feature-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch(self, client):
|
||||
"""Test deleting a branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_branch("feature-branch")
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches(self, client):
|
||||
"""Test comparing two branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
"commits": [{"id": "abc123"}],
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
|
||||
|
||||
class TestFileOperations:
|
||||
"""Tests for file operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents(self, client):
|
||||
"""Test getting file contents."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"content": "ZGVmIHRlc3Q=", # base64
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("test.py", ref="main")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file(self, client):
|
||||
"""Test creating a new file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "new_file.py",
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="new_file.py",
|
||||
content="print('hello')",
|
||||
commit_message="Add new file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "new_file.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file(self, client):
|
||||
"""Test updating an existing file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "existing.py",
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="existing.py",
|
||||
content="updated content",
|
||||
commit_message="Update file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "existing.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file(self, client):
|
||||
"""Test deleting a file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_file(
|
||||
file_path="old.py",
|
||||
commit_message="Remove old file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestWebhookOperations:
|
||||
"""Tests for webhook management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks(self, client):
|
||||
"""Test listing webhooks."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"id": 1, "url": "https://example.com/hook"},
|
||||
{"id": 2, "url": "https://example.com/another"},
|
||||
]
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook(self, client):
|
||||
"""Test getting a specific webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook",
|
||||
"push_events": True,
|
||||
}
|
||||
|
||||
result = client.get_webhook(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook(self, client):
|
||||
"""Test creating a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook",
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/hook",
|
||||
push_events=True,
|
||||
merge_request_events=True,
|
||||
)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook(self, client):
|
||||
"""Test updating a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook-updated",
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
url="https://example.com/hook-updated",
|
||||
)
|
||||
|
||||
assert result["url"] == "https://example.com/hook-updated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook(self, client):
|
||||
"""Test deleting a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_webhook(1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAsyncMethods:
|
||||
"""Tests for async method variants."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mr_async(self, client):
|
||||
"""Test async variant of create_mr."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
}
|
||||
|
||||
result = await client.create_mr_async(
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
title="Test MR",
|
||||
)
|
||||
|
||||
assert result["iid"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_async(self, client):
|
||||
"""Test async variant of list_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"name": "main"},
|
||||
]
|
||||
|
||||
result = await client.list_branches_async()
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestEncoding:
|
||||
"""Tests for URL encoding."""
|
||||
|
||||
def test_encode_project_path_simple(self):
|
||||
"""Test encoding simple project path."""
|
||||
result = encode_project_path("namespace/project")
|
||||
assert result == "namespace%2Fproject"
|
||||
|
||||
def test_encode_project_path_with_dots(self):
|
||||
"""Test encoding project path with dots."""
|
||||
result = encode_project_path("group.name/project")
|
||||
assert "group.name%2Fproject" in result or "group%2Ename%2Fproject" in result
|
||||
|
||||
def test_encode_project_path_with_slashes(self):
|
||||
"""Test encoding project path with nested groups."""
|
||||
result = encode_project_path("group/subgroup/project")
|
||||
assert result == "group%2Fsubgroup%2Fproject"
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Unit Tests for GitLab MR Context Gatherer Enhancements
|
||||
======================================================
|
||||
|
||||
Tests for enhanced context gathering including monorepo detection,
|
||||
related files finding, and AI bot comment detection.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Try imports with fallback for different environments
|
||||
try:
|
||||
from runners.gitlab.services.context_gatherer import (
|
||||
CONFIG_FILE_NAMES,
|
||||
GITLAB_AI_BOT_PATTERNS,
|
||||
MRContextGatherer,
|
||||
)
|
||||
except ImportError:
|
||||
from runners.gitlab.context_gatherer import (
|
||||
CONFIG_FILE_NAMES,
|
||||
GITLAB_AI_BOT_PATTERNS,
|
||||
MRContextGatherer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MagicMock()
|
||||
client.get_mr_async = AsyncMock()
|
||||
client.get_mr_changes_async = AsyncMock()
|
||||
client.get_mr_commits_async = AsyncMock()
|
||||
client.get_mr_notes_async = AsyncMock()
|
||||
client.get_mr_pipeline_async = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mr_data():
|
||||
"""Sample MR data from GitLab API."""
|
||||
return {
|
||||
"iid": 123,
|
||||
"title": "Add new feature",
|
||||
"description": "This adds a cool feature",
|
||||
"author": {"username": "developer"},
|
||||
"source_branch": "feature-branch",
|
||||
"target_branch": "main",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_changes_data():
|
||||
"""Sample MR changes data."""
|
||||
return {
|
||||
"changes": [
|
||||
{
|
||||
"new_path": "src/utils/helpers.py",
|
||||
"old_path": "src/utils/helpers.py",
|
||||
"diff": "@@ -1,1 +1,2 @@\n def helper():\n+ return True",
|
||||
"new_file": False,
|
||||
"deleted_file": False,
|
||||
"renamed_file": False,
|
||||
},
|
||||
],
|
||||
"additions": 10,
|
||||
"deletions": 5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_commits():
|
||||
"""Sample commit data."""
|
||||
return [
|
||||
{
|
||||
"id": "abc123",
|
||||
"short_id": "abc123",
|
||||
"title": "Add feature",
|
||||
"message": "Add feature",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_project_dir(tmp_path):
|
||||
"""Create a temporary project directory with structure."""
|
||||
# Create monorepo structure
|
||||
(tmp_path / "apps").mkdir()
|
||||
(tmp_path / "apps" / "backend").mkdir()
|
||||
(tmp_path / "apps" / "frontend").mkdir()
|
||||
(tmp_path / "packages").mkdir()
|
||||
(tmp_path / "packages" / "shared").mkdir()
|
||||
|
||||
# Create config files
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"workspaces": ["apps/*", "packages/*"]}', encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "tsconfig.json").write_text(
|
||||
'{"compilerOptions": {"paths": {"@/*": ["src/*"]}}}', encoding="utf-8"
|
||||
)
|
||||
(tmp_path / ".gitlab-ci.yml").write_text("stages:\n - test", encoding="utf-8")
|
||||
|
||||
# Create source files
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "utils").mkdir()
|
||||
(tmp_path / "src" / "utils" / "helpers.py").write_text(
|
||||
"def helper():\n return True", encoding="utf-8"
|
||||
)
|
||||
|
||||
# Create test files
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "tests" / "test_helpers.py").write_text(
|
||||
"def test_helper():\n assert True", encoding="utf-8"
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(tmp_project_dir):
|
||||
"""Create a context gatherer instance."""
|
||||
return MRContextGatherer(
|
||||
project_dir=tmp_project_dir,
|
||||
mr_iid=123,
|
||||
config=MagicMock(project="namespace/project", token="test-token"),
|
||||
)
|
||||
|
||||
|
||||
class TestAIBotPatterns:
|
||||
"""Test AI bot pattern detection."""
|
||||
|
||||
def test_gitlab_ai_bot_patterns_comprehensive(self):
|
||||
"""Test that AI bot patterns include major tools."""
|
||||
# Check for known AI tools
|
||||
assert "coderabbit" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "greptile" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "cursor" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "sourcery-ai" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "codium" in GITLAB_AI_BOT_PATTERNS
|
||||
|
||||
def test_config_file_names_include_gitlab_ci(self):
|
||||
"""Test that GitLab CI config is included."""
|
||||
assert ".gitlab-ci.yml" in CONFIG_FILE_NAMES
|
||||
|
||||
|
||||
class TestRepoStructureDetection:
|
||||
"""Test monorepo and project structure detection."""
|
||||
|
||||
def test_detect_monorepo_apps(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of apps/ directory."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Monorepo Apps" in structure
|
||||
assert "backend" in structure
|
||||
assert "frontend" in structure
|
||||
|
||||
def test_detect_monorepo_packages(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of packages/ directory."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Packages" in structure
|
||||
assert "shared" in structure
|
||||
|
||||
def test_detect_workspaces(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of npm workspaces."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Workspaces" in structure
|
||||
|
||||
def test_detect_gitlab_ci(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of GitLab CI config."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "GitLab CI" in structure
|
||||
|
||||
def test_detect_standard_repo(self, tmp_path):
|
||||
"""Test detection of standard repo without monorepo structure."""
|
||||
gatherer = MRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
config=MagicMock(project="namespace/project"),
|
||||
)
|
||||
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Standard single-package repository" in structure
|
||||
|
||||
|
||||
class TestRelatedFilesFinding:
|
||||
"""Test finding related files for context."""
|
||||
|
||||
def test_find_test_files(self, gatherer, tmp_project_dir):
|
||||
"""Test finding test files for a source file."""
|
||||
source_path = Path("src/utils/helpers.py")
|
||||
tests = gatherer._find_test_files(source_path)
|
||||
|
||||
# Should find the test file we created
|
||||
assert "tests/test_helpers.py" in tests
|
||||
|
||||
def test_find_config_files(self, gatherer, tmp_project_dir):
|
||||
"""Test finding config files in directory."""
|
||||
directory = Path(tmp_project_dir)
|
||||
configs = gatherer._find_config_files(directory)
|
||||
|
||||
# Should find config files in root
|
||||
assert "package.json" in configs
|
||||
assert "tsconfig.json" in configs
|
||||
assert ".gitlab-ci.yml" in configs
|
||||
|
||||
def test_find_type_definitions(self, gatherer, tmp_project_dir):
|
||||
"""Test finding TypeScript type definition files."""
|
||||
# Create a TypeScript file
|
||||
(tmp_project_dir / "src" / "types.ts").write_text(
|
||||
"export type Foo = string;", encoding="utf-8"
|
||||
)
|
||||
(tmp_project_dir / "src" / "types.d.ts").write_text(
|
||||
"export type Bar = number;", encoding="utf-8"
|
||||
)
|
||||
|
||||
source_path = Path("src/types.ts")
|
||||
type_defs = gatherer._find_type_definitions(source_path)
|
||||
|
||||
assert "src/types.d.ts" in type_defs
|
||||
|
||||
def test_find_dependents_limits_generic_names(self, gatherer, tmp_project_dir):
|
||||
"""Test that generic names are skipped in dependent finding."""
|
||||
# Generic names should be skipped to avoid too many matches
|
||||
for stem in ["index", "main", "app", "utils", "helpers", "types", "constants"]:
|
||||
result = gatherer._find_dependents(f"src/{stem}.py")
|
||||
assert result == set() # Should skip generic names
|
||||
|
||||
def test_prioritize_related_files(self, gatherer):
|
||||
"""Test prioritization of related files."""
|
||||
files = {
|
||||
"tests/test_utils.py", # Test file - highest priority
|
||||
"src/utils.d.ts", # Type definition - high priority
|
||||
"tsconfig.json", # Config - medium priority
|
||||
"src/random.py", # Other - low priority
|
||||
}
|
||||
|
||||
prioritized = gatherer._prioritize_related_files(files, limit=10)
|
||||
|
||||
# Test files should come first
|
||||
assert prioritized[0] == "tests/test_utils.py"
|
||||
assert "src/utils.d.ts" in prioritized[1:3] # Type files next
|
||||
assert "tsconfig.json" in prioritized # Configs included
|
||||
|
||||
|
||||
class TestJSONLoading:
|
||||
"""Test JSON loading with comment handling."""
|
||||
|
||||
def test_load_json_safe_standard(self, gatherer, tmp_project_dir):
|
||||
"""Test loading standard JSON without comments."""
|
||||
(tmp_project_dir / "standard.json").write_text(
|
||||
'{"key": "value"}', encoding="utf-8"
|
||||
)
|
||||
|
||||
result = gatherer._load_json_safe("standard.json")
|
||||
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_load_json_safe_with_comments(self, gatherer, tmp_project_dir):
|
||||
"""Test loading JSON with tsconfig-style comments."""
|
||||
(tmp_project_dir / "with-comments.json").write_text(
|
||||
"{\n"
|
||||
" // Single-line comment\n"
|
||||
' "key": "value",\n'
|
||||
" /* Multi-line\n"
|
||||
" comment */\n"
|
||||
' "key2": "value2"\n'
|
||||
"}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = gatherer._load_json_safe("with-comments.json")
|
||||
|
||||
assert result == {"key": "value", "key2": "value2"}
|
||||
|
||||
def test_load_json_safe_nonexistent(self, gatherer, tmp_project_dir):
|
||||
"""Test loading non-existent JSON file."""
|
||||
result = gatherer._load_json_safe("nonexistent.json")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_tsconfig_paths(self, gatherer, tmp_project_dir):
|
||||
"""Test loading tsconfig paths."""
|
||||
result = gatherer._load_tsconfig_paths()
|
||||
|
||||
assert result is not None
|
||||
assert "@/*" in result
|
||||
assert "src/*" in result["@/*"]
|
||||
|
||||
|
||||
class TestStaticMethods:
|
||||
"""Test static utility methods."""
|
||||
|
||||
def test_find_related_files_for_root(self, tmp_project_dir):
|
||||
"""Test static method for finding related files."""
|
||||
changed_files = [
|
||||
{"new_path": "src/utils/helpers.py", "old_path": "src/utils/helpers.py"},
|
||||
]
|
||||
|
||||
related = MRContextGatherer.find_related_files_for_root(
|
||||
changed_files=changed_files,
|
||||
project_root=tmp_project_dir,
|
||||
)
|
||||
|
||||
# Should find test file
|
||||
assert "tests/test_helpers.py" in related
|
||||
# Should not include the changed file itself
|
||||
assert "src/utils/helpers.py" not in related
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGatherIntegration:
|
||||
"""Test the full gather method integration."""
|
||||
|
||||
async def test_gather_with_enhancements(
|
||||
self, gatherer, mock_client, sample_mr_data, sample_changes_data, sample_commits
|
||||
):
|
||||
"""Test that gather includes repo structure and related files."""
|
||||
# Setup mock responses
|
||||
mock_client.get_mr_async.return_value = sample_mr_data
|
||||
mock_client.get_mr_changes_async.return_value = sample_changes_data
|
||||
mock_client.get_mr_commits_async.return_value = sample_commits
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
mock_client.get_mr_pipeline_async.return_value = {
|
||||
"id": 456,
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
result = await gatherer.gather()
|
||||
|
||||
# Verify enhanced fields are populated
|
||||
assert result.mr_iid == 123
|
||||
assert result.repo_structure != ""
|
||||
assert (
|
||||
"Monorepo" in result.repo_structure or "Standard" in result.repo_structure
|
||||
)
|
||||
assert isinstance(result.related_files, list)
|
||||
assert result.ci_status == "success"
|
||||
assert result.ci_pipeline_id == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_handles_missing_ci(
|
||||
self, gatherer, mock_client, sample_mr_data, sample_changes_data, sample_commits
|
||||
):
|
||||
"""Test that gather handles missing CI pipeline gracefully."""
|
||||
mock_client.get_mr_async.return_value = sample_mr_data
|
||||
mock_client.get_mr_changes_async.return_value = sample_changes_data
|
||||
mock_client.get_mr_commits_async.return_value = sample_commits
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
mock_client.get_mr_pipeline_async.return_value = None
|
||||
|
||||
result = await gatherer.gather()
|
||||
|
||||
# Should not fail, CI fields should be None
|
||||
assert result.ci_status is None
|
||||
assert result.ci_pipeline_id is None
|
||||
|
||||
|
||||
class TestAIBotCommentDetection:
|
||||
"""Test AI bot comment detection and parsing."""
|
||||
|
||||
def test_parse_ai_comment_known_tool(self, gatherer):
|
||||
"""Test parsing comment from known AI tool."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider using async/await here",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is not None
|
||||
assert result.tool_name == "CodeRabbit"
|
||||
assert result.author == "coderabbit[bot]"
|
||||
|
||||
def test_parse_ai_comment_unknown_user(self, gatherer):
|
||||
"""Test parsing comment from unknown user."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"author": {"username": "developer"},
|
||||
"body": "Just a regular comment",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_parse_ai_comment_no_author(self, gatherer):
|
||||
"""Test parsing comment with no author."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"body": "Anonymous comment",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test input validation functions."""
|
||||
|
||||
def test_validate_git_ref_valid(self):
|
||||
"""Test validation of valid git refs."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_git_ref
|
||||
|
||||
assert _validate_git_ref("main") is True
|
||||
assert _validate_git_ref("feature-branch") is True
|
||||
assert _validate_git_ref("feature/branch-123") is True
|
||||
assert _validate_git_ref("abc123def456") is True
|
||||
|
||||
def test_validate_git_ref_invalid(self):
|
||||
"""Test validation rejects invalid git refs."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_git_ref
|
||||
|
||||
assert _validate_git_ref("") is False # Empty
|
||||
assert _validate_git_ref("a" * 300) is False # Too long
|
||||
assert _validate_git_ref("branch;rm -rf") is False # Invalid chars
|
||||
|
||||
def test_validate_file_path_valid(self):
|
||||
"""Test validation of valid file paths."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_file_path
|
||||
|
||||
assert _validate_file_path("src/file.py") is True
|
||||
assert _validate_file_path("src/utils/helpers.ts") is True
|
||||
assert _validate_file_path("src/config.json") is True
|
||||
|
||||
def test_validate_file_path_invalid(self):
|
||||
"""Test validation rejects invalid file paths."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_file_path
|
||||
|
||||
assert _validate_file_path("") is False # Empty
|
||||
assert _validate_file_path("../etc/passwd") is False # Path traversal
|
||||
assert _validate_file_path("/etc/passwd") is False # Absolute path
|
||||
assert _validate_file_path("a" * 1100) is False # Too long
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
GitLab File Lock Tests
|
||||
=======================
|
||||
|
||||
Tests for file locking utilities for concurrent safety.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFileLock:
|
||||
"""Test FileLock for concurrent-safe operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def lock_file(self, tmp_path):
|
||||
"""Create a temporary lock file path."""
|
||||
return tmp_path / "test.lock"
|
||||
|
||||
def test_acquire_lock(self, lock_file):
|
||||
"""Test acquiring a lock."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
# Lock is held here
|
||||
assert lock_file.exists()
|
||||
|
||||
def test_lock_release(self, lock_file):
|
||||
"""Test lock is released after context."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
pass
|
||||
|
||||
# Lock file should be cleaned up
|
||||
assert not lock_file.exists()
|
||||
|
||||
def test_lock_timeout(self, lock_file):
|
||||
"""Test lock timeout when held by another process."""
|
||||
from runners.gitlab.utils.file_lock import FileLock, FileLockTimeout
|
||||
|
||||
# Hold lock in separate thread
|
||||
def hold_lock():
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
time.sleep(0.5)
|
||||
|
||||
thread = threading.Thread(target=hold_lock)
|
||||
thread.start()
|
||||
|
||||
# Wait a bit for lock to be acquired
|
||||
time.sleep(0.1)
|
||||
|
||||
# Try to acquire with short timeout
|
||||
with pytest.raises(FileLockTimeout):
|
||||
FileLock(lock_file, timeout=0.1).acquire()
|
||||
|
||||
thread.join()
|
||||
|
||||
def test_exclusive_lock(self, lock_file):
|
||||
"""Test exclusive lock prevents concurrent writes."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
results = []
|
||||
|
||||
def try_write(value):
|
||||
try:
|
||||
with FileLock(lock_file, timeout=1.0, exclusive=True):
|
||||
with open(
|
||||
lock_file.with_suffix(".txt"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(str(value))
|
||||
results.append(value)
|
||||
except Exception:
|
||||
results.append(None)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=try_write, args=(1,)),
|
||||
threading.Thread(target=try_write, args=(2,)),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Only one should have succeeded
|
||||
successful = [r for r in results if r is not None]
|
||||
assert len(successful) == 1
|
||||
|
||||
def test_lock_cleanup_on_error(self, lock_file):
|
||||
"""Test lock is cleaned up even on error."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
try:
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be cleaned up despite error
|
||||
assert not lock_file.exists()
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
"""Test atomic_write for safe file writes."""
|
||||
|
||||
@pytest.fixture
|
||||
def target_file(self, tmp_path):
|
||||
"""Create a temporary target file."""
|
||||
return tmp_path / "target.txt"
|
||||
|
||||
def test_atomic_write_creates_file(self, target_file):
|
||||
"""Test atomic write creates target file."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("test content")
|
||||
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text(encoding="utf-8") == "test content"
|
||||
|
||||
def test_atomic_write_preserves_on_error(self, target_file):
|
||||
"""Test atomic write doesn't corrupt on error."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
# Create initial content
|
||||
target_file.write_text("original content", encoding="utf-8")
|
||||
|
||||
try:
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("new content")
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Original content should be preserved
|
||||
assert target_file.read_text(encoding="utf-8") == "original content"
|
||||
|
||||
def test_atomic_write_context_manager(self, target_file):
|
||||
"""Test atomic write context manager."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("line 1\n")
|
||||
f.write("line 2\n")
|
||||
|
||||
content = target_file.read_text(encoding="utf-8")
|
||||
assert "line 1" in content
|
||||
assert "line 2" in content
|
||||
|
||||
|
||||
class TestLockedJsonOperations:
|
||||
"""Test locked JSON operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def data_file(self, tmp_path):
|
||||
"""Create a temporary data file."""
|
||||
return tmp_path / "data.json"
|
||||
|
||||
def test_locked_json_write(self, data_file):
|
||||
"""Test writing JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_write
|
||||
|
||||
data = {"key": "value", "number": 42}
|
||||
|
||||
locked_json_write(data_file, data)
|
||||
|
||||
assert data_file.exists()
|
||||
with open(data_file, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == data
|
||||
|
||||
def test_locked_json_read(self, data_file):
|
||||
"""Test reading JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read, locked_json_write
|
||||
|
||||
data = {"key": "value", "nested": {"item": 1}}
|
||||
locked_json_write(data_file, data)
|
||||
|
||||
loaded = locked_json_read(data_file)
|
||||
|
||||
assert loaded == data
|
||||
|
||||
def test_locked_json_update(self, data_file):
|
||||
"""Test updating JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
initial = {"key": "value"}
|
||||
locked_json_write(data_file, initial)
|
||||
|
||||
def update_fn(data):
|
||||
data["new_key"] = "new_value"
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, update_fn)
|
||||
|
||||
loaded = locked_json_read(data_file)
|
||||
assert loaded["key"] == "value"
|
||||
assert loaded["new_key"] == "new_value"
|
||||
|
||||
def test_locked_json_read_missing_file(self, tmp_path):
|
||||
"""Test reading missing JSON file returns None."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read
|
||||
|
||||
result = locked_json_read(tmp_path / "nonexistent.json")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_concurrent_json_writes(self, tmp_path):
|
||||
"""Test concurrent JSON writes are safe."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "concurrent.json"
|
||||
|
||||
# Initialize
|
||||
locked_json_write(data_file, {"counter": 0})
|
||||
|
||||
results = []
|
||||
|
||||
def increment():
|
||||
def updater(data):
|
||||
data["counter"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
result = locked_json_read(data_file)
|
||||
results.append(result["counter"])
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=increment),
|
||||
threading.Thread(target=increment),
|
||||
threading.Thread(target=increment),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Final value should be 3
|
||||
final = locked_json_read(data_file)
|
||||
assert final["counter"] == 3
|
||||
|
||||
|
||||
class TestLockedReadWrite:
|
||||
"""Test general locked read/write operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def data_file(self, tmp_path):
|
||||
"""Create a temporary data file."""
|
||||
return tmp_path / "data.txt"
|
||||
|
||||
def test_locked_write(self, data_file):
|
||||
"""Test writing with lock."""
|
||||
from runners.gitlab.utils.file_lock import locked_write
|
||||
|
||||
with locked_write(data_file) as f:
|
||||
f.write("test content")
|
||||
|
||||
assert data_file.read_text(encoding="utf-8") == "test content"
|
||||
|
||||
def test_locked_read(self, data_file):
|
||||
"""Test reading with lock."""
|
||||
from runners.gitlab.utils.file_lock import locked_read, locked_write
|
||||
|
||||
with locked_write(data_file) as f:
|
||||
f.write("read test")
|
||||
|
||||
with locked_read(data_file) as f:
|
||||
content = f.read()
|
||||
|
||||
assert content == "read test"
|
||||
|
||||
def test_locked_write_file_lock(self, data_file):
|
||||
"""Test locked_write with custom FileLock."""
|
||||
from runners.gitlab.utils.file_lock import FileLock, locked_write
|
||||
|
||||
with FileLock(data_file, timeout=5.0):
|
||||
with locked_write(data_file, lock=None) as f:
|
||||
f.write("custom lock")
|
||||
|
||||
assert data_file.read_text(encoding="utf-8") == "custom lock"
|
||||
|
||||
|
||||
class TestFileLockError:
|
||||
"""Test FileLockError exceptions."""
|
||||
|
||||
def test_file_lock_error(self):
|
||||
"""Test FileLockError is raised correctly."""
|
||||
from runners.gitlab.utils.file_lock import FileLockError
|
||||
|
||||
error = FileLockError("Custom error message")
|
||||
assert str(error) == "Custom error message"
|
||||
|
||||
def test_file_lock_timeout(self):
|
||||
"""Test FileLockTimeout is raised correctly."""
|
||||
from runners.gitlab.utils.file_lock import FileLockTimeout
|
||||
|
||||
error = FileLockTimeout("Timeout message")
|
||||
assert "Timeout" in str(error)
|
||||
|
||||
|
||||
class TestConcurrentSafety:
|
||||
"""Test concurrent safety scenarios."""
|
||||
|
||||
def test_multiple_readers(self, tmp_path):
|
||||
"""Test multiple readers can access file concurrently."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read, locked_json_write
|
||||
|
||||
data_file = tmp_path / "readers.json"
|
||||
locked_json_write(data_file, {"value": 42})
|
||||
|
||||
results = []
|
||||
|
||||
def read_value():
|
||||
data = locked_json_read(data_file)
|
||||
results.append(data["value"])
|
||||
|
||||
threads = [threading.Thread(target=read_value) for _ in range(5)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(results) == 5
|
||||
assert all(r == 42 for r in results)
|
||||
|
||||
def test_writers_exclusive(self, tmp_path):
|
||||
"""Test writers have exclusive access."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "writers.json"
|
||||
locked_json_write(data_file, {"counter": 0})
|
||||
|
||||
results = []
|
||||
|
||||
def increment():
|
||||
def updater(data):
|
||||
data["counter"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
result = locked_json_read(data_file)
|
||||
results.append(result["counter"])
|
||||
|
||||
threads = [threading.Thread(target=increment) for _ in range(10)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All increments should be applied
|
||||
final = locked_json_read(data_file)
|
||||
assert final["counter"] == 10
|
||||
assert len(results) == 10
|
||||
|
||||
def test_reader_writer_conflict(self, tmp_path):
|
||||
"""Test readers and writers don't conflict."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "rw.json"
|
||||
locked_json_write(data_file, {"reads": 0, "writes": 0})
|
||||
|
||||
read_results = []
|
||||
|
||||
def reader():
|
||||
for _ in range(10):
|
||||
data = locked_json_read(data_file)
|
||||
read_results.append(data["reads"])
|
||||
|
||||
def writer():
|
||||
for _ in range(5):
|
||||
|
||||
def updater(data):
|
||||
data["writes"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=reader),
|
||||
threading.Thread(target=writer),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All operations should complete
|
||||
final = locked_json_read(data_file)
|
||||
assert final["writes"] == 5
|
||||
assert len(read_results) == 10
|
||||
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Tests for GitLab File Operations
|
||||
===================================
|
||||
|
||||
Tests for file content retrieval, creation, updating, and deletion.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestGetFileContents:
|
||||
"""Tests for get_file_contents method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_current_version(self, client):
|
||||
"""Test getting file contents from current HEAD."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"file_path": "src/test.py",
|
||||
"size": 100,
|
||||
"encoding": "base64",
|
||||
"content": "cHJpbnQoJ2hlbGxvJyk=", # base64 for "print('hello')"
|
||||
"content_sha256": "abc123",
|
||||
"ref": "main",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("src/test.py")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
assert result["encoding"] == "base64"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_with_ref(self, client):
|
||||
"""Test getting file contents from specific ref."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "config.json",
|
||||
"ref": "develop",
|
||||
"content": "eyJjb25maWciOiB0cnVlfQ==",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("config.json", ref="develop")
|
||||
|
||||
assert result["ref"] == "develop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_async(self, client):
|
||||
"""Test async variant of get_file_contents."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"content": "dGVzdA==",
|
||||
}
|
||||
|
||||
result = await client.get_file_contents_async("test.py")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
|
||||
|
||||
class TestCreateFile:
|
||||
"""Tests for create_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_file(self, client):
|
||||
"""Test creating a new file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "new_file.py",
|
||||
"branch": "main",
|
||||
"commit_id": "abc123",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="new_file.py",
|
||||
content="print('hello world')",
|
||||
commit_message="Add new file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "new_file.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_with_author(self, client):
|
||||
"""Test creating a file with author information."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "authored.py",
|
||||
"commit_id": "def456",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="authored.py",
|
||||
content="# Author: John Doe",
|
||||
commit_message="Add file",
|
||||
branch="main",
|
||||
author_name="John Doe",
|
||||
author_email="john@example.com",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "def456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_async(self, client):
|
||||
"""Test async variant of create_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "async.py"}
|
||||
|
||||
result = await client.create_file_async(
|
||||
file_path="async.py",
|
||||
content="content",
|
||||
commit_message="Add",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "async.py"
|
||||
|
||||
|
||||
class TestUpdateFile:
|
||||
"""Tests for update_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_file(self, client):
|
||||
"""Test updating an existing file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "existing.py",
|
||||
"branch": "main",
|
||||
"commit_id": "ghi789",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="existing.py",
|
||||
content="updated content",
|
||||
commit_message="Update file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "ghi789"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file_with_author(self, client):
|
||||
"""Test updating file with author info."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "update.py",
|
||||
"commit_id": "jkl012",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="update.py",
|
||||
content="new content",
|
||||
commit_message="Modify file",
|
||||
branch="develop",
|
||||
author_name="Jane Doe",
|
||||
author_email="jane@example.com",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "jkl012"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file_async(self, client):
|
||||
"""Test async variant of update_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "update.py"}
|
||||
|
||||
result = await client.update_file_async(
|
||||
file_path="update.py",
|
||||
content="new content",
|
||||
commit_message="Update",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "update.py"
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""Tests for delete_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file(self, client):
|
||||
"""Test deleting a file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "old.py",
|
||||
"branch": "main",
|
||||
"commit_id": "mno345",
|
||||
}
|
||||
|
||||
result = client.delete_file(
|
||||
file_path="old.py",
|
||||
commit_message="Remove old file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "mno345"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_async(self, client):
|
||||
"""Test async variant of delete_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "delete.py"}
|
||||
|
||||
result = await client.delete_file_async(
|
||||
file_path="delete.py",
|
||||
commit_message="Delete",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "delete.py"
|
||||
|
||||
|
||||
class TestFileOperationErrors:
|
||||
"""Tests for file operation error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_file(self, client):
|
||||
"""Test getting a file that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 File Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_file_contents("nonexistent.py")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_already_exists(self, client):
|
||||
"""Test creating a file that already exists."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("400 File already exists")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.create_file(
|
||||
file_path="existing.py",
|
||||
content="content",
|
||||
commit_message="Add",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_file(self, client):
|
||||
"""Test deleting a file that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 File Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.delete_file(
|
||||
file_path="nonexistent.py",
|
||||
commit_message="Delete",
|
||||
branch="main",
|
||||
)
|
||||
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Unit Tests for GitLab Follow-up MR Reviewer
|
||||
============================================
|
||||
|
||||
Tests for FollowupReviewer class.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from runners.gitlab.models import (
|
||||
AutoFixState,
|
||||
AutoFixStatus,
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from runners.gitlab.services.followup_reviewer import FollowupReviewer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MagicMock()
|
||||
client.get_mr_async = AsyncMock()
|
||||
client.get_mr_notes_async = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_previous_review():
|
||||
"""Create a sample previous review result."""
|
||||
return MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="namespace/project",
|
||||
success=True,
|
||||
findings=[
|
||||
MRReviewFinding(
|
||||
id="finding-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection vulnerability",
|
||||
description="User input not sanitized",
|
||||
file="src/api/users.py",
|
||||
line=42,
|
||||
suggested_fix="Use parameterized queries",
|
||||
fixable=True,
|
||||
),
|
||||
MRReviewFinding(
|
||||
id="finding-2",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Missing error handling",
|
||||
description="No try-except around file I/O",
|
||||
file="src/utils/file.py",
|
||||
line=15,
|
||||
suggested_fix="Add error handling",
|
||||
fixable=True,
|
||||
),
|
||||
],
|
||||
summary="Found 2 issues",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="High severity issues must be resolved",
|
||||
reviewed_commit_sha="abc123def456",
|
||||
reviewed_file_blobs={"src/api/users.py": "blob1", "src/utils/file.py": "blob2"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reviewer(sample_previous_review):
|
||||
"""Create a FollowupReviewer instance."""
|
||||
return FollowupReviewer(
|
||||
project_dir="/tmp/project",
|
||||
gitlab_dir="/tmp/project/.auto-claude/gitlab",
|
||||
config=MagicMock(project="namespace/project"),
|
||||
progress_callback=None,
|
||||
use_ai=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_finding_resolved(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that resolved findings are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context where one finding was resolved
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Fix SQL injection"},
|
||||
],
|
||||
files_changed_since_review=["src/api/users.py"],
|
||||
diff_since_review="diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -40,7 +40,7 @@\n"
|
||||
"- query = f\"SELECT * FROM users WHERE name='{name}'\"\n"
|
||||
'+ query = "SELECT * FROM users WHERE name=%s"\n'
|
||||
" cursor.execute(query, (name,))",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.resolved_findings) > 0
|
||||
assert len(result.unresolved_findings) < 2 # At least one resolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_finding_unresolved(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that unresolved findings are tracked."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context where findings were not addressed
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Update docs"},
|
||||
],
|
||||
files_changed_since_review=["README.md"],
|
||||
diff_since_review="diff --git a/README.md b/README.md\n+ # Updated docs",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.unresolved_findings) == 2 # Both still unresolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_new_findings(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that new issues are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context with TODO comment in diff
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Add feature"},
|
||||
],
|
||||
files_changed_since_review=["src/feature.py"],
|
||||
diff_since_review="diff --git a/src/feature.py b/src/feature.py\n"
|
||||
"--- a/src/feature.py\n"
|
||||
"+++ b/src/feature.py\n"
|
||||
"@@ -0,0 +1,3 @@\n"
|
||||
"+ # TODO: implement error handling\n"
|
||||
"+ def feature():\n"
|
||||
"+ pass",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should detect TODO as new finding
|
||||
assert any(
|
||||
f.id.startswith("followup-todo-") and "todo" in f.title.lower()
|
||||
for f in result.findings
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_critical_blocks(reviewer, sample_previous_review):
|
||||
"""Test that critical issues block merge."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Critical security issue",
|
||||
description="Must fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.BLOCKED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_high_needs_revision(reviewer, sample_previous_review):
|
||||
"""Test that high issues require revision."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="High severity issue",
|
||||
description="Should fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.NEEDS_REVISION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_medium_merge_with_changes(
|
||||
reviewer, sample_previous_review
|
||||
):
|
||||
"""Test that medium issues suggest merge with changes."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Medium issue",
|
||||
description="Nice to fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_ready_to_merge(reviewer, sample_previous_review):
|
||||
"""Test that low or no issues allow merge."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Style issue",
|
||||
description="Optional fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_all_clear(reviewer, sample_previous_review):
|
||||
"""Test that no issues allows merge."""
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=[],
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
|
||||
def test_is_finding_addressed_file_changed(reviewer, sample_previous_review):
|
||||
"""Test finding detection when file is changed in the diff region."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -40,7 +40,7 @@\n"
|
||||
"- query = f\"SELECT * FROM users WHERE name='{name}'\"\n"
|
||||
'+ query = "SELECT * FROM users WHERE name=%s"\n'
|
||||
" cursor.execute(query, (name,))"
|
||||
)
|
||||
|
||||
finding = sample_previous_review.findings[0] # Line 42 in users.py
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is True # Line 42 is in the changed range (40-47)
|
||||
|
||||
|
||||
def test_is_finding_addressed_file_not_changed(reviewer, sample_previous_review):
|
||||
"""Test finding detection when file is not in diff."""
|
||||
diff = "diff --git a/README.md b/README.md\n+ # Updated docs"
|
||||
|
||||
finding = sample_previous_review.findings[0] # users.py
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_finding_addressed_line_not_in_range(reviewer, sample_previous_review):
|
||||
"""Test finding detection when line is outside changed range."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -1,7 +1,7 @@\n"
|
||||
" def hello():\n"
|
||||
"- print('hello')\n"
|
||||
"+ print('HELLO')\n"
|
||||
)
|
||||
|
||||
finding = sample_previous_review.findings[0] # Line 42, not in range 1-8
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_finding_addressed_test_pattern_added(reviewer, sample_previous_review):
|
||||
"""Test finding detection for test category when tests are added."""
|
||||
diff = (
|
||||
"diff --git a/tests/test_users.py b/tests/test_users.py\n"
|
||||
"+ def test_sql_injection():\n"
|
||||
"+ assert True"
|
||||
)
|
||||
|
||||
test_finding = MRReviewFinding(
|
||||
id="test-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.TEST,
|
||||
title="Missing tests",
|
||||
description="Add tests for users module",
|
||||
file="tests/test_users.py",
|
||||
line=1,
|
||||
)
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, test_finding)
|
||||
|
||||
assert result is True # Pattern matches "+ def test_"
|
||||
|
||||
|
||||
def test_is_finding_addressed_doc_pattern_added(reviewer, sample_previous_review):
|
||||
"""Test finding detection for documentation category when docs are added."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
'+ """\n'
|
||||
"+ User API module.\n"
|
||||
'+ """'
|
||||
)
|
||||
|
||||
doc_finding = MRReviewFinding(
|
||||
id="doc-1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.DOCS,
|
||||
title="Missing docstring",
|
||||
description="Add module docstring",
|
||||
file="src/api/users.py",
|
||||
line=1,
|
||||
)
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, doc_finding)
|
||||
|
||||
assert result is True # Pattern matches '+"""'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_comment_question_detection(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that questions in comments are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[{"id": "commit1"}],
|
||||
files_changed_since_review=[],
|
||||
diff_since_review="",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"commit_id": "commit1",
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Should we add error handling here?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should detect the question
|
||||
assert any("question" in f.title.lower() for f in result.findings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_comment_filters_by_commit(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that only comments from new commits are reviewed."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[{"id": "commit1"}],
|
||||
files_changed_since_review=[],
|
||||
diff_since_review="",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"commit_id": "commit1", # New commit
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Should we add error handling?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"commit_id": "old-commit", # Old commit, should be ignored
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Another question?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should only have one finding from the new commit
|
||||
question_findings = [f for f in result.findings if "question" in f.title.lower()]
|
||||
assert len(question_findings) == 1
|
||||
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
GitLab MR E2E Tests
|
||||
===================
|
||||
|
||||
End-to-end tests for MR review lifecycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_changes,
|
||||
mock_mr_commits,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
mock_pipeline_jobs,
|
||||
)
|
||||
|
||||
|
||||
class TestMREndToEnd:
|
||||
"""End-to-end MR review lifecycle tests."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_orchestrator(self, tmp_path):
|
||||
"""Create a mock orchestrator for testing."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
model="claude-sonnet-4-20250514",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
enable_bot_detection=False,
|
||||
enable_ci_checking=False,
|
||||
)
|
||||
return orchestrator
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_mr_review_lifecycle(self, mock_orchestrator):
|
||||
"""Test complete MR review from start to finish."""
|
||||
# Mock MR data
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
mock_orchestrator.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
# Mock review engine
|
||||
with patch(
|
||||
"runners.gitlab.services.context_gatherer.MRContextGatherer"
|
||||
) as mock_gatherer:
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRContext,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
mock_gatherer.return_value.gather.return_value = MRContext(
|
||||
mr_iid=123,
|
||||
title="Add feature",
|
||||
description="Implementation",
|
||||
author="john_doe",
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
state="opened",
|
||||
changed_files=[],
|
||||
diff="",
|
||||
commits=[],
|
||||
)
|
||||
|
||||
# Mock review engine to return findings
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Code style",
|
||||
description="Fix formatting",
|
||||
file="file.py",
|
||||
line=10,
|
||||
)
|
||||
]
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
findings,
|
||||
MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"Consider the suggestions",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.review_mr(123)
|
||||
|
||||
assert result.success is True
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.findings) == 1
|
||||
assert result.verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mr_review_with_ci_failure(self, mock_orchestrator):
|
||||
"""Test MR review blocked by CI failure."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
# Setup CI failure
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.ci_checker.CIChecker") as mock_checker:
|
||||
pipeline_info = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="feature",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
Mock(
|
||||
status="failed",
|
||||
name="test",
|
||||
stage="test",
|
||||
failure_reason="Assert failed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
mock_checker.return_value.check_mr_pipeline.return_value = pipeline_info
|
||||
mock_checker.return_value.get_blocking_reason.return_value = (
|
||||
"Test job failed"
|
||||
)
|
||||
mock_checker.return_value.format_pipeline_summary.return_value = (
|
||||
"CI Failed"
|
||||
)
|
||||
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = []
|
||||
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[],
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"Looks good",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.review_mr(123)
|
||||
|
||||
assert result.ci_status == "failed"
|
||||
assert result.ci_pipeline_id == 1001
|
||||
assert "CI" in result.summary
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_review_lifecycle(self, mock_orchestrator):
|
||||
"""Test follow-up review after initial review."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
# Create initial review
|
||||
initial_review = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=[
|
||||
Mock(id="find-1", title="Fix bug"),
|
||||
Mock(id="find-2", title="Add tests"),
|
||||
],
|
||||
reviewed_commit_sha="abc123",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Issues found",
|
||||
blockers=["find-1"],
|
||||
)
|
||||
|
||||
# Save initial review
|
||||
initial_review.save(mock_orchestrator.gitlab_dir)
|
||||
|
||||
# Mock new commits
|
||||
new_commits = mock_mr_commits() + [
|
||||
{
|
||||
"id": "new456",
|
||||
"sha": "new456",
|
||||
"message": "Fix the issues",
|
||||
}
|
||||
]
|
||||
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = new_commits
|
||||
|
||||
# Mock follow-up review
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[], # No new findings
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"All fixed",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.followup_review_mr(123)
|
||||
|
||||
assert result.is_followup_review is True
|
||||
assert result.reviewed_commit_sha == "new456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_detection_skips_review(self, tmp_path):
|
||||
"""Test bot detection skips bot-authored MRs."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
bot_username="auto-claude-bot",
|
||||
)
|
||||
|
||||
# Bot-authored MR
|
||||
bot_mr = mock_mr_data(author="auto-claude-bot")
|
||||
orchestrator.client.get_mr_async.return_value = bot_mr
|
||||
orchestrator.client.get_mr_commits_async.return_value = []
|
||||
|
||||
result = await orchestrator.review_mr(123)
|
||||
|
||||
assert result.success is False
|
||||
assert "bot" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooling_off_prevents_re_review(self, tmp_path):
|
||||
"""Test cooling off period prevents immediate re-review."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# First review
|
||||
orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
orchestrator.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[],
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"Good",
|
||||
[],
|
||||
)
|
||||
|
||||
result1 = await orchestrator.review_mr(123)
|
||||
|
||||
assert result1.success is True
|
||||
|
||||
# Immediate second review should be skipped
|
||||
result2 = await orchestrator.review_mr(123)
|
||||
|
||||
assert result2.success is False
|
||||
assert "cooling" in result2.error.lower()
|
||||
|
||||
|
||||
class TestMRReviewEngineIntegration:
|
||||
"""Test MR review engine integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self, tmp_path):
|
||||
"""Create review engine for testing."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.services.mr_review_engine import MRReviewEngine
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
gitlab_dir = tmp_path / ".auto-claude" / "gitlab"
|
||||
gitlab_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return MRReviewEngine(
|
||||
project_dir=tmp_path,
|
||||
gitlab_dir=gitlab_dir,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_engine_initialization(self, engine):
|
||||
"""Test engine initializes correctly."""
|
||||
assert engine.project_dir
|
||||
assert engine.gitlab_dir
|
||||
assert engine.config
|
||||
|
||||
def test_generate_summary(self, engine):
|
||||
"""Test summary generation."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection",
|
||||
description="Vulnerability",
|
||||
file="file.py",
|
||||
line=10,
|
||||
),
|
||||
MRReviewFinding(
|
||||
id="find-2",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Formatting",
|
||||
description="Style issue",
|
||||
file="file.py",
|
||||
line=20,
|
||||
),
|
||||
]
|
||||
|
||||
summary = engine.generate_summary(
|
||||
findings=findings,
|
||||
verdict=MergeVerdict.BLOCKED,
|
||||
verdict_reasoning="Critical security issue",
|
||||
blockers=["SQL injection"],
|
||||
)
|
||||
|
||||
assert "BLOCKED" in summary
|
||||
assert "SQL injection" in summary
|
||||
assert "Critical" in summary
|
||||
|
||||
|
||||
class TestMRContextGatherer:
|
||||
"""Test MR context gatherer."""
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(self, tmp_path):
|
||||
"""Create context gatherer for testing."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.context_gatherer.GitLabClient"):
|
||||
return MRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_context(self, gatherer):
|
||||
"""Test gathering MR context."""
|
||||
from runners.gitlab.models import MRContext
|
||||
|
||||
# Mock client responses
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
gatherer.client.get_mr_notes_async.return_value = []
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert isinstance(context, MRContext)
|
||||
assert context.mr_iid == 123
|
||||
assert context.title == "Add user authentication feature"
|
||||
assert context.author == "john_doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_ai_bot_comments(self, gatherer):
|
||||
"""Test gathering AI bot comments."""
|
||||
# Mock AI bot comments
|
||||
ai_notes = [
|
||||
{
|
||||
"id": 1001,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider adding error handling",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"author": {"username": "human_user"},
|
||||
"body": "Regular comment",
|
||||
"created_at": "2025-01-14T11:00:00",
|
||||
},
|
||||
]
|
||||
|
||||
gatherer.client.get_mr_notes_async.return_value = ai_notes
|
||||
|
||||
# First call should parse comments
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
# Note: _fetch_ai_bot_comments is called internally during gather()
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
# Verify AI bot comments were detected (context would have them if implemented)
|
||||
assert context.mr_iid == 123
|
||||
|
||||
|
||||
class TestFollowupContextGatherer:
|
||||
"""Test follow-up context gatherer."""
|
||||
|
||||
@pytest.fixture
|
||||
def previous_review(self):
|
||||
"""Create a previous review for testing."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
return MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=[
|
||||
Mock(id="find-1", title="Bug"),
|
||||
],
|
||||
reviewed_commit_sha="abc123",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Issues found",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(self, tmp_path, previous_review):
|
||||
"""Create follow-up context gatherer."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.context_gatherer import FollowupMRContextGatherer
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.context_gatherer.GitLabClient"):
|
||||
return FollowupMRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
previous_review=previous_review,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_followup_context(self, gatherer):
|
||||
"""Test gathering follow-up context."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Mock new commits since previous review
|
||||
new_commits = [
|
||||
{
|
||||
"id": "new456",
|
||||
"sha": "new456",
|
||||
"message": "Fix bug",
|
||||
}
|
||||
]
|
||||
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_commits_async.return_value = new_commits
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert isinstance(context, FollowupMRContext)
|
||||
assert context.mr_iid == 123
|
||||
assert context.previous_commit_sha == "abc123"
|
||||
assert context.current_commit_sha == "new456"
|
||||
assert len(context.commits_since_review) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_new_commits(self, gatherer):
|
||||
"""Test follow-up when no new commits."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Same commits as previous review
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert context.current_commit_sha == "abc123" # Same as previous
|
||||
|
||||
|
||||
class TestAIBotComment:
|
||||
"""Test AI bot comment detection."""
|
||||
|
||||
def test_parse_coderabbit_comment(self):
|
||||
"""Test parsing CodeRabbit comment."""
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
note = {
|
||||
"id": 1001,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Add error handling",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
gatherer_class = MRContextGatherer.__class__
|
||||
|
||||
comment = gatherer_class._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is not None
|
||||
assert comment.tool_name == "CodeRabbit"
|
||||
assert comment.comment_id == 1001
|
||||
|
||||
def test_parse_human_comment(self):
|
||||
"""Test human comment is not detected as AI."""
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
note = {
|
||||
"id": 1002,
|
||||
"author": {"username": "john_doe"},
|
||||
"body": "Regular comment",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
comment = MRContextGatherer._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is None
|
||||
|
||||
def test_parse_greptile_comment(self):
|
||||
"""Test parsing Greptile comment."""
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
note = {
|
||||
"id": 1003,
|
||||
"author": {"username": "greptile[bot]"},
|
||||
"body": "Consider this",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
comment = MRContextGatherer._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is not None
|
||||
assert comment.tool_name == "Greptile"
|
||||
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
GitLab MR Review Tests
|
||||
======================
|
||||
|
||||
Tests for MR review models, findings, verdicts.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_issue_data,
|
||||
mock_mr_data,
|
||||
)
|
||||
|
||||
|
||||
class TestMRReviewFinding:
|
||||
"""Test MRReviewFinding model."""
|
||||
|
||||
def test_finding_creation(self):
|
||||
"""Test creating a review finding."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection vulnerability",
|
||||
description="User input not sanitized in query",
|
||||
file="src/auth.py",
|
||||
line=42,
|
||||
end_line=45,
|
||||
suggested_fix="Use parameterized query",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
assert finding.id == "find-1"
|
||||
assert finding.severity == ReviewSeverity.HIGH
|
||||
assert finding.category == ReviewCategory.SECURITY
|
||||
assert finding.file == "src/auth.py"
|
||||
assert finding.line == 42
|
||||
assert finding.fixable is True
|
||||
|
||||
def test_finding_to_dict(self):
|
||||
"""Test converting finding to dictionary."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection",
|
||||
description="Vulnerability",
|
||||
file="src/auth.py",
|
||||
line=42,
|
||||
)
|
||||
|
||||
data = finding.to_dict()
|
||||
|
||||
assert data["id"] == "find-1"
|
||||
assert data["severity"] == "high"
|
||||
assert data["category"] == "security"
|
||||
|
||||
def test_finding_from_dict(self):
|
||||
"""Test loading finding from dictionary."""
|
||||
from runners.gitlab.models import MRReviewFinding
|
||||
|
||||
data = {
|
||||
"id": "find-1",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"title": "SQL injection",
|
||||
"description": "Vulnerability",
|
||||
"file": "src/auth.py",
|
||||
"line": 42,
|
||||
"end_line": 45,
|
||||
"suggested_fix": "Fix it",
|
||||
"fixable": True,
|
||||
}
|
||||
|
||||
finding = MRReviewFinding.from_dict(data)
|
||||
|
||||
assert finding.id == "find-1"
|
||||
assert finding.severity.value == "high"
|
||||
assert finding.line == 42
|
||||
|
||||
def test_finding_with_evidence_code(self):
|
||||
"""Test finding with evidence code."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewPass,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Command injection",
|
||||
description="User input in subprocess",
|
||||
file="src/exec.py",
|
||||
line=10,
|
||||
evidence_code="subprocess.call(user_input, shell=True)",
|
||||
found_by_pass=ReviewPass.SECURITY,
|
||||
)
|
||||
|
||||
assert finding.evidence_code == "subprocess.call(user_input, shell=True)"
|
||||
assert finding.found_by_pass == ReviewPass.SECURITY
|
||||
|
||||
|
||||
class TestStructuralIssue:
|
||||
"""Test StructuralIssue model."""
|
||||
|
||||
def test_structural_issue_creation(self):
|
||||
"""Test creating a structural issue."""
|
||||
from runners.gitlab.models import ReviewSeverity, StructuralIssue
|
||||
|
||||
issue = StructuralIssue(
|
||||
id="struct-1",
|
||||
type="feature_creep",
|
||||
title="Additional features added",
|
||||
description="MR includes features beyond original scope",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
files_affected=["src/auth.py", "src/users.py"],
|
||||
)
|
||||
|
||||
assert issue.id == "struct-1"
|
||||
assert issue.type == "feature_creep"
|
||||
assert issue.files_affected == ["src/auth.py", "src/users.py"]
|
||||
|
||||
def test_structural_issue_to_dict(self):
|
||||
"""Test converting structural issue to dictionary."""
|
||||
from runners.gitlab.models import StructuralIssue
|
||||
|
||||
issue = StructuralIssue(
|
||||
id="struct-1",
|
||||
type="scope_change",
|
||||
title="Scope increased",
|
||||
description="MR scope changed significantly",
|
||||
files_affected=["file1.py"],
|
||||
)
|
||||
|
||||
data = issue.to_dict()
|
||||
|
||||
assert data["id"] == "struct-1"
|
||||
assert data["type"] == "scope_change"
|
||||
|
||||
def test_structural_issue_from_dict(self):
|
||||
"""Test loading structural issue from dictionary."""
|
||||
from runners.gitlab.models import StructuralIssue
|
||||
|
||||
data = {
|
||||
"id": "struct-1",
|
||||
"type": "feature_creep",
|
||||
"title": "Extra features",
|
||||
"description": "Beyond scope",
|
||||
"severity": "medium",
|
||||
"files_affected": ["file.py"],
|
||||
}
|
||||
|
||||
issue = StructuralIssue.from_dict(data)
|
||||
|
||||
assert issue.type == "feature_creep"
|
||||
|
||||
|
||||
class TestAICommentTriage:
|
||||
"""Test AICommentTriage model."""
|
||||
|
||||
def test_triage_creation(self):
|
||||
"""Test creating AI comment triage."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Consider adding error handling",
|
||||
triage_result="valid",
|
||||
reasoning="Good point about error handling",
|
||||
file="src/auth.py",
|
||||
line=50,
|
||||
created_at="2025-01-14T10:00:00",
|
||||
)
|
||||
|
||||
assert triage.comment_id == 1001
|
||||
assert triage.tool_name == "CodeRabbit"
|
||||
assert triage.triage_result == "valid"
|
||||
|
||||
def test_triage_to_dict(self):
|
||||
"""Test converting triage to dictionary."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Add tests",
|
||||
triage_result="false_positive",
|
||||
reasoning="Tests already exist",
|
||||
)
|
||||
|
||||
data = triage.to_dict()
|
||||
|
||||
assert data["comment_id"] == 1001
|
||||
assert data["triage_result"] == "false_positive"
|
||||
|
||||
def test_triage_from_dict(self):
|
||||
"""Test loading triage from dictionary."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
data = {
|
||||
"comment_id": 1001,
|
||||
"tool_name": "Cursor",
|
||||
"original_comment": "Fix bug",
|
||||
"triage_result": "questionable",
|
||||
"reasoning": "Unclear if bug exists",
|
||||
"file": "file.py",
|
||||
"line": 10,
|
||||
}
|
||||
|
||||
triage = AICommentTriage.from_dict(data)
|
||||
|
||||
assert triage.tool_name == "Cursor"
|
||||
assert triage.triage_result == "questionable"
|
||||
|
||||
|
||||
class TestMRReviewResult:
|
||||
"""Test MRReviewResult model."""
|
||||
|
||||
def test_result_creation(self):
|
||||
"""Test creating review result."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Bug",
|
||||
description="Issue",
|
||||
file="file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=findings,
|
||||
summary="Review complete",
|
||||
overall_status="approve",
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="No issues found",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert result.findings == findings
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
def test_result_with_structural_issues(self):
|
||||
"""Test result with structural issues."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewResult,
|
||||
StructuralIssue,
|
||||
)
|
||||
|
||||
structural_issues = [
|
||||
StructuralIssue(
|
||||
id="struct-1",
|
||||
type="feature_creep",
|
||||
title="Extra features",
|
||||
description="Beyond scope",
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
structural_issues=structural_issues,
|
||||
verdict=MergeVerdict.MERGE_WITH_CHANGES,
|
||||
verdict_reasoning="Feature creep detected",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert len(result.structural_issues) == 1
|
||||
assert result.verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
def test_result_with_ai_triages(self):
|
||||
"""Test result with AI comment triages."""
|
||||
from runners.gitlab.models import (
|
||||
AICommentTriage,
|
||||
MergeVerdict,
|
||||
MRReviewResult,
|
||||
)
|
||||
|
||||
ai_triages = [
|
||||
AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Fix bug",
|
||||
triage_result="valid",
|
||||
reasoning="Correct",
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
ai_triages=ai_triages,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="All good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert len(result.ai_triages) == 1
|
||||
|
||||
def test_result_with_ci_status(self):
|
||||
"""Test result with CI/CD status."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
ci_status="failed",
|
||||
ci_pipeline_id=1001,
|
||||
verdict=MergeVerdict.BLOCKED,
|
||||
verdict_reasoning="CI failed",
|
||||
blockers=["CI Pipeline Failed"],
|
||||
)
|
||||
|
||||
assert result.ci_status == "failed"
|
||||
assert result.ci_pipeline_id == 1001
|
||||
assert result.verdict == MergeVerdict.BLOCKED
|
||||
|
||||
def test_result_to_dict(self):
|
||||
"""Test converting result to dictionary."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
|
||||
assert data["mr_iid"] == 123
|
||||
assert data["verdict"] == "ready_to_merge"
|
||||
|
||||
def test_result_from_dict(self):
|
||||
"""Test loading result from dictionary."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
data = {
|
||||
"mr_iid": 123,
|
||||
"project": "group/project",
|
||||
"success": True,
|
||||
"findings": [],
|
||||
"summary": "Review",
|
||||
"overall_status": "approve",
|
||||
"verdict": "ready_to_merge",
|
||||
"verdict_reasoning": "Good",
|
||||
"blockers": [],
|
||||
}
|
||||
|
||||
result = MRReviewResult.from_dict(data)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
def test_result_save_and_load(self, tmp_path):
|
||||
"""Test saving and loading result from disk."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
result.save(tmp_path)
|
||||
|
||||
loaded = MRReviewResult.load(tmp_path, 123)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.mr_iid == 123
|
||||
|
||||
def test_followup_review_fields(self):
|
||||
"""Test follow-up review fields."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha="abc123",
|
||||
resolved_findings=["find-1"],
|
||||
unresolved_findings=["find-2"],
|
||||
new_findings_since_last_review=["find-3"],
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert result.is_followup_review is True
|
||||
assert result.reviewed_commit_sha == "abc123"
|
||||
assert len(result.resolved_findings) == 1
|
||||
|
||||
|
||||
class TestReviewPass:
|
||||
"""Test ReviewPass enum."""
|
||||
|
||||
def test_all_passes_defined(self):
|
||||
"""Test all review passes are defined."""
|
||||
from runners.gitlab.models import ReviewPass
|
||||
|
||||
assert ReviewPass.QUICK_SCAN
|
||||
assert ReviewPass.SECURITY
|
||||
assert ReviewPass.QUALITY
|
||||
assert ReviewPass.DEEP_ANALYSIS
|
||||
assert ReviewPass.STRUCTURAL
|
||||
assert ReviewPass.AI_COMMENT_TRIAGE
|
||||
|
||||
def test_pass_values(self):
|
||||
"""Test pass enum values."""
|
||||
from runners.gitlab.models import ReviewPass
|
||||
|
||||
assert ReviewPass.QUICK_SCAN.value == "quick_scan"
|
||||
assert ReviewPass.SECURITY.value == "security"
|
||||
assert ReviewPass.QUALITY.value == "quality"
|
||||
assert ReviewPass.DEEP_ANALYSIS.value == "deep_analysis"
|
||||
assert ReviewPass.STRUCTURAL.value == "structural"
|
||||
assert ReviewPass.AI_COMMENT_TRIAGE.value == "ai_comment_triage"
|
||||
|
||||
|
||||
class TestMergeVerdict:
|
||||
"""Test MergeVerdict enum."""
|
||||
|
||||
def test_all_verdicts_defined(self):
|
||||
"""Test all verdicts are defined."""
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
assert MergeVerdict.READY_TO_MERGE
|
||||
assert MergeVerdict.MERGE_WITH_CHANGES
|
||||
assert MergeVerdict.NEEDS_REVISION
|
||||
assert MergeVerdict.BLOCKED
|
||||
|
||||
def test_verdict_values(self):
|
||||
"""Test verdict enum values."""
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
assert MergeVerdict.READY_TO_MERGE.value == "ready_to_merge"
|
||||
assert MergeVerdict.MERGE_WITH_CHANGES.value == "merge_with_changes"
|
||||
assert MergeVerdict.NEEDS_REVISION.value == "needs_revision"
|
||||
assert MergeVerdict.BLOCKED.value == "blocked"
|
||||
|
||||
|
||||
class TestReviewSeverity:
|
||||
"""Test ReviewSeverity enum."""
|
||||
|
||||
def test_all_severities(self):
|
||||
"""Test all severity levels."""
|
||||
from runners.gitlab.models import ReviewSeverity
|
||||
|
||||
assert ReviewSeverity.CRITICAL
|
||||
assert ReviewSeverity.HIGH
|
||||
assert ReviewSeverity.MEDIUM
|
||||
assert ReviewSeverity.LOW
|
||||
|
||||
|
||||
class TestReviewCategory:
|
||||
"""Test ReviewCategory enum."""
|
||||
|
||||
def test_all_categories(self):
|
||||
"""Test all categories."""
|
||||
from runners.gitlab.models import ReviewCategory
|
||||
|
||||
assert ReviewCategory.SECURITY
|
||||
assert ReviewCategory.QUALITY
|
||||
assert ReviewCategory.STYLE
|
||||
assert ReviewCategory.TEST
|
||||
assert ReviewCategory.DOCS
|
||||
assert ReviewCategory.PATTERN
|
||||
assert ReviewCategory.PERFORMANCE
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Unit Tests for GitLab Permission System
|
||||
========================================
|
||||
|
||||
Tests for GitLabPermissionChecker and permission verification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from runners.gitlab.permissions import (
|
||||
GitLabPermissionChecker,
|
||||
GitLabRole,
|
||||
PermissionCheckResult,
|
||||
)
|
||||
from runners.gitlab.permissions import PermissionError as GitLabPermissionError
|
||||
|
||||
|
||||
class MockGitLabClient:
|
||||
"""Mock GitLab API client for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._fetch_async = AsyncMock()
|
||||
self.get_project_members_async = AsyncMock(return_value=[])
|
||||
|
||||
def config(self):
|
||||
"""Return mock config."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.project = "namespace/project"
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_glab_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MockGitLabClient()
|
||||
client.config = MagicMock()
|
||||
client.config.project = "namespace/test-project"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def permission_checker(mock_glab_client):
|
||||
"""Create a permission checker instance."""
|
||||
return GitLabPermissionChecker(
|
||||
glab_client=mock_glab_client,
|
||||
project="namespace/test-project",
|
||||
allowed_roles=["OWNER", "MAINTAINER"],
|
||||
allow_external_contributors=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_success(permission_checker, mock_glab_client):
|
||||
"""Test successful token scope verification."""
|
||||
mock_glab_client._fetch_async.return_value = {
|
||||
"id": 123,
|
||||
"name": "test-project",
|
||||
"path_with_namespace": "namespace/test-project",
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_project_not_found(
|
||||
permission_checker, mock_glab_client
|
||||
):
|
||||
"""Test project not found raises GitLabPermissionError."""
|
||||
mock_glab_client._fetch_async.return_value = None
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="Cannot access project"):
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_success(permission_checker, mock_glab_client):
|
||||
"""Test successfully finding who added a label."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"user": {"username": "bob"},
|
||||
"action": "remove",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
]
|
||||
|
||||
username, role = await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
assert username == "alice"
|
||||
assert role in [
|
||||
"OWNER",
|
||||
"MAINTAINER",
|
||||
"DEVELOPER",
|
||||
"REPORTER",
|
||||
"GUEST",
|
||||
"NONE",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_label_not_found(permission_checker, mock_glab_client):
|
||||
"""Test label not found raises GitLabPermissionError."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "bug"},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="not found in issue"):
|
||||
await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_no_username(permission_checker, mock_glab_client):
|
||||
"""Test label event without username raises GitLabPermissionError."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="Could not determine who added"):
|
||||
await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_project_member(permission_checker, mock_glab_client):
|
||||
"""Test getting role for project member."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40, # MAINTAINER
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "MAINTAINER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_owner_via_namespace(permission_checker, mock_glab_client):
|
||||
"""Test getting OWNER role via namespace ownership."""
|
||||
# Not a direct member
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
[], # No project members
|
||||
{ # Project info
|
||||
"id": 123,
|
||||
"namespace": {
|
||||
"full_path": "namespace",
|
||||
"owner_id": 999,
|
||||
},
|
||||
},
|
||||
[ # User info matches owner
|
||||
{
|
||||
"id": 999,
|
||||
"username": "alice",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "OWNER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_no_relationship(permission_checker, mock_glab_client):
|
||||
"""Test getting role for user with no relationship."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
[], # No project members
|
||||
{ # Project info
|
||||
"id": 123,
|
||||
"namespace": {
|
||||
"full_path": "namespace",
|
||||
"owner_id": 999,
|
||||
},
|
||||
},
|
||||
[ # User doesn't match owner
|
||||
{
|
||||
"id": 111,
|
||||
"username": "alice",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_uses_cache(permission_checker, mock_glab_client):
|
||||
"""Test that role results are cached."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40,
|
||||
},
|
||||
]
|
||||
|
||||
# First call
|
||||
role1 = await permission_checker.get_user_role("alice")
|
||||
# Second call should use cache
|
||||
role2 = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role1 == role2 == "MAINTAINER"
|
||||
# Should only call API once
|
||||
assert mock_glab_client.get_project_members_async.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_allowed(permission_checker, mock_glab_client):
|
||||
"""Test user is allowed for auto-fix."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40, # MAINTAINER
|
||||
},
|
||||
]
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("alice")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.username == "alice"
|
||||
assert result.role == "MAINTAINER"
|
||||
assert result.reason is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_denied(permission_checker, mock_glab_client):
|
||||
"""Test user is denied for auto-fix."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "bob",
|
||||
"access_level": 20, # REPORTER (not in allowed roles)
|
||||
},
|
||||
]
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("bob")
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.username == "bob"
|
||||
assert result.role == "REPORTER"
|
||||
assert "not in allowed roles" in result.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_allowed(permission_checker, mock_glab_client):
|
||||
"""Test complete verification succeeds for allowed user."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
# Label events
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
],
|
||||
# User role check
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_denied_logs_warning(
|
||||
permission_checker, mock_glab_client, caplog
|
||||
):
|
||||
"""Test denial is logged with full context."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
# Label events
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "bob"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
],
|
||||
# User role check
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"username": "bob",
|
||||
"access_level": 20, # REPORTER
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is False
|
||||
|
||||
|
||||
def test_log_permission_denial(permission_checker, caplog):
|
||||
"""Test permission denial logging includes full context."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
permission_checker.log_permission_denial(
|
||||
action="auto-fix",
|
||||
username="bob",
|
||||
role="REPORTER",
|
||||
issue_iid=123,
|
||||
)
|
||||
|
||||
# Check that the log contains all relevant info
|
||||
assert len(caplog.records) > 0
|
||||
log_message = caplog.records[0].message
|
||||
assert "auto-fix" in log_message
|
||||
assert "bob" in log_message
|
||||
assert "REPORTER" in log_message
|
||||
assert "123" in log_message
|
||||
|
||||
|
||||
def test_access_levels():
|
||||
"""Test access level constants are correct."""
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["GUEST"] == 10
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["REPORTER"] == 20
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["DEVELOPER"] == 30
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["MAINTAINER"] == 40
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["OWNER"] == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_developer(permission_checker, mock_glab_client):
|
||||
"""Test getting DEVELOPER role."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "dev",
|
||||
"access_level": 30,
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("dev")
|
||||
|
||||
assert role == "DEVELOPER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_guest(permission_checker, mock_glab_client):
|
||||
"""Test getting GUEST role."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "guest",
|
||||
"access_level": 10,
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("guest")
|
||||
|
||||
assert role == "GUEST"
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
GitLab Provider Tests
|
||||
=====================
|
||||
|
||||
Tests for GitLabProvider implementation of the GitProvider protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_issue_data,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
)
|
||||
|
||||
# Mock ProviderType enum since GitHub runners aren't available in this branch
|
||||
# Note: GitLabProvider defines its own ProviderType when GitHub runners aren't available,
|
||||
# so we just use the string value for comparison
|
||||
GITLAB_PROVIDER_VALUE = "gitlab" # GitHub protocol uses lowercase
|
||||
|
||||
# Tests for GitLabProvider
|
||||
|
||||
|
||||
class TestGitLabProvider:
|
||||
"""Test GitLabProvider implements GitProvider protocol correctly."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self, tmp_path):
|
||||
"""Create a GitLabProvider instance for testing."""
|
||||
from runners.gitlab.providers.gitlab_provider import GitLabProvider
|
||||
|
||||
with patch(
|
||||
"runners.gitlab.providers.gitlab_provider.GitLabClient"
|
||||
) as mock_client:
|
||||
provider = GitLabProvider(
|
||||
_repo="group/project",
|
||||
_token="test-token",
|
||||
_instance_url="https://gitlab.example.com",
|
||||
_project_dir=tmp_path,
|
||||
_glab_client=mock_client.return_value,
|
||||
)
|
||||
return provider
|
||||
|
||||
def test_provider_type_property(self, provider):
|
||||
"""Test provider type is GitLab."""
|
||||
# Compare the value since ProviderType may be defined in different modules
|
||||
assert provider.provider_type.value == GITLAB_PROVIDER_VALUE
|
||||
|
||||
def test_repo_property(self, provider):
|
||||
"""Test repo property returns the repository."""
|
||||
assert provider.repo == "group/project"
|
||||
|
||||
def test_fetch_pr(self, provider):
|
||||
"""Test fetching a single MR."""
|
||||
# Mock client responses
|
||||
provider._glab_client.get_mr.return_value = mock_mr_data()
|
||||
provider._glab_client.get_mr_changes.return_value = {
|
||||
"changes": [
|
||||
{
|
||||
"diff": "@@ -0,0 +1,10 @@\n+new line",
|
||||
"new_path": "test.py",
|
||||
"old_path": "test.py",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Fetch MR
|
||||
pr = await_if_needed(provider.fetch_pr(123))
|
||||
|
||||
assert pr.number == 123
|
||||
assert pr.title == "Add user authentication feature"
|
||||
assert pr.author == "john_doe"
|
||||
assert pr.state == "opened"
|
||||
assert pr.source_branch == "feature/oauth-auth"
|
||||
assert pr.target_branch == "main"
|
||||
assert pr.provider.name == "GITLAB"
|
||||
|
||||
def test_fetch_prs_with_filters(self, provider):
|
||||
"""Test fetching multiple MRs with filters."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
mock_mr_data(iid=100),
|
||||
mock_mr_data(iid=101, state="closed"),
|
||||
]
|
||||
|
||||
prs = await_if_needed(provider.fetch_prs())
|
||||
|
||||
assert len(prs) == 2
|
||||
|
||||
def test_fetch_pr_diff(self, provider):
|
||||
"""Test fetching MR diff."""
|
||||
expected_diff = "diff content here"
|
||||
provider._glab_client.get_mr_diff.return_value = expected_diff
|
||||
|
||||
diff = await_if_needed(provider.fetch_pr_diff(123))
|
||||
|
||||
assert diff == expected_diff
|
||||
|
||||
def test_fetch_issue(self, provider):
|
||||
"""Test fetching a single issue."""
|
||||
from __tests__.fixtures.gitlab import SAMPLE_ISSUE_DATA
|
||||
|
||||
provider._glab_client._fetch.return_value = SAMPLE_ISSUE_DATA
|
||||
|
||||
issue = await_if_needed(provider.fetch_issue(42))
|
||||
|
||||
assert issue.number == 42
|
||||
assert issue.title == "Bug: Login button not working"
|
||||
assert issue.author == "jane_smith"
|
||||
assert issue.state == "opened"
|
||||
|
||||
def test_fetch_issues_with_filters(self, provider):
|
||||
"""Test fetching issues with filters."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
mock_issue_data(iid=10),
|
||||
mock_issue_data(iid=11),
|
||||
]
|
||||
|
||||
issues = await_if_needed(provider.fetch_issues())
|
||||
|
||||
assert len(issues) == 2
|
||||
|
||||
def test_post_review(self, provider):
|
||||
"""Test posting a review to an MR."""
|
||||
# Import ReviewData from GitHub protocol (which GitLabProvider uses)
|
||||
from runners.github.providers.protocol import ReviewData
|
||||
|
||||
provider._glab_client.post_mr_note.return_value = {"id": 999}
|
||||
provider._glab_client._fetch.return_value = {} # approve MR response
|
||||
|
||||
review = ReviewData(
|
||||
pr_number=123,
|
||||
body="LGTM with minor suggestions",
|
||||
event="approve",
|
||||
)
|
||||
|
||||
note_id = await_if_needed(provider.post_review(123, review))
|
||||
|
||||
assert note_id == 999
|
||||
provider._glab_client.post_mr_note.assert_called_once()
|
||||
|
||||
def test_merge_pr(self, provider):
|
||||
"""Test merging an MR."""
|
||||
provider._glab_client.merge_mr.return_value = {"status": "success"}
|
||||
|
||||
result = await_if_needed(provider.merge_pr(123, merge_method="merge"))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_close_pr(self, provider):
|
||||
"""Test closing an MR."""
|
||||
provider._glab_client._fetch.return_value = {}
|
||||
|
||||
result = await_if_needed(
|
||||
provider.close_pr(123, comment="Closing as not needed")
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_create_label(self, provider):
|
||||
"""Test creating a label."""
|
||||
# Use LabelData from the provider's fallback protocol
|
||||
from runners.gitlab.providers.gitlab_provider import (
|
||||
LabelData as GitLabLabelData,
|
||||
)
|
||||
|
||||
# Create an alias for readability
|
||||
LabelData = GitLabLabelData
|
||||
|
||||
provider._glab_client._fetch.return_value = {}
|
||||
|
||||
label = LabelData(
|
||||
name="bug",
|
||||
color="#ff0000",
|
||||
description="Bug report",
|
||||
)
|
||||
|
||||
await_if_needed(provider.create_label(label))
|
||||
|
||||
# Verify call was made (checking that it didn't raise)
|
||||
provider._glab_client._fetch.assert_called()
|
||||
|
||||
def test_list_labels(self, provider):
|
||||
"""Test listing labels."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
{"name": "bug", "color": "ff0000", "description": "Bug"},
|
||||
{"name": "feature", "color": "00ff00", "description": "Feature"},
|
||||
]
|
||||
|
||||
labels = await_if_needed(provider.list_labels())
|
||||
|
||||
assert len(labels) == 2
|
||||
assert labels[0].name == "bug"
|
||||
assert labels[0].color == "#ff0000"
|
||||
|
||||
def test_get_repository_info(self, provider):
|
||||
"""Test getting repository info."""
|
||||
provider._glab_client._fetch.return_value = {
|
||||
"name": "project",
|
||||
"path_with_namespace": "group/project",
|
||||
"default_branch": "main",
|
||||
}
|
||||
|
||||
info = await_if_needed(provider.get_repository_info())
|
||||
|
||||
assert info["default_branch"] == "main"
|
||||
|
||||
def test_get_default_branch(self, provider):
|
||||
"""Test getting default branch."""
|
||||
provider._glab_client._fetch.return_value = {
|
||||
"default_branch": "main",
|
||||
}
|
||||
|
||||
branch = await_if_needed(provider.get_default_branch())
|
||||
|
||||
assert branch == "main"
|
||||
|
||||
def test_api_get(self, provider):
|
||||
"""Test low-level API GET."""
|
||||
provider._glab_client._fetch.return_value = {"data": "value"}
|
||||
|
||||
result = await_if_needed(provider.api_get("/projects/1"))
|
||||
|
||||
assert result["data"] == "value"
|
||||
|
||||
def test_api_post(self, provider):
|
||||
"""Test low-level API POST."""
|
||||
provider._glab_client._fetch.return_value = {"id": 123}
|
||||
|
||||
result = await_if_needed(
|
||||
provider.api_post("/projects/1/notes", {"body": "test"})
|
||||
)
|
||||
|
||||
assert result["id"] == 123
|
||||
|
||||
|
||||
def await_if_needed(coro_or_result):
|
||||
"""Helper to await async functions if needed."""
|
||||
import asyncio
|
||||
|
||||
if hasattr(coro_or_result, "__await__"):
|
||||
return asyncio.run(coro_or_result)
|
||||
return coro_or_result
|
||||
@@ -0,0 +1,519 @@
|
||||
"""
|
||||
GitLab Rate Limiter Tests
|
||||
=========================
|
||||
|
||||
Tests for token bucket rate limiting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTokenBucket:
|
||||
"""Test TokenBucket for rate limiting."""
|
||||
|
||||
def test_token_bucket_initialization(self):
|
||||
"""Test token bucket initializes correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
assert bucket.capacity == 10
|
||||
assert bucket.refill_rate == 5.0
|
||||
assert bucket.tokens == 10
|
||||
|
||||
def test_token_bucket_consume_success(self):
|
||||
"""Test consuming tokens when available."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
success = bucket.consume(1)
|
||||
|
||||
assert success is True
|
||||
assert bucket.tokens == 9
|
||||
|
||||
def test_token_bucket_consume_multiple(self):
|
||||
"""Test consuming multiple tokens."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
success = bucket.consume(5)
|
||||
|
||||
assert success is True
|
||||
assert bucket.tokens == 5
|
||||
|
||||
def test_token_bucket_consume_insufficient(self):
|
||||
"""Test consuming when insufficient tokens."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
# Consume more than available
|
||||
success = bucket.consume(15)
|
||||
|
||||
assert success is False
|
||||
assert bucket.tokens == 10 # Should not change
|
||||
|
||||
def test_token_bucket_refill(self):
|
||||
"""Test token refill over time."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=10.0)
|
||||
|
||||
# Consume all tokens
|
||||
bucket.consume(10)
|
||||
assert bucket.tokens == 0
|
||||
|
||||
# Wait for refill (0.1 seconds at 10 tokens/sec = 1 token)
|
||||
time.sleep(0.11)
|
||||
|
||||
# Check refill
|
||||
available = bucket.tokens
|
||||
assert available >= 1
|
||||
|
||||
def test_token_bucket_refill_cap(self):
|
||||
"""Test tokens don't exceed capacity."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=100.0)
|
||||
|
||||
# Wait long time for refill
|
||||
time.sleep(0.2)
|
||||
|
||||
# Should not exceed capacity
|
||||
assert bucket.tokens <= 10
|
||||
|
||||
def test_token_bucket_wait_for_token(self):
|
||||
"""Test waiting for token availability."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=5, refill_rate=10.0)
|
||||
|
||||
# Consume all
|
||||
bucket.consume(5)
|
||||
|
||||
# Should wait for refill
|
||||
start = time.time()
|
||||
bucket.consume(1, wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should have waited at least 0.1 seconds
|
||||
assert elapsed >= 0.1
|
||||
|
||||
def test_token_bucket_wait_with_tokens(self):
|
||||
"""Test wait returns immediately when tokens available."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
start = time.time()
|
||||
bucket.consume(1, wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should be immediate
|
||||
assert elapsed < 0.01
|
||||
|
||||
def test_token_bucket_get_available(self):
|
||||
"""Test getting available token count."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
assert bucket.get_available() == 10
|
||||
|
||||
bucket.consume(3)
|
||||
assert bucket.get_available() == 7
|
||||
|
||||
def test_token_bucket_reset(self):
|
||||
"""Test resetting token bucket."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
bucket.consume(5)
|
||||
assert bucket.tokens == 5
|
||||
|
||||
bucket.reset()
|
||||
assert bucket.tokens == 10
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Test RateLimiter for API rate limiting."""
|
||||
|
||||
@pytest.fixture
|
||||
def limiter(self):
|
||||
"""Create a rate limiter for testing."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
return RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
def test_rate_limiter_initialization(self):
|
||||
"""Test rate limiter initializes correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
assert limiter.requests_per_minute == 60
|
||||
assert limiter.burst_size == 10
|
||||
|
||||
def test_acquire_request(self, limiter):
|
||||
"""Test acquiring a request slot."""
|
||||
success = limiter.acquire()
|
||||
|
||||
assert success is True
|
||||
|
||||
def test_acquire_burst(self, limiter):
|
||||
"""Test burst requests."""
|
||||
# Should be able to make burst_size requests immediately
|
||||
for _ in range(10):
|
||||
success = limiter.acquire()
|
||||
assert success is True
|
||||
|
||||
def test_acquire_exceeds_burst(self, limiter):
|
||||
"""Test exceeding burst limit."""
|
||||
# Consume burst capacity
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Next request should fail
|
||||
success = limiter.acquire()
|
||||
assert success is False
|
||||
|
||||
def test_acquire_with_wait(self, limiter):
|
||||
"""Test acquire with wait option."""
|
||||
# Consume burst
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Should wait for refill
|
||||
start = time.time()
|
||||
success = limiter.acquire(wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert success is True
|
||||
# At 60 req/min, 1 request = 1 second
|
||||
assert elapsed >= 0.9
|
||||
|
||||
def test_get_wait_time(self, limiter):
|
||||
"""Test getting wait time."""
|
||||
# No wait needed initially
|
||||
wait_time = limiter.get_wait_time()
|
||||
assert wait_time == 0
|
||||
|
||||
# Consume burst
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Should need to wait
|
||||
wait_time = limiter.get_wait_time()
|
||||
assert wait_time > 0
|
||||
|
||||
def test_reset(self, limiter):
|
||||
"""Test resetting rate limiter."""
|
||||
# Consume some capacity
|
||||
for _ in range(5):
|
||||
limiter.acquire()
|
||||
|
||||
limiter.reset()
|
||||
|
||||
# Should have full capacity
|
||||
success = limiter.acquire()
|
||||
assert success is True
|
||||
|
||||
def test_rate_limiter_state_tracking(self, limiter):
|
||||
"""Test rate limiter tracks request state."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = limiter.get_state()
|
||||
|
||||
assert isinstance(state, RateLimiterState)
|
||||
assert state.available_tokens >= 0
|
||||
assert state.available_tokens <= limiter.burst_size
|
||||
|
||||
def test_concurrent_requests(self, limiter):
|
||||
"""Test concurrent request handling."""
|
||||
import threading
|
||||
|
||||
results = []
|
||||
|
||||
def make_request():
|
||||
success = limiter.acquire(wait=True)
|
||||
results.append(success)
|
||||
|
||||
threads = [threading.Thread(target=make_request) for _ in range(15)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All requests should succeed (some wait for refill)
|
||||
assert all(results)
|
||||
|
||||
def test_rate_limiter_persistence(self, limiter, tmp_path):
|
||||
"""Test saving and loading rate limiter state."""
|
||||
state_file = tmp_path / "rate_limiter_state.json"
|
||||
|
||||
# Consume some tokens
|
||||
for _ in range(5):
|
||||
limiter.acquire()
|
||||
|
||||
# Save state
|
||||
limiter.save_state(state_file)
|
||||
|
||||
# Create new limiter and load state
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
new_limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
new_limiter.load_state(state_file)
|
||||
|
||||
# Should have same state
|
||||
original_state = limiter.get_state()
|
||||
loaded_state = new_limiter.get_state()
|
||||
|
||||
assert abs(original_state.available_tokens - loaded_state.available_tokens) < 1
|
||||
|
||||
|
||||
class TestRateLimiterIntegration:
|
||||
"""Integration tests for rate limiting with API calls."""
|
||||
|
||||
def test_rate_limiter_with_api_client(self):
|
||||
"""Test rate limiter integrates with API client."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=5,
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_api_call():
|
||||
nonlocal call_count
|
||||
if limiter.acquire(wait=True):
|
||||
call_count += 1
|
||||
return {"data": "success"}
|
||||
return {"error": "rate limited"}
|
||||
|
||||
# Make several calls
|
||||
results = [mock_api_call() for _ in range(8)]
|
||||
|
||||
# Should have made all calls successfully (some waited)
|
||||
assert call_count == 8
|
||||
assert all(r.get("data") for r in results)
|
||||
|
||||
def test_rate_limiter_respects_backoff(self):
|
||||
"""Test rate limiter handles backoff correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=30, # 0.5 req/sec
|
||||
burst_size=3,
|
||||
)
|
||||
|
||||
times = []
|
||||
|
||||
def track_time():
|
||||
times.append(time.time())
|
||||
return limiter.acquire(wait=True)
|
||||
|
||||
# Make burst + 1 requests
|
||||
for _ in range(4):
|
||||
track_time()
|
||||
|
||||
# First 3 should be immediate (burst)
|
||||
# 4th should have waited
|
||||
burst_duration = times[2] - times[0]
|
||||
wait_duration = times[3] - times[2]
|
||||
|
||||
# 4th request should have taken longer
|
||||
assert wait_duration > burst_duration
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_rate_limiting(self):
|
||||
"""Test rate limiting with async operations."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=5,
|
||||
)
|
||||
|
||||
async def make_request(i):
|
||||
if limiter.acquire(wait=True):
|
||||
await asyncio.sleep(0.01) # Simulate API call
|
||||
return f"request-{i}"
|
||||
return "rate-limited"
|
||||
|
||||
results = await asyncio.gather(*[make_request(i) for i in range(8)])
|
||||
|
||||
# All should succeed
|
||||
assert len(results) == 8
|
||||
assert all("rate-limited" not in r for r in results)
|
||||
|
||||
|
||||
class TestRateLimiterState:
|
||||
"""Test RateLimiterState model."""
|
||||
|
||||
def test_state_creation(self):
|
||||
"""Test creating state object."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = RateLimiterState(
|
||||
available_tokens=5.0,
|
||||
last_refill_time=1234567890.0,
|
||||
)
|
||||
|
||||
assert state.available_tokens == 5.0
|
||||
assert state.last_refill_time == 1234567890.0
|
||||
|
||||
def test_state_to_dict(self):
|
||||
"""Test converting state to dict."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = RateLimiterState(
|
||||
available_tokens=7.5,
|
||||
last_refill_time=1234567890.0,
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
|
||||
assert data["available_tokens"] == 7.5
|
||||
assert data["last_refill_time"] == 1234567890.0
|
||||
|
||||
def test_state_from_dict(self):
|
||||
"""Test loading state from dict."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
data = {
|
||||
"available_tokens": 8.0,
|
||||
"last_refill_time": 1234567890.0,
|
||||
}
|
||||
|
||||
state = RateLimiterState.from_dict(data)
|
||||
|
||||
assert state.available_tokens == 8.0
|
||||
assert state.last_refill_time == 1234567890.0
|
||||
|
||||
|
||||
class TestRateLimiterDecorators:
|
||||
"""Test rate limiter decorators."""
|
||||
|
||||
def test_rate_limit_decorator(self):
|
||||
"""Test rate limit decorator for functions."""
|
||||
from runners.gitlab.utils.rate_limiter import rate_limit
|
||||
|
||||
limiter = type(
|
||||
"MockLimiter",
|
||||
(),
|
||||
{
|
||||
"acquire": lambda wait=True: True,
|
||||
},
|
||||
)()
|
||||
|
||||
@rate_limit(limiter)
|
||||
def api_function():
|
||||
return "success"
|
||||
|
||||
result = api_function()
|
||||
assert result == "success"
|
||||
|
||||
def test_rate_limit_decorator_with_wait(self):
|
||||
"""Test rate limit decorator respects wait parameter."""
|
||||
from runners.gitlab.utils.rate_limiter import rate_limit
|
||||
|
||||
call_count = 0
|
||||
|
||||
class MockLimiter:
|
||||
def acquire(self, wait=True):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return call_count <= 3 # Fail after 3 calls
|
||||
|
||||
limiter = MockLimiter()
|
||||
|
||||
@rate_limit(limiter, wait=True)
|
||||
def api_function():
|
||||
return "success"
|
||||
|
||||
# First 3 succeed
|
||||
for _ in range(3):
|
||||
result = api_function()
|
||||
assert result == "success"
|
||||
|
||||
# 4th should fail (would wait but our mock returns False)
|
||||
result = api_function()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAdaptiveRateLimiting:
|
||||
"""Test adaptive rate limiting based on responses."""
|
||||
|
||||
def test_adaptive_backoff_on_429(self):
|
||||
"""Test adaptive backoff on rate limit errors."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
# Simulate rate limit response
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Should reduce rate
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor < 1.0
|
||||
|
||||
def test_adaptive_recovery_on_success(self):
|
||||
"""Test adaptive recovery on successful requests."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
# Trigger backoff
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Recover with successful requests
|
||||
for _ in range(10):
|
||||
limiter.handle_response(status_code=200)
|
||||
|
||||
# Should recover rate
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor >= 0.9
|
||||
|
||||
def test_adaptive_minimum_rate(self):
|
||||
"""Test adaptive rate has minimum floor."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
min_adaptive_factor=0.1,
|
||||
)
|
||||
|
||||
# Trigger many backoffs
|
||||
for _ in range(100):
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Should not go below minimum
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor >= 0.1
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Tests for GitLab Triage Engine
|
||||
=================================
|
||||
|
||||
Tests for AI-driven issue triage and categorization.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.models import TriageCategory, TriageResult
|
||||
from runners.gitlab.services.triage_engine import TriageEngine
|
||||
except ImportError:
|
||||
from glab_client import GitLabConfig
|
||||
from models import TriageCategory, TriageResult
|
||||
from runners.gitlab.triage_engine import TriageEngine
|
||||
|
||||
|
||||
# Mock response parser for testing
|
||||
def parse_findings_from_response(response: str) -> dict:
|
||||
"""Mock parser for testing triage engine."""
|
||||
import json
|
||||
import re
|
||||
|
||||
# Try to extract JSON from markdown code blocks
|
||||
json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
|
||||
if json_match:
|
||||
response = json_match.group(1)
|
||||
|
||||
try:
|
||||
return json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
return {"category": "bug", "confidence": 0.5}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
try:
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
|
||||
return GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to simple config with model attribute
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
config.model = "claude-sonnet-4-5-20250929"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issue():
|
||||
"""Sample issue data."""
|
||||
return {
|
||||
"iid": 123,
|
||||
"title": "Fix authentication bug",
|
||||
"description": "Users cannot log in when using special characters in password",
|
||||
"labels": ["bug", "critical"],
|
||||
"author": {"username": "reporter"},
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_config, tmp_path):
|
||||
"""Create a triage engine instance."""
|
||||
return TriageEngine(
|
||||
project_dir=tmp_path,
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestTriageEngineBasic:
|
||||
"""Tests for triage engine initialization and basic operations."""
|
||||
|
||||
def test_engine_initialization(self, engine):
|
||||
"""Test that engine initializes correctly."""
|
||||
assert engine is not None
|
||||
assert engine.project_dir is not None
|
||||
|
||||
def test_supported_categories(self, engine):
|
||||
"""Test that engine supports all required categories."""
|
||||
expected_categories = {
|
||||
TriageCategory.BUG,
|
||||
TriageCategory.FEATURE,
|
||||
TriageCategory.DUPLICATE,
|
||||
TriageCategory.QUESTION,
|
||||
TriageCategory.SPAM,
|
||||
TriageCategory.INVALID,
|
||||
TriageCategory.WONTFIX,
|
||||
}
|
||||
|
||||
# Engine should handle all categories
|
||||
for category in expected_categories:
|
||||
assert category in TriageCategory
|
||||
|
||||
|
||||
class ResponseParserTests:
|
||||
"""Tests for response parsing utilities."""
|
||||
|
||||
def test_parse_findings_valid_json(self, engine):
|
||||
"""Test parsing valid JSON response with findings."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "bug",
|
||||
"confidence": 0.9,
|
||||
"duplicate_of": null,
|
||||
"reasoning": "Clear bug report with reproduction steps",
|
||||
"suggested_labels": ["bug", "critical"]
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "bug"
|
||||
assert result["confidence"] == 0.9
|
||||
|
||||
def test_parse_findings_with_duplicate(self, engine):
|
||||
"""Test parsing response with duplicate reference."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "duplicate",
|
||||
"confidence": 0.95,
|
||||
"duplicate_of": 42,
|
||||
"reasoning": "Same as issue #42",
|
||||
"suggested_labels": ["duplicate"]
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "duplicate"
|
||||
assert result["duplicate_of"] == 42
|
||||
|
||||
def test_parse_findings_with_question(self, engine):
|
||||
"""Test parsing response for question-type issue."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "question",
|
||||
"confidence": 0.8,
|
||||
"reasoning": "User is asking for help, not reporting a bug",
|
||||
"suggested_response": "Please provide more details"
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "question"
|
||||
assert "suggested_response" in result
|
||||
|
||||
def test_parse_findings_markdown_only(self, engine):
|
||||
"""Test parsing response without JSON code blocks."""
|
||||
response = """{"category": "feature", "confidence": 0.7}"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "feature"
|
||||
|
||||
def test_parse_findings_invalid_json(self, engine):
|
||||
"""Test parsing invalid JSON response."""
|
||||
response = "This is not valid JSON at all"
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
# Should return defaults for invalid response
|
||||
assert "category" in result
|
||||
|
||||
|
||||
class TestTriageCategorization:
|
||||
"""Tests for issue categorization."""
|
||||
|
||||
def test_triage_categories_exist(self):
|
||||
"""Test that all triage categories are defined."""
|
||||
expected_categories = {
|
||||
TriageCategory.BUG,
|
||||
TriageCategory.FEATURE,
|
||||
TriageCategory.DUPLICATE,
|
||||
TriageCategory.QUESTION,
|
||||
TriageCategory.SPAM,
|
||||
TriageCategory.INVALID,
|
||||
TriageCategory.WONTFIX,
|
||||
}
|
||||
# Verify categories exist
|
||||
assert TriageCategory.BUG in expected_categories
|
||||
assert TriageCategory.FEATURE in expected_categories
|
||||
|
||||
|
||||
class TestTriageContextBuilding:
|
||||
"""Tests for context building."""
|
||||
|
||||
def test_build_triage_context_basic(self, engine, sample_issue):
|
||||
"""Test building basic triage context."""
|
||||
context = engine.build_triage_context(sample_issue, [])
|
||||
|
||||
assert "Issue #123" in context
|
||||
assert "Fix authentication bug" in context
|
||||
# The description contains "Users cannot log in" not "Cannot login"
|
||||
assert "Users cannot log in" in context
|
||||
|
||||
def test_build_triage_context_with_duplicates(self, engine):
|
||||
"""Test building context with potential duplicates."""
|
||||
issue = {
|
||||
"iid": 1,
|
||||
"title": "Login bug",
|
||||
"description": "Cannot login",
|
||||
"author": {"username": "user1"},
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"labels": ["bug"],
|
||||
}
|
||||
|
||||
all_issues = [
|
||||
issue,
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Login issue",
|
||||
"description": "Login not working",
|
||||
"author": {"username": "user2"},
|
||||
"created_at": "2024-01-02T00:00:00Z",
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
|
||||
context = engine.build_triage_context(issue, all_issues)
|
||||
|
||||
# Should include potential duplicates section
|
||||
assert "Potential Duplicates" in context
|
||||
assert "#2" in context
|
||||
|
||||
def test_build_triage_context_no_duplicates(self, engine, sample_issue):
|
||||
"""Test building context without duplicates."""
|
||||
context = engine.build_triage_context(sample_issue, [])
|
||||
|
||||
# Should NOT include duplicates section
|
||||
assert "Potential Duplicates" not in context
|
||||
|
||||
|
||||
class TestTriageErrors:
|
||||
"""Tests for error handling in triage."""
|
||||
|
||||
def test_triage_result_default_values(self):
|
||||
"""Test TriageResult can be created with default values."""
|
||||
result = TriageResult(
|
||||
issue_iid=1,
|
||||
project="test/project",
|
||||
category=TriageCategory.FEATURE,
|
||||
confidence=0.0,
|
||||
)
|
||||
assert result.issue_iid == 1
|
||||
assert result.category == TriageCategory.FEATURE
|
||||
assert result.confidence == 0.0
|
||||
|
||||
|
||||
class TestTriageResult:
|
||||
"""Tests for TriageResult model."""
|
||||
|
||||
def test_triage_result_creation(self):
|
||||
"""Test creating a triage result."""
|
||||
result = TriageResult(
|
||||
issue_iid=123,
|
||||
project="namespace/project",
|
||||
category=TriageCategory.BUG,
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
assert result.issue_iid == 123
|
||||
assert result.category == TriageCategory.BUG
|
||||
assert result.confidence == 0.9
|
||||
|
||||
def test_triage_result_with_duplicate(self):
|
||||
"""Test creating a triage result with duplicate reference."""
|
||||
result = TriageResult(
|
||||
issue_iid=456,
|
||||
project="namespace/project",
|
||||
category=TriageCategory.DUPLICATE,
|
||||
confidence=0.95,
|
||||
duplicate_of=123,
|
||||
)
|
||||
|
||||
assert result.duplicate_of == 123
|
||||
assert result.category == TriageCategory.DUPLICATE
|
||||
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Tests for GitLab TypedDict Definitions
|
||||
========================================
|
||||
|
||||
Tests for type definitions and TypedDict usage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.types import (
|
||||
GitLabCommit,
|
||||
GitLabIssue,
|
||||
GitLabLabel,
|
||||
GitLabMR,
|
||||
GitLabPipeline,
|
||||
GitLabUser,
|
||||
)
|
||||
except ImportError:
|
||||
from runners.gitlab.types import (
|
||||
GitLabCommit,
|
||||
GitLabIssue,
|
||||
GitLabLabel,
|
||||
GitLabMR,
|
||||
GitLabPipeline,
|
||||
GitLabUser,
|
||||
)
|
||||
|
||||
|
||||
class TestGitLabUserTypedDict:
|
||||
"""Tests for GitLabUser TypedDict."""
|
||||
|
||||
def test_user_dict_structure(self):
|
||||
"""Test that user dict conforms to expected structure."""
|
||||
user: GitLabUser = {
|
||||
"id": 123,
|
||||
"username": "testuser",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"avatar_url": "https://example.com/avatar.png",
|
||||
"web_url": "https://gitlab.example.com/testuser",
|
||||
}
|
||||
|
||||
assert user["id"] == 123
|
||||
assert user["username"] == "testuser"
|
||||
|
||||
def test_user_dict_optional_fields(self):
|
||||
"""Test user dict with optional fields omitted."""
|
||||
user: GitLabUser = {
|
||||
"id": 456,
|
||||
"username": "minimal",
|
||||
"name": "Minimal User",
|
||||
}
|
||||
|
||||
assert user["id"] == 456
|
||||
# Should work without email, avatar_url, web_url
|
||||
|
||||
|
||||
class TestGitLabLabelTypedDict:
|
||||
"""Tests for GitLabLabel TypedDict."""
|
||||
|
||||
def test_label_dict_structure(self):
|
||||
"""Test that label dict conforms to expected structure."""
|
||||
label: GitLabLabel = {
|
||||
"id": 1,
|
||||
"name": "bug",
|
||||
"color": "#FF0000",
|
||||
"description": "Bug report",
|
||||
}
|
||||
|
||||
assert label["name"] == "bug"
|
||||
assert label["color"] == "#FF0000"
|
||||
|
||||
def test_label_dict_optional_description(self):
|
||||
"""Test label dict without description."""
|
||||
label: GitLabLabel = {
|
||||
"id": 2,
|
||||
"name": "enhancement",
|
||||
"color": "#00FF00",
|
||||
}
|
||||
|
||||
assert label["name"] == "enhancement"
|
||||
|
||||
|
||||
class TestGitLabMRTypedDict:
|
||||
"""Tests for GitLabMR TypedDict."""
|
||||
|
||||
def test_mr_dict_structure(self):
|
||||
"""Test that MR dict conforms to expected structure."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test MR",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"merged_at": None,
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "author",
|
||||
"name": "Author",
|
||||
},
|
||||
"assignees": [],
|
||||
"reviewers": [],
|
||||
"source_branch": "feature",
|
||||
"target_branch": "main",
|
||||
"web_url": "https://gitlab.example.com/merge_requests/123",
|
||||
}
|
||||
|
||||
assert mr["iid"] == 123
|
||||
assert mr["state"] == "opened"
|
||||
|
||||
def test_mr_dict_with_merge_status(self):
|
||||
"""Test MR dict with merge status."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 456,
|
||||
"id": 789,
|
||||
"title": "Merged MR",
|
||||
"state": "merged",
|
||||
"merged_at": "2024-01-02T00:00:00Z",
|
||||
"author": {"id": 1, "username": "dev"},
|
||||
"assignees": [],
|
||||
"reviewers": [],
|
||||
"diff_refs": {
|
||||
"base_sha": "abc123",
|
||||
"head_sha": "def456",
|
||||
"start_sha": "abc123",
|
||||
"head_commit": {"id": "def456"},
|
||||
},
|
||||
"labels": [],
|
||||
}
|
||||
|
||||
assert mr["state"] == "merged"
|
||||
assert mr["merged_at"] is not None
|
||||
|
||||
|
||||
class TestGitLabIssueTypedDict:
|
||||
"""Tests for GitLabIssue TypedDict."""
|
||||
|
||||
def test_issue_dict_structure(self):
|
||||
"""Test that issue dict conforms to expected structure."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test Issue",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"closed_at": None,
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "reporter",
|
||||
"name": "Reporter",
|
||||
},
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"web_url": "https://gitlab.example.com/issues/123",
|
||||
}
|
||||
|
||||
assert issue["iid"] == 123
|
||||
assert issue["state"] == "opened"
|
||||
|
||||
def test_issue_dict_with_labels(self):
|
||||
"""Test issue dict with labels."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 789,
|
||||
"id": 101,
|
||||
"title": "Labeled Issue",
|
||||
"labels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "bug",
|
||||
"color": "#FF0000",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "critical",
|
||||
"color": "#00FF00",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
assert len(issue["labels"]) == 2
|
||||
assert issue["labels"][0]["name"] == "bug"
|
||||
|
||||
|
||||
class TestGitLabCommitTypedDict:
|
||||
"""Tests for GitLabCommit TypedDict."""
|
||||
|
||||
def test_commit_dict_structure(self):
|
||||
"""Test that commit dict conforms to expected structure."""
|
||||
commit: GitLabCommit = {
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123",
|
||||
"title": "Test commit",
|
||||
"message": "Test commit message",
|
||||
"author_name": "Developer",
|
||||
"author_email": "dev@example.com",
|
||||
"authored_date": "2024-01-01T00:00:00Z",
|
||||
"committed_date": "2024-01-01T00:00:01Z",
|
||||
"web_url": "https://gitlab.example.com/commit/abc123",
|
||||
}
|
||||
|
||||
assert commit["id"] == "abc123def456"
|
||||
assert commit["short_id"] == "abc123"
|
||||
assert commit["author_name"] == "Developer"
|
||||
|
||||
|
||||
class TestGitLabPipelineTypedDict:
|
||||
"""Tests for GitLabPipeline TypedDict."""
|
||||
|
||||
def test_pipeline_dict_structure(self):
|
||||
"""Test that pipeline dict conforms to expected structure."""
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 123,
|
||||
"iid": 456,
|
||||
"project_id": 789,
|
||||
"sha": "abc123",
|
||||
"ref": "main",
|
||||
"status": "success",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"finished_at": "2024-01-01T02:00:00Z",
|
||||
"duration": 120,
|
||||
"web_url": "https://gitlab.example.com/pipelines/123",
|
||||
}
|
||||
|
||||
assert pipeline["id"] == 123
|
||||
assert pipeline["status"] == "success"
|
||||
assert pipeline["duration"] == 120
|
||||
|
||||
def test_pipeline_dict_optional_fields(self):
|
||||
"""Test pipeline dict with optional fields omitted."""
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 456,
|
||||
"iid": 789,
|
||||
"project_id": 101,
|
||||
"sha": "def456",
|
||||
"ref": "develop",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"finished_at": None,
|
||||
"duration": None,
|
||||
}
|
||||
|
||||
assert pipeline["status"] == "running"
|
||||
assert pipeline["finished_at"] is None
|
||||
|
||||
|
||||
class TestTotalFalseBehavior:
|
||||
"""Tests for total=False behavior in TypedDict (all fields optional)."""
|
||||
|
||||
def test_mr_minimal_dict(self):
|
||||
"""Test creating MR with minimal required fields."""
|
||||
# In practice, GitLab API always returns certain fields
|
||||
# But TypedDict with total=False allows flexibility
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Minimal MR",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
assert mr["iid"] == 123
|
||||
|
||||
def test_issue_minimal_dict(self):
|
||||
"""Test creating issue with minimal required fields."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 456,
|
||||
"id": 789,
|
||||
"title": "Minimal Issue",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
assert issue["iid"] == 456
|
||||
|
||||
|
||||
class TestNestedTypedDicts:
|
||||
"""Tests for nested TypedDict structures."""
|
||||
|
||||
def test_mr_with_nested_user(self):
|
||||
"""Test MR with nested user objects."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "MR with author",
|
||||
"state": "opened",
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "dev",
|
||||
"name": "Developer",
|
||||
},
|
||||
"assignees": [
|
||||
{
|
||||
"id": 2,
|
||||
"username": "assignee1",
|
||||
"name": "Assignee One",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert mr["author"]["username"] == "dev"
|
||||
assert len(mr["assignees"]) == 1
|
||||
|
||||
def test_issue_with_nested_labels(self):
|
||||
"""Test issue with nested label objects."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Issue with labels",
|
||||
"state": "opened",
|
||||
"labels": [
|
||||
{"id": 1, "name": "bug", "color": "#FF0000"},
|
||||
{"id": 2, "name": "critical", "color": "#00FF00"},
|
||||
],
|
||||
}
|
||||
|
||||
assert issue["labels"][0]["name"] == "bug"
|
||||
assert len(issue["labels"]) == 2
|
||||
|
||||
|
||||
class TestTypeCompatibility:
|
||||
"""Tests for type compatibility and validation."""
|
||||
|
||||
def test_mr_type_accepts_all_states(self):
|
||||
"""Test that MR type accepts all valid GitLab MR states."""
|
||||
valid_states = ["opened", "closed", "locked", "merged"]
|
||||
|
||||
for state in valid_states:
|
||||
mr: GitLabMR = {
|
||||
"iid": 1,
|
||||
"id": 1,
|
||||
"title": f"MR in {state} state",
|
||||
"state": state,
|
||||
}
|
||||
assert mr["state"] == state
|
||||
|
||||
def test_pipeline_type_accepts_all_statuses(self):
|
||||
"""Test that pipeline type accepts all valid GitLab pipeline statuses."""
|
||||
valid_statuses = [
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"canceled",
|
||||
"skipped",
|
||||
"manual",
|
||||
"scheduled",
|
||||
]
|
||||
|
||||
for status in valid_statuses:
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 1,
|
||||
"iid": 1,
|
||||
"project_id": 1,
|
||||
"sha": "abc",
|
||||
"ref": "main",
|
||||
"status": status,
|
||||
}
|
||||
assert pipeline["status"] == status
|
||||
|
||||
|
||||
class TestDocumentation:
|
||||
"""Tests that types are self-documenting."""
|
||||
|
||||
def test_user_fields_are_documented(self):
|
||||
"""Test that user fields match documentation."""
|
||||
# GitLabUser should have: id, username, name, email, avatar_url, web_url
|
||||
user: GitLabUser = {
|
||||
"id": 1,
|
||||
"username": "test",
|
||||
"name": "Test",
|
||||
"email": "test@example.com",
|
||||
"avatar_url": "https://example.com/avatar.png",
|
||||
"web_url": "https://gitlab.example.com/test",
|
||||
}
|
||||
|
||||
# Verify expected fields exist
|
||||
expected_fields = ["id", "username", "name", "email", "avatar_url", "web_url"]
|
||||
for field in expected_fields:
|
||||
assert field in user
|
||||
|
||||
def test_mr_fields_are_documented(self):
|
||||
"""Test that MR fields match documentation."""
|
||||
# Key MR fields
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
}
|
||||
|
||||
expected_fields = ["iid", "id", "title", "state", "created_at", "updated_at"]
|
||||
for field in expected_fields:
|
||||
assert field in mr
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Tests for GitLab Webhook Operations
|
||||
======================================
|
||||
|
||||
Tests for webhook listing, creation, updating, and deletion.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_webhooks():
|
||||
"""Sample webhook data."""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"url": "https://example.com/webhook",
|
||||
"project_id": 123,
|
||||
"push_events": True,
|
||||
"issues_events": False,
|
||||
"merge_requests_events": True,
|
||||
"wiki_page_events": False,
|
||||
"repository_update_events": False,
|
||||
"tag_push_events": False,
|
||||
"note_events": False,
|
||||
"confidential_note_events": False,
|
||||
"job_events": False,
|
||||
"pipeline_events": False,
|
||||
"deployment_events": False,
|
||||
"release_events": False,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"url": "https://hooks.example.com/another",
|
||||
"project_id": 123,
|
||||
"push_events": False,
|
||||
"issues_events": True,
|
||||
"merge_requests_events": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestListWebhooks:
|
||||
"""Tests for list_webhooks method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_webhooks(self, client, sample_webhooks):
|
||||
"""Test listing all webhooks."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["id"] == 1
|
||||
assert result[0]["url"] == "https://example.com/webhook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks_empty(self, client):
|
||||
"""Test listing webhooks when none exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = []
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks_async(self, client, sample_webhooks):
|
||||
"""Test async variant of list_webhooks."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks
|
||||
|
||||
result = await client.list_webhooks_async()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestGetWebhook:
|
||||
"""Tests for get_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_webhook(self, client, sample_webhooks):
|
||||
"""Test getting an existing webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks[0]
|
||||
|
||||
result = client.get_webhook(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
assert result["url"] == "https://example.com/webhook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_async(self, client, sample_webhooks):
|
||||
"""Test async variant of get_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks[0]
|
||||
|
||||
result = await client.get_webhook_async(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_webhook(self, client):
|
||||
"""Test getting a webhook that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_webhook(999)
|
||||
|
||||
|
||||
class TestCreateWebhook:
|
||||
"""Tests for create_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_basic(self, client):
|
||||
"""Test creating a webhook with basic settings."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 3,
|
||||
"url": "https://example.com/new-hook",
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/new-hook",
|
||||
)
|
||||
|
||||
assert result["id"] == 3
|
||||
assert result["url"] == "https://example.com/new-hook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_with_events(self, client):
|
||||
"""Test creating a webhook with specific events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 4,
|
||||
"url": "https://example.com/push-hook",
|
||||
"push_events": True,
|
||||
"issues_events": True,
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/push-hook",
|
||||
push_events=True,
|
||||
issues_events=True,
|
||||
)
|
||||
|
||||
assert result["push_events"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_with_all_events(self, client):
|
||||
"""Test creating a webhook that listens to all events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 5}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/all-events",
|
||||
push_events=True,
|
||||
merge_request_events=True,
|
||||
issues_events=True,
|
||||
note_events=True,
|
||||
job_events=True,
|
||||
pipeline_events=True,
|
||||
wiki_page_events=True,
|
||||
)
|
||||
|
||||
assert result["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_async(self, client):
|
||||
"""Test async variant of create_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 6}
|
||||
|
||||
result = await client.create_webhook_async(
|
||||
url="https://example.com/async-hook",
|
||||
)
|
||||
|
||||
assert result["id"] == 6
|
||||
|
||||
|
||||
class TestUpdateWebhook:
|
||||
"""Tests for update_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_url(self, client):
|
||||
"""Test updating webhook URL."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/updated-url",
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
url="https://example.com/updated-url",
|
||||
)
|
||||
|
||||
assert result["url"] == "https://example.com/updated-url"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_events(self, client):
|
||||
"""Test updating webhook events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"push_events": False, # Disabled
|
||||
"issues_events": True, # Enabled
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
push_events=False,
|
||||
issues_events=True,
|
||||
)
|
||||
|
||||
assert result["push_events"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_async(self, client):
|
||||
"""Test async variant of update_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 1, "url": "new"}
|
||||
|
||||
result = await client.update_webhook_async(
|
||||
hook_id=1,
|
||||
url="new",
|
||||
)
|
||||
|
||||
assert result["url"] == "new"
|
||||
|
||||
|
||||
class TestDeleteWebhook:
|
||||
"""Tests for delete_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook(self, client):
|
||||
"""Test deleting a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_webhook(1)
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook_async(self, client):
|
||||
"""Test async variant of delete_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None
|
||||
|
||||
result = await client.delete_webhook_async(2)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestWebhookErrors:
|
||||
"""Tests for webhook error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_invalid_webhook_id(self, client):
|
||||
"""Test getting webhook with invalid ID."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_webhook(0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_invalid_url(self, client):
|
||||
"""Test creating webhook with invalid URL."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("400 Invalid URL")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.create_webhook(url="not-a-url")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_webhook(self, client):
|
||||
"""Test deleting webhook that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.delete_webhook(999)
|
||||
@@ -0,0 +1,733 @@
|
||||
"""
|
||||
GitLab Client Tests
|
||||
===================
|
||||
|
||||
Tests for GitLab client timeout, retry, and async operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import ConnectionError, Timeout
|
||||
|
||||
|
||||
class TestGitLabClient:
|
||||
"""Test GitLab client basic operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_client_initialization(self, client):
|
||||
"""Test client initializes correctly."""
|
||||
assert client.config.token == "glpat-test-token-12345"
|
||||
assert client.config.project == "group/project"
|
||||
assert client.config.instance_url == "https://gitlab.example.com"
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_client_custom_timeout(self):
|
||||
"""Test client with custom timeout."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
assert client.default_timeout == 30.0 # Uses default
|
||||
|
||||
def test_client_custom_retries(self):
|
||||
"""Test client with custom retry count."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
# Uses default max_retries of 3
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_build_url(self, client):
|
||||
"""Test URL building."""
|
||||
url = client._api_url("/projects/group%2Fproject/merge_requests")
|
||||
|
||||
assert "group%2Fproject" in url
|
||||
assert "merge_requests" in url
|
||||
assert "/api/v4/" in url
|
||||
|
||||
def test_build_url_with_params(self, client):
|
||||
"""Test URL building with query parameters."""
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
base_url = client._api_url("/projects/group%2Fproject/merge_requests")
|
||||
query_string = urlencode({"state": "opened", "per_page": 50}, doseq=True)
|
||||
full_url = f"{base_url}?{query_string}"
|
||||
|
||||
parsed = urlparse(full_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
assert "state=opened" in full_url or params.get("state") == ["opened"]
|
||||
assert "per_page=50" in full_url or params.get("per_page") == ["50"]
|
||||
|
||||
|
||||
class TestGitLabClientRetry:
|
||||
"""Test GitLab client retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
return client
|
||||
|
||||
def test_retry_on_timeout(self, client):
|
||||
"""Test retry on timeout exception."""
|
||||
from socket import timeout
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise TimeoutError("Request timed out")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 3 # Initial + 2 retries
|
||||
assert result["iid"] == 123
|
||||
|
||||
def test_retry_on_connection_error(self, client):
|
||||
"""Test retry on connection error."""
|
||||
from urllib.error import URLError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
raise URLError("Connection failed")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 2 # Initial + 1 retry
|
||||
assert result["iid"] == 123
|
||||
|
||||
def test_retry_exhausted(self, client):
|
||||
"""Test failure after retry exhaustion."""
|
||||
from urllib.error import URLError
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
raise URLError("Request timed out")
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
with pytest.raises(Exception, match="GitLab API network error"):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_retry_with_backoff(self, client):
|
||||
"""Test retry uses exponential backoff."""
|
||||
import time
|
||||
from socket import timeout
|
||||
|
||||
call_times = []
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
call_times.append(time.time())
|
||||
if len(call_times) < 3:
|
||||
raise TimeoutError("Request timed out")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
client.get_mr(123)
|
||||
|
||||
# Check delays between retries increase (exponential backoff)
|
||||
if len(call_times) > 2:
|
||||
delay1 = call_times[1] - call_times[0]
|
||||
delay2 = call_times[2] - call_times[1]
|
||||
# Second delay should be longer (exponential backoff)
|
||||
assert delay2 > delay1
|
||||
|
||||
def test_no_retry_on_client_error(self, client):
|
||||
"""Test no retry on 4xx client errors."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# 404 should not be retried (not in RETRYABLE_STATUS_CODES)
|
||||
raise HTTPError("url", 404, "Not Found", {}, None)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
with pytest.raises(Exception, match="GitLab API error"):
|
||||
client.get_mr(123)
|
||||
|
||||
# Should only be called once (no retry for 4xx)
|
||||
assert call_count == 1
|
||||
|
||||
def test_retry_on_server_error(self, client):
|
||||
"""Test retry on 5xx server errors."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
raise HTTPError(None, 503, "Service Unavailable", {}, None)
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 2
|
||||
assert result["iid"] == 123
|
||||
|
||||
|
||||
class TestGitLabClientAsync:
|
||||
"""Test GitLab client async operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_async(self, client):
|
||||
"""Test async get MR."""
|
||||
mock_data = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_async(123)
|
||||
|
||||
assert result["iid"] == 123
|
||||
assert result["title"] == "Test MR"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_changes_async(self, client):
|
||||
"""Test async get MR changes."""
|
||||
mock_data = {
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "file.py",
|
||||
"new_path": "file.py",
|
||||
"diff": "@@ -1,1 +1,2 @@",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_changes_async(123)
|
||||
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_commits_async(self, client):
|
||||
"""Test async get MR commits."""
|
||||
mock_data = [
|
||||
{"id": "abc123", "message": "Commit 1"},
|
||||
{"id": "def456", "message": "Commit 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_commits_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["id"] == "abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_notes_async(self, client):
|
||||
"""Test async get MR notes."""
|
||||
mock_data = [
|
||||
{"id": 1001, "body": "Comment 1"},
|
||||
{"id": 1002, "body": "Comment 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_notes_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_pipelines_async(self, client):
|
||||
"""Test async get MR pipelines."""
|
||||
mock_data = [
|
||||
{"id": 1001, "status": "success"},
|
||||
{"id": 1002, "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_pipelines_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_async(self, client):
|
||||
"""Test async get issue."""
|
||||
mock_data = {
|
||||
"iid": 456,
|
||||
"title": "Test Issue",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_issue_async(456)
|
||||
|
||||
assert result["iid"] == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_async(self, client):
|
||||
"""Test async get pipeline."""
|
||||
mock_data = {
|
||||
"id": 1001,
|
||||
"status": "running",
|
||||
"ref": "main",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_pipeline_status_async(1001)
|
||||
|
||||
assert result["id"] == 1001
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_jobs_async(self, client):
|
||||
"""Test async get pipeline jobs."""
|
||||
mock_data = [
|
||||
{"id": 2001, "name": "test", "status": "success"},
|
||||
{"id": 2002, "name": "build", "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_pipeline_jobs_async(1001)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_async_requests(self, client):
|
||||
"""Test concurrent async requests."""
|
||||
|
||||
async def fetch_mr(iid):
|
||||
return await client.get_mr_async(iid)
|
||||
|
||||
mock_data = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
results = await asyncio.gather(
|
||||
fetch_mr(123),
|
||||
fetch_mr(456),
|
||||
fetch_mr(789),
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_error_handling(self, client):
|
||||
"""Test async error handling."""
|
||||
with patch.object(client, "_fetch_async", side_effect=Exception("API Error")):
|
||||
with pytest.raises(Exception, match="API Error"):
|
||||
await client.get_mr_async(123)
|
||||
|
||||
|
||||
class TestGitLabClientAPI:
|
||||
"""Test GitLab client API methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_get_mr(self, client):
|
||||
"""Test getting MR details."""
|
||||
mock_response = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"author": {"username": "john_doe"},
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert result["iid"] == 123
|
||||
assert result["title"] == "Test MR"
|
||||
|
||||
def test_get_mr_changes(self, client):
|
||||
"""Test getting MR changes."""
|
||||
mock_response = {
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "src/file.py",
|
||||
"new_path": "src/file.py",
|
||||
"diff": "@@ -1,1 +1,2 @@",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_changes(123)
|
||||
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
def test_get_mr_commits(self, client):
|
||||
"""Test getting MR commits."""
|
||||
mock_response = [
|
||||
{"id": "abc123", "message": "First commit"},
|
||||
{"id": "def456", "message": "Second commit"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_commits(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_get_mr_notes(self, client):
|
||||
"""Test getting MR discussion notes."""
|
||||
mock_response = [
|
||||
{"id": 1001, "body": "Review comment", "author": {"username": "reviewer"}},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_notes(123)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
def test_post_mr_note(self, client):
|
||||
"""Test posting note to MR."""
|
||||
mock_response = {"id": 1002, "body": "New comment"}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.post_mr_note(123, "New comment")
|
||||
|
||||
assert result["id"] == 1002
|
||||
|
||||
def test_get_mr_pipelines(self, client):
|
||||
"""Test getting MR pipelines."""
|
||||
mock_response = [
|
||||
{"id": 1001, "status": "success", "ref": "feature"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_pipelines(123)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_pipeline(self, client):
|
||||
"""Test getting pipeline details."""
|
||||
mock_response = {
|
||||
"id": 1001,
|
||||
"status": "success",
|
||||
"ref": "main",
|
||||
"sha": "abc123",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_pipeline_status(1001)
|
||||
|
||||
assert result["id"] == 1001
|
||||
|
||||
def test_get_pipeline_jobs(self, client):
|
||||
"""Test getting pipeline jobs."""
|
||||
mock_response = [
|
||||
{"id": 2001, "name": "test", "stage": "test", "status": "passed"},
|
||||
{"id": 2002, "name": "build", "stage": "build", "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_pipeline_jobs(1001)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[1]["status"] == "failed"
|
||||
|
||||
def test_get_issue(self, client):
|
||||
"""Test getting issue details."""
|
||||
mock_response = {
|
||||
"iid": 456,
|
||||
"title": "Test Issue",
|
||||
"description": "Issue description",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_issue(456)
|
||||
|
||||
assert result["iid"] == 456
|
||||
|
||||
def test_list_issues(self, client):
|
||||
"""Test listing issues."""
|
||||
mock_response = [
|
||||
{"iid": 456, "title": "Issue 1"},
|
||||
{"iid": 457, "title": "Issue 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.list_issues(state="opened")
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_post_issue_note(self, client):
|
||||
"""Test posting note to issue."""
|
||||
mock_response = {"id": 2001, "body": "Issue comment"}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.post_issue_note(456, "Issue comment")
|
||||
|
||||
assert result["id"] == 2001
|
||||
|
||||
def test_get_file(self, client):
|
||||
"""Test getting file from repository."""
|
||||
mock_response = {
|
||||
"file_name": "README.md",
|
||||
"content": "SGVsbG8gV29ybGQ=", # Base64 encoded
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_file_contents("README.md", ref="main")
|
||||
|
||||
assert result["file_name"] == "README.md"
|
||||
|
||||
def test_list_projects(self, client):
|
||||
"""Test listing projects - removed in new API."""
|
||||
# This method was removed from the new GitLabClient API
|
||||
# Projects are now specified via the config
|
||||
assert client.config.project is not None
|
||||
|
||||
|
||||
class TestGitLabClientAuth:
|
||||
"""Test GitLab client authentication."""
|
||||
|
||||
def test_token_in_headers(self):
|
||||
"""Test token is included in request headers."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
client.config = dataclasses.replace(client.config, token="test-token-12345")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
# Mock response object with proper attributes
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'{"iid": 123}'
|
||||
mock_response.headers = {"Content-Type": "application/json"}
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
client.get_mr(123)
|
||||
|
||||
# Check that urlopen was called
|
||||
assert mock_urlopen.called
|
||||
|
||||
# Get the request object that was passed to urlopen
|
||||
call_args = mock_urlopen.call_args[0]
|
||||
request = call_args[0]
|
||||
|
||||
# Check the PRIVATE-TOKEN header (case-insensitive check)
|
||||
assert (
|
||||
"PRIVATE-TOKEN" in request.headers or "Private-token" in request.headers
|
||||
)
|
||||
# Use get() with case-insensitive fallback
|
||||
token_value = request.headers.get(
|
||||
"PRIVATE-TOKEN", request.headers.get("Private-token")
|
||||
)
|
||||
assert token_value == "test-token-12345"
|
||||
|
||||
def test_custom_instance_url(self):
|
||||
"""Test custom instance URL."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
client.config = dataclasses.replace(
|
||||
client.config, instance_url="https://gitlab.custom.com"
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
# Mock response object with proper attributes
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'{"iid": 123}'
|
||||
mock_response.headers = {"Content-Type": "application/json"}
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
client.get_mr(123)
|
||||
|
||||
# Check that urlopen was called with correct URL
|
||||
call_args = mock_urlopen.call_args[0]
|
||||
request = call_args[0]
|
||||
|
||||
assert "gitlab.custom.com" in request.full_url
|
||||
|
||||
|
||||
class TestGitLabClientConfig:
|
||||
"""Test GitLab configuration model."""
|
||||
|
||||
def test_config_creation(self):
|
||||
"""Test creating GitLab config."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
assert config.token == "test-token"
|
||||
assert config.project == "group/project"
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test config has sensible defaults."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
project_dir = Path(tempfile.mkdtemp())
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.com",
|
||||
)
|
||||
|
||||
client = GitLabClient(project_dir=project_dir, config=config)
|
||||
|
||||
assert client.config.instance_url == "https://gitlab.com"
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_config_to_dict(self):
|
||||
"""Test converting config to dict using dataclasses."""
|
||||
import dataclasses
|
||||
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.com",
|
||||
)
|
||||
|
||||
data = dataclasses.asdict(config)
|
||||
|
||||
assert data["token"] == "test-token"
|
||||
assert data["project"] == "group/project"
|
||||
|
||||
def test_config_from_dict(self):
|
||||
"""Test loading config from dict using dataclasses."""
|
||||
import dataclasses
|
||||
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
data = {
|
||||
"token": "test-token",
|
||||
"project": "group/project",
|
||||
"instance_url": "https://gitlab.example.com",
|
||||
}
|
||||
|
||||
config = GitLabConfig(**data)
|
||||
|
||||
assert config.token == "test-token"
|
||||
assert config.instance_url == "https://gitlab.example.com"
|
||||
|
||||
|
||||
class TestGitLabClientErrorHandling:
|
||||
"""Test GitLab client error handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_http_404_handling(self, client):
|
||||
"""Test 404 error handling."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
def mock_request(*args, **kwargs):
|
||||
raise HTTPError(None, 404, "404 Not Found", {}, None)
|
||||
|
||||
with patch.object(client, "_fetch", mock_request):
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_mr(99999)
|
||||
|
||||
def test_http_403_handling(self, client):
|
||||
"""Test 403 forbidden error handling."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
def mock_request(*args, **kwargs):
|
||||
raise HTTPError(None, 403, "403 Forbidden", {}, None)
|
||||
|
||||
with patch.object(client, "_fetch", mock_request):
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_network_error_handling(self, client):
|
||||
"""Test network error handling."""
|
||||
with patch.object(
|
||||
client, "_fetch", side_effect=ConnectionError("Network error")
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_timeout_handling(self, client):
|
||||
"""Test timeout handling."""
|
||||
from socket import timeout
|
||||
|
||||
with patch.object(
|
||||
client, "_fetch", side_effect=TimeoutError("Request timed out")
|
||||
):
|
||||
with pytest.raises(timeout):
|
||||
client.get_mr(123)
|
||||
@@ -9,12 +9,13 @@ Each runner can be invoked from CLI or programmatically.
|
||||
from .ai_analyzer_runner import main as run_ai_analyzer
|
||||
from .ideation_runner import main as run_ideation
|
||||
from .insights_runner import main as run_insights
|
||||
from .roadmap_runner import main as run_roadmap
|
||||
|
||||
# from .roadmap_runner import main as run_roadmap # Temporarily disabled - missing module
|
||||
from .spec_runner import main as run_spec
|
||||
|
||||
__all__ = [
|
||||
"run_spec",
|
||||
"run_roadmap",
|
||||
# "run_roadmap", # Temporarily disabled
|
||||
"run_ideation",
|
||||
"run_insights",
|
||||
"run_ai_analyzer",
|
||||
|
||||
@@ -49,33 +49,85 @@ try:
|
||||
from .services.io_utils import safe_print
|
||||
except (ImportError, ValueError, SystemError):
|
||||
# When imported directly (runner.py adds github dir to path)
|
||||
from bot_detection import BotDetector
|
||||
from context_gatherer import PRContext, PRContextGatherer
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
AICommentTriage,
|
||||
AICommentVerdict,
|
||||
AutoFixState,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
StructuralIssue,
|
||||
TriageResult,
|
||||
)
|
||||
from permissions import GitHubPermissionChecker
|
||||
from rate_limiter import RateLimiter
|
||||
from services import (
|
||||
AutoFixProcessor,
|
||||
BatchProcessor,
|
||||
PRReviewEngine,
|
||||
TriageEngine,
|
||||
)
|
||||
from services.io_utils import safe_print
|
||||
# Use try/except for each import to handle partial failures gracefully
|
||||
try:
|
||||
from bot_detection import BotDetector
|
||||
except ImportError:
|
||||
BotDetector = None # type: ignore
|
||||
|
||||
try:
|
||||
from .context_gatherer import PRContext, PRContextGatherer
|
||||
except ImportError:
|
||||
try:
|
||||
from context_gatherer import PRContext, PRContextGatherer
|
||||
except ImportError:
|
||||
PRContext = None # type: ignore
|
||||
PRContextGatherer = None # type: ignore
|
||||
|
||||
try:
|
||||
from gh_client import GHClient
|
||||
except ImportError:
|
||||
GHClient = None # type: ignore
|
||||
|
||||
# Try to import models, but allow partial failures
|
||||
try:
|
||||
from models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
AICommentTriage,
|
||||
AICommentVerdict,
|
||||
AutoFixState,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
StructuralIssue,
|
||||
TriageResult,
|
||||
)
|
||||
except ImportError:
|
||||
BRANCH_BEHIND_BLOCKER_MSG = None # type: ignore
|
||||
BRANCH_BEHIND_REASONING = None # type: ignore
|
||||
AICommentTriage = None # type: ignore
|
||||
AICommentVerdict = None # type: ignore
|
||||
AutoFixState = None # type: ignore
|
||||
GitHubRunnerConfig = None # type: ignore
|
||||
MergeVerdict = None # type: ignore
|
||||
PRReviewFinding = None # type: ignore
|
||||
PRReviewResult = None # type: ignore
|
||||
ReviewCategory = None # type: ignore
|
||||
ReviewSeverity = None # type: ignore
|
||||
StructuralIssue = None # type: ignore
|
||||
TriageResult = None # type: ignore
|
||||
|
||||
try:
|
||||
from permissions import GitHubPermissionChecker
|
||||
except ImportError:
|
||||
GitHubPermissionChecker = None # type: ignore
|
||||
|
||||
try:
|
||||
from rate_limiter import RateLimiter
|
||||
except ImportError:
|
||||
RateLimiter = None # type: ignore
|
||||
|
||||
try:
|
||||
from services import (
|
||||
AutoFixProcessor,
|
||||
BatchProcessor,
|
||||
PRReviewEngine,
|
||||
TriageEngine,
|
||||
)
|
||||
except ImportError:
|
||||
AutoFixProcessor = None # type: ignore
|
||||
BatchProcessor = None # type: ignore
|
||||
PRReviewEngine = None # type: ignore
|
||||
TriageEngine = None # type: ignore
|
||||
|
||||
try:
|
||||
from services.io_utils import safe_print
|
||||
except ImportError:
|
||||
safe_print = None # type: ignore
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Auto-Fix Processor
|
||||
==================
|
||||
|
||||
Handles automatic issue fixing workflow including permissions and state management.
|
||||
Ported from GitHub with GitLab API adaptations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ..models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from ..permissions import GitLabPermissionChecker
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from permissions import GitLabPermissionChecker
|
||||
|
||||
|
||||
class AutoFixProcessor:
|
||||
"""Handles auto-fix workflow for GitLab issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gitlab_dir: Path,
|
||||
config: GitLabRunnerConfig,
|
||||
permission_checker: GitLabPermissionChecker,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.gitlab_dir = Path(gitlab_dir)
|
||||
self.config = config
|
||||
self.permission_checker = permission_checker
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
# Fallback: try relative import
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
async def process_issue(
|
||||
self,
|
||||
issue_iid: int,
|
||||
issue: dict,
|
||||
trigger_label: str | None = None,
|
||||
) -> AutoFixState:
|
||||
"""
|
||||
Process an issue for auto-fix.
|
||||
|
||||
Args:
|
||||
issue_iid: The issue internal ID to fix
|
||||
issue: The issue data from GitLab
|
||||
trigger_label: Label that triggered this auto-fix (for permission checks)
|
||||
|
||||
Returns:
|
||||
AutoFixState tracking the fix progress
|
||||
|
||||
Raises:
|
||||
PermissionError: If the user who added the trigger label isn't authorized
|
||||
"""
|
||||
self._report_progress(
|
||||
"fetching",
|
||||
10,
|
||||
f"Fetching issue #{issue_iid}...",
|
||||
issue_iid=issue_iid,
|
||||
)
|
||||
|
||||
# Load or create state
|
||||
state = AutoFixState.load(self.gitlab_dir, issue_iid)
|
||||
if state and state.status not in [
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.COMPLETED,
|
||||
]:
|
||||
# Already in progress
|
||||
return state
|
||||
|
||||
try:
|
||||
# PERMISSION CHECK: Verify who triggered the auto-fix
|
||||
if trigger_label:
|
||||
self._report_progress(
|
||||
"verifying",
|
||||
15,
|
||||
f"Verifying permissions for issue #{issue_iid}...",
|
||||
issue_iid=issue_iid,
|
||||
)
|
||||
permission_result = (
|
||||
await self.permission_checker.verify_automation_trigger(
|
||||
issue_iid=issue_iid,
|
||||
trigger_label=trigger_label,
|
||||
)
|
||||
)
|
||||
if not permission_result.allowed:
|
||||
print(
|
||||
f"[PERMISSION] Auto-fix denied for #{issue_iid}: {permission_result.reason}",
|
||||
flush=True,
|
||||
)
|
||||
raise PermissionError(
|
||||
f"Auto-fix not authorized: {permission_result.reason}"
|
||||
)
|
||||
print(
|
||||
f"[PERMISSION] Auto-fix authorized for #{issue_iid} "
|
||||
f"(triggered by {permission_result.username}, role: {permission_result.role})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Construct issue URL
|
||||
instance_url = self.config.instance_url.rstrip("/")
|
||||
issue_url = f"{instance_url}/{self.config.project}/-/issues/{issue_iid}"
|
||||
|
||||
state = AutoFixState(
|
||||
issue_iid=issue_iid,
|
||||
issue_url=issue_url,
|
||||
project=self.config.project,
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await state.save(self.gitlab_dir)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 30, "Analyzing issue...", issue_iid=issue_iid
|
||||
)
|
||||
|
||||
# This would normally call the spec creation process
|
||||
# For now, we just create the state and let the frontend handle spec creation
|
||||
# via the existing investigation flow
|
||||
|
||||
state.update_status(AutoFixStatus.CREATING_SPEC)
|
||||
await state.save(self.gitlab_dir)
|
||||
|
||||
self._report_progress(
|
||||
"complete", 100, "Ready for spec creation", issue_iid=issue_iid
|
||||
)
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
if state:
|
||||
state.status = AutoFixStatus.FAILED
|
||||
state.error = str(e)
|
||||
await state.save(self.gitlab_dir)
|
||||
raise
|
||||
|
||||
async def get_queue(self) -> list[AutoFixState]:
|
||||
"""Get all issues in the auto-fix queue."""
|
||||
issues_dir = self.gitlab_dir / "issues"
|
||||
if not issues_dir.exists():
|
||||
return []
|
||||
|
||||
queue = []
|
||||
for f in issues_dir.glob("autofix_*.json"):
|
||||
try:
|
||||
issue_iid = int(f.stem.replace("autofix_", ""))
|
||||
state = AutoFixState.load(self.gitlab_dir, issue_iid)
|
||||
if state:
|
||||
queue.append(state)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
return sorted(queue, key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
async def check_labeled_issues(
|
||||
self, all_issues: list[dict], verify_permissions: bool = True
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Check for issues with auto-fix labels and return their details.
|
||||
|
||||
This is used by the frontend to detect new issues that should be auto-fixed.
|
||||
When verify_permissions is True, only returns issues where the label was
|
||||
added by an authorized user.
|
||||
|
||||
Args:
|
||||
all_issues: All open issues from GitLab
|
||||
verify_permissions: Whether to verify who added the trigger label
|
||||
|
||||
Returns:
|
||||
List of dicts with issue_iid, trigger_label, and authorized status
|
||||
"""
|
||||
if not self.config.auto_fix_enabled:
|
||||
return []
|
||||
|
||||
auto_fix_issues = []
|
||||
|
||||
for issue in all_issues:
|
||||
labels = issue.get("labels", [])
|
||||
# GitLab labels are simple strings in the API
|
||||
matching_labels = [
|
||||
lbl
|
||||
for lbl in self.config.auto_fix_labels
|
||||
if lbl.lower() in [label.lower() for label in labels]
|
||||
]
|
||||
|
||||
if not matching_labels:
|
||||
continue
|
||||
|
||||
# Check if not already in queue
|
||||
state = AutoFixState.load(self.gitlab_dir, issue["iid"])
|
||||
if state and state.status not in [
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.COMPLETED,
|
||||
]:
|
||||
continue
|
||||
|
||||
trigger_label = matching_labels[0] # Use first matching label
|
||||
|
||||
# Optionally verify permissions
|
||||
if verify_permissions:
|
||||
try:
|
||||
permission_result = (
|
||||
await self.permission_checker.verify_automation_trigger(
|
||||
issue_iid=issue["iid"],
|
||||
trigger_label=trigger_label,
|
||||
)
|
||||
)
|
||||
if not permission_result.allowed:
|
||||
print(
|
||||
f"[PERMISSION] Skipping #{issue['iid']}: {permission_result.reason}",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
print(
|
||||
f"[PERMISSION] #{issue['iid']} authorized "
|
||||
f"(by {permission_result.username}, role: {permission_result.role})",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[PERMISSION] Error checking #{issue['iid']}: {e}",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
auto_fix_issues.append(
|
||||
{
|
||||
"issue_iid": issue["iid"],
|
||||
"trigger_label": trigger_label,
|
||||
"title": issue.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return auto_fix_issues
|
||||
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
Issue Batching Service for GitLab
|
||||
==================================
|
||||
|
||||
Groups similar issues together for combined auto-fix:
|
||||
- Uses Claude AI to analyze issues and suggest optimal batching
|
||||
- Creates issue clusters for efficient batch processing
|
||||
- Generates combined specs for issue batches
|
||||
- Tracks batch state and progress
|
||||
|
||||
Ported from GitHub with GitLab API adaptations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitlabBatchStatus(str, Enum):
|
||||
"""Status of an issue batch."""
|
||||
|
||||
PENDING = "pending"
|
||||
ANALYZING = "analyzing"
|
||||
CREATING_SPEC = "creating_spec"
|
||||
BUILDING = "building"
|
||||
QA_REVIEW = "qa_review"
|
||||
MR_CREATED = "mr_created"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitlabIssueBatchItem:
|
||||
"""An issue within a batch."""
|
||||
|
||||
issue_iid: int # GitLab uses iid instead of number
|
||||
title: str
|
||||
body: str
|
||||
labels: list[str] = field(default_factory=list)
|
||||
similarity_to_primary: float = 1.0 # Primary issue has 1.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"issue_iid": self.issue_iid,
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"labels": self.labels,
|
||||
"similarity_to_primary": self.similarity_to_primary,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> GitlabIssueBatchItem:
|
||||
return cls(
|
||||
issue_iid=data["issue_iid"],
|
||||
title=data["title"],
|
||||
body=data.get("body", ""),
|
||||
labels=data.get("labels", []),
|
||||
similarity_to_primary=data.get("similarity_to_primary", 1.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitlabIssueBatch:
|
||||
"""A batch of related GitLab issues to be fixed together."""
|
||||
|
||||
batch_id: str
|
||||
project: str # namespace/project format
|
||||
primary_issue: int # The "anchor" issue iid for the batch
|
||||
issues: list[GitlabIssueBatchItem]
|
||||
common_themes: list[str] = field(default_factory=list)
|
||||
status: GitlabBatchStatus = GitlabBatchStatus.PENDING
|
||||
spec_id: str | None = None
|
||||
mr_iid: int | None = None # GitLab MR IID (not database ID)
|
||||
mr_url: str | None = None
|
||||
error: str | None = None
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
updated_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
# AI validation results
|
||||
validated: bool = False
|
||||
validation_confidence: float = 0.0
|
||||
validation_reasoning: str = ""
|
||||
theme: str = "" # Refined theme from validation
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"batch_id": self.batch_id,
|
||||
"project": self.project,
|
||||
"primary_issue": self.primary_issue,
|
||||
"issues": [i.to_dict() for i in self.issues],
|
||||
"common_themes": self.common_themes,
|
||||
"status": self.status.value,
|
||||
"spec_id": self.spec_id,
|
||||
"mr_iid": self.mr_iid,
|
||||
"mr_url": self.mr_url,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"validated": self.validated,
|
||||
"validation_confidence": self.validation_confidence,
|
||||
"validation_reasoning": self.validation_reasoning,
|
||||
"theme": self.theme,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> GitlabIssueBatch:
|
||||
return cls(
|
||||
batch_id=data["batch_id"],
|
||||
project=data["project"],
|
||||
primary_issue=data["primary_issue"],
|
||||
issues=[GitlabIssueBatchItem.from_dict(i) for i in data.get("issues", [])],
|
||||
common_themes=data.get("common_themes", []),
|
||||
status=GitlabBatchStatus(data.get("status", "pending")),
|
||||
spec_id=data.get("spec_id"),
|
||||
mr_iid=data.get("mr_iid"),
|
||||
mr_url=data.get("mr_url"),
|
||||
error=data.get("error"),
|
||||
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
|
||||
validated=data.get("validated", False),
|
||||
validation_confidence=data.get("validation_confidence", 0.0),
|
||||
validation_reasoning=data.get("validation_reasoning", ""),
|
||||
theme=data.get("theme", ""),
|
||||
)
|
||||
|
||||
|
||||
class ClaudeGitlabBatchAnalyzer:
|
||||
"""
|
||||
Claude-based batch analyzer for GitLab issues.
|
||||
|
||||
Uses a single Claude call to analyze a group of issues and suggest
|
||||
optimal batching, avoiding O(n²) pairwise comparisons.
|
||||
"""
|
||||
|
||||
def __init__(self, project_dir: Path | None = None):
|
||||
"""Initialize Claude batch analyzer."""
|
||||
self.project_dir = project_dir or Path.cwd()
|
||||
logger.info(
|
||||
f"[BATCH_ANALYZER] Initialized with project_dir: {self.project_dir}"
|
||||
)
|
||||
|
||||
async def analyze_and_batch_issues(
|
||||
self,
|
||||
issues: list[dict[str, Any]],
|
||||
max_batch_size: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Analyze a group of issues and suggest optimal batches.
|
||||
|
||||
Uses a SINGLE Claude call to analyze all issues and group them intelligently.
|
||||
|
||||
Args:
|
||||
issues: List of issues to analyze (GitLab format with iid)
|
||||
max_batch_size: Maximum issues per batch
|
||||
|
||||
Returns:
|
||||
List of batch suggestions, each containing:
|
||||
- issue_iids: list of issue IIDs in this batch
|
||||
- theme: common theme/description
|
||||
- reasoning: why these should be batched
|
||||
- confidence: 0.0-1.0
|
||||
"""
|
||||
if not issues:
|
||||
return []
|
||||
|
||||
if len(issues) == 1:
|
||||
# Single issue = single batch
|
||||
return [
|
||||
{
|
||||
"issue_iids": [issues[0]["iid"]],
|
||||
"theme": issues[0].get("title", "Single issue"),
|
||||
"reasoning": "Single issue in group",
|
||||
"confidence": 1.0,
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
import sys
|
||||
|
||||
import claude_agent_sdk # noqa: F401 - check availability
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(backend_path))
|
||||
from core.auth import ensure_claude_code_oauth_token
|
||||
except ImportError as e:
|
||||
logger.error(f"claude-agent-sdk not available: {e}")
|
||||
# Fallback: each issue is its own batch
|
||||
return self._fallback_batches(issues)
|
||||
|
||||
# Build issue list for the prompt
|
||||
issue_list = "\n".join(
|
||||
[
|
||||
f"- !{issue['iid']}: {issue.get('title', 'No title')}"
|
||||
f"\n Labels: {', '.join(issue.get('labels', [])) or 'none'}"
|
||||
f"\n Body: {(issue.get('description', '') or '')[:200]}..."
|
||||
for issue in issues
|
||||
]
|
||||
)
|
||||
|
||||
prompt = f"""Analyze these GitLab issues and group them into batches that should be fixed together.
|
||||
|
||||
ISSUES TO ANALYZE:
|
||||
{issue_list}
|
||||
|
||||
RULES:
|
||||
1. Group issues that share a common root cause or affect the same component
|
||||
2. Maximum {max_batch_size} issues per batch
|
||||
3. Issues that are unrelated should be in separate batches (even single-issue batches)
|
||||
4. Be conservative - only batch issues that clearly belong together
|
||||
5. Use issue IIDs (e.g., !123) when referring to issues
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"batches": [
|
||||
{{
|
||||
"issue_iids": [1, 2, 3],
|
||||
"theme": "Authentication issues",
|
||||
"reasoning": "All related to login flow",
|
||||
"confidence": 0.85
|
||||
}},
|
||||
{{
|
||||
"issue_iids": [4],
|
||||
"theme": "UI bug",
|
||||
"reasoning": "Unrelated to other issues",
|
||||
"confidence": 0.95
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
logger.info(
|
||||
f"[BATCH_ANALYZER] Analyzing {len(issues)} issues in single call"
|
||||
)
|
||||
|
||||
# Using Sonnet for better analysis (still just 1 call)
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="batch_analysis",
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
system_prompt="You are an expert at analyzing GitLab issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
|
||||
cwd=self.project_dir,
|
||||
)
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
response_text = await self._collect_response(client)
|
||||
|
||||
logger.info(
|
||||
f"[BATCH_ANALYZER] Received response: {len(response_text)} chars"
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
result = self._parse_json_response(response_text)
|
||||
|
||||
if "batches" in result:
|
||||
return result["batches"]
|
||||
else:
|
||||
logger.warning(
|
||||
"[BATCH_ANALYZER] No batches in response, using fallback"
|
||||
)
|
||||
return self._fallback_batches(issues)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[BATCH_ANALYZER] Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return self._fallback_batches(issues)
|
||||
|
||||
def _parse_json_response(self, response_text: str) -> dict[str, Any]:
|
||||
"""Parse JSON from Claude response, handling various formats."""
|
||||
content = response_text.strip()
|
||||
|
||||
if not content:
|
||||
raise ValueError("Empty response")
|
||||
|
||||
# Extract JSON from markdown code blocks if present
|
||||
if "```json" in content:
|
||||
content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
content = content.split("```")[1].split("```")[0].strip()
|
||||
else:
|
||||
# Look for JSON object
|
||||
if "{" in content:
|
||||
start = content.find("{")
|
||||
brace_count = 0
|
||||
for i, char in enumerate(content[start:], start):
|
||||
if char == "{":
|
||||
brace_count += 1
|
||||
elif char == "}":
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
content = content[start : i + 1]
|
||||
break
|
||||
|
||||
return json.loads(content)
|
||||
|
||||
def _fallback_batches(self, issues: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Fallback: each issue is its own batch."""
|
||||
return [
|
||||
{
|
||||
"issue_iids": [issue["iid"]],
|
||||
"theme": issue.get("title", ""),
|
||||
"reasoning": "Fallback: individual batch",
|
||||
"confidence": 0.5,
|
||||
}
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
async def _collect_response(self, client: Any) -> str:
|
||||
"""Collect text response from Claude client."""
|
||||
response_text = ""
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if type(block).__name__ == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
return response_text
|
||||
|
||||
|
||||
class GitlabIssueBatcher:
|
||||
"""
|
||||
Batches similar GitLab issues for combined auto-fix.
|
||||
|
||||
Uses Claude AI to intelligently group related issues,
|
||||
then creates batch specs for efficient processing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gitlab_dir: Path,
|
||||
project: str,
|
||||
project_dir: Path,
|
||||
similarity_threshold: float = 0.70,
|
||||
min_batch_size: int = 1,
|
||||
max_batch_size: int = 5,
|
||||
validate_batches: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the issue batcher.
|
||||
|
||||
Args:
|
||||
gitlab_dir: Directory for GitLab state (.auto-claude/gitlab/)
|
||||
project: Project in namespace/project format
|
||||
project_dir: Root directory of the project
|
||||
similarity_threshold: Minimum similarity for batching (0.0-1.0)
|
||||
min_batch_size: Minimum issues per batch
|
||||
max_batch_size: Maximum issues per batch
|
||||
validate_batches: Whether to validate batches with AI
|
||||
"""
|
||||
self.gitlab_dir = Path(gitlab_dir)
|
||||
self.project = project
|
||||
self.project_dir = Path(project_dir)
|
||||
self.similarity_threshold = similarity_threshold
|
||||
self.min_batch_size = min_batch_size
|
||||
self.max_batch_size = max_batch_size
|
||||
self.validate_batches = validate_batches
|
||||
|
||||
self.analyzer = ClaudeGitlabBatchAnalyzer(project_dir)
|
||||
|
||||
async def create_batches(
|
||||
self,
|
||||
issues: list[dict[str, Any]],
|
||||
) -> list[GitlabIssueBatch]:
|
||||
"""
|
||||
Create batches from a list of issues.
|
||||
|
||||
Args:
|
||||
issues: List of GitLab issues (with iid, title, description, labels)
|
||||
|
||||
Returns:
|
||||
List of GitlabIssueBatch objects
|
||||
"""
|
||||
logger.info(f"[BATCHER] Creating batches from {len(issues)} issues")
|
||||
|
||||
# Step 1: Get batch suggestions from Claude
|
||||
batch_suggestions = await self.analyzer.analyze_and_batch_issues(
|
||||
issues,
|
||||
max_batch_size=self.max_batch_size,
|
||||
)
|
||||
|
||||
# Step 2: Convert suggestions to IssueBatch objects
|
||||
batches = []
|
||||
for suggestion in batch_suggestions:
|
||||
issue_iids = suggestion["issue_iids"]
|
||||
batch_issues = [
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=iid,
|
||||
title=next(
|
||||
(i.get("title", "") for i in issues if i["iid"] == iid), ""
|
||||
),
|
||||
body=next(
|
||||
(i.get("description", "") for i in issues if i["iid"] == iid),
|
||||
"",
|
||||
),
|
||||
labels=next(
|
||||
(i.get("labels", []) for i in issues if i["iid"] == iid), []
|
||||
),
|
||||
)
|
||||
for iid in issue_iids
|
||||
]
|
||||
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id=self._generate_batch_id(issue_iids),
|
||||
project=self.project,
|
||||
primary_issue=issue_iids[0] if issue_iids else 0,
|
||||
issues=batch_issues,
|
||||
theme=suggestion.get("theme", ""),
|
||||
validation_reasoning=suggestion.get("reasoning", ""),
|
||||
validation_confidence=suggestion.get("confidence", 0.5),
|
||||
validated=True,
|
||||
)
|
||||
batches.append(batch)
|
||||
|
||||
logger.info(f"[BATCHER] Created {len(batches)} batches")
|
||||
return batches
|
||||
|
||||
def _generate_batch_id(self, issue_iids: list[int]) -> str:
|
||||
"""Generate a unique batch ID from issue IIDs."""
|
||||
sorted_iids = sorted(issue_iids)
|
||||
return f"batch-{'-'.join(str(iid) for iid in sorted_iids)}"
|
||||
|
||||
def save_batch(self, batch: GitlabIssueBatch) -> None:
|
||||
"""Save batch state to disk."""
|
||||
batches_dir = self.gitlab_dir / "batches"
|
||||
batches_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
batch_file = batches_dir / f"{batch.batch_id}.json"
|
||||
with open(batch_file, "w", encoding="utf-8") as f:
|
||||
json.dump(batch.to_dict(), f, indent=2)
|
||||
|
||||
logger.info(f"[BATCHER] Saved batch {batch.batch_id}")
|
||||
|
||||
@classmethod
|
||||
def load_batch(cls, gitlab_dir: Path, batch_id: str) -> GitlabIssueBatch | None:
|
||||
"""Load a batch from disk."""
|
||||
batch_file = gitlab_dir / "batches" / f"{batch_id}.json"
|
||||
if not batch_file.exists():
|
||||
return None
|
||||
|
||||
with open(batch_file, encoding="utf-8") as f:
|
||||
return GitlabIssueBatch.from_dict(json.load(f))
|
||||
|
||||
def list_batches(self) -> list[GitlabIssueBatch]:
|
||||
"""List all batches."""
|
||||
batches_dir = self.gitlab_dir / "batches"
|
||||
if not batches_dir.exists():
|
||||
return []
|
||||
|
||||
batches = []
|
||||
for batch_file in batches_dir.glob("batch-*.json"):
|
||||
try:
|
||||
with open(batch_file, encoding="utf-8") as f:
|
||||
batch = GitlabIssueBatch.from_dict(json.load(f))
|
||||
batches.append(batch)
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"[BATCHER] Failed to load {batch_file}: {e}")
|
||||
|
||||
return sorted(batches, key=lambda b: b.created_at, reverse=True)
|
||||
|
||||
|
||||
def format_batch_summary(batch: GitlabIssueBatch) -> str:
|
||||
"""
|
||||
Format a batch for display.
|
||||
|
||||
Args:
|
||||
batch: The batch to format
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
lines = [
|
||||
f"Batch: {batch.batch_id}",
|
||||
f"Status: {batch.status.value}",
|
||||
f"Primary Issue: !{batch.primary_issue}",
|
||||
f"Theme: {batch.theme or batch.common_themes[0] if batch.common_themes else 'N/A'}",
|
||||
f"Issues ({len(batch.issues)}):",
|
||||
]
|
||||
|
||||
for item in batch.issues:
|
||||
lines.append(f" - !{item.issue_iid}: {item.title}")
|
||||
|
||||
if batch.mr_iid:
|
||||
lines.append(f"MR: !{batch.mr_iid}")
|
||||
|
||||
if batch.error:
|
||||
lines.append(f"Error: {batch.error}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
Bot Detection for GitLab Automation
|
||||
====================================
|
||||
|
||||
Prevents infinite loops by detecting when the bot is reviewing its own work.
|
||||
|
||||
Key Features:
|
||||
- Identifies bot user from configured token
|
||||
- Skips MRs authored by the bot
|
||||
- Skips re-reviewing bot commits
|
||||
- Implements "cooling off" period to prevent rapid re-reviews
|
||||
- Tracks reviewed commits to avoid duplicate reviews
|
||||
|
||||
Usage:
|
||||
detector = BotDetector(
|
||||
state_dir=Path("/path/to/state"),
|
||||
bot_username="auto-claude-bot",
|
||||
review_own_mrs=False
|
||||
)
|
||||
|
||||
# Check if MR should be skipped
|
||||
should_skip, reason = detector.should_skip_mr_review(mr_iid=123, mr_data={}, commits=[])
|
||||
if should_skip:
|
||||
print(f"Skipping MR: {reason}")
|
||||
return
|
||||
|
||||
# After successful review, mark as reviewed
|
||||
detector.mark_reviewed(mr_iid=123, commit_sha="abc123")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from .utils.file_lock import FileLock, atomic_write
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from runners.gitlab.utils.file_lock import FileLock, atomic_write
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotDetectionState:
|
||||
"""State for tracking reviewed MRs and commits."""
|
||||
|
||||
# MR IID -> set of reviewed commit SHAs
|
||||
reviewed_commits: dict[int, list[str]] = field(default_factory=dict)
|
||||
|
||||
# MR IID -> last review timestamp (ISO format)
|
||||
last_review_times: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"reviewed_commits": self.reviewed_commits,
|
||||
"last_review_times": self.last_review_times,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> BotDetectionState:
|
||||
"""Load from dictionary."""
|
||||
return cls(
|
||||
reviewed_commits=data.get("reviewed_commits", {}),
|
||||
last_review_times=data.get("last_review_times", {}),
|
||||
)
|
||||
|
||||
def save(self, state_dir: Path) -> None:
|
||||
"""Save state to disk with file locking for concurrent safety."""
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_file = state_dir / "bot_detection_state.json"
|
||||
|
||||
# Use file locking to prevent concurrent write corruption
|
||||
with FileLock(state_file, timeout=5.0, exclusive=True):
|
||||
with atomic_write(state_file) as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, state_dir: Path) -> BotDetectionState:
|
||||
"""Load state from disk with file locking to prevent read-write race conditions."""
|
||||
state_file = state_dir / "bot_detection_state.json"
|
||||
|
||||
if not state_file.exists():
|
||||
return cls()
|
||||
|
||||
# Use shared file lock (non-exclusive) to prevent reading while another process writes
|
||||
with FileLock(state_file, timeout=5.0, exclusive=False):
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
|
||||
|
||||
# Known GitLab bot account patterns
|
||||
GITLAB_BOT_PATTERNS = [
|
||||
# GitLab official bots
|
||||
"gitlab-bot",
|
||||
"gitlab",
|
||||
# Bot suffixes
|
||||
"[bot]",
|
||||
"-bot",
|
||||
"_bot",
|
||||
".bot",
|
||||
# AI coding assistants
|
||||
"coderabbit",
|
||||
"greptile",
|
||||
"cursor",
|
||||
"sweep",
|
||||
"codium",
|
||||
"dependabot",
|
||||
"renovate",
|
||||
# Auto-generated patterns
|
||||
"project_",
|
||||
"bot_",
|
||||
]
|
||||
|
||||
|
||||
class BotDetector:
|
||||
"""
|
||||
Detects bot-authored MRs and commits to prevent infinite review loops.
|
||||
|
||||
Configuration:
|
||||
- bot_username: GitLab username of the bot account
|
||||
- review_own_mrs: Whether bot can review its own MRs
|
||||
|
||||
Automatic safeguards:
|
||||
- 1-minute cooling off period between reviews of same MR
|
||||
- Tracks reviewed commit SHAs to avoid duplicate reviews
|
||||
- Identifies bot user by username to skip bot-authored content
|
||||
"""
|
||||
|
||||
# Cooling off period in minutes
|
||||
COOLING_OFF_MINUTES = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_dir: Path,
|
||||
bot_username: str | None = None,
|
||||
review_own_mrs: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize bot detector.
|
||||
|
||||
Args:
|
||||
state_dir: Directory for storing detection state
|
||||
bot_username: GitLab username of the bot (to identify bot user)
|
||||
review_own_mrs: Whether to allow reviewing bot's own MRs
|
||||
"""
|
||||
self.state_dir = state_dir
|
||||
self.bot_username = bot_username
|
||||
self.review_own_mrs = review_own_mrs
|
||||
|
||||
# Load or initialize state
|
||||
self.state = BotDetectionState.load(state_dir)
|
||||
|
||||
logger.info(
|
||||
f"Initialized BotDetector: bot_user={bot_username}, review_own_mrs={review_own_mrs}"
|
||||
)
|
||||
|
||||
def _is_bot_username(self, username: str | None) -> bool:
|
||||
"""
|
||||
Check if a username matches known bot patterns.
|
||||
|
||||
Args:
|
||||
username: Username to check
|
||||
|
||||
Returns:
|
||||
True if username matches bot patterns
|
||||
"""
|
||||
if not username:
|
||||
return False
|
||||
|
||||
username_lower = username.lower()
|
||||
|
||||
# Check against known patterns
|
||||
for pattern in GITLAB_BOT_PATTERNS:
|
||||
if pattern.lower() in username_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_bot_mr(self, mr_data: dict) -> bool:
|
||||
"""
|
||||
Check if MR was created by the bot.
|
||||
|
||||
Args:
|
||||
mr_data: MR data from GitLab API (must have 'author' field)
|
||||
|
||||
Returns:
|
||||
True if MR author matches bot username or bot patterns
|
||||
"""
|
||||
author_data = mr_data.get("author", {})
|
||||
if not author_data:
|
||||
return False
|
||||
|
||||
author = author_data.get("username")
|
||||
|
||||
# Check if matches configured bot username
|
||||
if not self.review_own_mrs and author == self.bot_username:
|
||||
logger.info(f"MR is bot-authored: {author}")
|
||||
return True
|
||||
|
||||
# Check if matches bot patterns
|
||||
if not self.review_own_mrs and self._is_bot_username(author):
|
||||
logger.info(f"MR matches bot pattern: {author}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_bot_commit(self, commit_data: dict) -> bool:
|
||||
"""
|
||||
Check if commit was authored by the bot.
|
||||
|
||||
Args:
|
||||
commit_data: Commit data from GitLab API (must have 'author' field)
|
||||
|
||||
Returns:
|
||||
True if commit author matches bot username or bot patterns
|
||||
"""
|
||||
author_data = commit_data.get("author") or commit_data.get("author_email")
|
||||
if not author_data:
|
||||
return False
|
||||
|
||||
if isinstance(author_data, dict):
|
||||
author = author_data.get("username") or author_data.get("email")
|
||||
else:
|
||||
author = author_data
|
||||
|
||||
# Extract username from email if needed
|
||||
if "@" in str(author):
|
||||
author = str(author).split("@")[0]
|
||||
|
||||
# Check if matches configured bot username
|
||||
if not self.review_own_mrs and author == self.bot_username:
|
||||
logger.info(f"Commit is bot-authored: {author}")
|
||||
return True
|
||||
|
||||
# Check if matches bot patterns
|
||||
if not self.review_own_mrs and self._is_bot_username(author):
|
||||
logger.info(f"Commit matches bot pattern: {author}")
|
||||
return True
|
||||
|
||||
# Check for AI commit patterns
|
||||
commit_message = commit_data.get("message", "")
|
||||
if not self.review_own_mrs and self._is_ai_commit(commit_message):
|
||||
logger.info("Commit has AI pattern in message")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_ai_commit(self, commit_message: str) -> bool:
|
||||
"""
|
||||
Check if commit message indicates AI-generated commit.
|
||||
|
||||
Args:
|
||||
commit_message: Commit message text
|
||||
|
||||
Returns:
|
||||
True if commit appears to be AI-generated
|
||||
"""
|
||||
if not commit_message:
|
||||
return False
|
||||
|
||||
message_lower = commit_message.lower()
|
||||
|
||||
# Check for AI co-authorship patterns
|
||||
ai_patterns = [
|
||||
"co-authored-by: claude",
|
||||
"co-authored-by: gpt",
|
||||
"co-authored-by: gemini",
|
||||
"co-authored-by: ai assistant",
|
||||
"generated by ai",
|
||||
"auto-generated",
|
||||
]
|
||||
|
||||
for pattern in ai_patterns:
|
||||
if pattern in message_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_last_commit_sha(self, commits: list[dict]) -> str | None:
|
||||
"""
|
||||
Get the SHA of the most recent commit.
|
||||
|
||||
Args:
|
||||
commits: List of commit data from GitLab API
|
||||
|
||||
Returns:
|
||||
SHA of latest commit or None if no commits
|
||||
"""
|
||||
if not commits:
|
||||
return None
|
||||
|
||||
# GitLab API returns commits in chronological order (oldest first, newest last)
|
||||
latest = commits[-1]
|
||||
return latest.get("id") or latest.get("sha")
|
||||
|
||||
def is_within_cooling_off(self, mr_iid: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if MR is within cooling off period.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
|
||||
Returns:
|
||||
Tuple of (is_cooling_off, reason_message)
|
||||
"""
|
||||
last_review_str = self.state.last_review_times.get(str(mr_iid))
|
||||
|
||||
if not last_review_str:
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
time_since = datetime.now(timezone.utc) - last_review
|
||||
|
||||
if time_since < timedelta(minutes=self.COOLING_OFF_MINUTES):
|
||||
minutes_left = self.COOLING_OFF_MINUTES - (
|
||||
time_since.total_seconds() / 60
|
||||
)
|
||||
reason = (
|
||||
f"Cooling off period active (reviewed {int(time_since.total_seconds() / 60)}m ago, "
|
||||
f"{int(minutes_left)}m remaining)"
|
||||
)
|
||||
logger.info(f"MR !{mr_iid}: {reason}")
|
||||
return True, reason
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Error parsing last review time: {e}")
|
||||
|
||||
return False, ""
|
||||
|
||||
def has_reviewed_commit(self, mr_iid: int, commit_sha: str) -> bool:
|
||||
"""
|
||||
Check if we've already reviewed this specific commit.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
commit_sha: The commit SHA to check
|
||||
|
||||
Returns:
|
||||
True if this commit was already reviewed
|
||||
"""
|
||||
reviewed = self.state.reviewed_commits.get(str(mr_iid), [])
|
||||
return commit_sha in reviewed
|
||||
|
||||
def should_skip_mr_review(
|
||||
self,
|
||||
mr_iid: int,
|
||||
mr_data: dict,
|
||||
commits: list[dict] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Determine if we should skip reviewing this MR.
|
||||
|
||||
This is the main entry point for bot detection logic.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
mr_data: MR data from GitLab API
|
||||
commits: Optional list of commits in the MR
|
||||
|
||||
Returns:
|
||||
Tuple of (should_skip, reason)
|
||||
"""
|
||||
# Check 1: Is this a bot-authored MR?
|
||||
if not self.review_own_mrs and self.is_bot_mr(mr_data):
|
||||
reason = f"MR authored by bot user ({self.bot_username or 'bot pattern'})"
|
||||
logger.info(f"SKIP MR !{mr_iid}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 2: Is the latest commit by the bot?
|
||||
# Note: GitLab API returns commits oldest-first, so commits[-1] is the latest
|
||||
if commits and not self.review_own_mrs:
|
||||
latest_commit = commits[-1] if commits else None
|
||||
if latest_commit and self.is_bot_commit(latest_commit):
|
||||
reason = "Latest commit authored by bot (likely an auto-fix)"
|
||||
logger.info(f"SKIP MR !{mr_iid}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 3: Are we in the cooling off period?
|
||||
is_cooling, reason = self.is_within_cooling_off(mr_iid)
|
||||
if is_cooling:
|
||||
logger.info(f"SKIP MR !{mr_iid}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 4: Have we already reviewed this exact commit?
|
||||
head_sha = self.get_last_commit_sha(commits) if commits else None
|
||||
if head_sha and self.has_reviewed_commit(mr_iid, head_sha):
|
||||
reason = f"Already reviewed commit {head_sha[:8]}"
|
||||
logger.info(f"SKIP MR !{mr_iid}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# All checks passed - safe to review
|
||||
logger.info(f"MR !{mr_iid} is safe to review")
|
||||
return False, ""
|
||||
|
||||
def mark_reviewed(self, mr_iid: int, commit_sha: str) -> None:
|
||||
"""
|
||||
Mark an MR as reviewed at a specific commit.
|
||||
|
||||
This should be called after successfully posting a review.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
commit_sha: The commit SHA that was reviewed
|
||||
"""
|
||||
mr_key = str(mr_iid)
|
||||
|
||||
# Add to reviewed commits
|
||||
if mr_key not in self.state.reviewed_commits:
|
||||
self.state.reviewed_commits[mr_key] = []
|
||||
|
||||
if commit_sha not in self.state.reviewed_commits[mr_key]:
|
||||
self.state.reviewed_commits[mr_key].append(commit_sha)
|
||||
|
||||
# Update last review time
|
||||
self.state.last_review_times[mr_key] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
logger.info(
|
||||
f"Marked MR !{mr_iid} as reviewed at {commit_sha[:8]} "
|
||||
f"({len(self.state.reviewed_commits[mr_key])} total commits reviewed)"
|
||||
)
|
||||
|
||||
def clear_mr_state(self, mr_iid: int) -> None:
|
||||
"""
|
||||
Clear tracking state for an MR (e.g., when MR is closed/merged).
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
"""
|
||||
mr_key = str(mr_iid)
|
||||
|
||||
if mr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[mr_key]
|
||||
|
||||
if mr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[mr_key]
|
||||
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
logger.info(f"Cleared state for MR !{mr_iid}")
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""
|
||||
Get statistics about bot detection activity.
|
||||
|
||||
Returns:
|
||||
Dictionary with stats
|
||||
"""
|
||||
total_mrs = len(self.state.reviewed_commits)
|
||||
total_reviews = sum(
|
||||
len(commits) for commits in self.state.reviewed_commits.values()
|
||||
)
|
||||
|
||||
return {
|
||||
"bot_username": self.bot_username,
|
||||
"review_own_mrs": self.review_own_mrs,
|
||||
"total_mrs_tracked": total_mrs,
|
||||
"total_reviews_performed": total_reviews,
|
||||
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
|
||||
}
|
||||
|
||||
def cleanup_stale_mrs(self, max_age_days: int = 30) -> int:
|
||||
"""
|
||||
Remove tracking state for MRs that haven't been reviewed recently.
|
||||
|
||||
This prevents unbounded growth of the state file by cleaning up
|
||||
entries for MRs that are likely closed/merged.
|
||||
|
||||
Args:
|
||||
max_age_days: Remove MRs not reviewed in this many days (default: 30)
|
||||
|
||||
Returns:
|
||||
Number of MRs cleaned up
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
|
||||
mrs_to_remove: list[str] = []
|
||||
|
||||
for mr_key, last_review_str in self.state.last_review_times.items():
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
if last_review < cutoff:
|
||||
mrs_to_remove.append(mr_key)
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp - mark for removal
|
||||
mrs_to_remove.append(mr_key)
|
||||
|
||||
# Remove stale MRs
|
||||
for mr_key in mrs_to_remove:
|
||||
if mr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[mr_key]
|
||||
if mr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[mr_key]
|
||||
|
||||
if mrs_to_remove:
|
||||
self.state.save(self.state_dir)
|
||||
logger.info(
|
||||
f"Cleaned up {len(mrs_to_remove)} stale MRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
|
||||
return len(mrs_to_remove)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,18 @@ class ReviewCategory(str, Enum):
|
||||
PERFORMANCE = "performance"
|
||||
|
||||
|
||||
class TriageCategory(str, Enum):
|
||||
"""Issue triage categories."""
|
||||
|
||||
BUG = "bug"
|
||||
FEATURE = "feature"
|
||||
DUPLICATE = "duplicate"
|
||||
QUESTION = "question"
|
||||
SPAM = "spam"
|
||||
INVALID = "invalid"
|
||||
WONTFIX = "wontfix"
|
||||
|
||||
|
||||
class ReviewPass(str, Enum):
|
||||
"""Multi-pass review stages."""
|
||||
|
||||
@@ -43,6 +55,8 @@ class ReviewPass(str, Enum):
|
||||
SECURITY = "security"
|
||||
QUALITY = "quality"
|
||||
DEEP_ANALYSIS = "deep_analysis"
|
||||
STRUCTURAL = "structural"
|
||||
AI_COMMENT_TRIAGE = "ai_comment_triage"
|
||||
|
||||
|
||||
class MergeVerdict(str, Enum):
|
||||
@@ -54,6 +68,45 @@ class MergeVerdict(str, Enum):
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageResult:
|
||||
"""Result of issue triage."""
|
||||
|
||||
issue_iid: int
|
||||
project: str
|
||||
category: TriageCategory
|
||||
confidence: float # 0.0 to 1.0
|
||||
duplicate_of: int | None = None # If duplicate, which issue
|
||||
reasoning: str = ""
|
||||
suggested_labels: list[str] = field(default_factory=list)
|
||||
suggested_response: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"issue_iid": self.issue_iid,
|
||||
"project": self.project,
|
||||
"category": self.category.value,
|
||||
"confidence": self.confidence,
|
||||
"duplicate_of": self.duplicate_of,
|
||||
"reasoning": self.reasoning,
|
||||
"suggested_labels": self.suggested_labels,
|
||||
"suggested_response": self.suggested_response,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TriageResult:
|
||||
return cls(
|
||||
issue_iid=data["issue_iid"],
|
||||
project=data["project"],
|
||||
category=TriageCategory(data["category"]),
|
||||
confidence=data["confidence"],
|
||||
duplicate_of=data.get("duplicate_of"),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
suggested_labels=data.get("suggested_labels", []),
|
||||
suggested_response=data.get("suggested_response", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MRReviewFinding:
|
||||
"""A single finding from an MR review."""
|
||||
@@ -68,6 +121,10 @@ class MRReviewFinding:
|
||||
end_line: int | None = None
|
||||
suggested_fix: str | None = None
|
||||
fixable: bool = False
|
||||
# Evidence-based findings - code snippet proving the issue
|
||||
evidence_code: str | None = None
|
||||
# Pass that found this issue
|
||||
found_by_pass: ReviewPass | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -81,10 +138,13 @@ class MRReviewFinding:
|
||||
"end_line": self.end_line,
|
||||
"suggested_fix": self.suggested_fix,
|
||||
"fixable": self.fixable,
|
||||
"evidence_code": self.evidence_code,
|
||||
"found_by_pass": self.found_by_pass.value if self.found_by_pass else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> MRReviewFinding:
|
||||
found_by_pass = data.get("found_by_pass")
|
||||
return cls(
|
||||
id=data["id"],
|
||||
severity=ReviewSeverity(data["severity"]),
|
||||
@@ -96,6 +156,77 @@ class MRReviewFinding:
|
||||
end_line=data.get("end_line"),
|
||||
suggested_fix=data.get("suggested_fix"),
|
||||
fixable=data.get("fixable", False),
|
||||
evidence_code=data.get("evidence_code"),
|
||||
found_by_pass=ReviewPass(found_by_pass) if found_by_pass else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuralIssue:
|
||||
"""A structural issue detected during review (feature creep, scope changes)."""
|
||||
|
||||
id: str
|
||||
type: str # "feature_creep", "scope_change", "missing_requirement", etc.
|
||||
title: str
|
||||
description: str
|
||||
severity: ReviewSeverity = ReviewSeverity.MEDIUM
|
||||
files_affected: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"severity": self.severity.value,
|
||||
"files_affected": self.files_affected,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> StructuralIssue:
|
||||
return cls(
|
||||
id=data["id"],
|
||||
type=data["type"],
|
||||
title=data["title"],
|
||||
description=data["description"],
|
||||
severity=ReviewSeverity(data.get("severity", "medium")),
|
||||
files_affected=data.get("files_affected", []),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AICommentTriage:
|
||||
"""Result of triaging another AI tool's comment."""
|
||||
|
||||
comment_id: str
|
||||
tool_name: str # "CodeRabbit", "Cursor", etc.
|
||||
original_comment: str
|
||||
triage_result: str # "valid", "false_positive", "questionable", "addressed"
|
||||
reasoning: str
|
||||
file: str | None = None
|
||||
line: int | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"comment_id": self.comment_id,
|
||||
"tool_name": self.tool_name,
|
||||
"original_comment": self.original_comment,
|
||||
"triage_result": self.triage_result,
|
||||
"reasoning": self.reasoning,
|
||||
"file": self.file,
|
||||
"line": self.line,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> AICommentTriage:
|
||||
return cls(
|
||||
comment_id=data["comment_id"],
|
||||
tool_name=data["tool_name"],
|
||||
original_comment=data["original_comment"],
|
||||
triage_result=data["triage_result"],
|
||||
reasoning=data["reasoning"],
|
||||
file=data.get("file"),
|
||||
line=data.get("line"),
|
||||
)
|
||||
|
||||
|
||||
@@ -117,8 +248,13 @@ class MRReviewResult:
|
||||
verdict_reasoning: str = ""
|
||||
blockers: list[str] = field(default_factory=list)
|
||||
|
||||
# Multi-pass review results
|
||||
structural_issues: list[StructuralIssue] = field(default_factory=list)
|
||||
ai_triages: list[AICommentTriage] = field(default_factory=list)
|
||||
|
||||
# Follow-up review tracking
|
||||
reviewed_commit_sha: str | None = None
|
||||
reviewed_file_blobs: dict[str, str] = field(default_factory=dict)
|
||||
is_followup_review: bool = False
|
||||
previous_review_id: int | None = None
|
||||
resolved_findings: list[str] = field(default_factory=list)
|
||||
@@ -129,6 +265,10 @@ class MRReviewResult:
|
||||
has_posted_findings: bool = False
|
||||
posted_finding_ids: list[str] = field(default_factory=list)
|
||||
|
||||
# CI/CD status
|
||||
ci_status: str | None = None
|
||||
ci_pipeline_id: int | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"mr_iid": self.mr_iid,
|
||||
@@ -142,7 +282,10 @@ class MRReviewResult:
|
||||
"verdict": self.verdict.value,
|
||||
"verdict_reasoning": self.verdict_reasoning,
|
||||
"blockers": self.blockers,
|
||||
"structural_issues": [s.to_dict() for s in self.structural_issues],
|
||||
"ai_triages": [t.to_dict() for t in self.ai_triages],
|
||||
"reviewed_commit_sha": self.reviewed_commit_sha,
|
||||
"reviewed_file_blobs": self.reviewed_file_blobs,
|
||||
"is_followup_review": self.is_followup_review,
|
||||
"previous_review_id": self.previous_review_id,
|
||||
"resolved_findings": self.resolved_findings,
|
||||
@@ -150,6 +293,8 @@ class MRReviewResult:
|
||||
"new_findings_since_last_review": self.new_findings_since_last_review,
|
||||
"has_posted_findings": self.has_posted_findings,
|
||||
"posted_finding_ids": self.posted_finding_ids,
|
||||
"ci_status": self.ci_status,
|
||||
"ci_pipeline_id": self.ci_pipeline_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -166,7 +311,14 @@ class MRReviewResult:
|
||||
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
||||
verdict_reasoning=data.get("verdict_reasoning", ""),
|
||||
blockers=data.get("blockers", []),
|
||||
structural_issues=[
|
||||
StructuralIssue.from_dict(s) for s in data.get("structural_issues", [])
|
||||
],
|
||||
ai_triages=[
|
||||
AICommentTriage.from_dict(t) for t in data.get("ai_triages", [])
|
||||
],
|
||||
reviewed_commit_sha=data.get("reviewed_commit_sha"),
|
||||
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
|
||||
is_followup_review=data.get("is_followup_review", False),
|
||||
previous_review_id=data.get("previous_review_id"),
|
||||
resolved_findings=data.get("resolved_findings", []),
|
||||
@@ -176,6 +328,8 @@ class MRReviewResult:
|
||||
),
|
||||
has_posted_findings=data.get("has_posted_findings", False),
|
||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
||||
ci_status=data.get("ci_status"),
|
||||
ci_pipeline_id=data.get("ci_pipeline_id"),
|
||||
)
|
||||
|
||||
def save(self, gitlab_dir: Path) -> None:
|
||||
@@ -211,6 +365,10 @@ class GitLabRunnerConfig:
|
||||
model: str = "claude-sonnet-4-5-20250929"
|
||||
thinking_level: str = "medium"
|
||||
|
||||
# Auto-fix settings
|
||||
auto_fix_enabled: bool = False
|
||||
auto_fix_labels: list[str] = field(default_factory=lambda: ["auto-fix", "autofix"])
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"token": "***", # Never save token
|
||||
@@ -218,6 +376,8 @@ class GitLabRunnerConfig:
|
||||
"instance_url": self.instance_url,
|
||||
"model": self.model,
|
||||
"thinking_level": self.thinking_level,
|
||||
"auto_fix_enabled": self.auto_fix_enabled,
|
||||
"auto_fix_labels": self.auto_fix_labels,
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +398,11 @@ class MRContext:
|
||||
total_deletions: int = 0
|
||||
commits: list[dict] = field(default_factory=list)
|
||||
head_sha: str | None = None
|
||||
repo_structure: str = "" # Description of monorepo layout
|
||||
related_files: list[str] = field(default_factory=list) # Imports, tests, configs
|
||||
# CI/CD pipeline status
|
||||
ci_status: str | None = None
|
||||
ci_pipeline_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -253,3 +418,225 @@ class FollowupMRContext:
|
||||
commits_since_review: list[dict] = field(default_factory=list)
|
||||
files_changed_since_review: list[str] = field(default_factory=list)
|
||||
diff_since_review: str = ""
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Auto-Fix Models
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AutoFixStatus(str, Enum):
|
||||
"""Status for auto-fix operations."""
|
||||
|
||||
# Initial states
|
||||
PENDING = "pending"
|
||||
ANALYZING = "analyzing"
|
||||
|
||||
# Spec creation states
|
||||
CREATING_SPEC = "creating_spec"
|
||||
WAITING_APPROVAL = "waiting_approval" # Human review gate
|
||||
|
||||
# Build states
|
||||
BUILDING = "building"
|
||||
QA_REVIEW = "qa_review"
|
||||
|
||||
# MR states
|
||||
MR_CREATED = "mr_created"
|
||||
MERGE_CONFLICT = "merge_conflict" # Conflict resolution needed
|
||||
|
||||
# Terminal states
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled" # User cancelled
|
||||
|
||||
# Special states
|
||||
STALE = "stale" # Issue updated after spec creation
|
||||
RATE_LIMITED = "rate_limited" # Waiting for rate limit reset
|
||||
|
||||
@classmethod
|
||||
def terminal_states(cls) -> set[AutoFixStatus]:
|
||||
"""States that represent end of workflow."""
|
||||
return {cls.COMPLETED, cls.FAILED, cls.CANCELLED}
|
||||
|
||||
@classmethod
|
||||
def recoverable_states(cls) -> set[AutoFixStatus]:
|
||||
"""States that can be recovered from."""
|
||||
return {cls.FAILED, cls.STALE, cls.RATE_LIMITED, cls.MERGE_CONFLICT}
|
||||
|
||||
@classmethod
|
||||
def active_states(cls) -> set[AutoFixStatus]:
|
||||
"""States that indicate work in progress."""
|
||||
return {
|
||||
cls.PENDING,
|
||||
cls.ANALYZING,
|
||||
cls.CREATING_SPEC,
|
||||
cls.BUILDING,
|
||||
cls.QA_REVIEW,
|
||||
cls.WAITING_APPROVAL,
|
||||
cls.MR_CREATED,
|
||||
}
|
||||
|
||||
def can_transition_to(self, new_state: AutoFixStatus) -> bool:
|
||||
"""Check if state transition is valid."""
|
||||
# Define valid transitions
|
||||
transitions = {
|
||||
AutoFixStatus.PENDING: {
|
||||
AutoFixStatus.ANALYZING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.ANALYZING: {
|
||||
AutoFixStatus.CREATING_SPEC,
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.CANCELLED,
|
||||
AutoFixStatus.RATE_LIMITED,
|
||||
},
|
||||
AutoFixStatus.CREATING_SPEC: {
|
||||
AutoFixStatus.WAITING_APPROVAL,
|
||||
AutoFixStatus.BUILDING,
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.CANCELLED,
|
||||
AutoFixStatus.STALE,
|
||||
},
|
||||
AutoFixStatus.WAITING_APPROVAL: {
|
||||
AutoFixStatus.BUILDING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
AutoFixStatus.STALE,
|
||||
},
|
||||
AutoFixStatus.BUILDING: {
|
||||
AutoFixStatus.QA_REVIEW,
|
||||
AutoFixStatus.MR_CREATED,
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.MERGE_CONFLICT,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.QA_REVIEW: {
|
||||
AutoFixStatus.MR_CREATED,
|
||||
AutoFixStatus.BUILDING,
|
||||
AutoFixStatus.COMPLETED,
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.MR_CREATED: {
|
||||
AutoFixStatus.COMPLETED,
|
||||
AutoFixStatus.MERGE_CONFLICT,
|
||||
AutoFixStatus.FAILED,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
# Recoverable states
|
||||
AutoFixStatus.FAILED: {
|
||||
AutoFixStatus.ANALYZING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.STALE: {
|
||||
AutoFixStatus.ANALYZING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.RATE_LIMITED: {
|
||||
AutoFixStatus.PENDING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
AutoFixStatus.MERGE_CONFLICT: {
|
||||
AutoFixStatus.BUILDING,
|
||||
AutoFixStatus.CANCELLED,
|
||||
},
|
||||
}
|
||||
return new_state in transitions.get(self, set())
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoFixState:
|
||||
"""State tracking for auto-fix operations."""
|
||||
|
||||
issue_iid: int
|
||||
issue_url: str
|
||||
project: str
|
||||
status: AutoFixStatus = AutoFixStatus.PENDING
|
||||
spec_id: str | None = None
|
||||
spec_dir: str | None = None
|
||||
mr_iid: int | None = None # GitLab MR IID (not database ID)
|
||||
mr_url: str | None = None
|
||||
bot_comments: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"issue_iid": self.issue_iid,
|
||||
"issue_url": self.issue_url,
|
||||
"project": self.project,
|
||||
"status": self.status.value,
|
||||
"spec_id": self.spec_id,
|
||||
"spec_dir": self.spec_dir,
|
||||
"mr_iid": self.mr_iid,
|
||||
"mr_url": self.mr_url,
|
||||
"bot_comments": self.bot_comments,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> AutoFixState:
|
||||
issue_iid = data["issue_iid"]
|
||||
project = data["project"]
|
||||
# Construct issue_url if missing (for backwards compatibility)
|
||||
issue_url = (
|
||||
data.get("issue_url")
|
||||
or f"https://gitlab.com/{project}/-/issues/{issue_iid}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
issue_iid=issue_iid,
|
||||
issue_url=issue_url,
|
||||
project=project,
|
||||
status=AutoFixStatus(data.get("status", "pending")),
|
||||
spec_id=data.get("spec_id"),
|
||||
spec_dir=data.get("spec_dir"),
|
||||
mr_iid=data.get("mr_iid"),
|
||||
mr_url=data.get("mr_url"),
|
||||
bot_comments=data.get("bot_comments", []),
|
||||
error=data.get("error"),
|
||||
created_at=data.get("created_at", datetime.now().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
def update_status(self, status: AutoFixStatus) -> None:
|
||||
"""Update status and timestamp with transition validation."""
|
||||
if not self.status.can_transition_to(status):
|
||||
raise ValueError(
|
||||
f"Invalid state transition: {self.status.value} -> {status.value}"
|
||||
)
|
||||
self.status = status
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
async def save(self, gitlab_dir: Path) -> None:
|
||||
"""Save auto-fix state to .auto-claude/gitlab/issues/ with file locking."""
|
||||
try:
|
||||
from .utils.file_lock import atomic_write
|
||||
except ImportError:
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
issues_dir = gitlab_dir / "issues"
|
||||
issues_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
autofix_file = issues_dir / f"autofix_{self.issue_iid}.json"
|
||||
|
||||
# Atomic write
|
||||
with atomic_write(autofix_file, encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, gitlab_dir: Path, issue_iid: int) -> AutoFixState | None:
|
||||
"""Load auto-fix state from disk."""
|
||||
autofix_file = gitlab_dir / "issues" / f"autofix_{issue_iid}.json"
|
||||
if not autofix_file.exists():
|
||||
return None
|
||||
|
||||
with open(autofix_file, encoding="utf-8") as f:
|
||||
return cls.from_dict(json.load(f))
|
||||
|
||||
@classmethod
|
||||
async def load_async(cls, gitlab_dir: Path, issue_iid: int) -> AutoFixState | None:
|
||||
"""Async wrapper for loading state."""
|
||||
return cls.load(gitlab_dir, issue_iid)
|
||||
|
||||
@@ -3,8 +3,10 @@ GitLab Automation Orchestrator
|
||||
==============================
|
||||
|
||||
Main coordinator for GitLab automation workflows:
|
||||
- MR Review: AI-powered merge request review
|
||||
- MR Review: AI-powered merge request review with multi-pass analysis
|
||||
- Follow-up Review: Review changes since last review
|
||||
- Bot Detection: Prevents infinite review loops
|
||||
- CI/CD Checking: Pipeline status validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .bot_detection import BotDetector
|
||||
from .glab_client import GitLabClient, GitLabConfig
|
||||
from .models import (
|
||||
GitLabRunnerConfig,
|
||||
@@ -25,8 +28,11 @@ try:
|
||||
MRReviewResult,
|
||||
)
|
||||
from .services import MRReviewEngine
|
||||
from .services.ci_checker import CIChecker
|
||||
from .services.context_gatherer import MRContextGatherer
|
||||
except ImportError:
|
||||
# Fallback for direct script execution (not as a module)
|
||||
from bot_detection import BotDetector
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
from models import (
|
||||
GitLabRunnerConfig,
|
||||
@@ -35,6 +41,8 @@ except ImportError:
|
||||
MRReviewResult,
|
||||
)
|
||||
from services import MRReviewEngine
|
||||
from services.ci_checker import CIChecker
|
||||
from services.context_gatherer import MRContextGatherer
|
||||
|
||||
# Import safe_print for BrokenPipeError handling
|
||||
try:
|
||||
@@ -77,10 +85,15 @@ class GitLabOrchestrator:
|
||||
project_dir: Path,
|
||||
config: GitLabRunnerConfig,
|
||||
progress_callback: Callable[[ProgressCallback], None] | None = None,
|
||||
enable_bot_detection: bool = True,
|
||||
enable_ci_checking: bool = True,
|
||||
bot_username: str | None = None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
self.enable_bot_detection = enable_bot_detection
|
||||
self.enable_ci_checking = enable_ci_checking
|
||||
|
||||
# GitLab directory for storing state
|
||||
self.gitlab_dir = self.project_dir / ".auto-claude" / "gitlab"
|
||||
@@ -107,6 +120,25 @@ class GitLabOrchestrator:
|
||||
progress_callback=self._forward_progress,
|
||||
)
|
||||
|
||||
# Initialize bot detector
|
||||
if enable_bot_detection:
|
||||
self.bot_detector = BotDetector(
|
||||
state_dir=self.gitlab_dir,
|
||||
bot_username=bot_username,
|
||||
review_own_mrs=False,
|
||||
)
|
||||
else:
|
||||
self.bot_detector = None
|
||||
|
||||
# Initialize CI checker
|
||||
if enable_ci_checking:
|
||||
self.ci_checker = CIChecker(
|
||||
project_dir=self.project_dir,
|
||||
config=self.gitlab_config,
|
||||
)
|
||||
else:
|
||||
self.ci_checker = None
|
||||
|
||||
def _report_progress(
|
||||
self,
|
||||
phase: str,
|
||||
@@ -192,6 +224,8 @@ class GitLabOrchestrator:
|
||||
"""
|
||||
Perform AI-powered review of a merge request.
|
||||
|
||||
Includes bot detection and CI/CD status checking.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID to review
|
||||
|
||||
@@ -208,15 +242,79 @@ class GitLabOrchestrator:
|
||||
)
|
||||
|
||||
try:
|
||||
# Gather MR context
|
||||
context = await self._gather_mr_context(mr_iid)
|
||||
# Get MR data first for bot detection
|
||||
mr_data = await self.client.get_mr_async(mr_iid)
|
||||
commits = await self.client.get_mr_commits_async(mr_iid)
|
||||
|
||||
# Bot detection check
|
||||
if self.bot_detector:
|
||||
should_skip, skip_reason = self.bot_detector.should_skip_mr_review(
|
||||
mr_iid=mr_iid,
|
||||
mr_data=mr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
if should_skip:
|
||||
safe_print(f"[GitLab] Skipping MR !{mr_iid}: {skip_reason}")
|
||||
result = MRReviewResult(
|
||||
mr_iid=mr_iid,
|
||||
project=self.config.project,
|
||||
success=False,
|
||||
error=f"Skipped: {skip_reason}",
|
||||
)
|
||||
result.save(self.gitlab_dir)
|
||||
return result
|
||||
|
||||
# CI/CD status check
|
||||
ci_status = None
|
||||
ci_pipeline_id = None
|
||||
ci_blocking_reason = ""
|
||||
|
||||
if self.ci_checker:
|
||||
self._report_progress(
|
||||
"checking_ci",
|
||||
20,
|
||||
"Checking CI/CD pipeline status...",
|
||||
mr_iid=mr_iid,
|
||||
)
|
||||
|
||||
pipeline_info = await self.ci_checker.check_mr_pipeline(mr_iid)
|
||||
|
||||
if pipeline_info:
|
||||
ci_status = pipeline_info.status.value
|
||||
ci_pipeline_id = pipeline_info.pipeline_id
|
||||
|
||||
if pipeline_info.is_blocking:
|
||||
ci_blocking_reason = self.ci_checker.get_blocking_reason(
|
||||
pipeline_info
|
||||
)
|
||||
safe_print(f"[GitLab] CI blocking: {ci_blocking_reason}")
|
||||
|
||||
# For failed pipelines, still do review but note CI failure
|
||||
if pipeline_info.status == "success":
|
||||
pass # Continue normally
|
||||
elif pipeline_info.status == "failed":
|
||||
# Continue review but note the failure
|
||||
pass
|
||||
else:
|
||||
# For running/pending, we can still review
|
||||
pass
|
||||
|
||||
# Gather MR context using the context gatherer
|
||||
context_gatherer = MRContextGatherer(
|
||||
project_dir=self.project_dir,
|
||||
mr_iid=mr_iid,
|
||||
config=self.gitlab_config,
|
||||
)
|
||||
|
||||
context = await context_gatherer.gather()
|
||||
safe_print(
|
||||
f"[GitLab] Context gathered: {context.title} "
|
||||
f"({len(context.changed_files)} files, {context.total_additions}+/{context.total_deletions}-)"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 30, "Running AI review...", mr_iid=mr_iid
|
||||
"analyzing", 40, "Running AI review...", mr_iid=mr_iid
|
||||
)
|
||||
|
||||
# Run review
|
||||
@@ -225,6 +323,15 @@ class GitLabOrchestrator:
|
||||
)
|
||||
safe_print(f"[GitLab] Review complete: {len(findings)} findings")
|
||||
|
||||
# Adjust verdict based on CI status
|
||||
if ci_status == "failed" and ci_blocking_reason:
|
||||
# CI failure is a blocker
|
||||
blockers.insert(0, f"CI/CD Pipeline Failed: {ci_blocking_reason}")
|
||||
if verdict == MergeVerdict.READY_TO_MERGE:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
|
||||
# Map verdict to overall_status
|
||||
if verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
@@ -243,6 +350,13 @@ class GitLabOrchestrator:
|
||||
blockers=blockers,
|
||||
)
|
||||
|
||||
# Add CI section if CI was checked
|
||||
if ci_status and self.ci_checker:
|
||||
pipeline_info = await self.ci_checker.check_mr_pipeline(mr_iid)
|
||||
if pipeline_info:
|
||||
ci_section = self.ci_checker.format_pipeline_summary(pipeline_info)
|
||||
full_summary = f"{ci_section}\n\n---\n\n{full_summary}"
|
||||
|
||||
# Create result
|
||||
result = MRReviewResult(
|
||||
mr_iid=mr_iid,
|
||||
@@ -255,11 +369,17 @@ class GitLabOrchestrator:
|
||||
verdict_reasoning=summary,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=context.head_sha,
|
||||
ci_status=ci_status,
|
||||
ci_pipeline_id=ci_pipeline_id,
|
||||
)
|
||||
|
||||
# Save result
|
||||
result.save(self.gitlab_dir)
|
||||
|
||||
# Mark as reviewed in bot detector
|
||||
if self.bot_detector and context.head_sha:
|
||||
self.bot_detector.mark_reviewed(mr_iid, context.head_sha)
|
||||
|
||||
self._report_progress("complete", 100, "Review complete!", mr_iid=mr_iid)
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
GitLab Permission and Authorization System
|
||||
==========================================
|
||||
|
||||
Verifies who can trigger automation actions and validates token permissions.
|
||||
|
||||
Key features:
|
||||
- Label-adder verification (who added the trigger label)
|
||||
- Role-based access control (OWNER, MAINTAINER, DEVELOPER)
|
||||
- Token scope validation (fail fast if insufficient)
|
||||
- Group membership checks
|
||||
- Permission denial logging with actor info
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import encode_project_path for URL-encoding project paths
|
||||
try:
|
||||
from ..glab_client import encode_project_path
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from glab_client import encode_project_path
|
||||
|
||||
|
||||
# GitLab permission roles (access levels)
|
||||
# 50 = Reporter, 30 = Developer, 40 = Maintainer, 10 = Guest
|
||||
# Owner = Maintainer + owns project
|
||||
GitLabRole = Literal["OWNER", "MAINTAINER", "DEVELOPER", "REPORTER", "GUEST", "NONE"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionCheckResult:
|
||||
"""Result of a permission check."""
|
||||
|
||||
allowed: bool
|
||||
username: str
|
||||
role: GitLabRole
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class PermissionError(Exception):
|
||||
"""Raised when permission checks fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GitLabPermissionChecker:
|
||||
"""
|
||||
Verifies permissions for GitLab automation actions.
|
||||
|
||||
Required token scopes:
|
||||
- api: Full API access
|
||||
|
||||
Usage:
|
||||
checker = GitLabPermissionChecker(
|
||||
glab_client=glab_client,
|
||||
project="namespace/project",
|
||||
allowed_roles=["OWNER", "MAINTAINER"]
|
||||
)
|
||||
|
||||
# Check who added a label
|
||||
username, role = await checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
# Verify if user can trigger auto-fix
|
||||
result = await checker.is_allowed_for_autofix(username)
|
||||
"""
|
||||
|
||||
# GitLab access levels
|
||||
ACCESS_LEVELS = {
|
||||
"GUEST": 10,
|
||||
"REPORTER": 20,
|
||||
"DEVELOPER": 30,
|
||||
"MAINTAINER": 40,
|
||||
"OWNER": 50,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
glab_client, # GitLabClient from glab_client.py
|
||||
project: str,
|
||||
allowed_roles: list[str] | None = None,
|
||||
allow_external_contributors: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize permission checker.
|
||||
|
||||
Args:
|
||||
glab_client: GitLab API client instance
|
||||
project: Project in "namespace/project" format
|
||||
allowed_roles: List of allowed roles (default: OWNER, MAINTAINER, DEVELOPER)
|
||||
allow_external_contributors: Allow users with no write access (default: False)
|
||||
"""
|
||||
self.glab_client = glab_client
|
||||
self.project = project
|
||||
|
||||
# Default to trusted roles if not specified
|
||||
self.allowed_roles = allowed_roles or ["OWNER", "MAINTAINER"]
|
||||
self.allow_external_contributors = allow_external_contributors
|
||||
|
||||
# Cache for user roles (avoid repeated API calls)
|
||||
self._role_cache: dict[str, GitLabRole] = {}
|
||||
|
||||
logger.info(
|
||||
f"Initialized GitLab permission checker for {project} "
|
||||
f"with allowed roles: {self.allowed_roles}"
|
||||
)
|
||||
|
||||
async def verify_token_scopes(self) -> None:
|
||||
"""
|
||||
Verify token has required scopes. Raises PermissionError if insufficient.
|
||||
|
||||
This should be called at startup to fail fast if permissions are inadequate.
|
||||
"""
|
||||
logger.info("Verifying GitLab token and permissions...")
|
||||
|
||||
try:
|
||||
# Verify we can access the project (checks auth + project access)
|
||||
project_info = await self.glab_client._fetch_async(
|
||||
f"/projects/{encode_project_path(self.glab_client.config.project)}"
|
||||
)
|
||||
|
||||
if not project_info:
|
||||
raise PermissionError(
|
||||
f"Cannot access project {self.project}. "
|
||||
f"Check your token is valid and has 'api' scope."
|
||||
)
|
||||
|
||||
logger.info(f"✓ Token verified for {self.project}")
|
||||
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to verify token: {e}")
|
||||
raise PermissionError(f"Could not verify token permissions: {e}")
|
||||
|
||||
async def check_label_adder(
|
||||
self, issue_iid: int, label: str
|
||||
) -> tuple[str, GitLabRole]:
|
||||
"""
|
||||
Check who added a specific label to an issue.
|
||||
|
||||
Args:
|
||||
issue_iid: Issue internal ID (iid)
|
||||
label: Label name to check
|
||||
|
||||
Returns:
|
||||
Tuple of (username, role) who added the label
|
||||
|
||||
Raises:
|
||||
PermissionError: If label was not found or couldn't determine who added it
|
||||
"""
|
||||
logger.info(f"Checking who added label '{label}' to issue #{issue_iid}")
|
||||
|
||||
try:
|
||||
# Get issue resource label events (who added/removed labels)
|
||||
events = await self.glab_client._fetch_async(
|
||||
f"/projects/{encode_project_path(self.glab_client.config.project)}/issues/{issue_iid}/resource_label_events"
|
||||
)
|
||||
|
||||
# Find most recent label addition event
|
||||
for event in reversed(events):
|
||||
if (
|
||||
event.get("action") == "add"
|
||||
and event.get("label", {}).get("name") == label
|
||||
):
|
||||
user = event.get("user", {})
|
||||
username = user.get("username")
|
||||
|
||||
if not username:
|
||||
raise PermissionError(
|
||||
f"Could not determine who added label '{label}'"
|
||||
)
|
||||
|
||||
# Get role for this user
|
||||
role = await self.get_user_role(username)
|
||||
|
||||
logger.info(
|
||||
f"Label '{label}' was added by {username} (role: {role})"
|
||||
)
|
||||
return username, role
|
||||
|
||||
raise PermissionError(
|
||||
f"Label '{label}' not found in issue #{issue_iid} label events"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check label adder: {e}")
|
||||
raise PermissionError(f"Could not verify label adder: {e}")
|
||||
|
||||
async def get_user_role(self, username: str) -> GitLabRole:
|
||||
"""
|
||||
Get a user's role in the project.
|
||||
|
||||
Args:
|
||||
username: GitLab username
|
||||
|
||||
Returns:
|
||||
User's role (OWNER, MAINTAINER, DEVELOPER, REPORTER, GUEST, NONE)
|
||||
|
||||
Note:
|
||||
- OWNER: Project owner or namespace owner
|
||||
- MAINTAINER: Has Maintainer access level (40+)
|
||||
- DEVELOPER: Has Developer access level (30+)
|
||||
- REPORTER: Has Reporter access level (20+)
|
||||
- GUEST: Has Guest access level (10+)
|
||||
- NONE: No relationship to project
|
||||
"""
|
||||
# Check cache first
|
||||
if username in self._role_cache:
|
||||
return self._role_cache[username]
|
||||
|
||||
logger.debug(f"Checking role for user: {username}")
|
||||
|
||||
try:
|
||||
# Check project members
|
||||
members = await self.glab_client.get_project_members_async(query=username)
|
||||
|
||||
if members:
|
||||
# Use exact match verification to avoid privilege escalation
|
||||
# GitLab's query parameter performs fuzzy matching
|
||||
member = next(
|
||||
(m for m in members if m.get("username") == username), None
|
||||
)
|
||||
if not member:
|
||||
# No exact match found
|
||||
role = "NONE"
|
||||
self._role_cache[username] = role
|
||||
return role
|
||||
|
||||
access_level = member.get("access_level", 0)
|
||||
|
||||
if access_level >= self.ACCESS_LEVELS["OWNER"]:
|
||||
role = "OWNER"
|
||||
elif access_level >= self.ACCESS_LEVELS["MAINTAINER"]:
|
||||
role = "MAINTAINER"
|
||||
elif access_level >= self.ACCESS_LEVELS["DEVELOPER"]:
|
||||
role = "DEVELOPER"
|
||||
elif access_level >= self.ACCESS_LEVELS["REPORTER"]:
|
||||
role = "REPORTER"
|
||||
else:
|
||||
role = "GUEST"
|
||||
|
||||
self._role_cache[username] = role
|
||||
return role
|
||||
|
||||
# Not a direct member - check if user is the namespace owner
|
||||
project_info = await self.glab_client._fetch_async(
|
||||
f"/projects/{encode_project_path(self.glab_client.config.project)}"
|
||||
)
|
||||
namespace_path = project_info.get("namespace", {}).get("full_path", "")
|
||||
namespace_info = await self.glab_client._fetch_async(
|
||||
f"/namespaces/{encode_project_path(namespace_path)}"
|
||||
)
|
||||
|
||||
# Check if namespace owner matches username
|
||||
owner_id = namespace_info.get("owner_id")
|
||||
if owner_id:
|
||||
# Get user info
|
||||
user_info = await self.glab_client._fetch_async(
|
||||
f"/users?username={username}"
|
||||
)
|
||||
if user_info and user_info[0].get("id") == owner_id:
|
||||
role = "OWNER"
|
||||
self._role_cache[username] = role
|
||||
return role
|
||||
|
||||
# No relationship found
|
||||
role = "NONE"
|
||||
self._role_cache[username] = role
|
||||
return role
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking user role for {username}: {e}")
|
||||
# Fail safe - treat as no permission
|
||||
return "NONE"
|
||||
|
||||
async def is_allowed_for_autofix(self, username: str) -> PermissionCheckResult:
|
||||
"""
|
||||
Check if a user is allowed to trigger auto-fix.
|
||||
|
||||
Args:
|
||||
username: GitLab username to check
|
||||
|
||||
Returns:
|
||||
PermissionCheckResult with allowed status and details
|
||||
"""
|
||||
logger.info(f"Checking auto-fix permission for user: {username}")
|
||||
|
||||
role = await self.get_user_role(username)
|
||||
|
||||
# Check if role is allowed
|
||||
if role in self.allowed_roles:
|
||||
logger.info(f"✓ User {username} ({role}) is allowed to trigger auto-fix")
|
||||
return PermissionCheckResult(
|
||||
allowed=True, username=username, role=role, reason=None
|
||||
)
|
||||
|
||||
# Permission denied
|
||||
reason = (
|
||||
f"User {username} has role '{role}', which is not in allowed roles: "
|
||||
f"{self.allowed_roles}"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"✗ Auto-fix permission denied for {username}: {reason}",
|
||||
extra={
|
||||
"username": username,
|
||||
"role": role,
|
||||
"allowed_roles": self.allowed_roles,
|
||||
},
|
||||
)
|
||||
|
||||
return PermissionCheckResult(
|
||||
allowed=False, username=username, role=role, reason=reason
|
||||
)
|
||||
|
||||
async def verify_automation_trigger(
|
||||
self, issue_iid: int, trigger_label: str
|
||||
) -> PermissionCheckResult:
|
||||
"""
|
||||
Complete verification for an automation trigger (e.g., auto-fix label).
|
||||
|
||||
This is the main entry point for permission checks.
|
||||
|
||||
Args:
|
||||
issue_iid: Issue internal ID
|
||||
trigger_label: Label that triggered automation
|
||||
|
||||
Returns:
|
||||
PermissionCheckResult with full details
|
||||
|
||||
Raises:
|
||||
PermissionError: If verification fails
|
||||
"""
|
||||
logger.info(
|
||||
f"Verifying automation trigger for issue #{issue_iid}, label: {trigger_label}"
|
||||
)
|
||||
|
||||
# Step 1: Find who added the label
|
||||
username, role = await self.check_label_adder(issue_iid, trigger_label)
|
||||
|
||||
# Step 2: Check if they're allowed
|
||||
result = await self.is_allowed_for_autofix(username)
|
||||
|
||||
# Step 3: Log if denied
|
||||
if not result.allowed:
|
||||
self.log_permission_denial(
|
||||
action="auto-fix",
|
||||
username=username,
|
||||
role=role,
|
||||
issue_iid=issue_iid,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def log_permission_denial(
|
||||
self,
|
||||
action: str,
|
||||
username: str,
|
||||
role: GitLabRole,
|
||||
issue_iid: int | None = None,
|
||||
mr_iid: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a permission denial with full context.
|
||||
|
||||
Args:
|
||||
action: Action that was denied (e.g., "auto-fix", "mr-review")
|
||||
username: GitLab username
|
||||
role: User's role
|
||||
issue_iid: Optional issue internal ID
|
||||
mr_iid: Optional MR internal ID
|
||||
"""
|
||||
context = {
|
||||
"action": action,
|
||||
"username": username,
|
||||
"role": role,
|
||||
"project": self.project,
|
||||
"allowed_roles": self.allowed_roles,
|
||||
"allow_external_contributors": self.allow_external_contributors,
|
||||
}
|
||||
|
||||
if issue_iid:
|
||||
context["issue_iid"] = issue_iid
|
||||
if mr_iid:
|
||||
context["mr_iid"] = mr_iid
|
||||
|
||||
logger.warning(
|
||||
f"PERMISSION DENIED: {username} ({role}) attempted {action} in {self.project}",
|
||||
extra=context,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
GitLab Provider Package
|
||||
=======================
|
||||
|
||||
GitProvider protocol implementation for GitLab.
|
||||
"""
|
||||
|
||||
from .gitlab_provider import GitLabProvider
|
||||
|
||||
__all__ = ["GitLabProvider"]
|
||||
@@ -0,0 +1,911 @@
|
||||
"""
|
||||
GitLab Provider Implementation
|
||||
==============================
|
||||
|
||||
Implements the GitProvider protocol for GitLab using the GitLab REST API.
|
||||
Wraps the existing GitLabClient functionality and converts to provider-agnostic models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Import from parent package or direct import
|
||||
try:
|
||||
from ..glab_client import GitLabClient, GitLabConfig, encode_project_path
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from glab_client import GitLabClient, GitLabConfig, encode_project_path
|
||||
|
||||
# Import the protocol and data models from GitHub's protocol definition
|
||||
# This ensures compatibility across providers
|
||||
# If GitHub runners aren't available, define the types locally
|
||||
try:
|
||||
from ...github.providers.protocol import (
|
||||
IssueData,
|
||||
IssueFilters,
|
||||
LabelData,
|
||||
PRData,
|
||||
PRFilters,
|
||||
ProviderType,
|
||||
ReviewData,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
try:
|
||||
from runners.github.providers.protocol import (
|
||||
IssueData,
|
||||
IssueFilters,
|
||||
LabelData,
|
||||
PRData,
|
||||
PRFilters,
|
||||
ProviderType,
|
||||
ReviewData,
|
||||
)
|
||||
except ImportError:
|
||||
# GitHub runners not available - define protocol types locally
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from enum import Enum
|
||||
from typing import Any as _Any
|
||||
|
||||
class ProviderType(Enum):
|
||||
"""Git provider type."""
|
||||
|
||||
GITHUB = "GITHUB"
|
||||
GITLAB = "GITLAB"
|
||||
|
||||
@_dataclass
|
||||
class LabelData:
|
||||
"""Label data."""
|
||||
|
||||
name: str
|
||||
color: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
@_dataclass
|
||||
class IssueData:
|
||||
"""Issue data."""
|
||||
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
author: str
|
||||
labels: list[LabelData]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
assignees: list[str] | None = None
|
||||
url: str = ""
|
||||
milestone: str | None = None
|
||||
provider: ProviderType = ProviderType.GITLAB
|
||||
raw_data: dict[str, _Any] | None = None
|
||||
|
||||
@_dataclass
|
||||
class PRData:
|
||||
"""Pull request/MR data."""
|
||||
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
author: str
|
||||
source_branch: str
|
||||
target_branch: str
|
||||
labels: list[LabelData]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
provider: ProviderType
|
||||
diff: str | None = None
|
||||
assignees: list[str] | None = None
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
changed_files: int = 0
|
||||
files: list[_Any] | None = None
|
||||
url: str = ""
|
||||
reviewers: list[str] | None = None
|
||||
is_draft: bool = False
|
||||
mergeable: bool = True
|
||||
raw_data: dict[str, _Any] | None = None
|
||||
|
||||
@_dataclass
|
||||
class ReviewData:
|
||||
"""Review data."""
|
||||
|
||||
body: str
|
||||
event: str
|
||||
comments: list[_Any] | None = None
|
||||
|
||||
@_dataclass
|
||||
class IssueFilters:
|
||||
"""Issue filters."""
|
||||
|
||||
state: str | None = None
|
||||
labels: list[str] | None = None
|
||||
limit: int | None = None
|
||||
author: str | None = None
|
||||
include_prs: bool = True
|
||||
|
||||
@_dataclass
|
||||
class PRFilters:
|
||||
"""PR filters."""
|
||||
|
||||
state: str = "open"
|
||||
labels: list[str] | None = None
|
||||
limit: int | None = None
|
||||
author: str | None = None
|
||||
base_branch: str | None = None
|
||||
head_branch: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitLabProvider:
|
||||
"""
|
||||
GitLab implementation of the GitProvider protocol.
|
||||
|
||||
Uses the GitLab REST API for all operations.
|
||||
|
||||
Usage:
|
||||
provider = GitLabProvider(
|
||||
repo="group/project",
|
||||
token="glpat-...",
|
||||
instance_url="https://gitlab.com"
|
||||
)
|
||||
mr = await provider.fetch_pr(123)
|
||||
await provider.post_review(123, review)
|
||||
"""
|
||||
|
||||
_repo: str
|
||||
_token: str
|
||||
_instance_url: str = "https://gitlab.com"
|
||||
_project_dir: Path | None = None
|
||||
_glab_client: GitLabClient | None = None
|
||||
enable_rate_limiting: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self._glab_client is None:
|
||||
project_dir = Path(self._project_dir) if self._project_dir else Path.cwd()
|
||||
config = GitLabConfig(
|
||||
token=self._token,
|
||||
project=self._repo,
|
||||
instance_url=self._instance_url,
|
||||
)
|
||||
self._glab_client = GitLabClient(
|
||||
project_dir=project_dir,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@property
|
||||
def provider_type(self) -> ProviderType:
|
||||
return ProviderType.GITLAB
|
||||
|
||||
@property
|
||||
def repo(self) -> str:
|
||||
return self._repo
|
||||
|
||||
@property
|
||||
def glab_client(self) -> GitLabClient:
|
||||
"""Get the underlying GitLabClient."""
|
||||
return self._glab_client
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Pull Request Operations (GitLab calls them Merge Requests)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def fetch_pr(self, number: int) -> PRData:
|
||||
"""
|
||||
Fetch a merge request by IID.
|
||||
|
||||
Args:
|
||||
number: MR IID (GitLab uses IID, not global ID)
|
||||
|
||||
Returns:
|
||||
PRData with full MR details including diff
|
||||
"""
|
||||
# Get MR details
|
||||
mr_data = self._glab_client.get_mr(number)
|
||||
|
||||
# Get MR changes (includes diff)
|
||||
changes_data = self._glab_client.get_mr_changes(number)
|
||||
|
||||
# Build diff from changes
|
||||
diffs = []
|
||||
for change in changes_data.get("changes", []):
|
||||
diff = change.get("diff", "")
|
||||
if diff:
|
||||
diffs.append(diff)
|
||||
diff = "\n".join(diffs)
|
||||
|
||||
return self._parse_mr_data(mr_data, diff, changes_data)
|
||||
|
||||
async def fetch_prs(self, filters: PRFilters | None = None) -> list[PRData]:
|
||||
"""
|
||||
Fetch merge requests with optional filters.
|
||||
|
||||
Args:
|
||||
filters: Optional filters (state, labels, etc.)
|
||||
|
||||
Returns:
|
||||
List of PRData
|
||||
"""
|
||||
filters = filters or PRFilters()
|
||||
|
||||
# Build query parameters for GitLab API
|
||||
params = {}
|
||||
if filters.state == "open":
|
||||
params["state"] = "opened"
|
||||
elif filters.state == "closed":
|
||||
params["state"] = "closed"
|
||||
elif filters.state == "merged":
|
||||
params["state"] = "merged"
|
||||
|
||||
if filters.labels:
|
||||
params["labels"] = ",".join(filters.labels)
|
||||
|
||||
if filters.limit:
|
||||
params["per_page"] = min(filters.limit, 100) # GitLab max is 100
|
||||
|
||||
# Use direct API call for listing MRs
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
endpoint = f"/projects/{encoded_project}/merge_requests"
|
||||
|
||||
mrs_data = self._glab_client._fetch(endpoint, params=params)
|
||||
|
||||
result = []
|
||||
for mr_data in mrs_data:
|
||||
# Apply additional filters that aren't supported by GitLab API
|
||||
if filters.author:
|
||||
mr_author = mr_data.get("author", {}).get("username")
|
||||
if mr_author != filters.author:
|
||||
continue
|
||||
|
||||
if filters.base_branch:
|
||||
if mr_data.get("target_branch") != filters.base_branch:
|
||||
continue
|
||||
|
||||
if filters.head_branch:
|
||||
if mr_data.get("source_branch") != filters.head_branch:
|
||||
continue
|
||||
|
||||
# Parse to PRData (lightweight, no diff)
|
||||
result.append(self._parse_mr_data(mr_data, "", {}))
|
||||
|
||||
return result
|
||||
|
||||
async def fetch_pr_diff(self, number: int) -> str:
|
||||
"""
|
||||
Fetch the diff for a merge request.
|
||||
|
||||
Args:
|
||||
number: MR IID
|
||||
|
||||
Returns:
|
||||
Unified diff string
|
||||
"""
|
||||
return self._glab_client.get_mr_diff(number)
|
||||
|
||||
async def post_review(self, pr_number: int, review: ReviewData) -> int:
|
||||
"""
|
||||
Post a review to a merge request.
|
||||
|
||||
GitLab doesn't have the same review concept as GitHub.
|
||||
We implement this as:
|
||||
- approve → Approve MR + post note
|
||||
- request_changes → Post note with request changes
|
||||
- comment → Post note only
|
||||
|
||||
Args:
|
||||
pr_number: MR IID
|
||||
review: Review data with findings and comments
|
||||
|
||||
Returns:
|
||||
Note ID (or 0 if not available)
|
||||
"""
|
||||
# Post the review body as a note
|
||||
note_data = self._glab_client.post_mr_note(pr_number, review.body)
|
||||
|
||||
# If approving, also approve the MR
|
||||
if review.event == "approve":
|
||||
self._glab_client.approve_mr(pr_number)
|
||||
|
||||
# Return note ID
|
||||
return note_data.get("id", 0)
|
||||
|
||||
async def merge_pr(
|
||||
self,
|
||||
pr_number: int,
|
||||
merge_method: str = "merge",
|
||||
commit_title: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Merge a merge request.
|
||||
|
||||
Args:
|
||||
pr_number: MR IID
|
||||
merge_method: merge, squash, or rebase (GitLab supports merge and squash)
|
||||
commit_title: Optional commit title
|
||||
|
||||
Returns:
|
||||
True if merged successfully
|
||||
"""
|
||||
# Map merge method to GitLab parameters
|
||||
squash = merge_method == "squash"
|
||||
|
||||
try:
|
||||
result = self._glab_client.merge_mr(pr_number, squash=squash)
|
||||
# Check if merge was successful
|
||||
return result.get("status") != "failed"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def close_pr(
|
||||
self,
|
||||
pr_number: int,
|
||||
comment: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Close a merge request without merging.
|
||||
|
||||
Args:
|
||||
pr_number: MR IID
|
||||
comment: Optional closing comment
|
||||
|
||||
Returns:
|
||||
True if closed successfully
|
||||
"""
|
||||
try:
|
||||
# Post closing comment if provided
|
||||
if comment:
|
||||
self._glab_client.post_mr_note(pr_number, comment)
|
||||
|
||||
# GitLab doesn't have a direct "close" endpoint for MRs
|
||||
# We need to use the API to set the state event to close
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
data = {"state_event": "close"}
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/merge_requests/{pr_number}",
|
||||
method="PUT",
|
||||
data=data,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Issue Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def fetch_issue(self, number: int) -> IssueData:
|
||||
"""
|
||||
Fetch an issue by IID.
|
||||
|
||||
Args:
|
||||
number: Issue IID
|
||||
|
||||
Returns:
|
||||
IssueData with full issue details
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
issue_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{number}"
|
||||
)
|
||||
return self._parse_issue_data(issue_data)
|
||||
|
||||
async def fetch_issues(
|
||||
self, filters: IssueFilters | None = None
|
||||
) -> list[IssueData]:
|
||||
"""
|
||||
Fetch issues with optional filters.
|
||||
|
||||
Args:
|
||||
filters: Optional filters
|
||||
|
||||
Returns:
|
||||
List of IssueData
|
||||
"""
|
||||
filters = filters or IssueFilters()
|
||||
|
||||
# Build query parameters
|
||||
params = {}
|
||||
if filters.state:
|
||||
params["state"] = filters.state
|
||||
if filters.labels:
|
||||
params["labels"] = ",".join(filters.labels)
|
||||
if filters.limit:
|
||||
params["per_page"] = min(filters.limit, 100)
|
||||
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
endpoint = f"/projects/{encoded_project}/issues"
|
||||
|
||||
issues_data = self._glab_client._fetch(endpoint, params=params)
|
||||
|
||||
result = []
|
||||
for issue_data in issues_data:
|
||||
# Filter out MRs if requested
|
||||
# In GitLab, MRs are separate from issues, so this check is less relevant
|
||||
# But we check for the "merge_request" label or type
|
||||
if not filters.include_prs:
|
||||
# GitLab doesn't mix MRs with issues in the issues endpoint
|
||||
pass
|
||||
|
||||
# Apply author filter
|
||||
if filters.author:
|
||||
author = issue_data.get("author", {}).get("username")
|
||||
if author != filters.author:
|
||||
continue
|
||||
|
||||
result.append(self._parse_issue_data(issue_data))
|
||||
|
||||
return result
|
||||
|
||||
async def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: list[str] | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
) -> IssueData:
|
||||
"""
|
||||
Create a new issue.
|
||||
|
||||
Args:
|
||||
title: Issue title
|
||||
body: Issue body
|
||||
labels: Optional labels
|
||||
assignees: Optional assignees (usernames)
|
||||
|
||||
Returns:
|
||||
Created IssueData
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"description": body,
|
||||
}
|
||||
|
||||
if labels:
|
||||
data["labels"] = ",".join(labels)
|
||||
|
||||
# GitLab uses assignee IDs, not usernames
|
||||
# We need to look up user IDs first
|
||||
if assignees:
|
||||
assignee_ids = []
|
||||
for username in assignees:
|
||||
try:
|
||||
user_data = self._glab_client._fetch(f"/users?username={username}")
|
||||
if user_data:
|
||||
assignee_ids.append(user_data[0]["id"])
|
||||
except Exception:
|
||||
pass # Skip invalid users
|
||||
if assignee_ids:
|
||||
data["assignee_ids"] = assignee_ids
|
||||
|
||||
result = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues",
|
||||
method="POST",
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Return the created issue
|
||||
return await self.fetch_issue(result["iid"])
|
||||
|
||||
async def close_issue(
|
||||
self,
|
||||
number: int,
|
||||
comment: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Close an issue.
|
||||
|
||||
Args:
|
||||
number: Issue IID
|
||||
comment: Optional closing comment
|
||||
|
||||
Returns:
|
||||
True if closed successfully
|
||||
"""
|
||||
try:
|
||||
# Post closing comment if provided
|
||||
if comment:
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{number}/notes",
|
||||
method="POST",
|
||||
data={"body": comment},
|
||||
)
|
||||
|
||||
# Close the issue
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{number}",
|
||||
method="PUT",
|
||||
data={"state_event": "close"},
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def add_comment(
|
||||
self,
|
||||
issue_or_pr_number: int,
|
||||
body: str,
|
||||
) -> int:
|
||||
"""
|
||||
Add a comment to an issue or MR.
|
||||
|
||||
Args:
|
||||
issue_or_pr_number: Issue/MR IID
|
||||
body: Comment body
|
||||
|
||||
Returns:
|
||||
Note ID
|
||||
"""
|
||||
# Try MR first, then issue
|
||||
try:
|
||||
note_data = self._glab_client.post_mr_note(issue_or_pr_number, body)
|
||||
return note_data.get("id", 0)
|
||||
except Exception:
|
||||
try:
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
note_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{issue_or_pr_number}/notes",
|
||||
method="POST",
|
||||
data={"body": body},
|
||||
)
|
||||
return note_data.get("id", 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Label Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def apply_labels(
|
||||
self,
|
||||
issue_or_pr_number: int,
|
||||
labels: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Apply labels to an issue or MR.
|
||||
|
||||
Args:
|
||||
issue_or_pr_number: Issue/MR IID
|
||||
labels: Labels to apply
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
|
||||
# Try MR first
|
||||
try:
|
||||
current_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/merge_requests/{issue_or_pr_number}"
|
||||
)
|
||||
current_labels = current_data.get("labels", [])
|
||||
new_labels = list(set(current_labels + labels))
|
||||
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/merge_requests/{issue_or_pr_number}",
|
||||
method="PUT",
|
||||
data={"labels": ",".join(new_labels)},
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try issue
|
||||
try:
|
||||
current_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{issue_or_pr_number}"
|
||||
)
|
||||
current_labels = current_data.get("labels", [])
|
||||
new_labels = list(set(current_labels + labels))
|
||||
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{issue_or_pr_number}",
|
||||
method="PUT",
|
||||
data={"labels": ",".join(new_labels)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def remove_labels(
|
||||
self,
|
||||
issue_or_pr_number: int,
|
||||
labels: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Remove labels from an issue or MR.
|
||||
|
||||
Args:
|
||||
issue_or_pr_number: Issue/MR IID
|
||||
labels: Labels to remove
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
|
||||
# Try MR first
|
||||
try:
|
||||
current_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/merge_requests/{issue_or_pr_number}"
|
||||
)
|
||||
current_labels = current_data.get("labels", [])
|
||||
new_labels = [label for label in current_labels if label not in labels]
|
||||
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/merge_requests/{issue_or_pr_number}",
|
||||
method="PUT",
|
||||
data={"labels": ",".join(new_labels)},
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try issue
|
||||
try:
|
||||
current_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{issue_or_pr_number}"
|
||||
)
|
||||
current_labels = current_data.get("labels", [])
|
||||
new_labels = [label for label in current_labels if label not in labels]
|
||||
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/issues/{issue_or_pr_number}",
|
||||
method="PUT",
|
||||
data={"labels": ",".join(new_labels)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def create_label(self, label: LabelData) -> None:
|
||||
"""
|
||||
Create a label in the repository.
|
||||
|
||||
Args:
|
||||
label: Label data
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
|
||||
data = {
|
||||
"name": label.name,
|
||||
"color": label.color.lstrip("#"), # GitLab doesn't want # prefix
|
||||
}
|
||||
|
||||
if label.description:
|
||||
data["description"] = label.description
|
||||
|
||||
try:
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/labels",
|
||||
method="POST",
|
||||
data=data,
|
||||
)
|
||||
except Exception:
|
||||
# Label might already exist, try to update
|
||||
try:
|
||||
self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/labels/{urllib.parse.quote(label.name)}",
|
||||
method="PUT",
|
||||
data=data,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def list_labels(self) -> list[LabelData]:
|
||||
"""
|
||||
List all labels in the repository.
|
||||
|
||||
Returns:
|
||||
List of LabelData
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
|
||||
labels_data = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/labels",
|
||||
params={"per_page": 100},
|
||||
)
|
||||
|
||||
return [
|
||||
LabelData(
|
||||
name=label["name"],
|
||||
color=f"#{label['color']}", # Add # prefix for consistency
|
||||
description=label.get("description", ""),
|
||||
)
|
||||
for label in labels_data
|
||||
]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Repository Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_repository_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get repository information.
|
||||
|
||||
Returns:
|
||||
Repository metadata
|
||||
"""
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
return self._glab_client._fetch(f"/projects/{encoded_project}")
|
||||
|
||||
async def get_default_branch(self) -> str:
|
||||
"""
|
||||
Get the default branch name.
|
||||
|
||||
Returns:
|
||||
Default branch name (e.g., "main", "master")
|
||||
"""
|
||||
repo_info = await self.get_repository_info()
|
||||
return repo_info.get("default_branch", "main")
|
||||
|
||||
async def check_permissions(self, username: str) -> str:
|
||||
"""
|
||||
Check a user's permission level on the repository.
|
||||
|
||||
Args:
|
||||
username: GitLab username
|
||||
|
||||
Returns:
|
||||
Permission level (admin, maintain, developer, reporter, guest, none)
|
||||
"""
|
||||
try:
|
||||
encoded_project = encode_project_path(self._repo)
|
||||
result = self._glab_client._fetch(
|
||||
f"/projects/{encoded_project}/members/all",
|
||||
params={"query": username},
|
||||
)
|
||||
|
||||
if result:
|
||||
# GitLab access levels: 10=guest, 20=reporter, 30=developer, 40=maintainer, 50=owner
|
||||
access_level = result[0].get("access_level", 0)
|
||||
|
||||
level_map = {
|
||||
50: "admin",
|
||||
40: "maintain",
|
||||
30: "developer",
|
||||
20: "reporter",
|
||||
10: "guest",
|
||||
}
|
||||
|
||||
return level_map.get(access_level, "none")
|
||||
|
||||
return "none"
|
||||
except Exception:
|
||||
return "none"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API Operations (Low-level)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make a GET request to the GitLab API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
API response data
|
||||
"""
|
||||
return self._glab_client._fetch(endpoint, params=params)
|
||||
|
||||
async def api_post(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make a POST request to the GitLab API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
data: Request body
|
||||
|
||||
Returns:
|
||||
API response data
|
||||
"""
|
||||
return self._glab_client._fetch(endpoint, method="POST", data=data)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helper Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _parse_mr_data(
|
||||
self, data: dict[str, Any], diff: str, changes_data: dict[str, Any]
|
||||
) -> PRData:
|
||||
"""Parse GitLab MR data into PRData."""
|
||||
author_data = data.get("author", {})
|
||||
author = author_data.get("username", "unknown") if author_data else "unknown"
|
||||
|
||||
labels = data.get("labels", [])
|
||||
|
||||
# Extract files from changes data
|
||||
files = []
|
||||
if changes_data.get("changes"):
|
||||
for change in changes_data["changes"]:
|
||||
new_path = change.get("new_path")
|
||||
old_path = change.get("old_path")
|
||||
files.append(
|
||||
{
|
||||
"path": new_path or old_path,
|
||||
"new_path": new_path,
|
||||
"old_path": old_path,
|
||||
"status": change.get("new_file")
|
||||
and "added"
|
||||
or change.get("deleted_file")
|
||||
and "deleted"
|
||||
or change.get("renamed_file")
|
||||
and "renamed"
|
||||
or "modified",
|
||||
}
|
||||
)
|
||||
|
||||
return PRData(
|
||||
number=data.get("iid", 0),
|
||||
title=data.get("title", ""),
|
||||
body=data.get("description", "") or "",
|
||||
author=author,
|
||||
state=data.get("state", "opened"),
|
||||
source_branch=data.get("source_branch", ""),
|
||||
target_branch=data.get("target_branch", ""),
|
||||
additions=changes_data.get("additions", 0),
|
||||
deletions=changes_data.get("deletions", 0),
|
||||
changed_files=changes_data.get("changed_files_count", len(files)),
|
||||
files=files,
|
||||
diff=diff,
|
||||
url=data.get("web_url", ""),
|
||||
created_at=self._parse_datetime(data.get("created_at")),
|
||||
updated_at=self._parse_datetime(data.get("updated_at")),
|
||||
labels=labels,
|
||||
reviewers=[], # GitLab uses "assignees" not reviewers
|
||||
is_draft=data.get("draft", False),
|
||||
mergeable=data.get("merge_status") != "cannot_be_merged",
|
||||
provider=ProviderType.GITLAB,
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
def _parse_issue_data(self, data: dict[str, Any]) -> IssueData:
|
||||
"""Parse GitLab issue data into IssueData."""
|
||||
author_data = data.get("author", {})
|
||||
author = author_data.get("username", "unknown") if author_data else "unknown"
|
||||
|
||||
labels = data.get("labels", [])
|
||||
|
||||
assignees = []
|
||||
for assignee in data.get("assignees", []):
|
||||
if isinstance(assignee, dict):
|
||||
assignees.append(assignee.get("username", ""))
|
||||
|
||||
milestone = data.get("milestone")
|
||||
if isinstance(milestone, dict):
|
||||
milestone = milestone.get("title")
|
||||
|
||||
return IssueData(
|
||||
number=data.get("iid", 0),
|
||||
title=data.get("title", ""),
|
||||
body=data.get("description", "") or "",
|
||||
author=author,
|
||||
state=data.get("state", "opened"),
|
||||
labels=labels,
|
||||
created_at=self._parse_datetime(data.get("created_at")),
|
||||
updated_at=self._parse_datetime(data.get("updated_at")),
|
||||
url=data.get("web_url", ""),
|
||||
assignees=assignees,
|
||||
milestone=milestone,
|
||||
provider=ProviderType.GITLAB,
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
def _parse_datetime(self, dt_str: str | None) -> datetime:
|
||||
"""Parse ISO datetime string."""
|
||||
if not dt_str:
|
||||
return datetime.now(timezone.utc)
|
||||
try:
|
||||
return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -6,6 +6,9 @@ GitLab Automation Runner
|
||||
CLI interface for GitLab automation features:
|
||||
- MR Review: AI-powered merge request review
|
||||
- Follow-up Review: Review changes since last review
|
||||
- Triage: Classify and organize issues
|
||||
- Auto-fix: Automatically create specs from issues
|
||||
- Batch: Group and analyze similar issues
|
||||
|
||||
Usage:
|
||||
# Review a specific MR
|
||||
@@ -13,6 +16,15 @@ Usage:
|
||||
|
||||
# Follow-up review after new commits
|
||||
python runner.py followup-review-mr 123
|
||||
|
||||
# Triage issues
|
||||
python runner.py triage --state opened --limit 50
|
||||
|
||||
# Auto-fix an issue
|
||||
python runner.py auto-fix 42
|
||||
|
||||
# Batch similar issues
|
||||
python runner.py batch-issues --label "bug" --min 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -244,6 +256,277 @@ async def cmd_followup_review_mr(args) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
async def cmd_triage(args) -> int:
|
||||
"""
|
||||
Triage and classify GitLab issues.
|
||||
|
||||
Categorizes issues into: duplicates, spam, feature creep, actionable.
|
||||
"""
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
config = get_config(args)
|
||||
gitlab_config = GitLabConfig(
|
||||
token=config.token,
|
||||
project=config.project,
|
||||
instance_url=config.instance_url,
|
||||
)
|
||||
|
||||
client = GitLabClient(
|
||||
project_dir=args.project_dir,
|
||||
config=gitlab_config,
|
||||
)
|
||||
|
||||
safe_print(f"[Triage] Fetching issues (state={args.state}, limit={args.limit})...")
|
||||
|
||||
# Fetch issues (parse comma-separated labels into list)
|
||||
label_list = args.labels.split(",") if args.labels else None
|
||||
issues = client.list_issues(
|
||||
state=args.state,
|
||||
labels=label_list,
|
||||
per_page=args.limit,
|
||||
)
|
||||
|
||||
if not issues:
|
||||
safe_print("[Triage] No issues found matching criteria")
|
||||
return 0
|
||||
|
||||
safe_print(f"[Triage] Found {len(issues)} issues to triage")
|
||||
|
||||
# Basic triage logic
|
||||
actionable = []
|
||||
duplicates = []
|
||||
spam = []
|
||||
feature_creep = []
|
||||
|
||||
for issue in issues:
|
||||
title = issue.get("title", "").lower()
|
||||
description = issue.get("description", "").lower()
|
||||
|
||||
# Check for spam
|
||||
if any(word in title for word in ["test", "spam", "xxx"]):
|
||||
spam.append(issue)
|
||||
continue
|
||||
|
||||
# Check for duplicates (simple heuristic)
|
||||
if any(word in title for word in ["duplicate", "already", "same"]):
|
||||
duplicates.append(issue)
|
||||
continue
|
||||
|
||||
# Check for feature creep
|
||||
if any(word in title for word in ["also", "while", "additionally", "btw"]):
|
||||
feature_creep.append(issue)
|
||||
continue
|
||||
|
||||
actionable.append(issue)
|
||||
|
||||
# Print results
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Issue Triage Results")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Total Issues: {len(issues)}")
|
||||
print(f" Actionable: {len(actionable)}")
|
||||
print(f" Duplicates: {len(duplicates)}")
|
||||
print(f" Spam: {len(spam)}")
|
||||
print(f" Feature Creep: {len(feature_creep)}")
|
||||
|
||||
if args.verbose and actionable[:10]:
|
||||
print("\nActionable Issues (showing first 10):")
|
||||
for issue in actionable[:10]:
|
||||
iid = issue.get("iid")
|
||||
title = issue.get("title", "No title")
|
||||
labels = issue.get("labels", [])
|
||||
print(f" !{iid}: {title}")
|
||||
print(f" Labels: {', '.join(labels)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def cmd_auto_fix(args) -> int:
|
||||
"""
|
||||
Auto-fix an issue by creating a spec.
|
||||
|
||||
Analyzes the issue and creates a spec for implementation.
|
||||
"""
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
config = get_config(args)
|
||||
gitlab_config = GitLabConfig(
|
||||
token=config.token,
|
||||
project=config.project,
|
||||
instance_url=config.instance_url,
|
||||
)
|
||||
|
||||
client = GitLabClient(
|
||||
project_dir=args.project_dir,
|
||||
config=gitlab_config,
|
||||
)
|
||||
|
||||
safe_print(f"[Auto-fix] Fetching issue !{args.issue_iid}...")
|
||||
|
||||
# Fetch issue
|
||||
issue = client.get_issue(args.issue_iid)
|
||||
|
||||
if not issue:
|
||||
safe_print(f"[Auto-fix] Issue !{args.issue_iid} not found")
|
||||
return 1
|
||||
|
||||
title = issue.get("title", "")
|
||||
description = issue.get("description", "")
|
||||
labels = issue.get("labels", [])
|
||||
author = issue.get("author", {}).get("username", "")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Auto-fix for Issue !{args.issue_iid}")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Title: {title}")
|
||||
print(f"Author: {author}")
|
||||
print(f"Labels: {', '.join(labels)}")
|
||||
print(f"\nDescription:\n{description[:500]}...")
|
||||
|
||||
# Check if already auto-fixable
|
||||
if any(label in labels for label in ["auto-fix", "spec-created"]):
|
||||
safe_print("[Auto-fix] Issue already marked for auto-fix or has spec")
|
||||
return 0
|
||||
|
||||
# Add auto-fix label
|
||||
if not args.dry_run:
|
||||
try:
|
||||
client.update_issue(args.issue_iid, labels=list(set(labels + ["auto-fix"])))
|
||||
safe_print(f"[Auto-fix] Added 'auto-fix' label to issue !{args.issue_iid}")
|
||||
except Exception as e:
|
||||
safe_print(f"[Auto-fix] Failed to update issue: {e}")
|
||||
return 1
|
||||
else:
|
||||
safe_print("[Auto-fix] Dry run - would add 'auto-fix' label")
|
||||
|
||||
# Note: In a full implementation, this would:
|
||||
# 1. Analyze the issue with AI
|
||||
# 2. Create a spec in .auto-claude/specs/
|
||||
# 3. Run the spec creation pipeline
|
||||
|
||||
safe_print("[Auto-fix] Issue marked for auto-fix (spec creation not implemented)")
|
||||
safe_print(
|
||||
"[Auto-fix] Run 'python spec_runner.py --task \"<issue description>\"' to create spec"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def cmd_batch_issues(args) -> int:
|
||||
"""
|
||||
Batch similar issues together for analysis.
|
||||
|
||||
Groups issues by labels, keywords, or patterns.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
config = get_config(args)
|
||||
gitlab_config = GitLabConfig(
|
||||
token=config.token,
|
||||
project=config.project,
|
||||
instance_url=config.instance_url,
|
||||
)
|
||||
|
||||
client = GitLabClient(
|
||||
project_dir=args.project_dir,
|
||||
config=gitlab_config,
|
||||
)
|
||||
|
||||
safe_print(f"[Batch] Fetching issues (label={args.label}, limit={args.limit})...")
|
||||
|
||||
# Fetch issues
|
||||
issues = client.list_issues(
|
||||
state=args.state,
|
||||
labels=[args.label] if args.label else None,
|
||||
per_page=args.limit,
|
||||
)
|
||||
|
||||
if not issues:
|
||||
safe_print("[Batch] No issues found matching criteria")
|
||||
return 0
|
||||
|
||||
safe_print(f"[Batch] Found {len(issues)} issues")
|
||||
|
||||
# Group issues by keywords
|
||||
groups = defaultdict(list)
|
||||
keywords = [
|
||||
"bug",
|
||||
"error",
|
||||
"crash",
|
||||
"fix",
|
||||
"feature",
|
||||
"enhancement",
|
||||
"add",
|
||||
"implement",
|
||||
"refactor",
|
||||
"cleanup",
|
||||
"improve",
|
||||
"docs",
|
||||
"documentation",
|
||||
"readme",
|
||||
"test",
|
||||
"testing",
|
||||
"coverage",
|
||||
"performance",
|
||||
"slow",
|
||||
"optimize",
|
||||
]
|
||||
|
||||
for issue in issues:
|
||||
title = issue.get("title", "").lower()
|
||||
description = issue.get("description", "").lower()
|
||||
combined = f"{title} {description}"
|
||||
|
||||
matched = False
|
||||
for keyword in keywords:
|
||||
if keyword in combined:
|
||||
groups[keyword].append(issue)
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched:
|
||||
groups["other"].append(issue)
|
||||
|
||||
# Filter groups by minimum size
|
||||
filtered_groups = {k: v for k, v in groups.items() if len(v) >= args.min}
|
||||
|
||||
# Print results
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Batch Analysis Results")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Total Issues: {len(issues)}")
|
||||
print(f"Groups Found: {len(filtered_groups)}")
|
||||
|
||||
# Sort by group size
|
||||
sorted_groups = sorted(
|
||||
filtered_groups.items(), key=lambda x: len(x[1]), reverse=True
|
||||
)
|
||||
|
||||
for keyword, group_issues in sorted_groups:
|
||||
print(f"\n[{keyword.upper()}] - {len(group_issues)} issues:")
|
||||
for issue in group_issues[:5]: # Show first 5
|
||||
iid = issue.get("iid")
|
||||
title = issue.get("title", "No title")
|
||||
print(f" !{iid}: {title[:60]}...")
|
||||
if len(group_issues) > 5:
|
||||
print(f" ... and {len(group_issues) - 5} more")
|
||||
|
||||
# Suggest batch actions
|
||||
if len(sorted_groups) > 0:
|
||||
largest_group, largest_issues = sorted_groups[0]
|
||||
if len(largest_issues) >= args.min:
|
||||
print("\nSuggested batch action:")
|
||||
print(f" Group: {largest_group}")
|
||||
print(f" Size: {len(largest_issues)} issues")
|
||||
label_arg = f"--labels {args.label}" if args.label else ""
|
||||
limit_arg = f"--limit {len(largest_issues)}"
|
||||
print(f" Command: python runner.py triage {label_arg} {limit_arg}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
@@ -303,6 +586,47 @@ def main():
|
||||
)
|
||||
followup_parser.add_argument("mr_iid", type=int, help="MR IID to review")
|
||||
|
||||
# triage command
|
||||
triage_parser = subparsers.add_parser("triage", help="Triage and classify issues")
|
||||
triage_parser.add_argument(
|
||||
"--state", type=str, default="opened", help="Issue state to filter"
|
||||
)
|
||||
triage_parser.add_argument(
|
||||
"--labels", type=str, help="Comma-separated labels to filter"
|
||||
)
|
||||
triage_parser.add_argument(
|
||||
"--limit", type=int, default=50, help="Maximum issues to process"
|
||||
)
|
||||
triage_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Show detailed output"
|
||||
)
|
||||
|
||||
# auto-fix command
|
||||
autofix_parser = subparsers.add_parser(
|
||||
"auto-fix", help="Auto-fix an issue by creating a spec"
|
||||
)
|
||||
autofix_parser.add_argument("issue_iid", type=int, help="Issue IID to auto-fix")
|
||||
autofix_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
|
||||
# batch-issues command
|
||||
batch_parser = subparsers.add_parser(
|
||||
"batch-issues", help="Batch and analyze similar issues"
|
||||
)
|
||||
batch_parser.add_argument("--label", type=str, help="Label to filter issues")
|
||||
batch_parser.add_argument(
|
||||
"--state", type=str, default="opened", help="Issue state to filter"
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
"--limit", type=int, default=100, help="Maximum issues to process"
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
"--min", type=int, default=3, help="Minimum group size to report"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
@@ -313,6 +637,9 @@ def main():
|
||||
commands = {
|
||||
"review-mr": cmd_review_mr,
|
||||
"followup-review-mr": cmd_followup_review_mr,
|
||||
"triage": cmd_triage,
|
||||
"auto-fix": cmd_auto_fix,
|
||||
"batch-issues": cmd_batch_issues,
|
||||
}
|
||||
|
||||
handler = commands.get(args.command)
|
||||
|
||||
@@ -5,6 +5,23 @@ GitLab Runner Services
|
||||
Service layer for GitLab automation.
|
||||
"""
|
||||
|
||||
from .ci_checker import CIChecker, JobStatus, PipelineInfo, PipelineStatus
|
||||
from .context_gatherer import (
|
||||
AIBotComment,
|
||||
ChangedFile,
|
||||
FollowupMRContextGatherer,
|
||||
MRContextGatherer,
|
||||
)
|
||||
from .mr_review_engine import MRReviewEngine
|
||||
|
||||
__all__ = ["MRReviewEngine"]
|
||||
__all__ = [
|
||||
"MRReviewEngine",
|
||||
"CIChecker",
|
||||
"JobStatus",
|
||||
"PipelineInfo",
|
||||
"PipelineStatus",
|
||||
"MRContextGatherer",
|
||||
"FollowupMRContextGatherer",
|
||||
"ChangedFile",
|
||||
"AIBotComment",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Batch Processor for GitLab
|
||||
==========================
|
||||
|
||||
Handles batch processing of similar GitLab issues.
|
||||
Ported from GitHub with GitLab API adaptations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..glab_client import GitLabClient
|
||||
from ..models import GitLabRunnerConfig
|
||||
|
||||
try:
|
||||
from ..models import AutoFixState, AutoFixStatus
|
||||
from .io_utils import safe_print
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import AutoFixState, AutoFixStatus
|
||||
from services.io_utils import safe_print
|
||||
|
||||
|
||||
class GitlabBatchProcessor:
|
||||
"""Handles batch processing of similar GitLab issues."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
gitlab_dir: Path,
|
||||
config: GitLabRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.gitlab_dir = Path(gitlab_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""
|
||||
Report progress if callback is set.
|
||||
|
||||
Uses dynamic import to avoid circular dependency between batch_processor
|
||||
and orchestrator modules. Checks sys.modules first to avoid redundant
|
||||
import attempts when ProgressCallback is already loaded.
|
||||
"""
|
||||
if self.progress_callback:
|
||||
# Import at module level to avoid circular import issues
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
# Fallback: try relative import
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
async def batch_and_fix_issues(
|
||||
self,
|
||||
issues: list[dict],
|
||||
fetch_issue_callback,
|
||||
) -> list:
|
||||
"""
|
||||
Batch similar issues and create combined specs for each batch.
|
||||
|
||||
Args:
|
||||
issues: List of GitLab issues to batch
|
||||
fetch_issue_callback: Async function to fetch individual issues
|
||||
|
||||
Returns:
|
||||
List of GitlabIssueBatch objects that were created
|
||||
"""
|
||||
from .batch_issues import GitlabIssueBatcher
|
||||
|
||||
self._report_progress("batching", 10, "Analyzing issues for batching...")
|
||||
|
||||
try:
|
||||
if not issues:
|
||||
safe_print("[BATCH] No issues to batch")
|
||||
return []
|
||||
|
||||
safe_print(
|
||||
f"[BATCH] Analyzing {len(issues)} issues for similarity...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Initialize batcher with AI validation
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=self.gitlab_dir,
|
||||
project=self.config.project,
|
||||
project_dir=self.project_dir,
|
||||
similarity_threshold=0.70,
|
||||
min_batch_size=1,
|
||||
max_batch_size=5,
|
||||
validate_batches=True,
|
||||
)
|
||||
|
||||
# Create batches
|
||||
self._report_progress("batching", 30, "Creating issue batches...")
|
||||
batches = await batcher.create_batches(issues)
|
||||
|
||||
if not batches:
|
||||
safe_print("[BATCH] No batches created")
|
||||
return []
|
||||
|
||||
safe_print(f"[BATCH] Created {len(batches)} batches")
|
||||
for batch in batches:
|
||||
safe_print(f" - {batch.batch_id}: {len(batch.issues)} issues")
|
||||
batcher.save_batch(batch)
|
||||
|
||||
self._report_progress(
|
||||
"batching", 100, f"Batching complete: {len(batches)} batches"
|
||||
)
|
||||
return batches
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"[BATCH] Error during batching: {e}")
|
||||
self._report_progress("batching", 100, f"Batching failed: {e}")
|
||||
return []
|
||||
|
||||
async def process_batch(
|
||||
self,
|
||||
batch,
|
||||
glab_client: GitLabClient,
|
||||
) -> AutoFixState | None:
|
||||
"""
|
||||
Process a single batch of issues.
|
||||
|
||||
Creates a combined spec for all issues in the batch.
|
||||
|
||||
Args:
|
||||
batch: GitlabIssueBatch to process
|
||||
glab_client: GitLab API client
|
||||
|
||||
Returns:
|
||||
AutoFixState for the batch, or None if failed
|
||||
"""
|
||||
from .batch_issues import GitlabBatchStatus
|
||||
|
||||
self._report_progress(
|
||||
"batch_processing",
|
||||
10,
|
||||
f"Processing batch {batch.batch_id}...",
|
||||
batch_id=batch.batch_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# Update batch status
|
||||
batch.status = GitlabBatchStatus.ANALYZING
|
||||
from .batch_issues import GitlabIssueBatcher
|
||||
|
||||
# Create batcher instance to call save_batch (instance method)
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=self.gitlab_dir,
|
||||
project=self.config.project,
|
||||
project_dir=self.project_dir,
|
||||
similarity_threshold=0.7,
|
||||
)
|
||||
batcher.save_batch(batch)
|
||||
|
||||
# Build combined issue description
|
||||
combined_description = self._build_combined_description(batch)
|
||||
|
||||
# Create spec ID for this batch
|
||||
spec_id = f"batch-{batch.batch_id}"
|
||||
|
||||
# Create auto-fix state for the primary issue
|
||||
primary_issue = batch.issues[0]
|
||||
state = AutoFixState(
|
||||
issue_iid=primary_issue.issue_iid,
|
||||
issue_url=self._build_issue_url(primary_issue.issue_iid),
|
||||
project=self.config.project,
|
||||
status=AutoFixStatus.CREATING_SPEC,
|
||||
)
|
||||
|
||||
# Note: In a full implementation, this would trigger spec creation
|
||||
# For now, we just create the state
|
||||
await state.save(self.gitlab_dir)
|
||||
|
||||
# Update batch with spec ID
|
||||
batch.spec_id = spec_id
|
||||
batch.status = GitlabBatchStatus.CREATING_SPEC
|
||||
batcher.save_batch(batch)
|
||||
|
||||
self._report_progress(
|
||||
"batch_processing",
|
||||
50,
|
||||
f"Batch {batch.batch_id}: spec creation ready",
|
||||
batch_id=batch.batch_id,
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"[BATCH] Error processing batch {batch.batch_id}: {e}")
|
||||
batch.status = GitlabBatchStatus.FAILED
|
||||
batch.error = str(e)
|
||||
from .batch_issues import GitlabIssueBatcher
|
||||
|
||||
# Create batcher instance to save the failed batch state
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=self.gitlab_dir,
|
||||
project=self.config.project,
|
||||
project_dir=self.project_dir,
|
||||
)
|
||||
batcher.save_batch(batch)
|
||||
return None
|
||||
|
||||
def _build_combined_description(self, batch) -> str:
|
||||
"""Build a combined description for all issues in the batch."""
|
||||
lines = [
|
||||
f"# Batch Fix: {batch.theme or 'Multiple Issues'}",
|
||||
"",
|
||||
f"This batch addresses {len(batch.issues)} related issues:",
|
||||
"",
|
||||
]
|
||||
|
||||
for item in batch.issues:
|
||||
lines.append(f"## Issue !{item.issue_iid}: {item.title}")
|
||||
if item.body:
|
||||
# Truncate long descriptions
|
||||
body_preview = item.body[:500]
|
||||
if len(item.body) > 500:
|
||||
body_preview += "..."
|
||||
lines.append(f"{body_preview}")
|
||||
lines.append("")
|
||||
|
||||
if batch.validation_reasoning:
|
||||
lines.extend(
|
||||
[
|
||||
"**Batching Reasoning:**",
|
||||
batch.validation_reasoning,
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_issue_url(self, issue_iid: int) -> str:
|
||||
"""Build GitLab issue URL."""
|
||||
instance_url = self.config.instance_url.rstrip("/")
|
||||
return f"{instance_url}/{self.config.project}/-/issues/{issue_iid}"
|
||||
|
||||
async def get_queue(self) -> list:
|
||||
"""Get all batches in the queue."""
|
||||
from .batch_issues import GitlabIssueBatcher
|
||||
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=self.gitlab_dir,
|
||||
project=self.config.project,
|
||||
project_dir=self.project_dir,
|
||||
)
|
||||
|
||||
return batcher.list_batches()
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
CI/CD Pipeline Checker for GitLab
|
||||
==================================
|
||||
|
||||
Checks GitLab CI/CD pipeline status for merge requests.
|
||||
|
||||
Features:
|
||||
- Get pipeline status for an MR
|
||||
- Check for failed jobs
|
||||
- Detect security policy violations
|
||||
- Handle workflow approvals
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ..glab_client import GitLabClient, GitLabConfig
|
||||
from .io_utils import safe_print
|
||||
except ImportError:
|
||||
from core.io_utils import safe_print
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
class PipelineStatus(str, Enum):
|
||||
"""GitLab pipeline status."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
CANCELED = "canceled"
|
||||
SKIPPED = "skipped"
|
||||
MANUAL = "manual"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobStatus:
|
||||
"""Status of a single CI job."""
|
||||
|
||||
name: str
|
||||
status: str
|
||||
stage: str
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
duration: float | None = None
|
||||
failure_reason: str | None = None
|
||||
retry_count: int = 0
|
||||
allow_failure: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineInfo:
|
||||
"""Complete pipeline information."""
|
||||
|
||||
pipeline_id: int
|
||||
status: PipelineStatus
|
||||
ref: str
|
||||
sha: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
finished_at: str | None = None
|
||||
duration: float | None = None
|
||||
jobs: list[JobStatus] = None
|
||||
failed_jobs: list[JobStatus] = None
|
||||
blocked_jobs: list[JobStatus] = None
|
||||
security_issues: list[dict] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.jobs is None:
|
||||
self.jobs = []
|
||||
if self.failed_jobs is None:
|
||||
self.failed_jobs = []
|
||||
if self.blocked_jobs is None:
|
||||
self.blocked_jobs = []
|
||||
if self.security_issues is None:
|
||||
self.security_issues = []
|
||||
|
||||
@property
|
||||
def has_failures(self) -> bool:
|
||||
"""Check if pipeline has any failed jobs."""
|
||||
return len(self.failed_jobs) > 0
|
||||
|
||||
@property
|
||||
def has_security_issues(self) -> bool:
|
||||
"""Check if pipeline has security scan failures."""
|
||||
return len(self.security_issues) > 0
|
||||
|
||||
@property
|
||||
def is_blocking(self) -> bool:
|
||||
"""Check if pipeline status blocks merge."""
|
||||
# Only SUCCESS status allows merge
|
||||
# FAILED, CANCELED, RUNNING (with blocking jobs) block merge
|
||||
if self.status == PipelineStatus.SUCCESS:
|
||||
return False
|
||||
if self.status == PipelineStatus.FAILED:
|
||||
return True
|
||||
if self.status == PipelineStatus.CANCELED:
|
||||
return True
|
||||
if self.status in (PipelineStatus.RUNNING, PipelineStatus.PENDING):
|
||||
# Check if any critical jobs are expected to fail
|
||||
return any(
|
||||
not job.allow_failure for job in self.jobs if job.status == "failed"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class CIChecker:
|
||||
"""
|
||||
Checks CI/CD pipeline status for GitLab MRs.
|
||||
|
||||
Usage:
|
||||
checker = CIChecker(
|
||||
project_dir=Path("/path/to/project"),
|
||||
config=gitlab_config
|
||||
)
|
||||
pipeline_info = await checker.check_mr_pipeline(mr_iid=123)
|
||||
if pipeline_info.is_blocking:
|
||||
print(f"MR blocked by CI: {pipeline_info.status}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
config: GitLabConfig | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize CI checker.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
config: GitLab configuration (optional)
|
||||
"""
|
||||
self.project_dir = Path(project_dir)
|
||||
|
||||
if config:
|
||||
self.client = GitLabClient(
|
||||
project_dir=self.project_dir,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
# Try to load config from project
|
||||
from ..glab_client import load_gitlab_config
|
||||
|
||||
config = load_gitlab_config(self.project_dir)
|
||||
if config:
|
||||
self.client = GitLabClient(
|
||||
project_dir=self.project_dir,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
raise ValueError("GitLab configuration not found")
|
||||
|
||||
def _parse_job_status(self, job_data: dict) -> JobStatus:
|
||||
"""Parse job data from GitLab API."""
|
||||
return JobStatus(
|
||||
name=job_data.get("name", ""),
|
||||
status=job_data.get("status", "unknown"),
|
||||
stage=job_data.get("stage", ""),
|
||||
started_at=job_data.get("started_at"),
|
||||
finished_at=job_data.get("finished_at"),
|
||||
duration=job_data.get("duration"),
|
||||
failure_reason=job_data.get("failure_reason"),
|
||||
retry_count=job_data.get("retry_count", 0),
|
||||
allow_failure=job_data.get("allow_failure", False),
|
||||
)
|
||||
|
||||
async def check_mr_pipeline(self, mr_iid: int) -> PipelineInfo | None:
|
||||
"""
|
||||
Check pipeline status for an MR.
|
||||
|
||||
Args:
|
||||
mr_iid: The MR IID
|
||||
|
||||
Returns:
|
||||
PipelineInfo or None if no pipeline found
|
||||
"""
|
||||
# Get pipelines for this MR
|
||||
pipelines = await self.client.get_mr_pipelines_async(mr_iid)
|
||||
|
||||
if not pipelines:
|
||||
safe_print(f"[CI] No pipelines found for MR !{mr_iid}")
|
||||
return None
|
||||
|
||||
# Get the most recent pipeline (last in list)
|
||||
latest_pipeline_data = pipelines[-1]
|
||||
|
||||
pipeline_id = latest_pipeline_data.get("id")
|
||||
status_str = latest_pipeline_data.get("status", "unknown")
|
||||
|
||||
try:
|
||||
status = PipelineStatus(status_str)
|
||||
except ValueError:
|
||||
status = PipelineStatus.UNKNOWN
|
||||
|
||||
safe_print(f"[CI] MR !{mr_iid} has pipeline #{pipeline_id}: {status.value}")
|
||||
|
||||
# Get detailed pipeline info
|
||||
try:
|
||||
pipeline_detail = await self.client.get_pipeline_status_async(pipeline_id)
|
||||
except Exception as e:
|
||||
safe_print(f"[CI] Error fetching pipeline details: {e}")
|
||||
pipeline_detail = latest_pipeline_data
|
||||
|
||||
# Get jobs for this pipeline
|
||||
jobs_data = []
|
||||
try:
|
||||
jobs_data = await self.client.get_pipeline_jobs_async(pipeline_id)
|
||||
except Exception as e:
|
||||
safe_print(f"[CI] Error fetching pipeline jobs: {e}")
|
||||
|
||||
# Parse jobs
|
||||
jobs = [self._parse_job_status(job) for job in jobs_data]
|
||||
|
||||
# Find failed jobs (excluding allow_failure jobs)
|
||||
failed_jobs = [
|
||||
job for job in jobs if job.status == "failed" and not job.allow_failure
|
||||
]
|
||||
|
||||
# Find blocked/failed jobs
|
||||
blocked_jobs = [job for job in jobs if job.status in ("failed", "canceled")]
|
||||
|
||||
# Check for security scan failures
|
||||
security_issues = self._check_security_scans(jobs)
|
||||
|
||||
return PipelineInfo(
|
||||
pipeline_id=pipeline_id,
|
||||
status=status,
|
||||
ref=latest_pipeline_data.get("ref", ""),
|
||||
sha=latest_pipeline_data.get("sha", ""),
|
||||
created_at=latest_pipeline_data.get("created_at", ""),
|
||||
updated_at=latest_pipeline_data.get("updated_at", ""),
|
||||
finished_at=pipeline_detail.get("finished_at"),
|
||||
duration=pipeline_detail.get("duration"),
|
||||
jobs=jobs,
|
||||
failed_jobs=failed_jobs,
|
||||
blocked_jobs=blocked_jobs,
|
||||
security_issues=security_issues,
|
||||
)
|
||||
|
||||
def _check_security_scans(self, jobs: list[JobStatus]) -> list[dict]:
|
||||
"""
|
||||
Check for security scan failures.
|
||||
|
||||
Looks for common GitLab security job patterns:
|
||||
- sast
|
||||
- secret_detection
|
||||
- container_scanning
|
||||
- dependency_scanning
|
||||
- license_scanning
|
||||
"""
|
||||
issues = []
|
||||
|
||||
security_patterns = {
|
||||
"sast": "Static Application Security Testing",
|
||||
"secret_detection": "Secret Detection",
|
||||
"container_scanning": "Container Scanning",
|
||||
"dependency_scanning": "Dependency Scanning",
|
||||
"license_scanning": "License Scanning",
|
||||
"api_fuzzing": "API Fuzzing",
|
||||
"dast": "Dynamic Application Security Testing",
|
||||
}
|
||||
|
||||
for job in jobs:
|
||||
job_name_lower = job.name.lower()
|
||||
|
||||
# Check if this is a security job
|
||||
for pattern, scan_type in security_patterns.items():
|
||||
if pattern in job_name_lower:
|
||||
if job.status == "failed" and not job.allow_failure:
|
||||
issues.append(
|
||||
{
|
||||
"type": scan_type,
|
||||
"job_name": job.name,
|
||||
"status": job.status,
|
||||
"failure_reason": job.failure_reason,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return issues
|
||||
|
||||
def get_blocking_reason(self, pipeline: PipelineInfo) -> str:
|
||||
"""
|
||||
Get human-readable reason for why pipeline is blocking.
|
||||
|
||||
Args:
|
||||
pipeline: Pipeline info
|
||||
|
||||
Returns:
|
||||
Human-readable blocking reason
|
||||
"""
|
||||
if pipeline.status == PipelineStatus.SUCCESS:
|
||||
return ""
|
||||
|
||||
if pipeline.status == PipelineStatus.FAILED:
|
||||
if pipeline.failed_jobs:
|
||||
failed_job_names = [job.name for job in pipeline.failed_jobs[:3]]
|
||||
if len(pipeline.failed_jobs) > 3:
|
||||
failed_job_names.append(
|
||||
f"... and {len(pipeline.failed_jobs) - 3} more"
|
||||
)
|
||||
return (
|
||||
f"Pipeline failed: {', '.join(failed_job_names)}. "
|
||||
f"Fix these jobs before merging."
|
||||
)
|
||||
return "Pipeline failed. Check CI for details."
|
||||
|
||||
if pipeline.status == PipelineStatus.CANCELED:
|
||||
return "Pipeline was canceled."
|
||||
|
||||
if pipeline.status in (PipelineStatus.RUNNING, PipelineStatus.PENDING):
|
||||
return f"Pipeline is {pipeline.status.value}. Wait for completion."
|
||||
|
||||
if pipeline.has_security_issues:
|
||||
return (
|
||||
f"Security scan failures detected: "
|
||||
f"{', '.join(i['type'] for i in pipeline.security_issues[:3])}"
|
||||
)
|
||||
|
||||
return f"Pipeline status: {pipeline.status.value}"
|
||||
|
||||
def format_pipeline_summary(self, pipeline: PipelineInfo) -> str:
|
||||
"""
|
||||
Format pipeline info as a summary string.
|
||||
|
||||
Args:
|
||||
pipeline: Pipeline info
|
||||
|
||||
Returns:
|
||||
Formatted summary
|
||||
"""
|
||||
status_emoji = {
|
||||
PipelineStatus.SUCCESS: "✅",
|
||||
PipelineStatus.FAILED: "❌",
|
||||
PipelineStatus.RUNNING: "🔄",
|
||||
PipelineStatus.PENDING: "⏳",
|
||||
PipelineStatus.CANCELED: "🚫",
|
||||
PipelineStatus.SKIPPED: "⏭️",
|
||||
PipelineStatus.UNKNOWN: "❓",
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(pipeline.status, "⚪")
|
||||
|
||||
lines = [
|
||||
f"### CI/CD Pipeline #{pipeline.pipeline_id} {emoji}",
|
||||
f"**Status:** {pipeline.status.value.upper()}",
|
||||
f"**Branch:** {pipeline.ref}",
|
||||
f"**Commit:** {pipeline.sha[:8]}",
|
||||
"",
|
||||
]
|
||||
|
||||
if pipeline.duration:
|
||||
lines.append(
|
||||
f"**Duration:** {int(pipeline.duration // 60)}m {int(pipeline.duration % 60)}s"
|
||||
)
|
||||
|
||||
if pipeline.jobs:
|
||||
lines.append(f"**Jobs:** {len(pipeline.jobs)} total")
|
||||
|
||||
# Count by status
|
||||
status_counts = {}
|
||||
for job in pipeline.jobs:
|
||||
status_counts[job.status] = status_counts.get(job.status, 0) + 1
|
||||
|
||||
if status_counts:
|
||||
lines.append("**Job Status:**")
|
||||
for status, count in sorted(status_counts.items()):
|
||||
lines.append(f" - {status}: {count}")
|
||||
|
||||
# Security issues
|
||||
if pipeline.security_issues:
|
||||
lines.append("")
|
||||
lines.append("### 🚨 Security Issues")
|
||||
for issue in pipeline.security_issues:
|
||||
lines.append(f"- **{issue['type']}**: {issue['job_name']}")
|
||||
|
||||
# Failed jobs
|
||||
if pipeline.failed_jobs:
|
||||
lines.append("")
|
||||
lines.append("### Failed Jobs")
|
||||
for job in pipeline.failed_jobs[:5]:
|
||||
if job.failure_reason:
|
||||
lines.append(
|
||||
f"- **{job.name}** ({job.stage}): {job.failure_reason}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- **{job.name}** ({job.stage})")
|
||||
if len(pipeline.failed_jobs) > 5:
|
||||
lines.append(f"- ... and {len(pipeline.failed_jobs) - 5} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def wait_for_pipeline_completion(
|
||||
self,
|
||||
mr_iid: int,
|
||||
timeout_seconds: int = 1800, # 30 minutes default
|
||||
check_interval: int = 30,
|
||||
) -> PipelineInfo | None:
|
||||
"""
|
||||
Wait for pipeline to complete (for interactive workflows).
|
||||
|
||||
Args:
|
||||
mr_iid: MR IID
|
||||
timeout_seconds: Maximum time to wait
|
||||
check_interval: Seconds between checks
|
||||
|
||||
Returns:
|
||||
Final PipelineInfo or None if timeout
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
safe_print(f"[CI] Waiting for MR !{mr_iid} pipeline to complete...")
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < timeout_seconds:
|
||||
pipeline = await self.check_mr_pipeline(mr_iid)
|
||||
|
||||
if not pipeline:
|
||||
safe_print("[CI] No pipeline found")
|
||||
return None
|
||||
|
||||
if pipeline.status in (
|
||||
PipelineStatus.SUCCESS,
|
||||
PipelineStatus.FAILED,
|
||||
PipelineStatus.CANCELED,
|
||||
):
|
||||
safe_print(f"[CI] Pipeline completed: {pipeline.status.value}")
|
||||
return pipeline
|
||||
|
||||
safe_print(
|
||||
f"[CI] Pipeline still running... ({elapsed}s elapsed, "
|
||||
f"{timeout_seconds - elapsed}s remaining)"
|
||||
)
|
||||
|
||||
await asyncio.sleep(check_interval)
|
||||
elapsed += check_interval
|
||||
|
||||
safe_print(f"[CI] Timeout waiting for pipeline ({timeout_seconds}s)")
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
"""
|
||||
Follow-up MR Reviewer
|
||||
====================
|
||||
|
||||
Focused review of changes since last review for GitLab merge requests.
|
||||
- Only analyzes new commits
|
||||
- Checks if previous findings are resolved
|
||||
- Reviews new comments from contributors
|
||||
- Determines if MR is ready to merge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models import FollowupMRContext, GitLabRunnerConfig
|
||||
|
||||
try:
|
||||
from ..glab_client import GitLabClient
|
||||
from ..models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .io_utils import safe_print
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from glab_client import GitLabClient
|
||||
from models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from services.io_utils import safe_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
"high": ReviewSeverity.HIGH,
|
||||
"medium": ReviewSeverity.MEDIUM,
|
||||
"low": ReviewSeverity.LOW,
|
||||
}
|
||||
|
||||
|
||||
class FollowupReviewer:
|
||||
"""
|
||||
Performs focused follow-up reviews of GitLab MRs.
|
||||
|
||||
Key capabilities:
|
||||
1. Only reviews changes since last review (new commits)
|
||||
2. Checks if posted findings have been addressed
|
||||
3. Reviews new comments from contributors
|
||||
4. Determines if MR is ready to merge
|
||||
|
||||
Supports both heuristic and AI-powered review modes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
gitlab_dir: Path,
|
||||
config: GitLabRunnerConfig,
|
||||
progress_callback=None,
|
||||
use_ai: bool = True,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.gitlab_dir = Path(gitlab_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
self.use_ai = use_ai
|
||||
|
||||
def _report_progress(
|
||||
self, phase: str, progress: int, message: str, mr_iid: int
|
||||
) -> None:
|
||||
"""Report progress to callback if available."""
|
||||
if self.progress_callback:
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, mr_iid=mr_iid
|
||||
)
|
||||
)
|
||||
safe_print(f"[Followup] [{phase}] {message}")
|
||||
|
||||
async def review_followup(
|
||||
self,
|
||||
context: FollowupMRContext,
|
||||
glab_client: GitLabClient,
|
||||
) -> MRReviewResult:
|
||||
"""
|
||||
Perform a focused follow-up review.
|
||||
|
||||
Args:
|
||||
context: FollowupMRContext with previous review and current state
|
||||
glab_client: GitLab API client
|
||||
|
||||
Returns:
|
||||
MRReviewResult with updated findings and resolution status
|
||||
"""
|
||||
logger.info(f"[Followup] Starting follow-up review for MR !{context.mr_iid}")
|
||||
logger.info(f"[Followup] Previous review at: {context.previous_commit_sha[:8]}")
|
||||
logger.info(f"[Followup] Current HEAD: {context.current_commit_sha[:8]}")
|
||||
logger.info(
|
||||
f"[Followup] {len(context.commits_since_review)} new commits, "
|
||||
f"{len(context.files_changed_since_review)} files changed"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 20, "Checking finding resolution...", context.mr_iid
|
||||
)
|
||||
|
||||
# Phase 1: Check which previous findings are resolved
|
||||
previous_findings = context.previous_review.findings
|
||||
resolved, unresolved = self._check_finding_resolution(
|
||||
previous_findings,
|
||||
context.files_changed_since_review,
|
||||
context.diff_since_review,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing",
|
||||
40,
|
||||
f"Resolved: {len(resolved)}, Unresolved: {len(unresolved)}",
|
||||
context.mr_iid,
|
||||
)
|
||||
|
||||
# Phase 2: Review new changes for new issues
|
||||
self._report_progress(
|
||||
"analyzing", 60, "Analyzing new changes...", context.mr_iid
|
||||
)
|
||||
|
||||
# Heuristic-based review (fast, no AI cost)
|
||||
new_findings = self._check_new_changes_heuristic(
|
||||
context.diff_since_review,
|
||||
context.files_changed_since_review,
|
||||
)
|
||||
|
||||
# Phase 3: Review contributor comments for questions/concerns
|
||||
self._report_progress("analyzing", 80, "Reviewing comments...", context.mr_iid)
|
||||
|
||||
comment_findings = await self._review_comments(
|
||||
glab_client,
|
||||
context.mr_iid,
|
||||
context.commits_since_review,
|
||||
)
|
||||
|
||||
# Combine new findings
|
||||
all_new_findings = new_findings + comment_findings
|
||||
|
||||
# Determine verdict
|
||||
verdict = self._determine_verdict(unresolved, all_new_findings, context.mr_iid)
|
||||
|
||||
self._report_progress(
|
||||
"complete", 100, f"Review complete: {verdict.value}", context.mr_iid
|
||||
)
|
||||
|
||||
# Create result
|
||||
result = MRReviewResult(
|
||||
mr_iid=context.mr_iid,
|
||||
project=self.config.project,
|
||||
success=True,
|
||||
findings=previous_findings + all_new_findings,
|
||||
summary=self._generate_summary(resolved, unresolved, all_new_findings),
|
||||
overall_status="comment"
|
||||
if verdict != MergeVerdict.BLOCKED
|
||||
else "request_changes",
|
||||
verdict=verdict,
|
||||
verdict_reasoning=self._get_verdict_reasoning(
|
||||
verdict, resolved, unresolved, all_new_findings
|
||||
),
|
||||
is_followup_review=True,
|
||||
previous_review_id=context.previous_review.mr_iid,
|
||||
resolved_findings=[f.id for f in resolved],
|
||||
unresolved_findings=[f.id for f in unresolved],
|
||||
new_findings_since_last_review=[f.id for f in all_new_findings],
|
||||
)
|
||||
|
||||
# Save result
|
||||
result.save(self.gitlab_dir)
|
||||
|
||||
return result
|
||||
|
||||
def _check_finding_resolution(
|
||||
self,
|
||||
previous_findings: list[MRReviewFinding],
|
||||
changed_files: list[str],
|
||||
diff: str,
|
||||
) -> tuple[list[MRReviewFinding], list[MRReviewFinding]]:
|
||||
"""
|
||||
Check which previous findings have been resolved.
|
||||
|
||||
Args:
|
||||
previous_findings: List of findings from previous review
|
||||
changed_files: Files that changed since last review
|
||||
diff: Diff of changes since last review
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved_findings, unresolved_findings)
|
||||
"""
|
||||
resolved = []
|
||||
unresolved = []
|
||||
|
||||
for finding in previous_findings:
|
||||
file_changed = finding.file in changed_files
|
||||
|
||||
if not file_changed:
|
||||
# File unchanged - finding still unresolved
|
||||
unresolved.append(finding)
|
||||
continue
|
||||
|
||||
# Check if the specific line/region was modified
|
||||
if self._is_finding_addressed(diff, finding):
|
||||
resolved.append(finding)
|
||||
else:
|
||||
unresolved.append(finding)
|
||||
|
||||
return resolved, unresolved
|
||||
|
||||
def _is_finding_addressed(self, diff: str, finding: MRReviewFinding) -> bool:
|
||||
"""
|
||||
Check if a finding appears to be addressed in the diff.
|
||||
|
||||
This is a heuristic - looks for:
|
||||
- The file being modified near the finding's line
|
||||
- The issue pattern being changed
|
||||
"""
|
||||
# Look for the file in the diff
|
||||
file_pattern = f"diff --git a/{finding.file}"
|
||||
if file_pattern not in diff:
|
||||
return False
|
||||
|
||||
# Get the section of the diff for this file
|
||||
diff_sections = diff.split(file_pattern)
|
||||
if len(diff_sections) < 2:
|
||||
return False
|
||||
|
||||
file_diff = (
|
||||
diff_sections[1].split("diff --git")[0]
|
||||
if "diff --git" in diff_sections[1]
|
||||
else diff_sections[1]
|
||||
)
|
||||
|
||||
# Check if lines near the finding were modified
|
||||
# Look for +/- changes within 5 lines of the finding
|
||||
for line in file_diff.split("\n"):
|
||||
if line.startswith("@@"):
|
||||
# Parse hunk header - handle optional line counts for single-line changes
|
||||
# Format: @@ -old_start[,old_count] +new_start[,new_count] @@
|
||||
# Example with counts: @@ -10,5 +10,7 @@
|
||||
# Example without counts (single line): @@ -40 +40 @@
|
||||
match = re.search(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
|
||||
if match:
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
|
||||
# Check if finding line is in the changed range
|
||||
if old_start <= finding.line <= old_start + old_count:
|
||||
# Finding was in changed region
|
||||
return True
|
||||
|
||||
# Special patterns based on category
|
||||
if finding.category == ReviewCategory.TEST:
|
||||
# Look for added tests
|
||||
if "+ def test_" in file_diff or "+class Test" in file_diff:
|
||||
return True
|
||||
elif finding.category == ReviewCategory.DOCS:
|
||||
# Look for added docstrings or comments
|
||||
if '+"""' in file_diff or '+ """' in file_diff or "+ #" in file_diff:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_new_changes_heuristic(
|
||||
self,
|
||||
diff: str,
|
||||
changed_files: list[str],
|
||||
) -> list[MRReviewFinding]:
|
||||
"""
|
||||
Check new changes for obvious issues using heuristics.
|
||||
|
||||
This is fast and doesn't use AI.
|
||||
"""
|
||||
findings = []
|
||||
finding_id = 0
|
||||
|
||||
for file_path in changed_files:
|
||||
# Look for the file in the diff
|
||||
file_pattern = f"--- a/{file_path}"
|
||||
if (
|
||||
file_pattern not in diff
|
||||
and f"--- a/{file_path.replace('/', '_')}" not in diff
|
||||
):
|
||||
continue
|
||||
|
||||
# Check for common issues
|
||||
file_diff = diff.split(file_pattern)[1].split("\n")[0:50] # First 50 lines
|
||||
|
||||
# Look for TODO/FIXME comments
|
||||
for i, line in enumerate(file_diff):
|
||||
if "+" in line and (
|
||||
"TODO" in line or "FIXME" in line or "HACK" in line
|
||||
):
|
||||
finding_id += 1
|
||||
findings.append(
|
||||
MRReviewFinding(
|
||||
id=f"followup-todo-{finding_id}",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title=f"Developer TODO in {file_path}",
|
||||
description=f"Line contains: {line.strip()}",
|
||||
file=file_path,
|
||||
line=i,
|
||||
suggested_fix="Remove TODO or convert to issue",
|
||||
fixable=False,
|
||||
)
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
async def _review_comments(
|
||||
self,
|
||||
glab_client: GitLabClient,
|
||||
mr_iid: int,
|
||||
commits_since_review: list[dict],
|
||||
) -> list[MRReviewFinding]:
|
||||
"""
|
||||
Review comments for questions or concerns.
|
||||
|
||||
Args:
|
||||
glab_client: GitLab API client
|
||||
mr_iid: MR internal ID
|
||||
commits_since_review: Commits since last review
|
||||
|
||||
Returns:
|
||||
List of findings from comment analysis
|
||||
"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# Get MR notes/comments
|
||||
notes = await glab_client.get_mr_notes_async(mr_iid)
|
||||
|
||||
# Filter notes by commits since review
|
||||
reviewed_commit_shas = {c.get("id") for c in commits_since_review}
|
||||
|
||||
for note in notes:
|
||||
# Check if note was added in commits since review
|
||||
note_commit_id = note.get("commit_id")
|
||||
if note_commit_id not in reviewed_commit_shas:
|
||||
continue
|
||||
|
||||
author = note.get("author", {}).get("username", "")
|
||||
body = note.get("body", "")
|
||||
|
||||
# Look for questions or concerns
|
||||
if "?" in body and body.count("?") <= 3:
|
||||
# Likely a question (not too many)
|
||||
findings.append(
|
||||
MRReviewFinding(
|
||||
id=f"comment-question-{note.get('id')}",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Unresolved question in MR discussion",
|
||||
description=f"Comment by {author}: {body[:100]}...",
|
||||
file="MR Discussion",
|
||||
line=1,
|
||||
suggested_fix="Address the question in code or documentation",
|
||||
fixable=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to review comments: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
def _determine_verdict(
|
||||
self,
|
||||
unresolved: list[MRReviewFinding],
|
||||
new_findings: list[MRReviewFinding],
|
||||
mr_iid: int,
|
||||
) -> MergeVerdict:
|
||||
"""
|
||||
Determine if MR is ready to merge based on findings.
|
||||
"""
|
||||
# Check for critical issues
|
||||
critical_issues = [
|
||||
f
|
||||
for f in unresolved + new_findings
|
||||
if f.severity == ReviewSeverity.CRITICAL
|
||||
]
|
||||
if critical_issues:
|
||||
return MergeVerdict.BLOCKED
|
||||
|
||||
# Check for high issues
|
||||
high_issues = [
|
||||
f for f in unresolved + new_findings if f.severity == ReviewSeverity.HIGH
|
||||
]
|
||||
if high_issues:
|
||||
return MergeVerdict.NEEDS_REVISION
|
||||
|
||||
# Check for medium issues
|
||||
medium_issues = [
|
||||
f for f in unresolved + new_findings if f.severity == ReviewSeverity.MEDIUM
|
||||
]
|
||||
if medium_issues:
|
||||
return MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
# All clear or only low issues
|
||||
return MergeVerdict.READY_TO_MERGE
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
resolved: list[MRReviewFinding],
|
||||
unresolved: list[MRReviewFinding],
|
||||
new_findings: list[MRReviewFinding],
|
||||
) -> str:
|
||||
"""Generate a summary of the follow-up review."""
|
||||
lines = [
|
||||
"# Follow-up Review Summary",
|
||||
"",
|
||||
f"**Resolved Findings:** {len(resolved)}",
|
||||
f"**Unresolved Findings:** {len(unresolved)}",
|
||||
f"**New Findings:** {len(new_findings)}",
|
||||
"",
|
||||
]
|
||||
|
||||
if unresolved:
|
||||
lines.append("## Unresolved Issues")
|
||||
for finding in unresolved[:5]:
|
||||
lines.append(f"- **{finding.severity.value}:** {finding.title}")
|
||||
lines.append("")
|
||||
|
||||
if new_findings:
|
||||
lines.append("## New Issues")
|
||||
for finding in new_findings[:5]:
|
||||
lines.append(f"- **{finding.severity.value}:** {finding.title}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_verdict_reasoning(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
resolved: list[MRReviewFinding],
|
||||
unresolved: list[MRReviewFinding],
|
||||
new_findings: list[MRReviewFinding],
|
||||
) -> str:
|
||||
"""Get reasoning for the verdict."""
|
||||
if verdict == MergeVerdict.READY_TO_MERGE:
|
||||
return (
|
||||
f"All {len(resolved)} previous findings were resolved. "
|
||||
f"{len(new_findings)} new issues are low severity."
|
||||
)
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
return (
|
||||
f"{len(unresolved)} findings remain unresolved, "
|
||||
f"{len(new_findings)} new issues found. "
|
||||
f"Consider addressing before merge."
|
||||
)
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
return (
|
||||
f"{len([f for f in unresolved + new_findings if f.severity == ReviewSeverity.HIGH])} "
|
||||
f"high-severity issues must be resolved."
|
||||
)
|
||||
else: # BLOCKED
|
||||
return (
|
||||
f"{len([f for f in unresolved + new_findings if f.severity == ReviewSeverity.CRITICAL])} "
|
||||
f"critical issues block merge."
|
||||
)
|
||||
|
||||
async def _run_ai_review(self, context: FollowupMRContext) -> dict | None:
|
||||
"""Run AI-powered review (stub for future implementation)."""
|
||||
# This would integrate with the AI client for thorough review
|
||||
# For now, return None to trigger fallback to heuristic
|
||||
return None
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
I/O Utilities for GitLab Runner
|
||||
=================================
|
||||
|
||||
Re-exports from core.io_utils to avoid duplication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export all functions from core.io_utils
|
||||
from core.io_utils import is_pipe_broken, reset_pipe_state, safe_print
|
||||
|
||||
__all__ = ["safe_print", "is_pipe_broken", "reset_pipe_state"]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Prompt Manager
|
||||
==============
|
||||
|
||||
Centralized prompt template management for GitLab workflows.
|
||||
Ported from GitHub with GitLab-specific adaptations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ..models import ReviewPass
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import ReviewPass
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""Manages all prompt templates for GitLab automation workflows."""
|
||||
|
||||
def __init__(self, prompts_dir: Path | None = None):
|
||||
"""
|
||||
Initialize PromptManager.
|
||||
|
||||
Args:
|
||||
prompts_dir: Optional directory containing custom prompt files
|
||||
"""
|
||||
self.prompts_dir = prompts_dir or (
|
||||
Path(__file__).parent.parent.parent.parent / "prompts" / "gitlab"
|
||||
)
|
||||
|
||||
def get_review_pass_prompt(self, review_pass: ReviewPass) -> str:
|
||||
"""Get the specialized prompt for each review pass.
|
||||
|
||||
For now, falls back to the main MR review prompt. Pass-specific
|
||||
prompts can be added later by creating files like:
|
||||
- prompts/gitlab/review_pass_1.md
|
||||
- prompts/gitlab/review_pass_2.md
|
||||
etc.
|
||||
"""
|
||||
# Try pass-specific prompt file first
|
||||
# Use enum value (e.g., "quick_scan", "security") for filename
|
||||
pass_prompt_file = self.prompts_dir / f"review_pass_{review_pass.value}.md"
|
||||
if pass_prompt_file.exists():
|
||||
try:
|
||||
return pass_prompt_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
# Fall through to default MR prompt on read error
|
||||
pass
|
||||
|
||||
# Fallback to main MR review prompt
|
||||
return self.get_mr_review_prompt()
|
||||
|
||||
def get_mr_review_prompt(self) -> str:
|
||||
"""Get the main MR review prompt."""
|
||||
prompt_file = self.prompts_dir / "mr_reviewer.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return self._get_default_mr_review_prompt()
|
||||
|
||||
def _get_default_mr_review_prompt(self) -> str:
|
||||
"""Default MR review prompt if file doesn't exist."""
|
||||
return """# MR Review Agent
|
||||
|
||||
You are an AI code reviewer for GitLab. Analyze the provided merge request and identify:
|
||||
|
||||
1. **Security Issues** - vulnerabilities, injection risks, auth problems
|
||||
2. **Code Quality** - complexity, duplication, error handling
|
||||
3. **Style Issues** - naming, formatting, patterns
|
||||
4. **Test Coverage** - missing tests, edge cases
|
||||
5. **Documentation** - missing/outdated docs
|
||||
|
||||
For each finding, output a JSON array:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "finding-1",
|
||||
"severity": "critical|high|medium|low",
|
||||
"category": "security|quality|style|test|docs|pattern|performance",
|
||||
"title": "Brief issue title",
|
||||
"description": "Detailed explanation",
|
||||
"file": "path/to/file.ts",
|
||||
"line": 42,
|
||||
"suggested_fix": "Optional code or suggestion",
|
||||
"fixable": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Be specific and actionable. Focus on significant issues, not nitpicks.
|
||||
"""
|
||||
|
||||
def get_followup_review_prompt(self) -> str:
|
||||
"""Get the follow-up MR review prompt."""
|
||||
prompt_file = self.prompts_dir / "mr_followup.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return self._get_default_followup_review_prompt()
|
||||
|
||||
def _get_default_followup_review_prompt(self) -> str:
|
||||
"""Default follow-up review prompt if file doesn't exist."""
|
||||
return """# MR Follow-up Review Agent
|
||||
|
||||
You are performing a focused follow-up review of a merge request. The MR has already received an initial review.
|
||||
|
||||
Your tasks:
|
||||
1. Check if previous findings have been resolved
|
||||
2. Review only the NEW changes since last review
|
||||
3. Determine merge readiness
|
||||
|
||||
For each previous finding, determine:
|
||||
- RESOLVED: The issue was fixed
|
||||
- UNRESOLVED: The issue remains
|
||||
|
||||
For new issues in the diff, report them with:
|
||||
- severity: critical|high|medium|low
|
||||
- category: security|quality|logic|test
|
||||
- title, description, file, line, suggested_fix
|
||||
|
||||
Output JSON:
|
||||
```json
|
||||
{
|
||||
"finding_resolutions": [
|
||||
{"finding_id": "prev-1", "status": "resolved", "resolution_notes": "Fixed with parameterized query"}
|
||||
],
|
||||
"new_findings": [
|
||||
{"id": "new-1", "severity": "high", "category": "security", "title": "...", "description": "...", "file": "...", "line": 42}
|
||||
],
|
||||
"verdict": "READY_TO_MERGE|MERGE_WITH_CHANGES|NEEDS_REVISION|BLOCKED",
|
||||
"verdict_reasoning": "Explanation of the verdict"
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
def get_triage_prompt(self) -> str:
|
||||
"""Get the issue triage prompt."""
|
||||
prompt_file = self.prompts_dir / "issue_triager.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return self._get_default_triage_prompt()
|
||||
|
||||
def _get_default_triage_prompt(self) -> str:
|
||||
"""Default triage prompt if file doesn't exist."""
|
||||
return """# Issue Triage Agent
|
||||
|
||||
You are an issue triage assistant for GitLab. Analyze the GitLab issue and classify it.
|
||||
|
||||
Determine:
|
||||
1. **Category**: bug, feature, question, duplicate, spam, invalid, wontfix
|
||||
2. **Priority**: high, medium, low
|
||||
3. **Is Duplicate?**: Check against potential duplicates list
|
||||
4. **Is Spam?**: Check for promotional content, gibberish, abuse
|
||||
5. **Is Feature Creep?**: Multiple unrelated features in one issue
|
||||
|
||||
Output JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"category": "bug|feature|question|duplicate|spam|invalid|wontfix",
|
||||
"confidence": 0.0-1.0,
|
||||
"priority": "high|medium|low",
|
||||
"labels_to_add": ["type:bug", "priority:high"],
|
||||
"is_duplicate": false,
|
||||
"duplicate_of": null,
|
||||
"is_spam": false,
|
||||
"reasoning": "Brief explanation of your classification",
|
||||
"comment": "Optional bot comment"
|
||||
}
|
||||
```
|
||||
|
||||
Note on issue references:
|
||||
- Use the issue `iid` (internal ID) for duplicates, not the database `id`
|
||||
- For example: "duplicate_of": 123 refers to issue !123 in GitLab
|
||||
"""
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Response Parsers
|
||||
================
|
||||
|
||||
JSON parsing utilities for AI responses. Ported from GitHub to GitLab.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
try:
|
||||
from ..models import (
|
||||
AICommentTriage,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
StructuralIssue,
|
||||
TriageCategory,
|
||||
TriageResult,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import (
|
||||
AICommentTriage,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
StructuralIssue,
|
||||
TriageCategory,
|
||||
TriageResult,
|
||||
)
|
||||
|
||||
|
||||
# Evidence-based validation replaces confidence scoring
|
||||
MIN_EVIDENCE_LENGTH = 20 # Minimum chars for evidence to be considered valid
|
||||
|
||||
|
||||
def safe_print(msg: str, **kwargs) -> None:
|
||||
"""Thread-safe print helper."""
|
||||
print(msg, **kwargs)
|
||||
|
||||
|
||||
class ResponseParser:
|
||||
"""Parses AI responses into structured data."""
|
||||
|
||||
@staticmethod
|
||||
def parse_review_findings(
|
||||
response_text: str, require_evidence: bool = True
|
||||
) -> list[MRReviewFinding]:
|
||||
"""Parse findings from AI response with optional evidence validation.
|
||||
|
||||
Evidence-based validation: Instead of confidence scores, findings
|
||||
require actual code evidence proving the issue exists.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
json_match = re.search(
|
||||
r"```json\s*(\[.*?\])\s*```", response_text, re.DOTALL
|
||||
)
|
||||
if json_match:
|
||||
findings_data = json.loads(json_match.group(1))
|
||||
for i, f in enumerate(findings_data):
|
||||
# Get evidence (code snippet proving the issue)
|
||||
evidence = f.get("evidence") or f.get("code_snippet") or ""
|
||||
|
||||
# Apply evidence-based validation
|
||||
if require_evidence and len(evidence.strip()) < MIN_EVIDENCE_LENGTH:
|
||||
safe_print(
|
||||
f"[AI] Dropped finding '{f.get('title', 'unknown')}': "
|
||||
f"insufficient evidence ({len(evidence.strip())} chars < {MIN_EVIDENCE_LENGTH})",
|
||||
flush=True,
|
||||
)
|
||||
continue
|
||||
|
||||
findings.append(
|
||||
MRReviewFinding(
|
||||
id=f.get("id", f"finding-{i + 1}"),
|
||||
severity=ReviewSeverity(
|
||||
f.get("severity", "medium").lower()
|
||||
),
|
||||
category=ReviewCategory(
|
||||
f.get("category", "quality").lower()
|
||||
),
|
||||
title=f.get("title", "Finding"),
|
||||
description=f.get("description", ""),
|
||||
file=f.get("file", "unknown"),
|
||||
line=f.get("line", 1),
|
||||
end_line=f.get("end_line"),
|
||||
suggested_fix=f.get("suggested_fix"),
|
||||
fixable=f.get("fixable", False),
|
||||
evidence_code=evidence if evidence.strip() else None,
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
safe_print(f"Failed to parse findings: {e}")
|
||||
|
||||
return findings
|
||||
|
||||
@staticmethod
|
||||
def parse_structural_issues(response_text: str) -> list[StructuralIssue]:
|
||||
"""Parse structural issues from AI response."""
|
||||
issues = []
|
||||
|
||||
try:
|
||||
json_match = re.search(
|
||||
r"```json\s*(\[.*?\])\s*```", response_text, re.DOTALL
|
||||
)
|
||||
if json_match:
|
||||
issues_data = json.loads(json_match.group(1))
|
||||
for i, issue in enumerate(issues_data):
|
||||
issues.append(
|
||||
StructuralIssue(
|
||||
id=issue.get("id", f"struct-{i + 1}"),
|
||||
type=issue.get("issue_type", "scope_creep"),
|
||||
severity=ReviewSeverity(
|
||||
issue.get("severity", "medium").lower()
|
||||
),
|
||||
title=issue.get("title", "Structural issue"),
|
||||
description=issue.get("description", ""),
|
||||
files_affected=issue.get("files_affected", []),
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
safe_print(f"Failed to parse structural issues: {e}")
|
||||
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
def parse_ai_comment_triages(response_text: str) -> list[AICommentTriage]:
|
||||
"""Parse AI comment triages from AI response."""
|
||||
triages = []
|
||||
|
||||
try:
|
||||
json_match = re.search(
|
||||
r"```json\s*(\[.*?\])\s*```", response_text, re.DOTALL
|
||||
)
|
||||
if json_match:
|
||||
triages_data = json.loads(json_match.group(1))
|
||||
for triage in triages_data:
|
||||
triages.append(
|
||||
AICommentTriage(
|
||||
comment_id=str(triage.get("comment_id", "")),
|
||||
tool_name=triage.get("tool_name", "Unknown"),
|
||||
original_comment=triage.get("original_summary", ""),
|
||||
triage_result=triage.get("verdict", "trivial"),
|
||||
reasoning=triage.get("reasoning", ""),
|
||||
file=triage.get("file"),
|
||||
line=triage.get("line"),
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
safe_print(f"Failed to parse AI comment triages: {e}")
|
||||
|
||||
return triages
|
||||
|
||||
@staticmethod
|
||||
def parse_triage_result(
|
||||
issue: dict, response_text: str, project: str
|
||||
) -> TriageResult:
|
||||
"""Parse triage result from AI response.
|
||||
|
||||
Args:
|
||||
issue: GitLab issue dict from API
|
||||
response_text: AI response text containing JSON
|
||||
project: GitLab project path (namespace/project)
|
||||
"""
|
||||
# Default result
|
||||
result = TriageResult(
|
||||
issue_iid=issue.get("iid", 0),
|
||||
project=project,
|
||||
category=TriageCategory.FEATURE,
|
||||
confidence=0.5,
|
||||
)
|
||||
|
||||
try:
|
||||
json_match = re.search(
|
||||
r"```json\s*(\{.*?\})\s*```", response_text, re.DOTALL
|
||||
)
|
||||
if json_match:
|
||||
data = json.loads(json_match.group(1))
|
||||
|
||||
category_str = data.get("category", "feature").lower()
|
||||
# Map GitHub categories to GitLab categories
|
||||
if category_str == "documentation":
|
||||
category_str = "feature"
|
||||
if category_str in [c.value for c in TriageCategory]:
|
||||
result.category = TriageCategory(category_str)
|
||||
|
||||
result.confidence = float(data.get("confidence", 0.5))
|
||||
result.suggested_labels = data.get("labels_to_add", [])
|
||||
result.duplicate_of = data.get("duplicate_of")
|
||||
result.suggested_response = data.get("comment", "")
|
||||
result.reasoning = data.get("reasoning", "")
|
||||
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
safe_print(f"Failed to parse triage result: {e}")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Triage Engine
|
||||
=============
|
||||
|
||||
Issue triage logic for detecting duplicates, spam, and feature creep.
|
||||
Ported from GitHub with GitLab API adaptations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ...phase_config import resolve_model_id
|
||||
from ..models import GitLabRunnerConfig, TriageCategory, TriageResult
|
||||
from .prompt_manager import PromptManager
|
||||
from .response_parsers import ResponseParser
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import GitLabRunnerConfig, TriageCategory, TriageResult
|
||||
from phase_config import resolve_model_id
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.response_parsers import ResponseParser
|
||||
|
||||
|
||||
class TriageEngine:
|
||||
"""Handles issue triage workflow for GitLab."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
gitlab_dir: Path,
|
||||
config: GitLabRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.gitlab_dir = Path(gitlab_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
self.prompt_manager = PromptManager()
|
||||
self.parser = ResponseParser()
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
# Fallback: try relative import
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
async def triage_single_issue(
|
||||
self, issue: dict, all_issues: list[dict]
|
||||
) -> TriageResult:
|
||||
"""
|
||||
Triage a single issue using AI.
|
||||
|
||||
Args:
|
||||
issue: GitLab issue dict from API
|
||||
all_issues: List of all issues for duplicate detection
|
||||
|
||||
Returns:
|
||||
TriageResult with category and confidence
|
||||
"""
|
||||
from core.client import create_client
|
||||
|
||||
# Build context with issue and potential duplicates
|
||||
context = self.build_triage_context(issue, all_issues)
|
||||
|
||||
# Load prompt
|
||||
prompt = self.prompt_manager.get_triage_prompt()
|
||||
full_prompt = prompt + "\n\n---\n\n" + context
|
||||
|
||||
# Run AI
|
||||
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
|
||||
model = resolve_model_id(self.config.model or "sonnet")
|
||||
client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.gitlab_dir,
|
||||
model=model,
|
||||
agent_type="qa_reviewer",
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(full_prompt)
|
||||
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
return self.parser.parse_triage_result(
|
||||
issue, response_text, self.config.project
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Triage error for #{issue['iid']}: {e}")
|
||||
return TriageResult(
|
||||
issue_iid=issue["iid"],
|
||||
project=self.config.project,
|
||||
category=TriageCategory.FEATURE,
|
||||
confidence=0.0,
|
||||
)
|
||||
|
||||
def build_triage_context(self, issue: dict, all_issues: list[dict]) -> str:
|
||||
"""
|
||||
Build context for triage including potential duplicates.
|
||||
|
||||
Args:
|
||||
issue: GitLab issue dict
|
||||
all_issues: List of all issues for duplicate detection
|
||||
|
||||
Returns:
|
||||
Formatted context string for AI
|
||||
"""
|
||||
# Find potential duplicates by title similarity
|
||||
potential_dupes = []
|
||||
for other in all_issues:
|
||||
if other["iid"] == issue["iid"]:
|
||||
continue
|
||||
# Simple word overlap check
|
||||
title_words = set(issue["title"].lower().split())
|
||||
other_words = set(other["title"].lower().split())
|
||||
overlap = len(title_words & other_words) / max(len(title_words), 1)
|
||||
if overlap > 0.3:
|
||||
potential_dupes.append(other)
|
||||
|
||||
# Extract author username from GitLab API response
|
||||
author = issue.get("author", {})
|
||||
author_name = (
|
||||
author.get("username", "unknown") if isinstance(author, dict) else "unknown"
|
||||
)
|
||||
|
||||
# Extract labels from GitLab API response (simple list of strings)
|
||||
labels = issue.get("labels", [])
|
||||
if isinstance(labels, list):
|
||||
label_names = labels
|
||||
else:
|
||||
label_names = []
|
||||
|
||||
lines = [
|
||||
f"## Issue #{issue['iid']}",
|
||||
f"**Title:** {issue['title']}",
|
||||
f"**Author:** {author_name}",
|
||||
f"**Created:** {issue.get('created_at', 'unknown')}",
|
||||
f"**Labels:** {', '.join(label_names)}",
|
||||
"",
|
||||
"### Description",
|
||||
issue.get("description", "No description"),
|
||||
"",
|
||||
]
|
||||
|
||||
if potential_dupes:
|
||||
lines.append("### Potential Duplicates (similar titles)")
|
||||
for d in potential_dupes[:5]:
|
||||
lines.append(f"- #{d['iid']}: {d['title']}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Type definitions for GitLab API responses.
|
||||
|
||||
This module provides TypedDict classes for type-safe access to GitLab API data.
|
||||
All TypedDicts use total=False to allow partial responses from the API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class GitLabMR(TypedDict, total=False):
|
||||
"""Merge request data from GitLab API."""
|
||||
|
||||
iid: int
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
state: str # opened, closed, locked, merged
|
||||
created_at: str
|
||||
updated_at: str
|
||||
merged_at: str | None
|
||||
author: GitLabUser
|
||||
assignees: list[GitLabUser]
|
||||
reviewers: list[GitLabUser]
|
||||
source_branch: str
|
||||
target_branch: str
|
||||
web_url: str
|
||||
merge_status: str | None
|
||||
detailed_merge_status: GitLabMergeStatus | None
|
||||
diff_refs: GitLabDiffRefs
|
||||
labels: list[GitLabLabel]
|
||||
has_conflicts: bool
|
||||
squash: bool
|
||||
work_in_progress: bool
|
||||
merge_when_pipeline_succeeds: bool
|
||||
sha: str
|
||||
merge_commit_sha: str | None
|
||||
user_notes_count: int
|
||||
discussion_locked: bool
|
||||
should_remove_source_branch: bool
|
||||
force_remove_source_branch: bool
|
||||
references: dict[str, str]
|
||||
time_stats: dict[str, int]
|
||||
task_completion_status: dict[str, int]
|
||||
|
||||
|
||||
class GitLabUser(TypedDict, total=False):
|
||||
"""User data from GitLab API."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
name: str
|
||||
email: str
|
||||
avatar_url: str
|
||||
web_url: str
|
||||
created_at: str
|
||||
bio: str | None
|
||||
location: str | None
|
||||
public_email: str | None
|
||||
skype: str | None
|
||||
linkedin: str | None
|
||||
twitter: str | None
|
||||
website_url: str | None
|
||||
organization: str | None
|
||||
job_title: str | None
|
||||
pronouns: str | None
|
||||
bot: bool
|
||||
work_in_progress: bool | None
|
||||
|
||||
|
||||
class GitLabLabel(TypedDict, total=False):
|
||||
"""Label data from GitLab API."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
description: str
|
||||
text_color: str
|
||||
priority: int | None
|
||||
is_project_label: bool
|
||||
subscribed: bool
|
||||
|
||||
|
||||
class GitLabMergeStatus(TypedDict, total=False):
|
||||
"""Detailed merge status."""
|
||||
|
||||
iid: int
|
||||
project_id: int
|
||||
merge_status: str
|
||||
merged_by: GitLabUser | None
|
||||
detailed_merge_status: str
|
||||
merge_error: str | None
|
||||
merge_jid: str | None
|
||||
|
||||
|
||||
class GitLabDiffRefs(TypedDict, total=False):
|
||||
"""Diff references for rebase resistance."""
|
||||
|
||||
base_sha: str
|
||||
head_sha: str
|
||||
start_sha: str
|
||||
head_commit: GitLabCommit
|
||||
|
||||
|
||||
class GitLabCommit(TypedDict, total=False):
|
||||
"""Commit data."""
|
||||
|
||||
id: str
|
||||
short_id: str
|
||||
title: str
|
||||
message: str
|
||||
author_name: str
|
||||
author_email: str
|
||||
authored_date: str
|
||||
committer_name: str
|
||||
committer_email: str
|
||||
committed_date: str
|
||||
web_url: str
|
||||
stats: dict[str, int]
|
||||
|
||||
|
||||
class GitLabIssue(TypedDict, total=False):
|
||||
"""Issue data from GitLab API."""
|
||||
|
||||
iid: int
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
state: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
closed_at: str | None
|
||||
author: GitLabUser
|
||||
assignees: list[GitLabUser]
|
||||
labels: list[GitLabLabel]
|
||||
web_url: str
|
||||
project_id: int
|
||||
milestone: GitLabMilestone | None
|
||||
type: str # issue, incident, or test_case
|
||||
confidential: bool
|
||||
duplicated_to: dict | None
|
||||
weight: int | None
|
||||
discussion_locked: bool
|
||||
time_stats: dict[str, int]
|
||||
task_completion_status: dict[str, int]
|
||||
has_tasks: bool
|
||||
task_status: str
|
||||
|
||||
|
||||
class GitLabMilestone(TypedDict, total=False):
|
||||
"""Milestone data."""
|
||||
|
||||
id: int
|
||||
iid: int
|
||||
project_id: int
|
||||
title: str
|
||||
description: str
|
||||
state: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
due_date: str | None
|
||||
start_date: str | None
|
||||
expired: bool
|
||||
|
||||
|
||||
class GitLabPipeline(TypedDict, total=False):
|
||||
"""Pipeline data."""
|
||||
|
||||
id: int
|
||||
iid: int
|
||||
project_id: int
|
||||
sha: str
|
||||
ref: str
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
finished_at: str | None
|
||||
duration: int | None
|
||||
web_url: str
|
||||
user: GitLabUser | None
|
||||
name: str | None
|
||||
queue_duration: int | None
|
||||
variables: list[dict[str, str]]
|
||||
|
||||
|
||||
class GitLabJob(TypedDict, total=False):
|
||||
"""Pipeline job data."""
|
||||
|
||||
id: int
|
||||
project_id: int
|
||||
pipeline_id: int
|
||||
status: str
|
||||
stage: str
|
||||
name: str
|
||||
ref: str
|
||||
created_at: str
|
||||
started_at: str | None
|
||||
finished_at: str | None
|
||||
duration: float | None
|
||||
user: GitLabUser | None
|
||||
failure_reason: str | None
|
||||
retry_count: int
|
||||
artifacts: list[dict]
|
||||
runner: dict | None
|
||||
|
||||
|
||||
class GitLabBranch(TypedDict, total=False):
|
||||
"""Branch data."""
|
||||
|
||||
name: str
|
||||
merged: bool
|
||||
protected: bool
|
||||
default: bool
|
||||
can_push: bool
|
||||
web_url: str
|
||||
commit: GitLabCommit
|
||||
developers_can_push: bool
|
||||
developers_can_merge: bool
|
||||
commit_short_id: str
|
||||
|
||||
|
||||
class GitLabFile(TypedDict, total=False):
|
||||
"""File data from repository."""
|
||||
|
||||
file_name: str
|
||||
file_path: str
|
||||
size: int
|
||||
encoding: str
|
||||
content: str
|
||||
content_sha256: str
|
||||
ref: str
|
||||
blob_id: str
|
||||
commit_id: str
|
||||
last_commit_id: str
|
||||
|
||||
|
||||
class GitLabWebhook(TypedDict, total=False):
|
||||
"""Webhook data."""
|
||||
|
||||
id: int
|
||||
url: str
|
||||
project_id: int
|
||||
push_events: bool
|
||||
issues_events: bool
|
||||
merge_request_events: bool
|
||||
wiki_page_events: bool
|
||||
deployment_events: bool
|
||||
job_events: bool
|
||||
pipeline_events: bool
|
||||
releases_events: bool
|
||||
tag_push_events: bool
|
||||
note_events: bool
|
||||
confidential_note_events: bool
|
||||
wiki_page_events: bool
|
||||
custom_webhook_url: str
|
||||
enable_ssl_verification: bool
|
||||
|
||||
|
||||
class GitLabDiscussion(TypedDict, total=False):
|
||||
"""Discussion data."""
|
||||
|
||||
id: str
|
||||
individual_note: bool
|
||||
notes: list[GitLabNote]
|
||||
|
||||
|
||||
class GitLabNote(TypedDict, total=False):
|
||||
"""Note (comment) data."""
|
||||
|
||||
id: int
|
||||
type: str | None
|
||||
author: GitLabUser
|
||||
created_at: str
|
||||
updated_at: str
|
||||
system: bool
|
||||
body: str
|
||||
resolvable: bool
|
||||
resolved: bool
|
||||
position: dict | None
|
||||
|
||||
|
||||
class GitLabProject(TypedDict, total=False):
|
||||
"""Project data."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
name_with_namespace: str
|
||||
path: str
|
||||
path_with_namespace: str
|
||||
description: str
|
||||
default_branch: str
|
||||
created_at: str
|
||||
last_activity_at: str
|
||||
web_url: str
|
||||
avatar_url: str | None
|
||||
visibility: str
|
||||
archived: bool
|
||||
repository: GitLabRepository
|
||||
|
||||
|
||||
class GitLabRepository(TypedDict, total=False):
|
||||
"""Repository data."""
|
||||
|
||||
type: str
|
||||
name: str
|
||||
url: str
|
||||
description: str
|
||||
|
||||
|
||||
class GitLabChange(TypedDict, total=False):
|
||||
"""Diff change data."""
|
||||
|
||||
old_path: str
|
||||
new_path: str
|
||||
diff: str
|
||||
new_file: bool
|
||||
renamed_file: bool
|
||||
deleted_file: bool
|
||||
mode: str | None
|
||||
index: str | None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
GitLab Utilities Package
|
||||
========================
|
||||
|
||||
Utility modules for GitLab automation.
|
||||
"""
|
||||
|
||||
from .file_lock import (
|
||||
FileLock,
|
||||
FileLockError,
|
||||
FileLockTimeout,
|
||||
atomic_write,
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
locked_read,
|
||||
locked_write,
|
||||
)
|
||||
from .rate_limiter import (
|
||||
CostLimitExceeded,
|
||||
CostTracker,
|
||||
RateLimiter,
|
||||
RateLimitExceeded,
|
||||
TokenBucket,
|
||||
check_rate_limit,
|
||||
rate_limited,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# File locking
|
||||
"FileLock",
|
||||
"FileLockError",
|
||||
"FileLockTimeout",
|
||||
"atomic_write",
|
||||
"locked_json_read",
|
||||
"locked_json_update",
|
||||
"locked_json_write",
|
||||
"locked_read",
|
||||
"locked_write",
|
||||
# Rate limiting
|
||||
"CostLimitExceeded",
|
||||
"CostTracker",
|
||||
"RateLimitExceeded",
|
||||
"RateLimiter",
|
||||
"TokenBucket",
|
||||
"check_rate_limit",
|
||||
"rate_limited",
|
||||
]
|
||||
@@ -0,0 +1,491 @@
|
||||
"""
|
||||
File Locking for Concurrent Operations
|
||||
=====================================
|
||||
|
||||
Thread-safe and process-safe file locking utilities for GitHub automation.
|
||||
Uses fcntl.flock() on Unix systems and msvcrt.locking() on Windows for proper
|
||||
cross-process locking.
|
||||
|
||||
Example Usage:
|
||||
# Simple file locking
|
||||
async with FileLock("path/to/file.json", timeout=5.0):
|
||||
# Do work with locked file
|
||||
pass
|
||||
|
||||
# Atomic write with locking
|
||||
async with locked_write("path/to/file.json", timeout=5.0) as f:
|
||||
json.dump(data, f)
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_IS_WINDOWS = os.name == "nt"
|
||||
_WINDOWS_LOCK_SIZE = 1024 * 1024
|
||||
|
||||
try:
|
||||
import fcntl # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
fcntl = None
|
||||
|
||||
try:
|
||||
import msvcrt # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
msvcrt = None
|
||||
|
||||
|
||||
def _try_lock(fd: int, exclusive: bool) -> None:
|
||||
if _IS_WINDOWS:
|
||||
if msvcrt is None:
|
||||
raise FileLockError("msvcrt is required for file locking on Windows")
|
||||
if not exclusive:
|
||||
warnings.warn(
|
||||
"Shared file locks are not supported on Windows; using exclusive lock",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, _WINDOWS_LOCK_SIZE)
|
||||
return
|
||||
|
||||
if fcntl is None:
|
||||
raise FileLockError(
|
||||
"fcntl is required for file locking on non-Windows platforms"
|
||||
)
|
||||
|
||||
lock_mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
||||
fcntl.flock(fd, lock_mode | fcntl.LOCK_NB)
|
||||
|
||||
|
||||
def _unlock(fd: int) -> None:
|
||||
if _IS_WINDOWS:
|
||||
if msvcrt is None:
|
||||
warnings.warn(
|
||||
"msvcrt unavailable; cannot unlock file descriptor",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, _WINDOWS_LOCK_SIZE)
|
||||
return
|
||||
|
||||
if fcntl is None:
|
||||
warnings.warn(
|
||||
"fcntl unavailable; cannot unlock file descriptor",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class FileLockError(Exception):
|
||||
"""Raised when file locking operations fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FileLockTimeout(FileLockError):
|
||||
"""Raised when lock acquisition times out."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FileLock:
|
||||
"""
|
||||
Cross-process file lock using platform-specific locking (fcntl.flock on Unix,
|
||||
msvcrt.locking on Windows).
|
||||
|
||||
Supports both sync and async context managers for flexible usage.
|
||||
|
||||
Args:
|
||||
filepath: Path to file to lock (will be created if needed)
|
||||
timeout: Maximum seconds to wait for lock (default: 5.0)
|
||||
exclusive: Whether to use exclusive lock (default: True)
|
||||
|
||||
Example:
|
||||
# Synchronous usage
|
||||
with FileLock("/path/to/file.json"):
|
||||
# File is locked
|
||||
pass
|
||||
|
||||
# Asynchronous usage
|
||||
async with FileLock("/path/to/file.json"):
|
||||
# File is locked
|
||||
pass
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filepath: str | Path,
|
||||
timeout: float = 5.0,
|
||||
exclusive: bool = True,
|
||||
):
|
||||
self.filepath = Path(filepath)
|
||||
self.timeout = timeout
|
||||
self.exclusive = exclusive
|
||||
self._lock_file: Path | None = None
|
||||
self._fd: int | None = None
|
||||
|
||||
def _get_lock_file(self) -> Path:
|
||||
"""Get lock file path (separate .lock file)."""
|
||||
return self.filepath.parent / f"{self.filepath.name}.lock"
|
||||
|
||||
def _acquire_lock(self) -> None:
|
||||
"""Acquire the file lock (blocking with timeout)."""
|
||||
self._lock_file = self._get_lock_file()
|
||||
self._lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open lock file
|
||||
self._fd = os.open(str(self._lock_file), os.O_CREAT | os.O_RDWR)
|
||||
|
||||
# Try to acquire lock with timeout
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Non-blocking lock attempt
|
||||
_try_lock(self._fd, self.exclusive)
|
||||
return # Lock acquired
|
||||
except (BlockingIOError, OSError):
|
||||
# Lock held by another process
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= self.timeout:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
raise FileLockTimeout(
|
||||
f"Failed to acquire lock on {self.filepath} within "
|
||||
f"{self.timeout}s"
|
||||
)
|
||||
|
||||
# Wait a bit before retrying
|
||||
time.sleep(0.01)
|
||||
|
||||
def _release_lock(self) -> None:
|
||||
"""Release the file lock."""
|
||||
if self._fd is not None:
|
||||
try:
|
||||
_unlock(self._fd)
|
||||
os.close(self._fd)
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
finally:
|
||||
self._fd = None
|
||||
|
||||
# Clean up lock file
|
||||
if self._lock_file and self._lock_file.exists():
|
||||
try:
|
||||
self._lock_file.unlink()
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
def __enter__(self):
|
||||
"""Synchronous context manager entry."""
|
||||
self._acquire_lock()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Synchronous context manager exit."""
|
||||
self._release_lock()
|
||||
return False
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
# Run blocking lock acquisition in thread pool
|
||||
await asyncio.get_running_loop().run_in_executor(None, self._acquire_lock)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await asyncio.get_running_loop().run_in_executor(None, self._release_lock)
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_write(filepath: str | Path, mode: str = "w", encoding: str = "utf-8"):
|
||||
"""
|
||||
Atomic file write using temp file and rename.
|
||||
|
||||
Writes to .tmp file first, then atomically replaces target file
|
||||
using os.replace() which is atomic on POSIX systems.
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
mode: File open mode (default: "w")
|
||||
encoding: File encoding (default: "utf-8")
|
||||
|
||||
Example:
|
||||
with atomic_write("/path/to/file.json") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
with atomic_write("/path/to/file.txt", encoding="utf-8") as f:
|
||||
f.write("Hello, world!")
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create temp file in same directory for atomic rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
)
|
||||
|
||||
try:
|
||||
# Open temp file with requested mode and encoding
|
||||
if "b" in mode:
|
||||
# Binary mode - no encoding
|
||||
with os.fdopen(fd, mode) as f:
|
||||
yield f
|
||||
else:
|
||||
# Text mode - use encoding
|
||||
with os.fdopen(fd, mode, encoding=encoding) as f:
|
||||
yield f
|
||||
|
||||
# Atomic replace - succeeds or fails completely
|
||||
os.replace(tmp_path, filepath)
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def locked_write(
|
||||
filepath: str | Path, timeout: float = 5.0, mode: str = "w"
|
||||
) -> Any:
|
||||
"""
|
||||
Async context manager combining file locking and atomic writes.
|
||||
|
||||
Acquires exclusive lock, writes to temp file, atomically replaces target.
|
||||
This is the recommended way to safely write shared state files.
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
timeout: Lock timeout in seconds (default: 5.0)
|
||||
mode: File open mode (default: "w")
|
||||
|
||||
Example:
|
||||
async with locked_write("/path/to/file.json", timeout=5.0) as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
Raises:
|
||||
FileLockTimeout: If lock cannot be acquired within timeout
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
# Acquire lock
|
||||
lock = FileLock(filepath, timeout=timeout, exclusive=True)
|
||||
await lock.__aenter__()
|
||||
|
||||
try:
|
||||
# Atomic write in thread pool (since it uses sync file I/O)
|
||||
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
lambda: tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Open temp file and yield to caller
|
||||
f = os.fdopen(fd, mode)
|
||||
try:
|
||||
yield f
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
# Atomic replace
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, os.replace, tmp_path, filepath
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, os.unlink, tmp_path
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Release lock
|
||||
await lock.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def locked_read(filepath: str | Path, timeout: float = 5.0) -> Any:
|
||||
"""
|
||||
Async context manager for locked file reading.
|
||||
|
||||
Acquires shared lock for reading, allowing multiple concurrent readers
|
||||
but blocking writers.
|
||||
|
||||
Args:
|
||||
filepath: File path to read
|
||||
timeout: Lock timeout in seconds (default: 5.0)
|
||||
|
||||
Example:
|
||||
async with locked_read("/path/to/file.json", timeout=5.0) as f:
|
||||
data = json.load(f)
|
||||
|
||||
Raises:
|
||||
FileLockTimeout: If lock cannot be acquired within timeout
|
||||
FileNotFoundError: If file doesn't exist
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
|
||||
# Acquire shared lock (allows multiple readers)
|
||||
lock = FileLock(filepath, timeout=timeout, exclusive=False)
|
||||
await lock.__aenter__()
|
||||
|
||||
try:
|
||||
# Open file for reading
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
yield f
|
||||
finally:
|
||||
# Release lock
|
||||
await lock.__aexit__(None, None, None)
|
||||
|
||||
|
||||
async def locked_json_write(
|
||||
filepath: str | Path, data: Any, timeout: float = 5.0, indent: int = 2
|
||||
) -> None:
|
||||
"""
|
||||
Helper function for writing JSON with locking and atomicity.
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
data: Data to serialize as JSON
|
||||
timeout: Lock timeout in seconds (default: 5.0)
|
||||
indent: JSON indentation (default: 2)
|
||||
|
||||
Example:
|
||||
await locked_json_write("/path/to/file.json", {"key": "value"})
|
||||
|
||||
Raises:
|
||||
FileLockTimeout: If lock cannot be acquired within timeout
|
||||
"""
|
||||
async with locked_write(filepath, timeout=timeout) as f:
|
||||
json.dump(data, f, indent=indent)
|
||||
|
||||
|
||||
async def locked_json_read(filepath: str | Path, timeout: float = 5.0) -> Any:
|
||||
"""
|
||||
Helper function for reading JSON with locking.
|
||||
|
||||
Args:
|
||||
filepath: File path to read
|
||||
timeout: Lock timeout in seconds (default: 5.0)
|
||||
|
||||
Returns:
|
||||
Parsed JSON data
|
||||
|
||||
Example:
|
||||
data = await locked_json_read("/path/to/file.json")
|
||||
|
||||
Raises:
|
||||
FileLockTimeout: If lock cannot be acquired within timeout
|
||||
FileNotFoundError: If file doesn't exist
|
||||
json.JSONDecodeError: If file contains invalid JSON
|
||||
"""
|
||||
async with locked_read(filepath, timeout=timeout) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
async def locked_json_update(
|
||||
filepath: str | Path,
|
||||
updater: Callable[[Any], Any],
|
||||
timeout: float = 5.0,
|
||||
indent: int = 2,
|
||||
) -> Any:
|
||||
"""
|
||||
Helper for atomic read-modify-write of JSON files.
|
||||
|
||||
Acquires exclusive lock, reads current data, applies updater function,
|
||||
writes updated data atomically.
|
||||
|
||||
Args:
|
||||
filepath: File path to update
|
||||
updater: Function that takes current data and returns updated data
|
||||
timeout: Lock timeout in seconds (default: 5.0)
|
||||
indent: JSON indentation (default: 2)
|
||||
|
||||
Returns:
|
||||
Updated data
|
||||
|
||||
Example:
|
||||
def add_item(data):
|
||||
data["items"].append({"new": "item"})
|
||||
return data
|
||||
|
||||
updated = await locked_json_update("/path/to/file.json", add_item)
|
||||
|
||||
Raises:
|
||||
FileLockTimeout: If lock cannot be acquired within timeout
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
|
||||
# Acquire exclusive lock
|
||||
lock = FileLock(filepath, timeout=timeout, exclusive=True)
|
||||
await lock.__aenter__()
|
||||
|
||||
try:
|
||||
# Read current data
|
||||
def _read_json():
|
||||
if filepath.exists():
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
data = await asyncio.get_running_loop().run_in_executor(None, _read_json)
|
||||
|
||||
# Apply update function
|
||||
updated_data = updater(data)
|
||||
|
||||
# Write atomically
|
||||
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
lambda: tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(updated_data, f, indent=indent)
|
||||
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, os.replace, tmp_path, filepath
|
||||
)
|
||||
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
None, os.unlink, tmp_path
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return updated_data
|
||||
|
||||
finally:
|
||||
await lock.__aexit__(None, None, None)
|
||||
@@ -0,0 +1,701 @@
|
||||
"""
|
||||
Rate Limiting Protection for API Automation
|
||||
============================================
|
||||
|
||||
Comprehensive rate limiting system that protects against:
|
||||
1. API rate limits (configurable based on platform)
|
||||
2. AI API cost overruns (configurable budget per run)
|
||||
3. Thundering herd problems (exponential backoff)
|
||||
|
||||
Components:
|
||||
- TokenBucket: Classic token bucket algorithm for rate limiting
|
||||
- RateLimiter: Singleton managing API and AI cost limits
|
||||
- @rate_limited decorator: Automatic pre-flight checks with retry logic
|
||||
- Cost tracking: Per-model AI API cost calculation and budgeting
|
||||
|
||||
Usage:
|
||||
# Singleton instance
|
||||
limiter = RateLimiter.get_instance(
|
||||
api_limit=5000, # GitLab: varies by tier, GitHub: 5000/hour
|
||||
api_refill_rate=1.4, # tokens per second
|
||||
cost_limit=10.0, # $10 per run
|
||||
)
|
||||
|
||||
# Decorate API operations
|
||||
@rate_limited(operation_type="api")
|
||||
async def fetch_pr_data(pr_number: int):
|
||||
result = subprocess.run(["glab", "mr", "view", str(pr_number)])
|
||||
return result
|
||||
|
||||
# Track AI costs
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
model="claude-sonnet-4-20250514"
|
||||
)
|
||||
|
||||
# Manual rate check
|
||||
if not await limiter.acquire_api():
|
||||
raise RateLimitExceeded("API rate limit reached")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, TypeVar
|
||||
|
||||
# Type for decorated functions
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
"""Raised when rate limit is exceeded and cannot proceed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CostLimitExceeded(Exception):
|
||||
"""Raised when AI cost budget is exceeded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenBucket:
|
||||
"""
|
||||
Token bucket algorithm for rate limiting.
|
||||
|
||||
The bucket has a maximum capacity and refills at a constant rate.
|
||||
Each operation consumes one token. If bucket is empty, operations
|
||||
must wait for refill or be rejected.
|
||||
|
||||
Args:
|
||||
capacity: Maximum number of tokens (e.g., 5000 for GitHub)
|
||||
refill_rate: Tokens added per second (e.g., 1.4 for 5000/hour)
|
||||
"""
|
||||
|
||||
capacity: int
|
||||
refill_rate: float # tokens per second
|
||||
tokens: float = field(init=False)
|
||||
last_refill: float = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize bucket as full."""
|
||||
self.tokens = float(self.capacity)
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""Refill bucket based on elapsed time."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
tokens_to_add = elapsed * self.refill_rate
|
||||
self.tokens = min(self.capacity, self.tokens + tokens_to_add)
|
||||
self.last_refill = now
|
||||
|
||||
def try_acquire(self, tokens: int = 1) -> bool:
|
||||
"""
|
||||
Try to acquire tokens from bucket.
|
||||
|
||||
Returns:
|
||||
True if tokens acquired, False if insufficient tokens
|
||||
"""
|
||||
self._refill()
|
||||
if self.tokens >= tokens:
|
||||
self.tokens -= tokens
|
||||
return True
|
||||
return False
|
||||
|
||||
async def acquire(self, tokens: int = 1, timeout: float | None = None) -> bool:
|
||||
"""
|
||||
Acquire tokens from bucket, waiting if necessary.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens to acquire
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
True if tokens acquired, False if timeout reached
|
||||
"""
|
||||
start_time = time.monotonic()
|
||||
|
||||
while True:
|
||||
if self.try_acquire(tokens):
|
||||
return True
|
||||
|
||||
# Check timeout
|
||||
if timeout is not None:
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= timeout:
|
||||
return False
|
||||
|
||||
# Wait for next refill
|
||||
# Calculate time until we have enough tokens
|
||||
tokens_needed = tokens - self.tokens
|
||||
wait_time = min(tokens_needed / self.refill_rate, 1.0) # Max 1 second wait
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
def available(self) -> int:
|
||||
"""Get number of available tokens."""
|
||||
self._refill()
|
||||
return int(self.tokens)
|
||||
|
||||
def time_until_available(self, tokens: int = 1) -> float:
|
||||
"""
|
||||
Calculate seconds until requested tokens available.
|
||||
|
||||
Returns:
|
||||
0 if tokens immediately available, otherwise seconds to wait
|
||||
"""
|
||||
self._refill()
|
||||
if self.tokens >= tokens:
|
||||
return 0.0
|
||||
tokens_needed = tokens - self.tokens
|
||||
return tokens_needed / self.refill_rate
|
||||
|
||||
|
||||
# AI model pricing (per 1M tokens) - Updated 2026
|
||||
AI_PRICING = {
|
||||
# Claude models (2026)
|
||||
"claude-sonnet-4-5-20250929": {"input": 3.00, "output": 15.00},
|
||||
"claude-opus-4-5-20250929": {"input": 15.00, "output": 75.00},
|
||||
"claude-sonnet-3-5-20241022": {"input": 3.00, "output": 15.00},
|
||||
"claude-haiku-3-5-20241022": {"input": 0.25, "output": 1.25},
|
||||
"claude-opus-4-5-20251101": {"input": 15.00, "output": 75.00},
|
||||
"claude-sonnet-4-5-20251101": {"input": 3.00, "output": 15.00},
|
||||
# Legacy model names (for compatibility)
|
||||
"claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00},
|
||||
"claude-opus-4-20250514": {"input": 15.00, "output": 75.00},
|
||||
# Default fallback
|
||||
"default": {"input": 3.00, "output": 15.00},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostTracker:
|
||||
"""Track AI API costs."""
|
||||
|
||||
total_cost: float = 0.0
|
||||
cost_limit: float = 10.0
|
||||
operations: list[dict] = field(default_factory=list)
|
||||
|
||||
def add_operation(
|
||||
self,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
model: str,
|
||||
operation_name: str = "unknown",
|
||||
) -> float:
|
||||
"""
|
||||
Track cost of an AI operation.
|
||||
|
||||
Args:
|
||||
input_tokens: Number of input tokens
|
||||
output_tokens: Number of output tokens
|
||||
model: Model identifier
|
||||
operation_name: Name of operation for tracking
|
||||
|
||||
Returns:
|
||||
Cost of this operation in dollars
|
||||
|
||||
Raises:
|
||||
CostLimitExceeded: If operation would exceed budget
|
||||
"""
|
||||
cost = self.calculate_cost(input_tokens, output_tokens, model)
|
||||
|
||||
# Check if this would exceed limit
|
||||
if self.total_cost + cost > self.cost_limit:
|
||||
raise CostLimitExceeded(
|
||||
f"Operation would exceed cost limit: "
|
||||
f"${self.total_cost + cost:.2f} > ${self.cost_limit:.2f}"
|
||||
)
|
||||
|
||||
self.total_cost += cost
|
||||
self.operations.append(
|
||||
{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation_name,
|
||||
"model": model,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cost": cost,
|
||||
}
|
||||
)
|
||||
|
||||
return cost
|
||||
|
||||
@staticmethod
|
||||
def calculate_cost(input_tokens: int, output_tokens: int, model: str) -> float:
|
||||
"""
|
||||
Calculate cost for model usage.
|
||||
|
||||
Args:
|
||||
input_tokens: Number of input tokens
|
||||
output_tokens: Number of output tokens
|
||||
model: Model identifier
|
||||
|
||||
Returns:
|
||||
Cost in dollars
|
||||
"""
|
||||
# Get pricing for model (fallback to default)
|
||||
pricing = AI_PRICING.get(model, AI_PRICING["default"])
|
||||
|
||||
input_cost = (input_tokens / 1_000_000) * pricing["input"]
|
||||
output_cost = (output_tokens / 1_000_000) * pricing["output"]
|
||||
|
||||
return input_cost + output_cost
|
||||
|
||||
def remaining_budget(self) -> float:
|
||||
"""Get remaining budget in dollars."""
|
||||
return max(0.0, self.cost_limit - self.total_cost)
|
||||
|
||||
def usage_report(self) -> str:
|
||||
"""Generate cost usage report."""
|
||||
lines = [
|
||||
"Cost Usage Report",
|
||||
"=" * 50,
|
||||
f"Total Cost: ${self.total_cost:.4f}",
|
||||
f"Budget: ${self.cost_limit:.2f}",
|
||||
f"Remaining: ${self.remaining_budget():.4f}",
|
||||
f"Usage: {(self.total_cost / self.cost_limit * 100):.1f}%",
|
||||
"",
|
||||
f"Operations: {len(self.operations)}",
|
||||
]
|
||||
|
||||
if self.operations:
|
||||
lines.append("")
|
||||
lines.append("Top 5 Most Expensive Operations:")
|
||||
sorted_ops = sorted(self.operations, key=lambda x: x["cost"], reverse=True)
|
||||
for op in sorted_ops[:5]:
|
||||
lines.append(
|
||||
f" ${op['cost']:.4f} - {op['operation']} "
|
||||
f"({op['input_tokens']} in, {op['output_tokens']} out)"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Singleton rate limiter for GitHub automation.
|
||||
|
||||
Manages:
|
||||
- GitHub API rate limits (token bucket)
|
||||
- AI cost limits (budget tracking)
|
||||
- Request queuing and backoff
|
||||
"""
|
||||
|
||||
_instance: RateLimiter | None = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
github_limit: int = 5000,
|
||||
github_refill_rate: float = 1.4, # ~5000/hour
|
||||
cost_limit: float = 10.0,
|
||||
max_retry_delay: float = 300.0, # 5 minutes
|
||||
):
|
||||
"""
|
||||
Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
github_limit: Maximum GitHub API calls (default: 5000/hour)
|
||||
github_refill_rate: Tokens per second refill rate
|
||||
cost_limit: Maximum AI cost in dollars per run
|
||||
max_retry_delay: Maximum exponential backoff delay
|
||||
"""
|
||||
if RateLimiter._initialized:
|
||||
return
|
||||
|
||||
self.github_bucket = TokenBucket(
|
||||
capacity=github_limit,
|
||||
refill_rate=github_refill_rate,
|
||||
)
|
||||
self.cost_tracker = CostTracker(cost_limit=cost_limit)
|
||||
self.max_retry_delay = max_retry_delay
|
||||
|
||||
# Request statistics
|
||||
self.github_requests = 0
|
||||
self.github_rate_limited = 0
|
||||
self.github_errors = 0
|
||||
self.start_time = datetime.now()
|
||||
|
||||
RateLimiter._initialized = True
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
cls,
|
||||
github_limit: int = 5000,
|
||||
github_refill_rate: float = 1.4,
|
||||
cost_limit: float = 10.0,
|
||||
max_retry_delay: float = 300.0,
|
||||
) -> RateLimiter:
|
||||
"""
|
||||
Get or create singleton instance.
|
||||
|
||||
Args:
|
||||
github_limit: Maximum GitHub API calls
|
||||
github_refill_rate: Tokens per second refill rate
|
||||
cost_limit: Maximum AI cost in dollars
|
||||
max_retry_delay: Maximum retry delay
|
||||
|
||||
Returns:
|
||||
RateLimiter singleton instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = RateLimiter(
|
||||
github_limit=github_limit,
|
||||
github_refill_rate=github_refill_rate,
|
||||
cost_limit=cost_limit,
|
||||
max_retry_delay=max_retry_delay,
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset singleton (for testing)."""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
async def acquire_github(self, timeout: float | None = None) -> bool:
|
||||
"""
|
||||
Acquire permission for GitHub API call.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait (None = wait forever)
|
||||
|
||||
Returns:
|
||||
True if permission granted, False if timeout
|
||||
"""
|
||||
self.github_requests += 1
|
||||
success = await self.github_bucket.acquire(tokens=1, timeout=timeout)
|
||||
if not success:
|
||||
self.github_rate_limited += 1
|
||||
return success
|
||||
|
||||
def check_github_available(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if GitHub API is available without consuming token.
|
||||
|
||||
Returns:
|
||||
(available, message) tuple
|
||||
"""
|
||||
available = self.github_bucket.available()
|
||||
|
||||
if available > 0:
|
||||
return True, f"{available} requests available"
|
||||
|
||||
wait_time = self.github_bucket.time_until_available()
|
||||
return False, f"Rate limited. Wait {wait_time:.1f}s for next request"
|
||||
|
||||
def track_ai_cost(
|
||||
self,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
model: str,
|
||||
operation_name: str = "unknown",
|
||||
) -> float:
|
||||
"""
|
||||
Track AI API cost.
|
||||
|
||||
Args:
|
||||
input_tokens: Number of input tokens
|
||||
output_tokens: Number of output tokens
|
||||
model: Model identifier
|
||||
operation_name: Operation name for tracking
|
||||
|
||||
Returns:
|
||||
Cost of operation
|
||||
|
||||
Raises:
|
||||
CostLimitExceeded: If budget exceeded
|
||||
"""
|
||||
return self.cost_tracker.add_operation(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
model=model,
|
||||
operation_name=operation_name,
|
||||
)
|
||||
|
||||
def check_cost_available(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if cost budget is available.
|
||||
|
||||
Returns:
|
||||
(available, message) tuple
|
||||
"""
|
||||
remaining = self.cost_tracker.remaining_budget()
|
||||
|
||||
if remaining > 0:
|
||||
return True, f"${remaining:.2f} budget remaining"
|
||||
|
||||
return False, f"Cost budget exceeded (${self.cost_tracker.total_cost:.2f})"
|
||||
|
||||
def record_github_error(self) -> None:
|
||||
"""Record a GitHub API error."""
|
||||
self.github_errors += 1
|
||||
|
||||
def statistics(self) -> dict:
|
||||
"""
|
||||
Get rate limiter statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary of statistics
|
||||
"""
|
||||
runtime = (datetime.now() - self.start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"runtime_seconds": runtime,
|
||||
"github": {
|
||||
"total_requests": self.github_requests,
|
||||
"rate_limited": self.github_rate_limited,
|
||||
"errors": self.github_errors,
|
||||
"available_tokens": self.github_bucket.available(),
|
||||
"requests_per_second": self.github_requests / max(runtime, 1),
|
||||
},
|
||||
"cost": {
|
||||
"total_cost": self.cost_tracker.total_cost,
|
||||
"budget": self.cost_tracker.cost_limit,
|
||||
"remaining": self.cost_tracker.remaining_budget(),
|
||||
"operations": len(self.cost_tracker.operations),
|
||||
},
|
||||
}
|
||||
|
||||
def report(self) -> str:
|
||||
"""Generate comprehensive usage report."""
|
||||
stats = self.statistics()
|
||||
runtime = timedelta(seconds=int(stats["runtime_seconds"]))
|
||||
|
||||
lines = [
|
||||
"Rate Limiter Report",
|
||||
"=" * 60,
|
||||
f"Runtime: {runtime}",
|
||||
"",
|
||||
"GitHub API:",
|
||||
f" Total Requests: {stats['github']['total_requests']}",
|
||||
f" Rate Limited: {stats['github']['rate_limited']}",
|
||||
f" Errors: {stats['github']['errors']}",
|
||||
f" Available Tokens: {stats['github']['available_tokens']}",
|
||||
f" Rate: {stats['github']['requests_per_second']:.2f} req/s",
|
||||
"",
|
||||
"AI Cost:",
|
||||
f" Total: ${stats['cost']['total_cost']:.4f}",
|
||||
f" Budget: ${stats['cost']['budget']:.2f}",
|
||||
f" Remaining: ${stats['cost']['remaining']:.4f}",
|
||||
f" Operations: {stats['cost']['operations']}",
|
||||
"",
|
||||
self.cost_tracker.usage_report(),
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def rate_limited(
|
||||
operation_type: str = "github",
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Decorator to add rate limiting to functions.
|
||||
|
||||
Features:
|
||||
- Pre-flight rate check
|
||||
- Automatic retry with exponential backoff
|
||||
- Error handling for 403/429 responses
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation ("github" or "ai")
|
||||
max_retries: Maximum number of retries
|
||||
base_delay: Base delay for exponential backoff
|
||||
|
||||
Usage:
|
||||
@rate_limited(operation_type="github")
|
||||
async def fetch_pr_data(pr_number: int):
|
||||
result = subprocess.run(["gh", "pr", "view", str(pr_number)])
|
||||
return result
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
limiter = RateLimiter.get_instance()
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
# Pre-flight check
|
||||
if operation_type == "github":
|
||||
available, msg = limiter.check_github_available()
|
||||
if not available and attempt == 0:
|
||||
# Try to acquire (will wait if needed)
|
||||
if not await limiter.acquire_github(timeout=30.0):
|
||||
raise RateLimitExceeded(
|
||||
f"GitHub API rate limit exceeded: {msg}"
|
||||
)
|
||||
elif not available:
|
||||
# On retry, wait for token
|
||||
await limiter.acquire_github(
|
||||
timeout=limiter.max_retry_delay
|
||||
)
|
||||
|
||||
# Execute function
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
|
||||
except CostLimitExceeded:
|
||||
# Cost limit is hard stop - no retry
|
||||
raise
|
||||
|
||||
except RateLimitExceeded as e:
|
||||
if attempt >= max_retries:
|
||||
raise
|
||||
|
||||
# Exponential backoff
|
||||
delay = min(
|
||||
base_delay * (2**attempt),
|
||||
limiter.max_retry_delay,
|
||||
)
|
||||
print(
|
||||
f"[RateLimit] Retry {attempt + 1}/{max_retries} "
|
||||
f"after {delay:.1f}s: {e}",
|
||||
flush=True,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
except Exception as e:
|
||||
# Check if it's a rate limit error (403/429)
|
||||
error_str = str(e).lower()
|
||||
if (
|
||||
"403" in error_str
|
||||
or "429" in error_str
|
||||
or "rate limit" in error_str
|
||||
):
|
||||
limiter.record_github_error()
|
||||
|
||||
if attempt >= max_retries:
|
||||
raise RateLimitExceeded(
|
||||
f"GitHub API rate limit (HTTP 403/429): {e}"
|
||||
)
|
||||
|
||||
# Exponential backoff
|
||||
delay = min(
|
||||
base_delay * (2**attempt),
|
||||
limiter.max_retry_delay,
|
||||
)
|
||||
print(
|
||||
f"[RateLimit] HTTP 403/429 detected. "
|
||||
f"Retry {attempt + 1}/{max_retries} after {delay:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
# Not a rate limit error - propagate immediately
|
||||
raise
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# For sync functions, run in event loop
|
||||
return asyncio.run(async_wrapper(*args, **kwargs))
|
||||
|
||||
# Return appropriate wrapper
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper # type: ignore
|
||||
else:
|
||||
return sync_wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Convenience function for pre-flight checks
|
||||
async def check_rate_limit(operation_type: str = "github") -> None:
|
||||
"""
|
||||
Pre-flight rate limit check.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation to check
|
||||
|
||||
Raises:
|
||||
RateLimitExceeded: If rate limit would be exceeded
|
||||
CostLimitExceeded: If cost budget would be exceeded
|
||||
"""
|
||||
limiter = RateLimiter.get_instance()
|
||||
|
||||
if operation_type == "github":
|
||||
available, msg = limiter.check_github_available()
|
||||
if not available:
|
||||
raise RateLimitExceeded(f"GitHub API not available: {msg}")
|
||||
|
||||
elif operation_type == "cost":
|
||||
available, msg = limiter.check_cost_available()
|
||||
if not available:
|
||||
raise CostLimitExceeded(f"Cost budget exceeded: {msg}")
|
||||
|
||||
|
||||
# Example usage and testing
|
||||
if __name__ == "__main__":
|
||||
|
||||
async def example_usage():
|
||||
"""Example of using the rate limiter."""
|
||||
|
||||
# Initialize with custom limits
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=5000,
|
||||
github_refill_rate=1.4,
|
||||
cost_limit=10.0,
|
||||
)
|
||||
|
||||
print("Rate Limiter Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Example 1: Manual rate check
|
||||
print("\n1. Manual rate check:")
|
||||
available, msg = limiter.check_github_available()
|
||||
print(f" GitHub API: {msg}")
|
||||
|
||||
# Example 2: Acquire token
|
||||
print("\n2. Acquire GitHub token:")
|
||||
if await limiter.acquire_github():
|
||||
print(" ✓ Token acquired")
|
||||
else:
|
||||
print(" ✗ Rate limited")
|
||||
|
||||
# Example 3: Track AI cost
|
||||
print("\n3. Track AI cost:")
|
||||
try:
|
||||
cost = limiter.track_ai_cost(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
model="claude-sonnet-4-20250514",
|
||||
operation_name="PR review",
|
||||
)
|
||||
print(f" Cost: ${cost:.4f}")
|
||||
print(
|
||||
f" Remaining budget: ${limiter.cost_tracker.remaining_budget():.2f}"
|
||||
)
|
||||
except CostLimitExceeded as e:
|
||||
print(f" ✗ {e}")
|
||||
|
||||
# Example 4: Decorated function
|
||||
print("\n4. Using @rate_limited decorator:")
|
||||
|
||||
@rate_limited(operation_type="github")
|
||||
async def fetch_github_data(resource: str):
|
||||
print(f" Fetching: {resource}")
|
||||
# Simulate GitHub API call
|
||||
await asyncio.sleep(0.1)
|
||||
return {"data": "example"}
|
||||
|
||||
try:
|
||||
result = await fetch_github_data("pr/123")
|
||||
print(f" Result: {result}")
|
||||
except RateLimitExceeded as e:
|
||||
print(f" ✗ {e}")
|
||||
|
||||
# Final report
|
||||
print("\n" + limiter.report())
|
||||
|
||||
# Run example
|
||||
asyncio.run(example_usage())
|
||||
@@ -379,4 +379,4 @@ describe('Task Lifecycle Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ function setupTestDirs(): void {
|
||||
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'project-store-test-'));
|
||||
USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
||||
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
|
||||
|
||||
mkdirSync(USER_DATA_PATH, { recursive: true });
|
||||
mkdirSync(path.join(USER_DATA_PATH, 'store'), { recursive: true });
|
||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||
|
||||
@@ -151,14 +151,14 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
|
||||
const handleReviewAgain = async () => {
|
||||
if (!onReviewAgain) return;
|
||||
|
||||
|
||||
setIsResetting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Clear the staged flag via IPC
|
||||
const result = await window.electronAPI.clearStagedState(task.id);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to reset staged state');
|
||||
return;
|
||||
@@ -238,7 +238,7 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Secondary actions row */}
|
||||
<div className="flex gap-2">
|
||||
{/* Mark Done Only (when worktree exists) - allows keeping worktree */}
|
||||
@@ -263,7 +263,7 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Review Again button - only show if worktree exists and callback provided */}
|
||||
{hasWorktree && onReviewAgain && (
|
||||
<Button
|
||||
@@ -287,11 +287,11 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
|
||||
{hasWorktree && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"Delete Worktree & Mark Done" cleans up the isolated workspace. "Mark Done Only" keeps it for reference.
|
||||
|
||||
@@ -50,8 +50,21 @@ class EncodingChecker:
|
||||
# Check 1: open() without encoding
|
||||
# Pattern: open(...) without encoding= parameter
|
||||
# Use negative lookbehind to exclude os.open(), urlopen(), etc.
|
||||
for match in re.finditer(r'(?<![a-zA-Z_\.])open\s*\([^)]+\)', content):
|
||||
call = match.group()
|
||||
# We need to find the full open() call including nested parentheses
|
||||
for match in re.finditer(r'(?<![a-zA-Z_\.])open\s*\(', content):
|
||||
start_pos = match.end()
|
||||
|
||||
# Find the matching closing parenthesis (handle nesting)
|
||||
paren_depth = 1
|
||||
end_pos = start_pos
|
||||
while end_pos < len(content) and paren_depth > 0:
|
||||
if content[end_pos] == '(':
|
||||
paren_depth += 1
|
||||
elif content[end_pos] == ')':
|
||||
paren_depth -= 1
|
||||
end_pos += 1
|
||||
|
||||
call = content[match.start():end_pos]
|
||||
|
||||
# Skip if it's binary mode (must contain 'b' in mode string)
|
||||
# Matches: "rb", "wb", "ab", "r+b", "w+b", etc.
|
||||
|
||||
@@ -313,7 +313,8 @@ def process_files(input_path, output_path):
|
||||
result = checker.check_file(temp_path)
|
||||
|
||||
assert result is False
|
||||
assert len(checker.issues) == 2
|
||||
# Expects 3 issues: 2 open() calls (one in comment, one actual) + 1 write_text() call
|
||||
assert len(checker.issues) == 3
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
@@ -628,7 +628,8 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, discovery):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
# Use a path that's guaranteed not to exist (avoid /nonexistent which may exist)
|
||||
fake_dir = Path("/tmp/nonexistent_test_path_xyz123_that_does_not_exist")
|
||||
|
||||
# Should not raise
|
||||
result = discovery.discover(fake_dir)
|
||||
|
||||
@@ -530,7 +530,8 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, discovery):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
# Use a path that's guaranteed not to exist (avoid /nonexistent which may exist)
|
||||
fake_dir = Path("/tmp/nonexistent_test_path_xyz123_that_does_not_exist")
|
||||
|
||||
# Should not raise
|
||||
result = discovery.discover(fake_dir)
|
||||
|
||||
@@ -7,7 +7,7 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -161,7 +161,8 @@ class TestCoolingOff:
|
||||
def test_within_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR within cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
import datetime as dt
|
||||
half_min_ago = dt.datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
@@ -172,7 +173,8 @@ class TestCoolingOff:
|
||||
def test_outside_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR outside cooling off period."""
|
||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
import datetime as dt
|
||||
two_min_ago = dt.datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
@@ -263,7 +265,8 @@ class TestShouldSkipReview:
|
||||
def test_skip_cooling_off(self, mock_bot_detector):
|
||||
"""Test skipping during cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
import datetime as dt
|
||||
half_min_ago = dt.datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
|
||||
@@ -382,7 +382,8 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, scanner):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
# Use a path that's guaranteed not to exist (avoid /nonexistent which may exist)
|
||||
fake_dir = Path("/tmp/nonexistent_test_path_xyz123_that_does_not_exist")
|
||||
|
||||
# Should not crash, may have errors
|
||||
result = scanner.scan(fake_dir)
|
||||
|
||||
@@ -437,7 +437,8 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
# Use a path that's guaranteed not to exist (avoid /nonexistent which may exist)
|
||||
fake_dir = Path("/tmp/nonexistent_test_path_xyz123_that_does_not_exist")
|
||||
|
||||
# Should not crash
|
||||
orchestrator = ServiceOrchestrator(fake_dir)
|
||||
|
||||
@@ -631,7 +631,8 @@ class TestEdgeCases:
|
||||
|
||||
def test_nonexistent_directory(self, builder):
|
||||
"""Test handling of non-existent directory."""
|
||||
fake_dir = Path("/nonexistent/path")
|
||||
# Use a path that's guaranteed not to exist (avoid /nonexistent which may exist)
|
||||
fake_dir = Path("/tmp/nonexistent_test_path_xyz123_that_does_not_exist")
|
||||
|
||||
# Should not crash, returns unknown
|
||||
strategy = builder.build_strategy(fake_dir, fake_dir, "medium")
|
||||
|
||||
Reference in New Issue
Block a user