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)
|
# 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)
|
# 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"
|
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
|
# Determine Python executable from venv
|
||||||
VENV_PYTHON=""
|
VENV_PYTHON=""
|
||||||
if [ -f ".venv/bin/python" ]; then
|
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
|
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$VENV_PYTHON" ]; then
|
if [ -n "$VENV_PYTHON" ]; then
|
||||||
# Check if pytest is installed in venv
|
# Check if pytest is installed in venv
|
||||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||||
|
|||||||
@@ -64,3 +64,4 @@ tests/
|
|||||||
|
|
||||||
# Auto Claude data directory
|
# Auto Claude data directory
|
||||||
.auto-claude/
|
.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 .ai_analyzer_runner import main as run_ai_analyzer
|
||||||
from .ideation_runner import main as run_ideation
|
from .ideation_runner import main as run_ideation
|
||||||
from .insights_runner import main as run_insights
|
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
|
from .spec_runner import main as run_spec
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"run_spec",
|
"run_spec",
|
||||||
"run_roadmap",
|
# "run_roadmap", # Temporarily disabled
|
||||||
"run_ideation",
|
"run_ideation",
|
||||||
"run_insights",
|
"run_insights",
|
||||||
"run_ai_analyzer",
|
"run_ai_analyzer",
|
||||||
|
|||||||
@@ -49,33 +49,85 @@ try:
|
|||||||
from .services.io_utils import safe_print
|
from .services.io_utils import safe_print
|
||||||
except (ImportError, ValueError, SystemError):
|
except (ImportError, ValueError, SystemError):
|
||||||
# When imported directly (runner.py adds github dir to path)
|
# When imported directly (runner.py adds github dir to path)
|
||||||
from bot_detection import BotDetector
|
# Use try/except for each import to handle partial failures gracefully
|
||||||
from context_gatherer import PRContext, PRContextGatherer
|
try:
|
||||||
from gh_client import GHClient
|
from bot_detection import BotDetector
|
||||||
from models import (
|
except ImportError:
|
||||||
BRANCH_BEHIND_BLOCKER_MSG,
|
BotDetector = None # type: ignore
|
||||||
BRANCH_BEHIND_REASONING,
|
|
||||||
AICommentTriage,
|
try:
|
||||||
AICommentVerdict,
|
from .context_gatherer import PRContext, PRContextGatherer
|
||||||
AutoFixState,
|
except ImportError:
|
||||||
GitHubRunnerConfig,
|
try:
|
||||||
MergeVerdict,
|
from context_gatherer import PRContext, PRContextGatherer
|
||||||
PRReviewFinding,
|
except ImportError:
|
||||||
PRReviewResult,
|
PRContext = None # type: ignore
|
||||||
ReviewCategory,
|
PRContextGatherer = None # type: ignore
|
||||||
ReviewSeverity,
|
|
||||||
StructuralIssue,
|
try:
|
||||||
TriageResult,
|
from gh_client import GHClient
|
||||||
)
|
except ImportError:
|
||||||
from permissions import GitHubPermissionChecker
|
GHClient = None # type: ignore
|
||||||
from rate_limiter import RateLimiter
|
|
||||||
from services import (
|
# Try to import models, but allow partial failures
|
||||||
AutoFixProcessor,
|
try:
|
||||||
BatchProcessor,
|
from models import (
|
||||||
PRReviewEngine,
|
BRANCH_BEHIND_BLOCKER_MSG,
|
||||||
TriageEngine,
|
BRANCH_BEHIND_REASONING,
|
||||||
)
|
AICommentTriage,
|
||||||
from services.io_utils import safe_print
|
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
|
@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"
|
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):
|
class ReviewPass(str, Enum):
|
||||||
"""Multi-pass review stages."""
|
"""Multi-pass review stages."""
|
||||||
|
|
||||||
@@ -43,6 +55,8 @@ class ReviewPass(str, Enum):
|
|||||||
SECURITY = "security"
|
SECURITY = "security"
|
||||||
QUALITY = "quality"
|
QUALITY = "quality"
|
||||||
DEEP_ANALYSIS = "deep_analysis"
|
DEEP_ANALYSIS = "deep_analysis"
|
||||||
|
STRUCTURAL = "structural"
|
||||||
|
AI_COMMENT_TRIAGE = "ai_comment_triage"
|
||||||
|
|
||||||
|
|
||||||
class MergeVerdict(str, Enum):
|
class MergeVerdict(str, Enum):
|
||||||
@@ -54,6 +68,45 @@ class MergeVerdict(str, Enum):
|
|||||||
BLOCKED = "blocked"
|
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
|
@dataclass
|
||||||
class MRReviewFinding:
|
class MRReviewFinding:
|
||||||
"""A single finding from an MR review."""
|
"""A single finding from an MR review."""
|
||||||
@@ -68,6 +121,10 @@ class MRReviewFinding:
|
|||||||
end_line: int | None = None
|
end_line: int | None = None
|
||||||
suggested_fix: str | None = None
|
suggested_fix: str | None = None
|
||||||
fixable: bool = False
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -81,10 +138,13 @@ class MRReviewFinding:
|
|||||||
"end_line": self.end_line,
|
"end_line": self.end_line,
|
||||||
"suggested_fix": self.suggested_fix,
|
"suggested_fix": self.suggested_fix,
|
||||||
"fixable": self.fixable,
|
"fixable": self.fixable,
|
||||||
|
"evidence_code": self.evidence_code,
|
||||||
|
"found_by_pass": self.found_by_pass.value if self.found_by_pass else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> MRReviewFinding:
|
def from_dict(cls, data: dict) -> MRReviewFinding:
|
||||||
|
found_by_pass = data.get("found_by_pass")
|
||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
severity=ReviewSeverity(data["severity"]),
|
severity=ReviewSeverity(data["severity"]),
|
||||||
@@ -96,6 +156,77 @@ class MRReviewFinding:
|
|||||||
end_line=data.get("end_line"),
|
end_line=data.get("end_line"),
|
||||||
suggested_fix=data.get("suggested_fix"),
|
suggested_fix=data.get("suggested_fix"),
|
||||||
fixable=data.get("fixable", False),
|
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 = ""
|
verdict_reasoning: str = ""
|
||||||
blockers: list[str] = field(default_factory=list)
|
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
|
# Follow-up review tracking
|
||||||
reviewed_commit_sha: str | None = None
|
reviewed_commit_sha: str | None = None
|
||||||
|
reviewed_file_blobs: dict[str, str] = field(default_factory=dict)
|
||||||
is_followup_review: bool = False
|
is_followup_review: bool = False
|
||||||
previous_review_id: int | None = None
|
previous_review_id: int | None = None
|
||||||
resolved_findings: list[str] = field(default_factory=list)
|
resolved_findings: list[str] = field(default_factory=list)
|
||||||
@@ -129,6 +265,10 @@ class MRReviewResult:
|
|||||||
has_posted_findings: bool = False
|
has_posted_findings: bool = False
|
||||||
posted_finding_ids: list[str] = field(default_factory=list)
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"mr_iid": self.mr_iid,
|
"mr_iid": self.mr_iid,
|
||||||
@@ -142,7 +282,10 @@ class MRReviewResult:
|
|||||||
"verdict": self.verdict.value,
|
"verdict": self.verdict.value,
|
||||||
"verdict_reasoning": self.verdict_reasoning,
|
"verdict_reasoning": self.verdict_reasoning,
|
||||||
"blockers": self.blockers,
|
"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_commit_sha": self.reviewed_commit_sha,
|
||||||
|
"reviewed_file_blobs": self.reviewed_file_blobs,
|
||||||
"is_followup_review": self.is_followup_review,
|
"is_followup_review": self.is_followup_review,
|
||||||
"previous_review_id": self.previous_review_id,
|
"previous_review_id": self.previous_review_id,
|
||||||
"resolved_findings": self.resolved_findings,
|
"resolved_findings": self.resolved_findings,
|
||||||
@@ -150,6 +293,8 @@ class MRReviewResult:
|
|||||||
"new_findings_since_last_review": self.new_findings_since_last_review,
|
"new_findings_since_last_review": self.new_findings_since_last_review,
|
||||||
"has_posted_findings": self.has_posted_findings,
|
"has_posted_findings": self.has_posted_findings,
|
||||||
"posted_finding_ids": self.posted_finding_ids,
|
"posted_finding_ids": self.posted_finding_ids,
|
||||||
|
"ci_status": self.ci_status,
|
||||||
|
"ci_pipeline_id": self.ci_pipeline_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -166,7 +311,14 @@ class MRReviewResult:
|
|||||||
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
||||||
verdict_reasoning=data.get("verdict_reasoning", ""),
|
verdict_reasoning=data.get("verdict_reasoning", ""),
|
||||||
blockers=data.get("blockers", []),
|
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_commit_sha=data.get("reviewed_commit_sha"),
|
||||||
|
reviewed_file_blobs=data.get("reviewed_file_blobs", {}),
|
||||||
is_followup_review=data.get("is_followup_review", False),
|
is_followup_review=data.get("is_followup_review", False),
|
||||||
previous_review_id=data.get("previous_review_id"),
|
previous_review_id=data.get("previous_review_id"),
|
||||||
resolved_findings=data.get("resolved_findings", []),
|
resolved_findings=data.get("resolved_findings", []),
|
||||||
@@ -176,6 +328,8 @@ class MRReviewResult:
|
|||||||
),
|
),
|
||||||
has_posted_findings=data.get("has_posted_findings", False),
|
has_posted_findings=data.get("has_posted_findings", False),
|
||||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
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:
|
def save(self, gitlab_dir: Path) -> None:
|
||||||
@@ -211,6 +365,10 @@ class GitLabRunnerConfig:
|
|||||||
model: str = "claude-sonnet-4-5-20250929"
|
model: str = "claude-sonnet-4-5-20250929"
|
||||||
thinking_level: str = "medium"
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"token": "***", # Never save token
|
"token": "***", # Never save token
|
||||||
@@ -218,6 +376,8 @@ class GitLabRunnerConfig:
|
|||||||
"instance_url": self.instance_url,
|
"instance_url": self.instance_url,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"thinking_level": self.thinking_level,
|
"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
|
total_deletions: int = 0
|
||||||
commits: list[dict] = field(default_factory=list)
|
commits: list[dict] = field(default_factory=list)
|
||||||
head_sha: str | None = None
|
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
|
@dataclass
|
||||||
@@ -253,3 +418,225 @@ class FollowupMRContext:
|
|||||||
commits_since_review: list[dict] = field(default_factory=list)
|
commits_since_review: list[dict] = field(default_factory=list)
|
||||||
files_changed_since_review: list[str] = field(default_factory=list)
|
files_changed_since_review: list[str] = field(default_factory=list)
|
||||||
diff_since_review: str = ""
|
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:
|
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
|
- Follow-up Review: Review changes since last review
|
||||||
|
- Bot Detection: Prevents infinite review loops
|
||||||
|
- CI/CD Checking: Pipeline status validation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,6 +19,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from .bot_detection import BotDetector
|
||||||
from .glab_client import GitLabClient, GitLabConfig
|
from .glab_client import GitLabClient, GitLabConfig
|
||||||
from .models import (
|
from .models import (
|
||||||
GitLabRunnerConfig,
|
GitLabRunnerConfig,
|
||||||
@@ -25,8 +28,11 @@ try:
|
|||||||
MRReviewResult,
|
MRReviewResult,
|
||||||
)
|
)
|
||||||
from .services import MRReviewEngine
|
from .services import MRReviewEngine
|
||||||
|
from .services.ci_checker import CIChecker
|
||||||
|
from .services.context_gatherer import MRContextGatherer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback for direct script execution (not as a module)
|
# Fallback for direct script execution (not as a module)
|
||||||
|
from bot_detection import BotDetector
|
||||||
from glab_client import GitLabClient, GitLabConfig
|
from glab_client import GitLabClient, GitLabConfig
|
||||||
from models import (
|
from models import (
|
||||||
GitLabRunnerConfig,
|
GitLabRunnerConfig,
|
||||||
@@ -35,6 +41,8 @@ except ImportError:
|
|||||||
MRReviewResult,
|
MRReviewResult,
|
||||||
)
|
)
|
||||||
from services import MRReviewEngine
|
from services import MRReviewEngine
|
||||||
|
from services.ci_checker import CIChecker
|
||||||
|
from services.context_gatherer import MRContextGatherer
|
||||||
|
|
||||||
# Import safe_print for BrokenPipeError handling
|
# Import safe_print for BrokenPipeError handling
|
||||||
try:
|
try:
|
||||||
@@ -77,10 +85,15 @@ class GitLabOrchestrator:
|
|||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
config: GitLabRunnerConfig,
|
config: GitLabRunnerConfig,
|
||||||
progress_callback: Callable[[ProgressCallback], None] | None = None,
|
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.project_dir = Path(project_dir)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
|
self.enable_bot_detection = enable_bot_detection
|
||||||
|
self.enable_ci_checking = enable_ci_checking
|
||||||
|
|
||||||
# GitLab directory for storing state
|
# GitLab directory for storing state
|
||||||
self.gitlab_dir = self.project_dir / ".auto-claude" / "gitlab"
|
self.gitlab_dir = self.project_dir / ".auto-claude" / "gitlab"
|
||||||
@@ -107,6 +120,25 @@ class GitLabOrchestrator:
|
|||||||
progress_callback=self._forward_progress,
|
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(
|
def _report_progress(
|
||||||
self,
|
self,
|
||||||
phase: str,
|
phase: str,
|
||||||
@@ -192,6 +224,8 @@ class GitLabOrchestrator:
|
|||||||
"""
|
"""
|
||||||
Perform AI-powered review of a merge request.
|
Perform AI-powered review of a merge request.
|
||||||
|
|
||||||
|
Includes bot detection and CI/CD status checking.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mr_iid: The MR IID to review
|
mr_iid: The MR IID to review
|
||||||
|
|
||||||
@@ -208,15 +242,79 @@ class GitLabOrchestrator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Gather MR context
|
# Get MR data first for bot detection
|
||||||
context = await self._gather_mr_context(mr_iid)
|
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(
|
safe_print(
|
||||||
f"[GitLab] Context gathered: {context.title} "
|
f"[GitLab] Context gathered: {context.title} "
|
||||||
f"({len(context.changed_files)} files, {context.total_additions}+/{context.total_deletions}-)"
|
f"({len(context.changed_files)} files, {context.total_additions}+/{context.total_deletions}-)"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._report_progress(
|
self._report_progress(
|
||||||
"analyzing", 30, "Running AI review...", mr_iid=mr_iid
|
"analyzing", 40, "Running AI review...", mr_iid=mr_iid
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run review
|
# Run review
|
||||||
@@ -225,6 +323,15 @@ class GitLabOrchestrator:
|
|||||||
)
|
)
|
||||||
safe_print(f"[GitLab] Review complete: {len(findings)} findings")
|
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
|
# Map verdict to overall_status
|
||||||
if verdict == MergeVerdict.BLOCKED:
|
if verdict == MergeVerdict.BLOCKED:
|
||||||
overall_status = "request_changes"
|
overall_status = "request_changes"
|
||||||
@@ -243,6 +350,13 @@ class GitLabOrchestrator:
|
|||||||
blockers=blockers,
|
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
|
# Create result
|
||||||
result = MRReviewResult(
|
result = MRReviewResult(
|
||||||
mr_iid=mr_iid,
|
mr_iid=mr_iid,
|
||||||
@@ -255,11 +369,17 @@ class GitLabOrchestrator:
|
|||||||
verdict_reasoning=summary,
|
verdict_reasoning=summary,
|
||||||
blockers=blockers,
|
blockers=blockers,
|
||||||
reviewed_commit_sha=context.head_sha,
|
reviewed_commit_sha=context.head_sha,
|
||||||
|
ci_status=ci_status,
|
||||||
|
ci_pipeline_id=ci_pipeline_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save result
|
# Save result
|
||||||
result.save(self.gitlab_dir)
|
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)
|
self._report_progress("complete", 100, "Review complete!", mr_iid=mr_iid)
|
||||||
|
|
||||||
return result
|
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:
|
CLI interface for GitLab automation features:
|
||||||
- MR Review: AI-powered merge request review
|
- MR Review: AI-powered merge request review
|
||||||
- Follow-up Review: Review changes since last 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:
|
Usage:
|
||||||
# Review a specific MR
|
# Review a specific MR
|
||||||
@@ -13,6 +16,15 @@ Usage:
|
|||||||
|
|
||||||
# Follow-up review after new commits
|
# Follow-up review after new commits
|
||||||
python runner.py followup-review-mr 123
|
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
|
from __future__ import annotations
|
||||||
@@ -244,6 +256,277 @@ async def cmd_followup_review_mr(args) -> int:
|
|||||||
return 1
|
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():
|
def main():
|
||||||
"""CLI entry point."""
|
"""CLI entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -303,6 +586,47 @@ def main():
|
|||||||
)
|
)
|
||||||
followup_parser.add_argument("mr_iid", type=int, help="MR IID to review")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.command:
|
if not args.command:
|
||||||
@@ -313,6 +637,9 @@ def main():
|
|||||||
commands = {
|
commands = {
|
||||||
"review-mr": cmd_review_mr,
|
"review-mr": cmd_review_mr,
|
||||||
"followup-review-mr": cmd_followup_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)
|
handler = commands.get(args.command)
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ GitLab Runner Services
|
|||||||
Service layer for GitLab automation.
|
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
|
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-'));
|
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'project-store-test-'));
|
||||||
USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
USER_DATA_PATH = path.join(TEST_DIR, 'userData');
|
||||||
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||||
|
|
||||||
mkdirSync(USER_DATA_PATH, { recursive: true });
|
mkdirSync(USER_DATA_PATH, { recursive: true });
|
||||||
mkdirSync(path.join(USER_DATA_PATH, 'store'), { recursive: true });
|
mkdirSync(path.join(USER_DATA_PATH, 'store'), { recursive: true });
|
||||||
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
mkdirSync(TEST_PROJECT_PATH, { recursive: true });
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
|||||||
|
|
||||||
const handleReviewAgain = async () => {
|
const handleReviewAgain = async () => {
|
||||||
if (!onReviewAgain) return;
|
if (!onReviewAgain) return;
|
||||||
|
|
||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clear the staged flag via IPC
|
// Clear the staged flag via IPC
|
||||||
const result = await window.electronAPI.clearStagedState(task.id);
|
const result = await window.electronAPI.clearStagedState(task.id);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || 'Failed to reset staged state');
|
setError(result.error || 'Failed to reset staged state');
|
||||||
return;
|
return;
|
||||||
@@ -238,7 +238,7 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary actions row */}
|
{/* Secondary actions row */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* Mark Done Only (when worktree exists) - allows keeping worktree */}
|
{/* Mark Done Only (when worktree exists) - allows keeping worktree */}
|
||||||
@@ -263,7 +263,7 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Review Again button - only show if worktree exists and callback provided */}
|
{/* Review Again button - only show if worktree exists and callback provided */}
|
||||||
{hasWorktree && onReviewAgain && (
|
{hasWorktree && onReviewAgain && (
|
||||||
<Button
|
<Button
|
||||||
@@ -287,11 +287,11 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false,
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasWorktree && (
|
{hasWorktree && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
"Delete Worktree & Mark Done" cleans up the isolated workspace. "Mark Done Only" keeps it for reference.
|
"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
|
# Check 1: open() without encoding
|
||||||
# Pattern: open(...) without encoding= parameter
|
# Pattern: open(...) without encoding= parameter
|
||||||
# Use negative lookbehind to exclude os.open(), urlopen(), etc.
|
# Use negative lookbehind to exclude os.open(), urlopen(), etc.
|
||||||
for match in re.finditer(r'(?<![a-zA-Z_\.])open\s*\([^)]+\)', content):
|
# We need to find the full open() call including nested parentheses
|
||||||
call = match.group()
|
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)
|
# Skip if it's binary mode (must contain 'b' in mode string)
|
||||||
# Matches: "rb", "wb", "ab", "r+b", "w+b", etc.
|
# 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)
|
result = checker.check_file(temp_path)
|
||||||
|
|
||||||
assert result is False
|
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:
|
finally:
|
||||||
temp_path.unlink()
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|||||||
@@ -628,7 +628,8 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
def test_nonexistent_directory(self, discovery):
|
def test_nonexistent_directory(self, discovery):
|
||||||
"""Test handling of non-existent directory."""
|
"""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
|
# Should not raise
|
||||||
result = discovery.discover(fake_dir)
|
result = discovery.discover(fake_dir)
|
||||||
|
|||||||
@@ -530,7 +530,8 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
def test_nonexistent_directory(self, discovery):
|
def test_nonexistent_directory(self, discovery):
|
||||||
"""Test handling of non-existent directory."""
|
"""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
|
# Should not raise
|
||||||
result = discovery.discover(fake_dir)
|
result = discovery.discover(fake_dir)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -161,7 +161,8 @@ class TestCoolingOff:
|
|||||||
def test_within_cooling_off(self, mock_bot_detector):
|
def test_within_cooling_off(self, mock_bot_detector):
|
||||||
"""Test PR within cooling off period."""
|
"""Test PR within cooling off period."""
|
||||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
# 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()
|
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||||
|
|
||||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
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):
|
def test_outside_cooling_off(self, mock_bot_detector):
|
||||||
"""Test PR outside cooling off period."""
|
"""Test PR outside cooling off period."""
|
||||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
# 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()
|
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||||
|
|
||||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
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):
|
def test_skip_cooling_off(self, mock_bot_detector):
|
||||||
"""Test skipping during cooling off period."""
|
"""Test skipping during cooling off period."""
|
||||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
# 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()
|
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||||
|
|
||||||
pr_data = {"author": {"login": "alice"}}
|
pr_data = {"author": {"login": "alice"}}
|
||||||
|
|||||||
@@ -382,7 +382,8 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
def test_nonexistent_directory(self, scanner):
|
def test_nonexistent_directory(self, scanner):
|
||||||
"""Test handling of non-existent directory."""
|
"""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
|
# Should not crash, may have errors
|
||||||
result = scanner.scan(fake_dir)
|
result = scanner.scan(fake_dir)
|
||||||
|
|||||||
@@ -437,7 +437,8 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
def test_nonexistent_directory(self):
|
def test_nonexistent_directory(self):
|
||||||
"""Test handling of non-existent directory."""
|
"""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
|
# Should not crash
|
||||||
orchestrator = ServiceOrchestrator(fake_dir)
|
orchestrator = ServiceOrchestrator(fake_dir)
|
||||||
|
|||||||
@@ -631,7 +631,8 @@ class TestEdgeCases:
|
|||||||
|
|
||||||
def test_nonexistent_directory(self, builder):
|
def test_nonexistent_directory(self, builder):
|
||||||
"""Test handling of non-existent directory."""
|
"""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
|
# Should not crash, returns unknown
|
||||||
strategy = builder.build_strategy(fake_dir, fake_dir, "medium")
|
strategy = builder.build_strategy(fake_dir, fake_dir, "medium")
|
||||||
|
|||||||
Reference in New Issue
Block a user