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>
This commit is contained in:
bu5hm4nn
2026-01-30 16:08:17 +04:00
committed by GitHub
parent 02ed91c91c
commit cd423c65c7
21 changed files with 2866 additions and 24 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Git Provider Detection
======================
Utility to detect git hosting provider (GitHub, GitLab, or unknown) from git remote URLs.
Supports both SSH and HTTPS remote formats, and self-hosted GitLab instances.
"""
import re
from pathlib import Path
from .git_executable import run_git
def detect_git_provider(project_dir: str | Path, remote_name: str | None = None) -> str:
"""Detect the git hosting provider from the git remote URL.
Args:
project_dir: Path to the git repository
remote_name: Name of the remote to check (defaults to "origin")
Returns:
'github' if GitHub remote detected
'gitlab' if GitLab remote detected (cloud or self-hosted)
'unknown' if no remote or unsupported provider
Examples:
>>> detect_git_provider('/path/to/repo')
'github' # for git@github.com:user/repo.git
'gitlab' # for git@gitlab.com:user/repo.git
'gitlab' # for https://gitlab.company.com/user/repo.git
'unknown' # for no remote or other providers
"""
try:
# Get the remote URL (use specified remote or default to origin)
remote = remote_name if remote_name else "origin"
result = run_git(
["remote", "get-url", remote],
cwd=project_dir,
timeout=5,
)
# If command failed or no output, return unknown
if result.returncode != 0 or not result.stdout.strip():
return "unknown"
remote_url = result.stdout.strip()
# Parse ssh:// URL format: ssh://[user@]host[:port]/path
ssh_url_match = re.match(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/", remote_url)
if ssh_url_match:
hostname = ssh_url_match.group(1)
return _classify_hostname(hostname)
# Parse HTTPS/HTTP format: https://host/path or http://host/path
# Must check before scp-like format to avoid matching "https" as hostname
https_match = re.match(r"^https?://([^/]+)/", remote_url)
if https_match:
hostname = https_match.group(1)
return _classify_hostname(hostname)
# Parse scp-like format: [user@]host:path (any username, not just 'git')
# This handles git@github.com:user/repo.git and similar formats
scp_match = re.match(r"^(?:[^@]+@)?([^:]+):", remote_url)
if scp_match:
hostname = scp_match.group(1)
# Exclude paths that look like Windows drives (e.g., C:)
if len(hostname) > 1:
return _classify_hostname(hostname)
# Unrecognized URL format
return "unknown"
except Exception:
# Any error (subprocess issues, etc.) -> unknown
return "unknown"
def _classify_hostname(hostname: str) -> str:
"""Classify a hostname as github, gitlab, or unknown.
Args:
hostname: The git remote hostname (e.g., 'github.com', 'gitlab.example.com')
Returns:
'github', 'gitlab', or 'unknown'
"""
hostname_lower = hostname.lower()
# Check for GitHub (cloud and self-hosted/enterprise)
# Match github.com, *.github.com, or domains where a segment is or starts with 'github'
hostname_parts = hostname_lower.split(".")
if (
hostname_lower == "github.com"
or hostname_lower.endswith(".github.com")
or any(
part == "github" or part.startswith("github-") for part in hostname_parts
)
):
return "github"
# Check for GitLab (cloud and self-hosted)
# Match gitlab.com, *.gitlab.com, or domains where a segment is or starts with 'gitlab'
if (
hostname_lower == "gitlab.com"
or hostname_lower.endswith(".gitlab.com")
or any(
part == "gitlab" or part.startswith("gitlab-") for part in hostname_parts
)
):
return "gitlab"
# Unknown provider
return "unknown"
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
GitLab CLI Executable Finder
============================
Utility to find the glab (GitLab CLI) executable, with platform-specific fallbacks.
"""
import os
import shutil
import subprocess
_cached_glab_path: str | None = None
def invalidate_glab_cache() -> None:
"""Invalidate the cached glab executable path.
Useful when glab may have been uninstalled, updated, or when
GITLAB_CLI_PATH environment variable has changed.
"""
global _cached_glab_path
_cached_glab_path = None
def _verify_glab_executable(path: str) -> bool:
"""Verify that a path is a valid glab executable by checking version.
Args:
path: Path to the potential glab executable
Returns:
True if the path points to a valid glab executable, False otherwise
"""
try:
result = subprocess.run(
[path, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def _run_where_command() -> str | None:
"""Run Windows 'where glab' command to find glab executable.
Returns:
First path found, or None if command failed
"""
try:
result = subprocess.run(
"where glab",
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
shell=True, # Required: 'where' command must be executed through shell on Windows
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
if (
found_path
and os.path.isfile(found_path)
and _verify_glab_executable(found_path)
):
return found_path
except (subprocess.TimeoutExpired, OSError):
# 'where' command failed or timed out - fall through to return None
pass
return None
def get_glab_executable() -> str | None:
"""Find the glab executable, with platform-specific fallbacks.
Returns the path to glab executable, or None if not found.
Priority order:
1. GITLAB_CLI_PATH env var (user-configured path from frontend)
2. shutil.which (if glab is in PATH)
3. Homebrew paths on macOS
4. Windows Program Files paths
5. Windows 'where' command
Caches the result after first successful find. Use invalidate_glab_cache()
to force re-detection (e.g., after glab installation/uninstallation).
"""
global _cached_glab_path
# Return cached result if available AND still exists
if _cached_glab_path is not None and os.path.isfile(_cached_glab_path):
return _cached_glab_path
_cached_glab_path = _find_glab_executable()
return _cached_glab_path
def _find_glab_executable() -> str | None:
"""Internal function to find glab executable."""
# 1. Check GITLAB_CLI_PATH env var (set by Electron frontend)
env_path = os.environ.get("GITLAB_CLI_PATH")
if env_path and os.path.isfile(env_path) and _verify_glab_executable(env_path):
return env_path
# 2. Try shutil.which (works if glab is in PATH)
glab_path = shutil.which("glab")
if glab_path and _verify_glab_executable(glab_path):
return glab_path
# 3. macOS-specific: check Homebrew paths
if os.name != "nt": # Unix-like systems (macOS, Linux)
homebrew_paths = [
"/opt/homebrew/bin/glab", # Apple Silicon
"/usr/local/bin/glab", # Intel Mac
"/home/linuxbrew/.linuxbrew/bin/glab", # Linux Homebrew
]
for path in homebrew_paths:
if os.path.isfile(path) and _verify_glab_executable(path):
return path
# 4. Windows-specific: check Program Files paths
# glab uses Inno Setup with DefaultDirName={autopf}\glab
if os.name == "nt":
windows_paths = [
os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"),
os.path.expandvars(r"%PROGRAMFILES(X86)%\glab\glab.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"),
]
for path in windows_paths:
if os.path.isfile(path) and _verify_glab_executable(path):
return path
# 5. Try 'where' command with shell=True (more reliable on Windows)
return _run_where_command()
return None
def run_glab(
args: list[str],
cwd: str | None = None,
timeout: int = 60,
input_data: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a glab command with proper executable finding.
Args:
args: glab command arguments (without 'glab' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
Returns:
CompletedProcess with command results.
"""
glab = get_glab_executable()
if not glab:
return subprocess.CompletedProcess(
args=["glab"] + args,
returncode=-1,
stdout="",
stderr="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
)
try:
return subprocess.run(
[glab] + args,
cwd=cwd,
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=[glab] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=[glab] + args,
returncode=-1,
stdout="",
stderr="GitLab CLI (glab) executable not found. Install from https://gitlab.com/gitlab-org/cli",
)
+255 -16
View File
@@ -15,6 +15,7 @@ This allows:
""" """
import asyncio import asyncio
import json
import logging import logging
import os import os
import re import re
@@ -29,6 +30,8 @@ from typing import TypedDict, TypeVar
from core.gh_executable import get_gh_executable, invalidate_gh_cache from core.gh_executable import get_gh_executable, invalidate_gh_cache
from core.git_executable import get_git_executable, get_isolated_git_env, run_git from core.git_executable import get_git_executable, get_isolated_git_env, run_git
from core.git_provider import detect_git_provider
from core.glab_executable import get_glab_executable, invalidate_glab_cache
from core.model_config import get_utility_model_config from core.model_config import get_utility_model_config
from debug import debug_warning from debug import debug_warning
@@ -140,6 +143,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
pushed: bool pushed: bool
remote: str remote: str
branch: str branch: str
provider: str # 'github', 'gitlab', or 'unknown'
pr_url: str | None # None when PR was created but URL couldn't be extracted pr_url: str | None # None when PR was created but URL couldn't be extracted
already_exists: bool already_exists: bool
error: str error: str
@@ -179,8 +183,8 @@ class WorktreeManager:
# Timeout constants for subprocess operations # Timeout constants for subprocess operations
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations) GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
def __init__(self, project_dir: Path, base_branch: str | None = None): def __init__(self, project_dir: Path, base_branch: str | None = None):
self.project_dir = project_dir self.project_dir = project_dir
@@ -1254,7 +1258,7 @@ class WorktreeManager:
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
timeout=self.GH_CLI_TIMEOUT, timeout=self.CLI_TIMEOUT,
env=get_isolated_git_env(), env=get_isolated_git_env(),
) )
@@ -1330,7 +1334,165 @@ class WorktreeManager:
invalidate_gh_cache() invalidate_gh_cache()
return PullRequestResult( return PullRequestResult(
success=False, success=False,
error="gh CLI not found. Install from https://cli.github.com/", error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
)
def create_merge_request(
self,
spec_name: str,
target_branch: str | None = None,
title: str | None = None,
draft: bool = False,
) -> PullRequestResult:
"""
Create a GitLab merge request for a spec's branch using glab CLI with retry logic.
Args:
spec_name: The spec folder name
target_branch: Target branch for MR (defaults to base_branch)
title: MR title (defaults to spec name)
draft: Whether to create as draft MR
Returns:
PullRequestResult with keys:
- success: bool
- pr_url: str (if created)
- already_exists: bool (if MR already exists)
- error: str (if failed)
"""
info = self.get_worktree_info(spec_name)
if not info:
return PullRequestResult(
success=False,
error=f"No worktree found for spec: {spec_name}",
)
target = target_branch or self.base_branch
mr_title = title or f"auto-claude: {spec_name}"
# Get MR body from spec.md if available
mr_body = self._extract_spec_summary(spec_name)
# Find glab executable before attempting MR creation
glab_executable = get_glab_executable()
if not glab_executable:
return PullRequestResult(
success=False,
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
)
# Build glab mr create command
glab_args = [
glab_executable,
"mr",
"create",
"--target-branch",
target,
"--source-branch",
info.branch,
"--title",
mr_title,
"--description",
mr_body,
]
if draft:
glab_args.append("--draft")
def is_mr_retryable(stderr: str) -> bool:
"""Check if MR creation error is retryable (network or HTTP 5xx)."""
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
stderr
)
def do_create_mr() -> tuple[bool, PullRequestResult | None, str]:
"""Execute MR creation for retry wrapper."""
try:
result = subprocess.run(
glab_args,
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_TIMEOUT,
env=get_isolated_git_env(),
)
# Check for "already exists" case (success, no retry needed)
if result.returncode != 0 and "already exists" in result.stderr.lower():
existing_url = self._get_existing_mr_url(spec_name, target)
result_dict = PullRequestResult(
success=True,
pr_url=existing_url,
already_exists=True,
)
if existing_url is None:
result_dict["message"] = (
"MR already exists but URL could not be retrieved"
)
return (True, result_dict, "")
if result.returncode == 0:
# Extract MR URL from output
mr_url: str | None = result.stdout.strip()
if not mr_url.startswith("http"):
# Try to find URL in output
# GitLab URL pattern: matches any HTTPS URL with /merge_requests/<number> or /-/merge_requests/<number> path
match = re.search(
r"https://[^\s]+(?:/merge_requests/|/-/merge_requests/)\d+",
result.stdout,
)
if match:
mr_url = match.group(0)
else:
# Invalid output - no valid URL found
mr_url = None
return (
True,
PullRequestResult(
success=True,
pr_url=mr_url,
already_exists=False,
),
"",
)
return (False, None, result.stderr)
except FileNotFoundError:
# glab CLI not installed - not retryable, raise to exit retry loop
raise
max_retries = 3
try:
result, last_error = _with_retry(
operation=do_create_mr,
max_retries=max_retries,
is_retryable=is_mr_retryable,
)
if result:
return result
# Handle timeout error message
if last_error == "Operation timed out":
return PullRequestResult(
success=False,
error=f"MR creation timed out after {max_retries} attempts.",
)
return PullRequestResult(
success=False,
error=f"Failed to create MR: {last_error}",
)
except FileNotFoundError:
# Cached glab path became invalid - clear cache so next call re-discovers
invalidate_glab_cache()
return PullRequestResult(
success=False,
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
) )
def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]: def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]:
@@ -1568,7 +1730,7 @@ class WorktreeManager:
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
timeout=self.GH_QUERY_TIMEOUT, timeout=self.CLI_QUERY_TIMEOUT,
env=get_isolated_git_env(), env=get_isolated_git_env(),
) )
if result.returncode == 0: if result.returncode == 0:
@@ -1587,6 +1749,57 @@ class WorktreeManager:
return None return None
def _get_existing_mr_url(self, spec_name: str, target_branch: str) -> str | None:
"""Get the URL of an existing MR for this branch."""
info = self.get_worktree_info(spec_name)
if not info:
return None
glab_executable = get_glab_executable()
if not glab_executable:
# glab CLI not found - return None and let caller handle it
return None
try:
result = subprocess.run(
[
glab_executable,
"mr",
"view",
info.branch,
"--output",
"json",
],
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_QUERY_TIMEOUT,
env=get_isolated_git_env(),
)
if result.returncode == 0 and result.stdout.strip():
# Parse JSON output to extract web_url (glab uses snake_case)
try:
data = json.loads(result.stdout)
return data.get("web_url")
except json.JSONDecodeError:
# If JSON parsing fails, return None
pass
except (
subprocess.TimeoutExpired,
FileNotFoundError,
subprocess.SubprocessError,
) as e:
# Silently ignore errors when fetching existing MR URL - this is a best-effort
# lookup that may fail due to network issues, missing glab CLI, or auth problems.
# Returning None allows the caller to handle missing URLs gracefully.
if isinstance(e, FileNotFoundError):
invalidate_glab_cache()
debug_warning("worktree", f"Could not get existing MR URL: {e}")
return None
def push_and_create_pr( def push_and_create_pr(
self, self,
spec_name: str, spec_name: str,
@@ -1596,13 +1809,14 @@ class WorktreeManager:
force_push: bool = False, force_push: bool = False,
) -> PushAndCreatePRResult: ) -> PushAndCreatePRResult:
""" """
Push branch and create a pull request in one operation. Push branch and create a pull request/merge request in one operation.
Automatically detects git provider (GitHub or GitLab) and routes to the appropriate CLI.
Args: Args:
spec_name: The spec folder name spec_name: The spec folder name
target_branch: Target branch for PR (defaults to base_branch) target_branch: Target branch for PR/MR (defaults to base_branch)
title: PR title (defaults to spec name) title: PR/MR title (defaults to spec name)
draft: Whether to create as draft PR draft: Whether to create as draft PR/MR
force_push: Whether to force push the branch force_push: Whether to force push the branch
Returns: Returns:
@@ -1610,7 +1824,8 @@ class WorktreeManager:
- success: bool - success: bool
- pr_url: str (if created) - pr_url: str (if created)
- pushed: bool (if push succeeded) - pushed: bool (if push succeeded)
- already_exists: bool (if PR already exists) - provider: str ('github', 'gitlab', or 'unknown')
- already_exists: bool (if PR/MR already exists)
- error: str (if failed) - error: str (if failed)
""" """
# Step 1: Push the branch # Step 1: Push the branch
@@ -1624,20 +1839,44 @@ class WorktreeManager:
error=push_result.get("error", "Push failed"), error=push_result.get("error", "Push failed"),
) )
# Step 2: Create the PR # Step 2: Detect git provider (use the remote that was pushed to)
pr_result = self.create_pull_request( provider = detect_git_provider(
spec_name=spec_name, self.project_dir, remote_name=push_result.get("remote")
target_branch=target_branch,
title=title,
draft=draft,
) )
# Step 3: Create the PR/MR based on provider
if provider == "github":
pr_result = self.create_pull_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
elif provider == "gitlab":
pr_result = self.create_merge_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
else:
# Unknown provider
return PushAndCreatePRResult(
success=False,
pushed=True,
remote=push_result.get("remote"),
branch=push_result.get("branch"),
provider=provider,
error="Unable to determine git hosting provider. Supported: GitHub, GitLab.",
)
# Combine results # Combine results
return PushAndCreatePRResult( return PushAndCreatePRResult(
success=pr_result.get("success", False), success=pr_result.get("success", False),
pushed=True, pushed=True,
remote=push_result.get("remote"), remote=push_result.get("remote"),
branch=push_result.get("branch"), branch=push_result.get("branch"),
provider=provider,
pr_url=pr_result.get("pr_url"), pr_url=pr_result.get("pr_url"),
already_exists=pr_result.get("already_exists", False), already_exists=pr_result.get("already_exists", False),
error=pr_result.get("error"), error=pr_result.get("error"),
@@ -29,7 +29,7 @@ import { killProcessGracefully, isWindows } from '../platform';
/** /**
* Type for supported CLI tools * Type for supported CLI tools
*/ */
type CliTool = 'claude' | 'gh'; type CliTool = 'claude' | 'gh' | 'glab';
/** /**
* Mapping of CLI tools to their environment variable names * Mapping of CLI tools to their environment variable names
@@ -37,7 +37,8 @@ type CliTool = 'claude' | 'gh';
*/ */
const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = { const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = {
claude: 'CLAUDE_CLI_PATH', claude: 'CLAUDE_CLI_PATH',
gh: 'GITHUB_CLI_PATH' gh: 'GITHUB_CLI_PATH',
glab: 'GITLAB_CLI_PATH'
} as const; } as const;
@@ -201,12 +202,14 @@ export class AgentProcessManager {
// Detect and pass CLI tool paths to Python backend // Detect and pass CLI tool paths to Python backend
const claudeCliEnv = this.detectAndSetCliPath('claude'); const claudeCliEnv = this.detectAndSetCliPath('claude');
const ghCliEnv = this.detectAndSetCliPath('gh'); const ghCliEnv = this.detectAndSetCliPath('gh');
const glabCliEnv = this.detectAndSetCliPath('glab');
return { return {
...augmentedEnv, ...augmentedEnv,
...gitBashEnv, ...gitBashEnv,
...claudeCliEnv, ...claudeCliEnv,
...ghCliEnv, ...ghCliEnv,
...glabCliEnv,
...extraEnv, ...extraEnv,
...profileEnv, ...profileEnv,
PYTHONUNBUFFERED: '1', PYTHONUNBUFFERED: '1',
+266 -1
View File
@@ -46,6 +46,7 @@ import {
getWindowsExecutablePaths, getWindowsExecutablePaths,
getWindowsExecutablePathsAsync, getWindowsExecutablePathsAsync,
WINDOWS_GIT_PATHS, WINDOWS_GIT_PATHS,
WINDOWS_GLAB_PATHS,
findWindowsExecutableViaWhere, findWindowsExecutableViaWhere,
findWindowsExecutableViaWhereAsync, findWindowsExecutableViaWhereAsync,
isSecurePath, isSecurePath,
@@ -54,7 +55,7 @@ import {
/** /**
* Supported CLI tools managed by this system * Supported CLI tools managed by this system
*/ */
export type CLITool = 'python' | 'git' | 'gh' | 'claude'; export type CLITool = 'python' | 'git' | 'gh' | 'glab' | 'claude';
/** /**
* User configuration for CLI tool paths * User configuration for CLI tool paths
@@ -64,6 +65,7 @@ export interface ToolConfig {
pythonPath?: string; pythonPath?: string;
gitPath?: string; gitPath?: string;
githubCLIPath?: string; githubCLIPath?: string;
gitlabCLIPath?: string;
claudePath?: string; claudePath?: string;
} }
@@ -370,6 +372,8 @@ class CLIToolManager {
return this.detectGit(); return this.detectGit();
case 'gh': case 'gh':
return this.detectGitHubCLI(); return this.detectGitHubCLI();
case 'glab':
return this.detectGitLabCLI();
case 'claude': case 'claude':
return this.detectClaude(); return this.detectClaude();
default: default:
@@ -719,6 +723,105 @@ class CLIToolManager {
}; };
} }
/**
* Detect GitLab CLI with multi-level priority
*
* Priority order:
* 1. User configuration (if valid for current platform)
* 2. Homebrew glab (macOS)
* 3. System PATH
* 4. Windows Program Files
*
* @returns Detection result for GitLab CLI
*/
private detectGitLabCLI(): ToolDetectionResult {
// 1. User configuration
if (this.userConfig.gitlabCLIPath) {
// Check if path is from wrong platform (e.g., Windows path on macOS)
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
console.warn(
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
);
} else {
const validation = this.validateGitLabCLI(this.userConfig.gitlabCLIPath);
if (validation.valid) {
return {
found: true,
path: this.userConfig.gitlabCLIPath,
version: validation.version,
source: 'user-config',
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
};
}
console.warn(
`[GitLab CLI] User-configured path invalid: ${validation.message}`
);
}
}
// 2. Homebrew (macOS)
if (isMacOS()) {
const homebrewPaths = [
'/opt/homebrew/bin/glab', // Apple Silicon
'/usr/local/bin/glab', // Intel Mac
];
for (const glabPath of homebrewPaths) {
if (existsSync(glabPath)) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'homebrew',
message: `Using Homebrew GitLab CLI: ${glabPath}`,
};
}
}
}
}
// 3. System PATH (augmented)
const glabPath = findExecutable('glab');
if (glabPath) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using system GitLab CLI: ${glabPath}`,
};
}
}
// 4. Windows Program Files
if (isWindows()) {
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
for (const glabPath of windowsPaths) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using Windows GitLab CLI: ${glabPath}`,
};
}
}
}
// 5. Not found
return {
found: false,
source: 'fallback',
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
};
}
/** /**
* Detect Claude CLI with multi-level priority * Detect Claude CLI with multi-level priority
* *
@@ -846,6 +949,7 @@ class CLIToolManager {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 5000, timeout: 5000,
windowsHide: true, windowsHide: true,
env: getAugmentedEnv(),
}).trim(); }).trim();
const match = version.match(/Python (\d+\.\d+\.\d+)/); const match = version.match(/Python (\d+\.\d+\.\d+)/);
@@ -896,6 +1000,7 @@ class CLIToolManager {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 5000, timeout: 5000,
windowsHide: true, windowsHide: true,
env: getAugmentedEnv(),
}).trim(); }).trim();
const match = version.match(/git version (\d+\.\d+\.\d+)/); const match = version.match(/git version (\d+\.\d+\.\d+)/);
@@ -926,6 +1031,7 @@ class CLIToolManager {
encoding: 'utf-8', encoding: 'utf-8',
timeout: 5000, timeout: 5000,
windowsHide: true, windowsHide: true,
env: getAugmentedEnv(),
}).trim(); }).trim();
const match = version.match(/gh version (\d+\.\d+\.\d+)/); const match = version.match(/gh version (\d+\.\d+\.\d+)/);
@@ -944,6 +1050,38 @@ class CLIToolManager {
} }
} }
/**
* Validate GitLab CLI availability and version
*
* @param glabCmd - The GitLab CLI command to validate
* @returns Validation result with version information
*/
private validateGitLabCLI(glabCmd: string): ToolValidation {
try {
const version = execFileSync(glabCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
const versionStr = match ? match[1] : version.split('\n')[0];
return {
valid: true,
version: versionStr,
message: `GitLab CLI ${versionStr} is available`,
};
} catch (error) {
return {
valid: false,
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** /**
* Validate Claude CLI availability and version * Validate Claude CLI availability and version
* *
@@ -1097,6 +1235,8 @@ class CLIToolManager {
return this.detectGitAsync(); return this.detectGitAsync();
case 'gh': case 'gh':
return this.detectGitHubCLIAsync(); return this.detectGitHubCLIAsync();
case 'glab':
return this.detectGitLabCLIAsync();
default: default:
return { return {
found: false, found: false,
@@ -1298,6 +1438,39 @@ class CLIToolManager {
} }
} }
/**
* Validate GitLab CLI availability and version asynchronously (non-blocking)
*
* @param glabCmd - The GitLab CLI command to validate
* @returns Promise resolving to validation result
*/
private async validateGitLabCLIAsync(glabCmd: string): Promise<ToolValidation> {
try {
const { stdout } = await execFileAsync(glabCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: await getAugmentedEnvAsync(),
});
const version = stdout.trim();
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
const versionStr = match ? match[1] : version.split('\n')[0];
return {
valid: true,
version: versionStr,
message: `GitLab CLI ${versionStr} is available`,
};
} catch (error) {
return {
valid: false,
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** /**
* Detect Claude CLI asynchronously (non-blocking) * Detect Claude CLI asynchronously (non-blocking)
* *
@@ -1724,6 +1897,98 @@ class CLIToolManager {
}; };
} }
/**
* Detect GitLab CLI asynchronously (non-blocking)
*
* Same detection logic as detectGitLabCLI but uses async validation.
*
* @returns Promise resolving to detection result
*/
private async detectGitLabCLIAsync(): Promise<ToolDetectionResult> {
// 1. User configuration
if (this.userConfig.gitlabCLIPath) {
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
console.warn(
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
);
} else {
const validation = await this.validateGitLabCLIAsync(this.userConfig.gitlabCLIPath);
if (validation.valid) {
return {
found: true,
path: this.userConfig.gitlabCLIPath,
version: validation.version,
source: 'user-config',
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
};
}
console.warn(`[GitLab CLI] User-configured path invalid: ${validation.message}`);
}
}
// 2. Homebrew (macOS)
if (isMacOS()) {
const homebrewPaths = [
'/opt/homebrew/bin/glab',
'/usr/local/bin/glab',
];
for (const glabPath of homebrewPaths) {
if (await existsAsync(glabPath)) {
const validation = await this.validateGitLabCLIAsync(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'homebrew',
message: `Using Homebrew GitLab CLI: ${glabPath}`,
};
}
}
}
}
// 3. System PATH (augmented)
const glabPath = await findExecutableAsync('glab');
if (glabPath) {
const validation = await this.validateGitLabCLIAsync(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using system GitLab CLI: ${glabPath}`,
};
}
}
// 4. Windows Program Files
if (isWindows()) {
const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
for (const winGlabPath of windowsPaths) {
const validation = await this.validateGitLabCLIAsync(winGlabPath);
if (validation.valid) {
return {
found: true,
path: winGlabPath,
version: validation.version,
source: 'system-path',
message: `Using Windows GitLab CLI: ${winGlabPath}`,
};
}
}
}
// 5. Not found
return {
found: false,
source: 'fallback',
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
};
}
/** /**
* Get bundled Python path for packaged apps * Get bundled Python path for packaged apps
* *
@@ -139,7 +139,7 @@ export function registerSettingsHandlers(
// Fixes issue where Windows paths persisted on macOS (and vice versa) // Fixes issue where Windows paths persisted on macOS (and vice versa)
// when settings were synced/transferred between platforms // when settings were synced/transferred between platforms
// See: https://github.com/AndyMik90/Auto-Claude/issues/XXX // See: https://github.com/AndyMik90/Auto-Claude/issues/XXX
const pathFields = ['pythonPath', 'gitPath', 'githubCLIPath', 'claudePath', 'autoBuildPath'] as const; const pathFields = ['pythonPath', 'gitPath', 'githubCLIPath', 'gitlabCLIPath', 'claudePath', 'autoBuildPath'] as const;
for (const field of pathFields) { for (const field of pathFields) {
const pathValue = settings[field]; const pathValue = settings[field];
if (pathValue && isPathFromWrongPlatform(pathValue)) { if (pathValue && isPathFromWrongPlatform(pathValue)) {
@@ -174,6 +174,7 @@ export function registerSettingsHandlers(
pythonPath: settings.pythonPath, pythonPath: settings.pythonPath,
gitPath: settings.gitPath, gitPath: settings.gitPath,
githubCLIPath: settings.githubCLIPath, githubCLIPath: settings.githubCLIPath,
gitlabCLIPath: settings.gitlabCLIPath,
claudePath: settings.claudePath, claudePath: settings.claudePath,
}); });
@@ -215,12 +216,14 @@ export function registerSettingsHandlers(
settings.pythonPath !== undefined || settings.pythonPath !== undefined ||
settings.gitPath !== undefined || settings.gitPath !== undefined ||
settings.githubCLIPath !== undefined || settings.githubCLIPath !== undefined ||
settings.gitlabCLIPath !== undefined ||
settings.claudePath !== undefined settings.claudePath !== undefined
) { ) {
configureTools({ configureTools({
pythonPath: newSettings.pythonPath, pythonPath: newSettings.pythonPath,
gitPath: newSettings.gitPath, gitPath: newSettings.gitPath,
githubCLIPath: newSettings.githubCLIPath, githubCLIPath: newSettings.githubCLIPath,
gitlabCLIPath: newSettings.gitlabCLIPath,
claudePath: newSettings.claudePath, claudePath: newSettings.claudePath,
}); });
@@ -260,6 +263,7 @@ export function registerSettingsHandlers(
python: ReturnType<typeof getToolInfo>; python: ReturnType<typeof getToolInfo>;
git: ReturnType<typeof getToolInfo>; git: ReturnType<typeof getToolInfo>;
gh: ReturnType<typeof getToolInfo>; gh: ReturnType<typeof getToolInfo>;
glab: ReturnType<typeof getToolInfo>;
claude: ReturnType<typeof getToolInfo>; claude: ReturnType<typeof getToolInfo>;
}>> => { }>> => {
try { try {
@@ -269,6 +273,7 @@ export function registerSettingsHandlers(
python: getToolInfo('python'), python: getToolInfo('python'),
git: getToolInfo('git'), git: getToolInfo('git'),
gh: getToolInfo('gh'), gh: getToolInfo('gh'),
glab: getToolInfo('glab'),
claude: getToolInfo('claude'), claude: getToolInfo('claude'),
}, },
}; };
@@ -37,6 +37,17 @@ export const WINDOWS_GIT_PATHS: WindowsToolPaths = {
], ],
}; };
export const WINDOWS_GLAB_PATHS: WindowsToolPaths = {
toolName: 'GitLab CLI',
executable: 'glab.exe',
patterns: [
// Official Inno Setup installer path (DefaultDirName={autopf}\glab)
'%PROGRAMFILES%\\glab',
'%PROGRAMFILES(X86)%\\glab',
'%LOCALAPPDATA%\\Programs\\glab',
],
};
export function isSecurePath(pathStr: string): boolean { export function isSecurePath(pathStr: string): boolean {
const dangerousPatterns = [ const dangerousPatterns = [
/[;&|`${}[\]<>!"^]/, // Shell metacharacters (parentheses removed - safe when quoted) /[;&|`${}[\]<>!"^]/, // Shell metacharacters (parentheses removed - safe when quoted)
@@ -42,7 +42,7 @@ export function GitLabMergeRequests({ projectId, onOpenSettings }: GitLabMergeRe
mergeMR, mergeMR,
assignMR, assignMR,
approveMR, approveMR,
} = useGitLabMRs(projectId); } = useGitLabMRs(projectId, { stateFilter });
const handleCreateSuccess = async (mrIid: number) => { const handleCreateSuccess = async (mrIid: number) => {
// Refresh the list and select the newly created MR // Refresh the list and select the newly created MR
@@ -15,6 +15,11 @@ import {
export type { GitLabMergeRequest, GitLabMRReviewResult, GitLabMRReviewProgress }; export type { GitLabMergeRequest, GitLabMRReviewResult, GitLabMRReviewProgress };
export type { GitLabMRReviewFinding } from '../../../../shared/types'; export type { GitLabMRReviewFinding } from '../../../../shared/types';
interface UseGitLabMRsOptions {
/** Filter MRs by state */
stateFilter?: 'opened' | 'closed' | 'merged' | 'all';
}
interface UseGitLabMRsResult { interface UseGitLabMRsResult {
mergeRequests: GitLabMergeRequest[]; mergeRequests: GitLabMergeRequest[];
isLoading: boolean; isLoading: boolean;
@@ -47,14 +52,14 @@ interface UseGitLabMRsResult {
} | null; } | null;
} }
export function useGitLabMRs(projectId?: string): UseGitLabMRsResult { export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = {}): UseGitLabMRsResult {
const { stateFilter = 'opened' } = options;
const [mergeRequests, setMergeRequests] = useState<GitLabMergeRequest[]>([]); const [mergeRequests, setMergeRequests] = useState<GitLabMergeRequest[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedMRIid, setSelectedMRIid] = useState<number | null>(null); const [selectedMRIid, setSelectedMRIid] = useState<number | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [projectPath, setProjectPath] = useState<string | null>(null); const [projectPath, setProjectPath] = useState<string | null>(null);
const [stateFilter] = useState<'opened' | 'closed' | 'merged' | 'all'>('opened');
// Get MR review state from the global store // Get MR review state from the global store
const mrReviews = useMRReviewStore((state) => state.mrReviews); const mrReviews = useMRReviewStore((state) => state.mrReviews);
@@ -96,6 +96,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
python: ToolDetectionResult; python: ToolDetectionResult;
git: ToolDetectionResult; git: ToolDetectionResult;
gh: ToolDetectionResult; gh: ToolDetectionResult;
glab: ToolDetectionResult;
claude: ToolDetectionResult; claude: ToolDetectionResult;
} | null>(null); } | null>(null);
const [isLoadingTools, setIsLoadingTools] = useState(false); const [isLoadingTools, setIsLoadingTools] = useState(false);
@@ -106,7 +107,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
setIsLoadingTools(true); setIsLoadingTools(true);
window.electronAPI window.electronAPI
.getCliToolsInfo() .getCliToolsInfo()
.then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; claude: ToolDetectionResult } }) => { .then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; glab: ToolDetectionResult; claude: ToolDetectionResult } }) => {
if (result.success && result.data) { if (result.success && result.data) {
setToolsInfo(result.data); setToolsInfo(result.data);
} }
@@ -305,6 +306,24 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
/> />
)} )}
</div> </div>
<div className="space-y-3">
<Label htmlFor="gitlabCLIPath" className="text-sm font-medium text-foreground">{t('general.gitlabCLIPath')}</Label>
<p className="text-sm text-muted-foreground">{t('general.gitlabCLIPathDescription')}</p>
<Input
id="gitlabCLIPath"
placeholder={t('general.gitlabCLIPathPlaceholder')}
className="w-full max-w-lg"
value={settings.gitlabCLIPath || ''}
onChange={(e) => onSettingsChange({ ...settings, gitlabCLIPath: e.target.value })}
/>
{!settings.gitlabCLIPath && (
<ToolDetectionDisplay
info={toolsInfo?.glab || null}
isLoading={isLoadingTools}
t={t}
/>
)}
</div>
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="claudePath" className="text-sm font-medium text-foreground">{t('general.claudePath')}</Label> <Label htmlFor="claudePath" className="text-sm font-medium text-foreground">{t('general.claudePath')}</Label>
<p className="text-sm text-muted-foreground">{t('general.claudePathDescription')}</p> <p className="text-sm text-muted-foreground">{t('general.claudePathDescription')}</p>
@@ -29,6 +29,7 @@ export const settingsMock = {
python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
glab: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
claude: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' } claude: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }
} }
}), }),
@@ -31,6 +31,7 @@ export const DEFAULT_APP_SETTINGS = {
pythonPath: undefined as string | undefined, pythonPath: undefined as string | undefined,
gitPath: undefined as string | undefined, gitPath: undefined as string | undefined,
githubCLIPath: undefined as string | undefined, githubCLIPath: undefined as string | undefined,
gitlabCLIPath: undefined as string | undefined,
autoBuildPath: undefined as string | undefined, autoBuildPath: undefined as string | undefined,
autoUpdateAutoBuild: true, autoUpdateAutoBuild: true,
autoNameTerminals: true, autoNameTerminals: true,
@@ -208,6 +208,9 @@
"githubCLIPath": "GitHub CLI Path", "githubCLIPath": "GitHub CLI Path",
"githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)", "githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)",
"githubCLIPathPlaceholder": "gh (default)", "githubCLIPathPlaceholder": "gh (default)",
"gitlabCLIPath": "GitLab CLI Path",
"gitlabCLIPathDescription": "Path to GitLab CLI (glab) executable (leave empty for auto-detection)",
"gitlabCLIPathPlaceholder": "glab (default)",
"claudePath": "Claude CLI Path", "claudePath": "Claude CLI Path",
"claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)", "claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)",
"claudePathPlaceholder": "claude (default)", "claudePathPlaceholder": "claude (default)",
@@ -208,6 +208,9 @@
"githubCLIPath": "Chemin GitHub CLI", "githubCLIPath": "Chemin GitHub CLI",
"githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)", "githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)",
"githubCLIPathPlaceholder": "gh (par défaut)", "githubCLIPathPlaceholder": "gh (par défaut)",
"gitlabCLIPath": "Chemin GitLab CLI",
"gitlabCLIPathDescription": "Chemin vers l'exécutable GitLab CLI (glab) (laisser vide pour détection automatique)",
"gitlabCLIPathPlaceholder": "glab (par défaut)",
"claudePath": "Chemin Claude CLI", "claudePath": "Chemin Claude CLI",
"claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)", "claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)",
"claudePathPlaceholder": "claude (par défaut)", "claudePathPlaceholder": "claude (par défaut)",
+1
View File
@@ -355,6 +355,7 @@ export interface ElectronAPI {
python: import('./cli').ToolDetectionResult; python: import('./cli').ToolDetectionResult;
git: import('./cli').ToolDetectionResult; git: import('./cli').ToolDetectionResult;
gh: import('./cli').ToolDetectionResult; gh: import('./cli').ToolDetectionResult;
glab: import('./cli').ToolDetectionResult;
claude: import('./cli').ToolDetectionResult; claude: import('./cli').ToolDetectionResult;
}>>; }>>;
/** Check if Claude Code onboarding is complete (reads ~/.claude.json) */ /** Check if Claude Code onboarding is complete (reads ~/.claude.json) */
@@ -224,6 +224,7 @@ export interface AppSettings {
pythonPath?: string; pythonPath?: string;
gitPath?: string; gitPath?: string;
githubCLIPath?: string; githubCLIPath?: string;
gitlabCLIPath?: string;
claudePath?: string; claudePath?: string;
autoBuildPath?: string; autoBuildPath?: string;
autoUpdateAutoBuild: boolean; autoUpdateAutoBuild: boolean;
+107
View File
@@ -157,6 +157,8 @@ def pytest_runtest_setup(item):
pass pass
# ============================================================================= # =============================================================================
# DIRECTORY FIXTURES # DIRECTORY FIXTURES
# ============================================================================= # =============================================================================
@@ -1141,3 +1143,108 @@ def temp_project(temp_git_repo: Path):
) )
return temp_git_repo return temp_git_repo
# =============================================================================
# WORKTREE MANAGER FIXTURES - For GitLab/GitHub integration tests
# =============================================================================
@pytest.fixture
def temp_project_dir(tmp_path):
"""Create a temporary project directory with proper git setup.
IMPORTANT: This fixture properly isolates git operations by passing
a sanitized environment to subprocess.run calls, clearing git environment
variables that may be set by pre-commit hooks. Without this isolation,
git operations could affect the parent repository when tests run inside
a git worktree (e.g., during pre-commit validation).
See: https://git-scm.com/docs/git#_environment_variables
"""
project_dir = tmp_path / "test-project"
project_dir.mkdir()
# Create a sanitized environment for git commands to prevent leaking
# into parent repos when running inside git worktrees (e.g., pre-commit)
git_env = os.environ.copy()
git_vars_to_clear = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
]
for var in git_vars_to_clear:
git_env.pop(var, None)
# Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
git_env["GIT_CEILING_DIRECTORIES"] = str(tmp_path.parent)
# Initialize git repo
subprocess.run(
["git", "init"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
# Disable GPG signing to prevent hangs in CI
subprocess.run(
["git", "config", "commit.gpgsign", "false"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
# Create initial commit
readme = project_dir / "README.md"
readme.write_text("# Test Project\n")
subprocess.run(
["git", "add", "README.md"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=project_dir,
capture_output=True,
check=True,
env=git_env,
)
return project_dir
@pytest.fixture
def worktree_manager(temp_project_dir):
"""Create a WorktreeManager instance."""
from core.worktree import WorktreeManager
# Create .auto-claude directories
auto_claude_dir = temp_project_dir / ".auto-claude"
auto_claude_dir.mkdir(exist_ok=True)
(auto_claude_dir / "specs").mkdir(exist_ok=True)
(auto_claude_dir / "worktrees" / "tasks").mkdir(parents=True, exist_ok=True)
return WorktreeManager(
project_dir=temp_project_dir,
base_branch="main",
)
+401
View File
@@ -0,0 +1,401 @@
"""
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"])
+440
View File
@@ -0,0 +1,440 @@
"""
Regression tests for GitHub PR creation after GitLab support was added.
This test suite verifies that:
1. GitHub remotes are still detected correctly
2. push_and_create_pr correctly routes to create_pull_request for GitHub
3. gh CLI is still invoked with correct arguments
4. No regressions in existing GitHub PR functionality
5. Provider field is correctly set to "github"
"""
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 detect_git_provider
from worktree import PullRequestResult, WorktreeInfo, WorktreeManager
class TestGitHubProviderDetection:
"""Test that GitHub remotes are still detected correctly."""
def test_github_https_detection(self, tmp_path):
"""Test GitHub HTTPS URL detection."""
repo_path = tmp_path / "test-repo"
repo_path.mkdir()
# Initialize git repo with GitHub remote
subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
subprocess.run(
["git", "remote", "add", "origin", "https://github.com/user/repo.git"],
cwd=repo_path,
check=True,
capture_output=True,
)
provider = detect_git_provider(repo_path)
assert provider == "github", f"Expected 'github', got '{provider}'"
def test_github_ssh_detection(self, tmp_path):
"""Test GitHub SSH URL detection."""
repo_path = tmp_path / "test-repo"
repo_path.mkdir()
# Initialize git repo with GitHub remote
subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
subprocess.run(
["git", "remote", "add", "origin", "git@github.com:user/repo.git"],
cwd=repo_path,
check=True,
capture_output=True,
)
provider = detect_git_provider(repo_path)
assert provider == "github", f"Expected 'github', got '{provider}'"
def test_github_enterprise_detection(self, tmp_path):
"""Test GitHub Enterprise URL detection."""
repo_path = tmp_path / "test-repo"
repo_path.mkdir()
# Initialize git repo with GitHub Enterprise remote
subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
subprocess.run(
[
"git",
"remote",
"add",
"origin",
"https://github.company.com/user/repo.git",
],
cwd=repo_path,
check=True,
capture_output=True,
)
provider = detect_git_provider(repo_path)
assert provider == "github", f"Expected 'github', got '{provider}'"
class TestGitHubPRRouting:
"""Test that push_and_create_pr correctly routes to create_pull_request for GitHub."""
def test_github_routing_to_create_pull_request(
self, worktree_manager, temp_project_dir
):
"""Test that GitHub remotes route to create_pull_request."""
spec_name = "test-spec"
# Mock push_branch to succeed
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
# Mock PR creation result
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/123",
already_exists=False,
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
# Patch on the module object directly to handle importlib shim loading
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
) as mock_create_pr,
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
target_branch="main",
title="Test PR",
draft=False,
)
# Verify create_pull_request was called
mock_create_pr.assert_called_once_with(
spec_name=spec_name,
target_branch="main",
title="Test PR",
draft=False,
)
# Verify result
assert result["success"] is True
assert result["pushed"] is True
assert result["provider"] == "github"
assert result["pr_url"] == "https://github.com/user/repo/pull/123"
def test_github_provider_field_set_correctly(
self, worktree_manager, temp_project_dir
):
"""Test that provider field is set to 'github' in result."""
spec_name = "test-spec"
# Mock push_branch to succeed
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
# Mock PR creation result
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/123",
already_exists=False,
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
# Patch on the module object directly to handle importlib shim loading
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
),
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
target_branch="main",
title="Test PR",
draft=False,
)
# Verify provider field
assert result["provider"] == "github", (
f"Expected provider='github', got '{result['provider']}'"
)
assert result["pushed"] is True
class TestGitHubCLIInvocation:
"""Test that gh CLI is still invoked correctly with proper arguments."""
def test_gh_cli_invoked_with_correct_args(self, tmp_path):
"""Test that gh pr create is invoked with correct arguments."""
# Setup
project_dir = tmp_path / "project"
project_dir.mkdir()
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Create .auto-claude directories
auto_claude_dir = project_dir / ".auto-claude"
auto_claude_dir.mkdir(exist_ok=True)
# Create WorktreeManager
manager = WorktreeManager(
project_dir=project_dir,
base_branch="main",
)
# Mock get_worktree_info to return a valid WorktreeInfo
mock_worktree_info = WorktreeInfo(
path=spec_dir,
branch="auto-claude/001-test-spec",
spec_name="001-test-spec",
base_branch="main",
is_active=True,
)
# Mock subprocess result
mock_subprocess_result = MagicMock(
returncode=0,
stdout="https://github.com/user/repo/pull/123\n",
stderr="",
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(manager, "get_worktree_info", return_value=mock_worktree_info),
patch.object(
worktree_module, "get_gh_executable", return_value="/usr/bin/gh"
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
) as mock_run,
patch.object(manager, "_extract_spec_summary", return_value="Test PR body"),
):
result = manager.create_pull_request(
spec_name="001-test-spec",
target_branch="main",
title="Test PR Title",
draft=False,
)
# Verify gh CLI was called with correct arguments
assert mock_run.called
call_args = mock_run.call_args[0][0]
assert call_args[0] == "/usr/bin/gh"
assert "pr" in call_args
assert "create" in call_args
assert "--base" in call_args
assert "main" in call_args
assert "--title" in call_args
assert "Test PR Title" in call_args
assert "--body" in call_args
# Verify result
assert result["success"] is True
assert result["pr_url"] == "https://github.com/user/repo/pull/123"
def test_gh_cli_draft_flag(self, tmp_path):
"""Test that --draft flag is passed to gh CLI when draft=True."""
# Setup
project_dir = tmp_path / "project"
project_dir.mkdir()
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Create .auto-claude directories
auto_claude_dir = project_dir / ".auto-claude"
auto_claude_dir.mkdir(exist_ok=True)
# Create WorktreeManager
manager = WorktreeManager(
project_dir=project_dir,
base_branch="main",
)
# Mock get_worktree_info
mock_worktree_info = WorktreeInfo(
path=spec_dir,
branch="auto-claude/001-test-spec",
spec_name="001-test-spec",
base_branch="main",
is_active=True,
)
# Mock subprocess result
mock_subprocess_result = MagicMock(
returncode=0,
stdout="https://github.com/user/repo/pull/123\n",
stderr="",
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(manager, "get_worktree_info", return_value=mock_worktree_info),
patch.object(
worktree_module, "get_gh_executable", return_value="/usr/bin/gh"
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
) as mock_run,
patch.object(manager, "_extract_spec_summary", return_value="Test PR body"),
):
result = manager.create_pull_request(
spec_name="001-test-spec",
target_branch="main",
title="Draft PR",
draft=True,
)
# Verify --draft flag is present
call_args = mock_run.call_args[0][0]
assert "--draft" in call_args
assert result["success"] is True
class TestGitHubErrorHandling:
"""Test that GitHub error handling still works correctly."""
def test_missing_gh_cli_error(self, tmp_path):
"""Test error message when gh CLI is not installed."""
# Setup
project_dir = tmp_path / "project"
project_dir.mkdir()
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Create .auto-claude directories
auto_claude_dir = project_dir / ".auto-claude"
auto_claude_dir.mkdir(exist_ok=True)
# Create WorktreeManager
manager = WorktreeManager(
project_dir=project_dir,
base_branch="main",
)
# Mock get_worktree_info
mock_worktree_info = WorktreeInfo(
path=spec_dir,
branch="auto-claude/001-test-spec",
spec_name="001-test-spec",
base_branch="main",
is_active=True,
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(manager, "get_worktree_info", return_value=mock_worktree_info),
patch.object(worktree_module, "get_gh_executable", return_value=None),
):
result = manager.create_pull_request(
spec_name="001-test-spec",
target_branch="main",
title="Test PR",
draft=False,
)
# Verify error message
assert result["success"] is False
assert "GitHub CLI (gh) not found" in result["error"]
def test_already_exists_handling(self, tmp_path):
"""Test that 'already exists' case is handled correctly."""
# Setup
project_dir = tmp_path / "project"
project_dir.mkdir()
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Create .auto-claude directories
auto_claude_dir = project_dir / ".auto-claude"
auto_claude_dir.mkdir(exist_ok=True)
# Create WorktreeManager
manager = WorktreeManager(
project_dir=project_dir,
base_branch="main",
)
# Mock get_worktree_info
mock_worktree_info = WorktreeInfo(
path=spec_dir,
branch="auto-claude/001-test-spec",
spec_name="001-test-spec",
base_branch="main",
is_active=True,
)
# Mock subprocess result for "already exists" error
mock_subprocess_result = MagicMock(
returncode=1,
stdout="",
stderr="pull request already exists",
)
# Import the actual module to patch it directly
import core.worktree as worktree_module
with (
patch.object(manager, "get_worktree_info", return_value=mock_worktree_info),
patch.object(
worktree_module, "get_gh_executable", return_value="/usr/bin/gh"
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
),
patch.object(
manager,
"_get_existing_pr_url",
return_value="https://github.com/user/repo/pull/123",
),
patch.object(manager, "_extract_spec_summary", return_value="Test PR body"),
):
result = manager.create_pull_request(
spec_name="001-test-spec",
target_branch="main",
title="Test PR",
draft=False,
)
# Verify it's treated as success with already_exists flag
assert result["success"] is True
assert result["already_exists"] is True
assert result["pr_url"] == "https://github.com/user/repo/pull/123"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""
End-to-End Testing Script for GitLab Support
=============================================
This script performs end-to-end testing of the GitLab MR creation functionality.
It tests provider detection, CLI availability, WorktreeManager integration,
and error handling.
Usage:
# Run as pytest
cd apps/backend && uv run pytest ../../tests/test_gitlab_e2e.py -v
# Run as standalone script
python tests/test_gitlab_e2e.py
Requirements:
- glab CLI installed and authenticated (for full test)
- Git repository with proper remotes configured
"""
import inspect
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
# Add apps/backend directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
from core.git_provider import detect_git_provider
from core.glab_executable import get_glab_executable
def print_header(title: str) -> None:
"""Print a test section header."""
print("\n" + "=" * 70)
print(f" {title}")
print("=" * 70)
def print_test(name: str) -> None:
"""Print a test name."""
print(f"\n→ Test: {name}")
def print_result(success: bool, message: str) -> None:
"""Print test result."""
status = "✓ PASS" if success else "✗ FAIL"
print(f" {status}: {message}")
def _check_glab_detection() -> bool:
"""Helper: Verify glab CLI detection."""
print_test("Detect glab CLI installation")
glab_path = get_glab_executable()
if glab_path:
print_result(True, f"glab CLI found at: {glab_path}")
# Verify version
try:
result = subprocess.run(
[glab_path, "--version"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
version = result.stdout.strip()
print(f" Version: {version}")
return True
else:
print_result(False, "glab version check failed")
return False
except Exception as e:
print_result(False, f"Error checking glab version: {e}")
return False
else:
print_result(False, "glab CLI not found - some tests will be skipped")
print(" Install glab from: https://gitlab.com/gitlab-org/cli")
return False
def create_test_git_repo(repo_path: Path, remote_url: str) -> bool:
"""Create a test git repository with a remote.
Args:
repo_path: Path where to create the repo
remote_url: Git remote URL to set
Returns:
True if successful, False otherwise
"""
try:
repo_path.mkdir(parents=True, exist_ok=True)
# Initialize git repo
subprocess.run(
["git", "init"],
cwd=repo_path,
capture_output=True,
check=True,
)
# Configure git user for commits
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=repo_path,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=repo_path,
capture_output=True,
check=True,
)
# Disable GPG signing to prevent hangs in CI
subprocess.run(
["git", "config", "commit.gpgsign", "false"],
cwd=repo_path,
capture_output=True,
check=True,
)
# Add remote
subprocess.run(
["git", "remote", "add", "origin", remote_url],
cwd=repo_path,
capture_output=True,
check=True,
)
# Create initial commit
(repo_path / "README.md").write_text("# Test Repository\n")
subprocess.run(
["git", "add", "README.md"],
cwd=repo_path,
capture_output=True,
check=True,
)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_path,
capture_output=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print_result(False, f"Failed to create test repo: {e}")
return False
def _check_provider_detection() -> bool:
"""Helper: Provider detection for various URL patterns."""
print_test("Detect provider from various remote URL patterns")
test_cases = [
("GitHub HTTPS", "https://github.com/user/repo.git", "github"),
("GitHub SSH", "git@github.com:user/repo.git", "github"),
("GitHub Enterprise", "https://github.company.com/user/repo.git", "github"),
("GitLab Cloud HTTPS", "https://gitlab.com/user/repo.git", "gitlab"),
("GitLab Cloud SSH", "git@gitlab.com:user/repo.git", "gitlab"),
(
"Self-hosted GitLab HTTPS",
"https://gitlab.company.com/user/repo.git",
"gitlab",
),
("Self-hosted GitLab SSH", "git@gitlab.company.com:user/repo.git", "gitlab"),
(
"Self-hosted GitLab Subdomain",
"https://gitlab.example.org/user/repo.git",
"gitlab",
),
]
all_passed = True
with tempfile.TemporaryDirectory() as tmpdir:
for name, remote_url, expected_provider in test_cases:
repo_path = Path(tmpdir) / name.replace(" ", "_")
if not create_test_git_repo(repo_path, remote_url):
print_result(False, f"{name}: Could not create test repo")
all_passed = False
continue
detected = detect_git_provider(str(repo_path))
if detected == expected_provider:
print_result(True, f"{name}: Detected '{detected}' for {remote_url}")
else:
print_result(
False, f"{name}: Expected '{expected_provider}', got '{detected}'"
)
all_passed = False
return all_passed
def _check_method_signatures() -> bool:
"""Helper: WorktreeManager has correct method signatures."""
print_test("Verify WorktreeManager method signatures")
try:
from core.worktree import WorktreeManager
# Check push_and_create_pr signature
sig = inspect.signature(WorktreeManager.push_and_create_pr)
params = list(sig.parameters.keys())
expected_params = [
"self",
"spec_name",
"target_branch",
"title",
"draft",
"force_push",
]
if all(p in params for p in expected_params):
print_result(True, f"push_and_create_pr has correct parameters: {params}")
else:
print_result(
False, f"Missing parameters. Expected {expected_params}, got {params}"
)
return False
# Verify create_merge_request method exists
if hasattr(WorktreeManager, "create_merge_request"):
print_result(True, "create_merge_request method exists")
else:
print_result(False, "create_merge_request method not found")
return False
# Verify create_pull_request method still exists (GitHub regression check)
if hasattr(WorktreeManager, "create_pull_request"):
print_result(
True, "create_pull_request method exists (no GitHub regression)"
)
else:
print_result(
False, "create_pull_request method missing (GitHub regression!)"
)
return False
return True
except Exception as e:
print_result(False, f"Error checking method signatures: {e}")
return False
def _check_error_message_missing_glab() -> bool:
"""Helper: Error message when glab is not installed."""
print_test("Error handling for missing glab CLI")
try:
# Mock get_glab_executable to return None (simulate missing glab)
with patch("core.glab_executable.get_glab_executable", return_value=None):
from core.glab_executable import run_glab
result = run_glab(["mr", "create", "--help"])
expected_error = "GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli"
if result.returncode != 0 and expected_error in result.stderr:
print_result(True, "Correct error message when glab missing")
return True
elif result.returncode != 0 and "glab" in result.stderr.lower():
# Partial match - error mentions glab
print_result(True, f"Error message mentions glab: {result.stderr}")
return True
else:
print_result(
False,
f"Unexpected result: returncode={result.returncode}, stderr={result.stderr}",
)
return False
except Exception as e:
print_result(False, f"Unexpected exception: {e}")
return False
def _check_worktree_integration() -> bool:
"""Helper: Integration test with WorktreeManager."""
print_test("WorktreeManager integration with GitLab remote")
try:
from core.worktree import WorktreeManager
with tempfile.TemporaryDirectory() as tmpdir:
repo_path = Path(tmpdir) / "test-project"
# Create test repo with GitLab remote
if not create_test_git_repo(
repo_path, "https://gitlab.com/test-user/test-repo.git"
):
print_result(False, "Could not create test repository")
return False
print_result(True, "Created test repository with GitLab remote")
# Detect provider
provider = detect_git_provider(str(repo_path))
if provider != "gitlab":
print_result(False, f"Expected 'gitlab', got '{provider}'")
return False
print_result(True, f"Provider correctly detected: {provider}")
# Create WorktreeManager instance (verifies constructor doesn't raise)
_ = WorktreeManager(project_dir=repo_path, base_branch="main")
print_result(True, "WorktreeManager instance created successfully")
return True
except Exception as e:
print_result(False, f"Error during test: {e}")
return False
# =============================================================================
# Pytest Test Functions
# =============================================================================
def test_glab_detection():
"""Pytest: Verify glab CLI detection works when glab is installed."""
from core.glab_executable import get_glab_executable
glab_path = get_glab_executable()
if not glab_path:
pytest.skip("glab CLI not installed - skipping glab detection test")
assert _check_glab_detection(), "glab CLI detection failed"
def test_provider_detection():
"""Pytest: Provider detection for various URL patterns."""
assert _check_provider_detection(), (
"Provider detection failed for one or more URL patterns"
)
def test_worktree_manager_method_signatures():
"""Pytest: WorktreeManager has correct method signatures."""
assert _check_method_signatures(), "WorktreeManager method signature check failed"
def test_error_message_missing_glab():
"""Pytest: Error message when glab is not installed."""
assert _check_error_message_missing_glab(), (
"Missing glab error message check failed"
)
def test_worktree_integration():
"""Pytest: Integration test with WorktreeManager."""
assert _check_worktree_integration(), "WorktreeManager integration test failed"
def run_all_tests() -> int:
"""Run all end-to-end tests."""
print_header("GitLab Support - End-to-End Testing")
print("\nThis script tests the GitLab MR creation functionality:")
print(" 1. glab CLI detection")
print(" 2. Provider detection (GitHub, GitLab cloud, self-hosted)")
print(" 3. WorktreeManager method signatures")
print(" 4. Error handling for missing glab CLI")
print(" 5. WorktreeManager integration")
results = {}
# Run all tests
print_header("Running Tests")
results["glab_detection"] = _check_glab_detection()
results["provider_detection"] = _check_provider_detection()
results["method_signatures"] = _check_method_signatures()
results["missing_glab_error"] = _check_error_message_missing_glab()
results["worktree_integration"] = _check_worktree_integration()
# Print summary
print_header("Test Summary")
total = len(results)
passed = sum(1 for r in results.values() if r)
failed = total - passed
print(f"\nTotal Tests: {total}")
print(f"Passed: {passed}")
print(f"Failed: {failed}")
if failed > 0:
print("\nFailed tests:")
for test_name, result in results.items():
if not result:
print(f"{test_name}")
print("\n" + "=" * 70)
if failed == 0:
print("✓ All tests passed!")
return 0
else:
print(f"{failed} test(s) failed")
return 1
if __name__ == "__main__":
try:
exit_code = run_all_tests()
sys.exit(exit_code)
except KeyboardInterrupt:
print("\n\nTests interrupted by user")
sys.exit(130)
except Exception as e:
print(f"\n\nUnexpected error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+601
View File
@@ -0,0 +1,601 @@
"""
Integration Tests for WorktreeManager GitLab/GitHub PR/MR Creation
==================================================================
Tests the WorktreeManager class methods for creating pull requests (GitHub)
and merge requests (GitLab), including provider detection and CLI routing.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# 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 worktree import (
PullRequestResult,
WorktreeInfo,
)
class TestCreateMergeRequest:
"""Test create_merge_request method for GitLab MR creation."""
def test_successful_mr_creation(self, worktree_manager, temp_project_dir):
"""Test successful MR creation with glab CLI."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
# Mock get_worktree_info to return a valid WorktreeInfo
mock_worktree_info = WorktreeInfo(
path=temp_project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name,
branch=f"auto-claude/{spec_name}",
spec_name=spec_name,
base_branch="main",
is_active=True,
)
# Mock subprocess for glab CLI
mock_subprocess_result = MagicMock(
returncode=0,
stdout="https://gitlab.com/user/repo/-/merge_requests/42\n",
stderr="",
)
with (
patch.object(
worktree_manager, "get_worktree_info", return_value=mock_worktree_info
),
patch.object(
worktree_module,
"get_glab_executable",
return_value="/usr/local/bin/glab",
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
),
patch.object(
worktree_manager, "_extract_spec_summary", return_value="Test MR body"
),
):
result = worktree_manager.create_merge_request(
spec_name=spec_name,
target_branch="main",
title="Test MR",
draft=False,
)
# Verify result
assert result["success"] is True
assert result["pr_url"] == "https://gitlab.com/user/repo/-/merge_requests/42"
assert result.get("already_exists") is False
assert "error" not in result or result["error"] is None
def test_mr_already_exists(self, worktree_manager, temp_project_dir):
"""Test MR already exists scenario."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_worktree_info = WorktreeInfo(
path=temp_project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name,
branch=f"auto-claude/{spec_name}",
spec_name=spec_name,
base_branch="main",
is_active=True,
)
# Mock glab CLI returning "already exists" error
mock_subprocess_result = MagicMock(
returncode=1,
stdout="",
stderr="Error: merge request already exists\n",
)
# Mock _get_existing_mr_url to return existing URL
existing_url = "https://gitlab.com/user/repo/-/merge_requests/42"
with (
patch.object(
worktree_manager, "get_worktree_info", return_value=mock_worktree_info
),
patch.object(
worktree_module,
"get_glab_executable",
return_value="/usr/local/bin/glab",
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
),
patch.object(
worktree_manager, "_extract_spec_summary", return_value="Test MR body"
),
patch.object(
worktree_manager, "_get_existing_mr_url", return_value=existing_url
),
):
result = worktree_manager.create_merge_request(
spec_name=spec_name,
target_branch="main",
)
# Verify result
assert result["success"] is True
assert result["pr_url"] == existing_url
assert result["already_exists"] is True
assert "error" not in result or result["error"] is None
def test_missing_glab_cli(self, worktree_manager, temp_project_dir):
"""Test error when glab CLI is not installed."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_worktree_info = WorktreeInfo(
path=temp_project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name,
branch=f"auto-claude/{spec_name}",
spec_name=spec_name,
base_branch="main",
is_active=True,
)
with (
patch.object(
worktree_manager, "get_worktree_info", return_value=mock_worktree_info
),
patch.object(worktree_module, "get_glab_executable", return_value=None),
):
result = worktree_manager.create_merge_request(spec_name=spec_name)
# Verify error
assert result["success"] is False
assert "GitLab CLI (glab) not found" in result["error"]
assert "https://gitlab.com/gitlab-org/cli" in result["error"]
def test_no_worktree_found(self, worktree_manager):
"""Test error when worktree doesn't exist."""
spec_name = "nonexistent-spec"
with patch.object(worktree_manager, "get_worktree_info", return_value=None):
result = worktree_manager.create_merge_request(spec_name=spec_name)
# Verify error
assert result["success"] is False
assert f"No worktree found for spec: {spec_name}" in result["error"]
def test_mr_with_draft_flag(self, worktree_manager, temp_project_dir):
"""Test MR creation with draft flag."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_worktree_info = WorktreeInfo(
path=temp_project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name,
branch=f"auto-claude/{spec_name}",
spec_name=spec_name,
base_branch="main",
is_active=True,
)
mock_subprocess_result = MagicMock(
returncode=0,
stdout="https://gitlab.com/user/repo/-/merge_requests/43\n",
stderr="",
)
with (
patch.object(
worktree_manager, "get_worktree_info", return_value=mock_worktree_info
),
patch.object(
worktree_module,
"get_glab_executable",
return_value="/usr/local/bin/glab",
),
patch.object(
worktree_module.subprocess, "run", return_value=mock_subprocess_result
) as mock_run,
patch.object(
worktree_manager, "_extract_spec_summary", return_value="Test MR body"
),
):
result = worktree_manager.create_merge_request(
spec_name=spec_name,
draft=True,
)
# Verify draft flag was passed to glab
call_args = mock_run.call_args[0][0]
assert "--draft" in call_args
assert result["success"] is True
def test_network_error_retry(self, worktree_manager, temp_project_dir):
"""Test retry logic for network errors."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_worktree_info = WorktreeInfo(
path=temp_project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name,
branch=f"auto-claude/{spec_name}",
spec_name=spec_name,
base_branch="main",
is_active=True,
)
# First call fails with network error, second succeeds
mock_failure = MagicMock(
returncode=1,
stdout="",
stderr="Error: connection timeout\n",
)
mock_success = MagicMock(
returncode=0,
stdout="https://gitlab.com/user/repo/-/merge_requests/44\n",
stderr="",
)
with (
patch.object(
worktree_manager, "get_worktree_info", return_value=mock_worktree_info
),
patch.object(
worktree_module,
"get_glab_executable",
return_value="/usr/local/bin/glab",
),
patch.object(
worktree_module.subprocess,
"run",
side_effect=[mock_failure, mock_success],
),
patch.object(
worktree_manager, "_extract_spec_summary", return_value="Test MR body"
),
patch.object(worktree_module.time, "sleep"), # Skip sleep in tests
):
result = worktree_manager.create_merge_request(spec_name=spec_name)
# Verify retry succeeded
assert result["success"] is True
assert result["pr_url"] == "https://gitlab.com/user/repo/-/merge_requests/44"
class TestPushAndCreatePR:
"""Test push_and_create_pr method with provider detection."""
def test_gitlab_routing(self, worktree_manager, temp_project_dir):
"""Test routing to create_merge_request for GitLab repos."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
# Mock push_branch to succeed
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
# Mock MR creation result
mock_mr_result = PullRequestResult(
success=True,
pr_url="https://gitlab.com/user/repo/-/merge_requests/42",
already_exists=False,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(worktree_module, "detect_git_provider", return_value="gitlab"),
patch.object(
worktree_manager, "create_merge_request", return_value=mock_mr_result
) as mock_create_mr,
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
target_branch="main",
title="Test MR",
)
# Verify routing to GitLab
mock_create_mr.assert_called_once_with(
spec_name=spec_name,
target_branch="main",
title="Test MR",
draft=False,
)
# Verify result
assert result["success"] is True
assert result["pushed"] is True
assert result["provider"] == "gitlab"
assert result["pr_url"] == "https://gitlab.com/user/repo/-/merge_requests/42"
def test_unknown_provider_error(self, worktree_manager, temp_project_dir):
"""Test error handling for unknown git providers."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
# Mock push_branch to succeed
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(
worktree_module, "detect_git_provider", return_value="unknown"
),
):
result = worktree_manager.push_and_create_pr(spec_name=spec_name)
# Verify error
assert result["success"] is False
assert result["pushed"] is True
assert result["provider"] == "unknown"
assert "Unable to determine git hosting provider" in result["error"]
assert "Supported: GitHub, GitLab" in result["error"]
def test_push_failure(self, worktree_manager, temp_project_dir):
"""Test handling of push failures."""
spec_name = "test-feature"
# Mock push_branch to fail
mock_push_result = {
"success": False,
"error": "Failed to push: remote rejected",
}
with patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
):
result = worktree_manager.push_and_create_pr(spec_name=spec_name)
# Verify error
assert result["success"] is False
assert result["pushed"] is False
assert "Failed to push: remote rejected" in result["error"]
def test_draft_pr_flag(self, worktree_manager, temp_project_dir):
"""Test draft flag is passed through correctly."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/124",
already_exists=False,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
) as mock_create_pr,
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
draft=True,
)
# Verify draft flag was passed
assert mock_create_pr.call_args[1]["draft"] is True
assert result["success"] is True
def test_force_push_flag(self, worktree_manager, temp_project_dir):
"""Test force push flag is passed to push_branch."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/125",
already_exists=False,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
) as mock_push,
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
),
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
force_push=True,
)
# Verify force flag was passed to push_branch
assert mock_push.call_args[1]["force"] is True
assert result["success"] is True
def test_custom_target_branch(self, worktree_manager, temp_project_dir):
"""Test custom target branch is passed through."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
custom_target = "develop"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/126",
already_exists=False,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
) as mock_create_pr,
):
result = worktree_manager.push_and_create_pr(
spec_name=spec_name,
target_branch=custom_target,
)
# Verify target branch was passed
assert mock_create_pr.call_args[1]["target_branch"] == custom_target
assert result["success"] is True
class TestProviderIntegration:
"""Test integration between provider detection and CLI routing."""
def test_self_hosted_gitlab_routing(self, worktree_manager, temp_project_dir):
"""Test that self-hosted GitLab instances route to glab CLI."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
mock_mr_result = PullRequestResult(
success=True,
pr_url="https://gitlab.company.com/team/repo/-/merge_requests/1",
already_exists=False,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(
worktree_module, "detect_git_provider", return_value="gitlab"
), # Self-hosted detected as gitlab
patch.object(
worktree_manager, "create_merge_request", return_value=mock_mr_result
) as mock_create_mr,
):
result = worktree_manager.push_and_create_pr(spec_name=spec_name)
# Verify routing to GitLab (not GitHub)
mock_create_mr.assert_called_once()
assert result["provider"] == "gitlab"
assert result["success"] is True
def test_pr_already_exists_propagation(self, worktree_manager, temp_project_dir):
"""Test that already_exists flag propagates correctly."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
# Mock PR that already exists
mock_pr_result = PullRequestResult(
success=True,
pr_url="https://github.com/user/repo/pull/127",
already_exists=True,
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
),
):
result = worktree_manager.push_and_create_pr(spec_name=spec_name)
# Verify already_exists flag
assert result["success"] is True
assert result["already_exists"] is True
assert result["pr_url"] == "https://github.com/user/repo/pull/127"
def test_error_propagation_from_pr_creation(
self, worktree_manager, temp_project_dir
):
"""Test that errors from PR/MR creation propagate correctly."""
# Import the actual module to patch it directly (handles importlib shim)
import core.worktree as worktree_module
spec_name = "test-feature"
mock_push_result = {
"success": True,
"remote": "origin",
"branch": f"auto-claude/{spec_name}",
}
# Mock PR creation failure
mock_pr_result = PullRequestResult(
success=False,
error="Authentication failed",
)
with (
patch.object(
worktree_manager, "push_branch", return_value=mock_push_result
),
patch.object(worktree_module, "detect_git_provider", return_value="github"),
patch.object(
worktree_manager, "create_pull_request", return_value=mock_pr_result
),
):
result = worktree_manager.push_and_create_pr(spec_name=spec_name)
# Verify error propagation
assert result["success"] is False
assert result["pushed"] is True
assert "Authentication failed" in result["error"]