Files
Aperant/tests/test_git_provider.py
T
bu5hm4nn cd423c65c7 Fix/gitlab bugs (#1519 and #1521) (#1544)
* Fix GitLab Merged MRs Not Displaying

Fixes https://github.com/AndyMik90/Auto-Claude/issues/1521

- Added UseGitLabMRsOptions interface with stateFilter parameter
- Updated hook signature to accept optional options parameter
- Removed hardcoded useState for stateFilter
- Defaults to 'opened' for backward compatibility
- Pass stateFilter state to useGitLabMRs hook via options parameter
- Enables proper filtering of MRs by state (opened/merged/closed/all)
- Completes frontend implementation for GitLab MR state filtering

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* GitLab Support for Create PR Button (#2)

Title:
  feat: add GitLab support for Create PR button (#2)
  Fixes: https://github.com/AndyMik90/Auto-Claude/issues/1519

  Body:
  Add automatic git remote detection to route GitLab repositories to the
  `glab` CLI for creating merge requests, while preserving existing GitHub
  functionality.

  ## Changes

  - Add `git_provider.py` for detecting GitHub vs GitLab from remote URLs
    - Supports SSH and HTTPS formats
    - Supports self-hosted GitLab instances (detects "gitlab" in hostname)
  - Add `glab_executable.py` for finding GitLab CLI with platform-specific fallbacks
  - Update `WorktreeManager.push_and_create_pr()` to detect provider and route
    to either `create_pull_request()` (GitHub) or `create_merge_request()` (GitLab)
  - Add `create_merge_request()` method for GitLab MR creation via glab CLI
  - Update error messages to include provider-specific installation instructions

  ## Testing

  - Unit tests for `git_provider.py` detection logic
  - Integration tests for WorktreeManager PR/MR creation
  - Manual E2E tests for GitLab remote repositories
  - Regression tests to ensure GitHub PR creation still works

  Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add GitLab CLI (glab) Path Configuration to Settings (#3)

feat(frontend): add GitLab CLI (glab) path configuration to Settings

  Add support for configuring the GitLab CLI (glab) path in the Settings
  page, consistent with existing GitHub CLI (gh) path configuration.

  Changes:
  - Add 'glab' to CLITool type union and gitlabCLIPath to ToolConfig
  - Implement detectGitLabCLI() and validateGitLabCLI() with multi-level
    detection (user config, Homebrew, system PATH, Windows Program Files)
  - Implement async variants for non-blocking detection
  - Add gitlabCLIPath to AppSettings interface and DEFAULT_APP_SETTINGS
  - Add glab to getCliToolsInfo IPC handler return type
  - Add gitlabCLIPath to pathFields array and configureTools calls
  - Add English and French translation keys for GitLab CLI path
  - Add GitLab CLI path input field to GeneralSettings component
  - Fix glab version regex to match actual output format ("glab X.Y.Z"
    instead of "glab version X.Y.Z")
  - Add augmented env to all sync CLI validators (Python, Git, gh, glab)
    for consistency with validateClaude and async validators

  Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GitLab bugs from CodeRabbitAI review comments

Address actionable comments reported by CodeRabbitAI:

- Fix SSH URL parsing in git_provider.py to support ssh:// URLs and
  arbitrary usernames (not just git@)
- Fix Windows glab paths to use correct installation directory
  (glab\glab.exe instead of GitLab CLI\glab.exe)
- Fix regex for GitLab MR URLs to correctly match both /merge_requests/
  and /-/merge_requests/ patterns
- Move inline json import to top-level in worktree.py
- Fix incorrect mock paths in test_worktree_gitlab.py
- Remove unused imports and f-strings without placeholders
- Add WINDOWS_GLAB_PATHS constant to frontend for centralized path management
- Replace fragile monkeypatch with unittest.mock.patch in manual tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Consolidate GitLab test files and move tests to tests/ directory

- Remove duplicate test_gitlab_pr_manual.py, consolidate into test_gitlab_e2e.py
- Expand provider detection to test 8 URL patterns (GitHub/GitLab variants)
- Add WorktreeManager method signature verification test
- Improve error message test to use unittest.mock.patch
- Move all GitLab test files from apps/backend/core/ to tests/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Convert GitLab E2E tests to pytest-style assertions

Rename test functions to _check_* helpers and create proper test_*
pytest functions with assertions. This fixes PytestReturnNotNoneWarning
warnings and ensures tests actually fail when checks return False.

Fixes: https://github.com/AndyMik90/Auto-Claude/pull/1544#discussion_r2729260291

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix GitHub Enterprise detection in git_provider

Broaden the hostname check in _classify_hostname to also detect
GitHub Enterprise hostnames (e.g., github.company.com) by checking
for "github" substring, matching the pattern already used for GitLab.

Addresses CodeRabbitAI review comment on PR #1544.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix CI failures: Ruff formatting and test isolation issues

- Split long regex line in worktree.py for Ruff compliance
- Fix test isolation issue caused by worktree.py importlib shim
- Convert patch() calls to patch.object() pattern in test files
- Add fixtures to test_github_pr_regression.py for consistency

The importlib shim in apps/backend/worktree.py causes module-level
patches to fail when tests run after test_agent_flow.py. Using
patch.object() on the imported module directly resolves this.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Skip glab detection test when glab CLI is not installed

Address CodeRabbit recommendation: add pytest import and guard
test_glab_detection with get_glab_executable() check, using
pytest.skip() when glab is not available on the system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Disable GPG signing in test git repos to prevent CI hangs

Tests may hang if the runner has global GPG signing enabled.
Explicitly disable commit.gpgsign in create_test_git_repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix glab CLI path not passed to backend subprocess

The frontend detected glab but never set GITLAB_CLI_PATH env var.
Added 'glab' to CliTool type and CLI_TOOL_ENV_MAP, and call
detectAndSetCliPath('glab') in setupProcessEnvironment.

This ensures GitLab MR creation works when the app is launched
from Finder/Dock and glab is in a non-standard PATH location.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix glab CLI flags and JSON field name in _get_existing_mr_url

glab uses --output json (not --json fieldName like gh CLI) and
returns snake_case field names (web_url instead of webUrl).

Verified with actual glab mr view command on lcoffice repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Extract shared test fixtures to conftest.py

Move temp_project_dir and worktree_manager fixtures from
test_gitlab_worktree.py and test_github_pr_regression.py
to a shared conftest.py file.

Also adds GPG signing disable to the shared fixture for CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use throwaway variable for unused WorktreeManager instance

Replace `manager` with `_` to indicate the variable is intentionally
unused - the test only verifies the constructor doesn't raise.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove unused imports in test_gitlab_worktree.py

Remove pytest and WorktreeManager imports that are not used in the file.
Fixtures are provided by conftest.py which handles the imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address PR review findings: cleanup and improve hostname matching

- Remove unused MergeRequestResult TypedDict (dead code)
- Rename GH_CLI_TIMEOUT/GH_QUERY_TIMEOUT to provider-neutral CLI_TIMEOUT/CLI_QUERY_TIMEOUT
- Improve hostname classification to use precise domain segment matching
  (rejects edge cases like attacker-github.com while still matching github-enterprise.local)
- Add test coverage for GitHub/GitLab hostname detection edge cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert "Extract shared test fixtures to conftest.py"

This reverts commit 22ab6662ee2710b86651dd630ee613e2d73ce395.

* Extract shared test fixtures to conftest.py

- Move temp_project_dir and worktree_manager fixtures to conftest.py
  (shared across GitLab and GitHub test suites)
- Remove duplicate fixtures from test_github_pr_regression.py and
  test_gitlab_worktree.py
- Remove unused subprocess import from test_gitlab_worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use consistent shim imports in GitLab/GitHub tests

Switch to shim imports (worktree instead of core.worktree) to match
other test files and avoid module aliasing issues caused by Python's
module caching when tests use different import paths for the same module.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Isolate git environment in temp_project_dir fixture

Pass sanitized environment to subprocess.run calls to prevent git
operations from leaking into parent repos when tests run inside
git worktrees (e.g., during pre-commit hooks).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix CodeQL findings in test files

- Remove URL substring check that triggered py/incomplete-url-substring-sanitization
  (redundant - error message check is sufficient)
- Remove unused pytest import in test_gitlab_worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use pushed remote for provider detection in multi-remote repos

detect_git_provider() now accepts an optional remote_name parameter
so push_and_create_pr() can pass the actual pushed remote instead of
always checking 'origin'. This fixes incorrect PR/MR creation when
repos have multiple remotes pointing to different providers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com>
2026-01-30 13:08:17 +01:00

402 lines
14 KiB
Python

"""
Tests for Git Provider Detection Module
========================================
Tests the detect_git_provider function to ensure it correctly identifies
GitHub, GitLab (cloud and self-hosted), and unknown providers from remote URLs.
"""
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add apps/backend directory to path for imports
_backend_dir = Path(__file__).parent.parent / "apps" / "backend"
if str(_backend_dir) not in sys.path:
sys.path.insert(0, str(_backend_dir))
from core.git_provider import _classify_hostname, detect_git_provider
@pytest.fixture
def temp_repo_dir(tmp_path):
"""Create a temporary directory simulating a git repository."""
repo_dir = tmp_path / "test-repo"
repo_dir.mkdir()
return repo_dir
class TestDetectGitProviderSSH:
"""Test git provider detection for SSH remote URLs."""
def test_github_ssh_url(self, temp_repo_dir):
"""Test detection of GitHub SSH URL."""
mock_result = MagicMock(
returncode=0,
stdout="git@github.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "github"
def test_gitlab_cloud_ssh_url(self, temp_repo_dir):
"""Test detection of GitLab cloud SSH URL."""
mock_result = MagicMock(
returncode=0,
stdout="git@gitlab.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
def test_gitlab_self_hosted_ssh_url(self, temp_repo_dir):
"""Test detection of self-hosted GitLab SSH URL."""
mock_result = MagicMock(
returncode=0,
stdout="git@gitlab.company.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
def test_gitlab_custom_domain_ssh_url(self, temp_repo_dir):
"""Test detection of GitLab on custom domain."""
mock_result = MagicMock(
returncode=0,
stdout="git@git.example.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
# Should be unknown because 'gitlab' is not in hostname
assert provider == "unknown"
def test_ssh_url_without_git_suffix(self, temp_repo_dir):
"""Test SSH URL without .git suffix."""
mock_result = MagicMock(
returncode=0,
stdout="git@github.com:user/repo\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "github"
class TestDetectGitProviderHTTPS:
"""Test git provider detection for HTTPS remote URLs."""
def test_github_https_url(self, temp_repo_dir):
"""Test detection of GitHub HTTPS URL."""
mock_result = MagicMock(
returncode=0,
stdout="https://github.com/user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "github"
def test_gitlab_cloud_https_url(self, temp_repo_dir):
"""Test detection of GitLab cloud HTTPS URL."""
mock_result = MagicMock(
returncode=0,
stdout="https://gitlab.com/user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
def test_gitlab_self_hosted_https_url(self, temp_repo_dir):
"""Test detection of self-hosted GitLab HTTPS URL."""
mock_result = MagicMock(
returncode=0,
stdout="https://gitlab.enterprise.org/user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
def test_http_url(self, temp_repo_dir):
"""Test detection of HTTP URL (not HTTPS)."""
mock_result = MagicMock(
returncode=0,
stdout="http://github.com/user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "github"
def test_https_url_without_git_suffix(self, temp_repo_dir):
"""Test HTTPS URL without .git suffix."""
mock_result = MagicMock(
returncode=0,
stdout="https://gitlab.com/user/repo\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
def test_https_url_with_port(self, temp_repo_dir):
"""Test HTTPS URL with custom port."""
mock_result = MagicMock(
returncode=0,
stdout="https://gitlab.example.com:8443/user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "gitlab"
class TestDetectGitProviderEdgeCases:
"""Test edge cases and error handling."""
def test_no_remote_configured(self, temp_repo_dir):
"""Test repository with no remote configured."""
mock_result = MagicMock(
returncode=128,
stdout="",
stderr="fatal: No such remote 'origin'",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_empty_remote_url(self, temp_repo_dir):
"""Test repository with empty remote URL."""
mock_result = MagicMock(
returncode=0,
stdout=" \n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_malformed_ssh_url(self, temp_repo_dir):
"""Test malformed SSH URL."""
mock_result = MagicMock(
returncode=0,
stdout="malformed-url-without-colon\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_malformed_https_url(self, temp_repo_dir):
"""Test malformed HTTPS URL."""
mock_result = MagicMock(
returncode=0,
stdout="https://malformed\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_unknown_provider(self, temp_repo_dir):
"""Test unknown provider (Bitbucket)."""
mock_result = MagicMock(
returncode=0,
stdout="git@bitbucket.org:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_subprocess_exception(self, temp_repo_dir):
"""Test handling of subprocess exceptions."""
with patch("core.git_provider.run_git", side_effect=subprocess.SubprocessError("Failed")):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_generic_exception(self, temp_repo_dir):
"""Test handling of generic exceptions."""
with patch("core.git_provider.run_git", side_effect=Exception("Unexpected error")):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
def test_timeout_handling(self, temp_repo_dir):
"""Test handling of command timeout."""
mock_result = MagicMock(
returncode=-1,
stdout="",
stderr="Command timed out after 5 seconds",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(temp_repo_dir)
assert provider == "unknown"
class TestDetectGitProviderPathTypes:
"""Test that function works with both string and Path objects."""
def test_with_string_path(self):
"""Test detection with string path."""
mock_result = MagicMock(
returncode=0,
stdout="git@github.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider("/path/to/repo")
assert provider == "github"
def test_with_path_object(self):
"""Test detection with Path object."""
mock_result = MagicMock(
returncode=0,
stdout="git@gitlab.com:user/repo.git\n",
)
with patch("core.git_provider.run_git", return_value=mock_result):
provider = detect_git_provider(Path("/path/to/repo"))
assert provider == "gitlab"
class TestClassifyHostname:
"""Test the _classify_hostname helper function."""
def test_github_com(self):
"""Test classification of github.com."""
assert _classify_hostname("github.com") == "github"
def test_github_com_uppercase(self):
"""Test classification with uppercase (case-insensitive)."""
assert _classify_hostname("GITHUB.COM") == "github"
def test_github_com_mixed_case(self):
"""Test classification with mixed case."""
assert _classify_hostname("GitHub.com") == "github"
def test_github_keyword_in_hostname(self):
"""Test that 'github' at start of domain segment is detected."""
# Segments starting with 'github-' are detected (e.g., GitHub Enterprise)
assert _classify_hostname("github-enterprise.company.com") == "github"
assert _classify_hostname("github-internal.local") == "github"
# Embedded 'github' (not at segment start) returns unknown for security
assert _classify_hostname("attacker-github.com") == "unknown"
assert _classify_hostname("mygithub.dev") == "unknown"
def test_gitlab_com(self):
"""Test classification of gitlab.com."""
assert _classify_hostname("gitlab.com") == "gitlab"
def test_gitlab_self_hosted_subdomain(self):
"""Test classification of GitLab self-hosted with subdomain."""
assert _classify_hostname("gitlab.company.com") == "gitlab"
def test_gitlab_self_hosted_main_domain(self):
"""Test classification of GitLab self-hosted as main domain."""
assert _classify_hostname("gitlab.example.org") == "gitlab"
def test_gitlab_with_port(self):
"""Test classification of GitLab hostname with port."""
assert _classify_hostname("gitlab.company.com:8443") == "gitlab"
def test_gitlab_keyword_in_hostname(self):
"""Test that 'gitlab' at start of domain segment is detected."""
# Segments starting with 'gitlab-' are detected
assert _classify_hostname("gitlab-server.local") == "gitlab"
assert _classify_hostname("gitlab-internal.company.com") == "gitlab"
# Embedded 'gitlab' (not at segment start) returns unknown for security
assert _classify_hostname("mygitlab.dev") == "unknown"
assert _classify_hostname("code-gitlab.enterprise") == "unknown"
def test_bitbucket(self):
"""Test classification of Bitbucket (unknown)."""
assert _classify_hostname("bitbucket.org") == "unknown"
def test_custom_domain(self):
"""Test classification of custom domain without keywords."""
assert _classify_hostname("git.example.com") == "unknown"
def test_codeberg(self):
"""Test classification of Codeberg (unknown)."""
assert _classify_hostname("codeberg.org") == "unknown"
def test_sourceforge(self):
"""Test classification of SourceForge (unknown)."""
assert _classify_hostname("sourceforge.net") == "unknown"
def test_empty_hostname(self):
"""Test classification of empty hostname."""
assert _classify_hostname("") == "unknown"
def test_localhost(self):
"""Test classification of localhost."""
assert _classify_hostname("localhost") == "unknown"
def test_ip_address(self):
"""Test classification of IP address."""
assert _classify_hostname("192.168.1.100") == "unknown"
class TestGitCommandIntegration:
"""Test that run_git is called with correct parameters."""
def test_run_git_called_with_correct_args(self, temp_repo_dir):
"""Test that run_git is called with correct arguments."""
mock_result = MagicMock(returncode=0, stdout="git@github.com:user/repo.git\n")
with patch("core.git_provider.run_git", return_value=mock_result) as mock_run_git:
detect_git_provider(temp_repo_dir)
# Verify run_git was called with correct parameters
mock_run_git.assert_called_once_with(
["remote", "get-url", "origin"],
cwd=temp_repo_dir,
timeout=5,
)
def test_run_git_respects_timeout(self, temp_repo_dir):
"""Test that the 5-second timeout is used."""
mock_result = MagicMock(returncode=0, stdout="git@github.com:user/repo.git\n")
with patch("core.git_provider.run_git", return_value=mock_result) as mock_run_git:
detect_git_provider(temp_repo_dir)
# Verify timeout parameter
call_kwargs = mock_run_git.call_args[1]
assert call_kwargs["timeout"] == 5
if __name__ == "__main__":
pytest.main([__file__, "-v"])