diff --git a/apps/backend/core/git_provider.py b/apps/backend/core/git_provider.py new file mode 100644 index 00000000..929e5a11 --- /dev/null +++ b/apps/backend/core/git_provider.py @@ -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" diff --git a/apps/backend/core/glab_executable.py b/apps/backend/core/glab_executable.py new file mode 100644 index 00000000..046e6cf7 --- /dev/null +++ b/apps/backend/core/glab_executable.py @@ -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", + ) diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index 8b19ead6..8b782a6e 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -15,6 +15,7 @@ This allows: """ import asyncio +import json import logging import os import re @@ -29,6 +30,8 @@ from typing import TypedDict, TypeVar 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_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 debug import debug_warning @@ -140,6 +143,7 @@ class PushAndCreatePRResult(TypedDict, total=False): pushed: bool remote: str branch: str + provider: str # 'github', 'gitlab', or 'unknown' pr_url: str | None # None when PR was created but URL couldn't be extracted already_exists: bool error: str @@ -179,8 +183,8 @@ class WorktreeManager: # Timeout constants for subprocess operations GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations) - GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands - GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries + CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab) + CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab) def __init__(self, project_dir: Path, base_branch: str | None = None): self.project_dir = project_dir @@ -1254,7 +1258,7 @@ class WorktreeManager: text=True, encoding="utf-8", errors="replace", - timeout=self.GH_CLI_TIMEOUT, + timeout=self.CLI_TIMEOUT, env=get_isolated_git_env(), ) @@ -1330,7 +1334,165 @@ class WorktreeManager: invalidate_gh_cache() return PullRequestResult( 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/ or /-/merge_requests/ 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]: @@ -1568,7 +1730,7 @@ class WorktreeManager: text=True, encoding="utf-8", errors="replace", - timeout=self.GH_QUERY_TIMEOUT, + timeout=self.CLI_QUERY_TIMEOUT, env=get_isolated_git_env(), ) if result.returncode == 0: @@ -1587,6 +1749,57 @@ class WorktreeManager: 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( self, spec_name: str, @@ -1596,13 +1809,14 @@ class WorktreeManager: force_push: bool = False, ) -> 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: spec_name: The spec folder name - target_branch: Target branch for PR (defaults to base_branch) - title: PR title (defaults to spec name) - draft: Whether to create as draft PR + target_branch: Target branch for PR/MR (defaults to base_branch) + title: PR/MR title (defaults to spec name) + draft: Whether to create as draft PR/MR force_push: Whether to force push the branch Returns: @@ -1610,7 +1824,8 @@ class WorktreeManager: - success: bool - pr_url: str (if created) - 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) """ # Step 1: Push the branch @@ -1624,20 +1839,44 @@ class WorktreeManager: error=push_result.get("error", "Push failed"), ) - # Step 2: Create the PR - pr_result = self.create_pull_request( - spec_name=spec_name, - target_branch=target_branch, - title=title, - draft=draft, + # Step 2: Detect git provider (use the remote that was pushed to) + provider = detect_git_provider( + self.project_dir, remote_name=push_result.get("remote") ) + # 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 return PushAndCreatePRResult( success=pr_result.get("success", False), pushed=True, remote=push_result.get("remote"), branch=push_result.get("branch"), + provider=provider, pr_url=pr_result.get("pr_url"), already_exists=pr_result.get("already_exists", False), error=pr_result.get("error"), diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index b579bbb1..631ff6ca 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -29,7 +29,7 @@ import { killProcessGracefully, isWindows } from '../platform'; /** * Type for supported CLI tools */ -type CliTool = 'claude' | 'gh'; +type CliTool = 'claude' | 'gh' | 'glab'; /** * Mapping of CLI tools to their environment variable names @@ -37,7 +37,8 @@ type CliTool = 'claude' | 'gh'; */ const CLI_TOOL_ENV_MAP: Readonly> = { claude: 'CLAUDE_CLI_PATH', - gh: 'GITHUB_CLI_PATH' + gh: 'GITHUB_CLI_PATH', + glab: 'GITLAB_CLI_PATH' } as const; @@ -201,12 +202,14 @@ export class AgentProcessManager { // Detect and pass CLI tool paths to Python backend const claudeCliEnv = this.detectAndSetCliPath('claude'); const ghCliEnv = this.detectAndSetCliPath('gh'); + const glabCliEnv = this.detectAndSetCliPath('glab'); return { ...augmentedEnv, ...gitBashEnv, ...claudeCliEnv, ...ghCliEnv, + ...glabCliEnv, ...extraEnv, ...profileEnv, PYTHONUNBUFFERED: '1', diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index f749160e..f3815ab2 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -46,6 +46,7 @@ import { getWindowsExecutablePaths, getWindowsExecutablePathsAsync, WINDOWS_GIT_PATHS, + WINDOWS_GLAB_PATHS, findWindowsExecutableViaWhere, findWindowsExecutableViaWhereAsync, isSecurePath, @@ -54,7 +55,7 @@ import { /** * 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 @@ -64,6 +65,7 @@ export interface ToolConfig { pythonPath?: string; gitPath?: string; githubCLIPath?: string; + gitlabCLIPath?: string; claudePath?: string; } @@ -370,6 +372,8 @@ class CLIToolManager { return this.detectGit(); case 'gh': return this.detectGitHubCLI(); + case 'glab': + return this.detectGitLabCLI(); case 'claude': return this.detectClaude(); 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 * @@ -846,6 +949,7 @@ class CLIToolManager { encoding: 'utf-8', timeout: 5000, windowsHide: true, + env: getAugmentedEnv(), }).trim(); const match = version.match(/Python (\d+\.\d+\.\d+)/); @@ -896,6 +1000,7 @@ class CLIToolManager { encoding: 'utf-8', timeout: 5000, windowsHide: true, + env: getAugmentedEnv(), }).trim(); const match = version.match(/git version (\d+\.\d+\.\d+)/); @@ -926,6 +1031,7 @@ class CLIToolManager { encoding: 'utf-8', timeout: 5000, windowsHide: true, + env: getAugmentedEnv(), }).trim(); 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 * @@ -1097,6 +1235,8 @@ class CLIToolManager { return this.detectGitAsync(); case 'gh': return this.detectGitHubCLIAsync(); + case 'glab': + return this.detectGitLabCLIAsync(); default: return { 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 { + 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) * @@ -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 { + // 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 * diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 4b1ba2e1..e2189e3f 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -139,7 +139,7 @@ export function registerSettingsHandlers( // Fixes issue where Windows paths persisted on macOS (and vice versa) // when settings were synced/transferred between platforms // 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) { const pathValue = settings[field]; if (pathValue && isPathFromWrongPlatform(pathValue)) { @@ -174,6 +174,7 @@ export function registerSettingsHandlers( pythonPath: settings.pythonPath, gitPath: settings.gitPath, githubCLIPath: settings.githubCLIPath, + gitlabCLIPath: settings.gitlabCLIPath, claudePath: settings.claudePath, }); @@ -215,12 +216,14 @@ export function registerSettingsHandlers( settings.pythonPath !== undefined || settings.gitPath !== undefined || settings.githubCLIPath !== undefined || + settings.gitlabCLIPath !== undefined || settings.claudePath !== undefined ) { configureTools({ pythonPath: newSettings.pythonPath, gitPath: newSettings.gitPath, githubCLIPath: newSettings.githubCLIPath, + gitlabCLIPath: newSettings.gitlabCLIPath, claudePath: newSettings.claudePath, }); @@ -260,6 +263,7 @@ export function registerSettingsHandlers( python: ReturnType; git: ReturnType; gh: ReturnType; + glab: ReturnType; claude: ReturnType; }>> => { try { @@ -269,6 +273,7 @@ export function registerSettingsHandlers( python: getToolInfo('python'), git: getToolInfo('git'), gh: getToolInfo('gh'), + glab: getToolInfo('glab'), claude: getToolInfo('claude'), }, }; diff --git a/apps/frontend/src/main/utils/windows-paths.ts b/apps/frontend/src/main/utils/windows-paths.ts index 00ceaf05..1a2c8e34 100644 --- a/apps/frontend/src/main/utils/windows-paths.ts +++ b/apps/frontend/src/main/utils/windows-paths.ts @@ -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 { const dangerousPatterns = [ /[;&|`${}[\]<>!"^]/, // Shell metacharacters (parentheses removed - safe when quoted) diff --git a/apps/frontend/src/renderer/components/gitlab-merge-requests/GitLabMergeRequests.tsx b/apps/frontend/src/renderer/components/gitlab-merge-requests/GitLabMergeRequests.tsx index 3246b2f1..e11e1157 100644 --- a/apps/frontend/src/renderer/components/gitlab-merge-requests/GitLabMergeRequests.tsx +++ b/apps/frontend/src/renderer/components/gitlab-merge-requests/GitLabMergeRequests.tsx @@ -42,7 +42,7 @@ export function GitLabMergeRequests({ projectId, onOpenSettings }: GitLabMergeRe mergeMR, assignMR, approveMR, - } = useGitLabMRs(projectId); + } = useGitLabMRs(projectId, { stateFilter }); const handleCreateSuccess = async (mrIid: number) => { // Refresh the list and select the newly created MR diff --git a/apps/frontend/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts b/apps/frontend/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts index 3c30cbc7..ad6dc931 100644 --- a/apps/frontend/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts +++ b/apps/frontend/src/renderer/components/gitlab-merge-requests/hooks/useGitLabMRs.ts @@ -15,6 +15,11 @@ import { export type { GitLabMergeRequest, GitLabMRReviewResult, GitLabMRReviewProgress }; export type { GitLabMRReviewFinding } from '../../../../shared/types'; +interface UseGitLabMRsOptions { + /** Filter MRs by state */ + stateFilter?: 'opened' | 'closed' | 'merged' | 'all'; +} + interface UseGitLabMRsResult { mergeRequests: GitLabMergeRequest[]; isLoading: boolean; @@ -47,14 +52,14 @@ interface UseGitLabMRsResult { } | null; } -export function useGitLabMRs(projectId?: string): UseGitLabMRsResult { +export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = {}): UseGitLabMRsResult { + const { stateFilter = 'opened' } = options; const [mergeRequests, setMergeRequests] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedMRIid, setSelectedMRIid] = useState(null); const [isConnected, setIsConnected] = useState(false); const [projectPath, setProjectPath] = useState(null); - const [stateFilter] = useState<'opened' | 'closed' | 'merged' | 'all'>('opened'); // Get MR review state from the global store const mrReviews = useMRReviewStore((state) => state.mrReviews); diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index b03e1b8f..d59bed0f 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -96,6 +96,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; + glab: ToolDetectionResult; claude: ToolDetectionResult; } | null>(null); const [isLoadingTools, setIsLoadingTools] = useState(false); @@ -106,7 +107,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General setIsLoadingTools(true); window.electronAPI .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) { setToolsInfo(result.data); } @@ -305,6 +306,24 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General /> )} +
+ +

{t('general.gitlabCLIPathDescription')}

+ onSettingsChange({ ...settings, gitlabCLIPath: e.target.value })} + /> + {!settings.gitlabCLIPath && ( + + )} +

{t('general.claudePathDescription')}

diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index ea96ea64..35441190 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -29,6 +29,7 @@ export const settingsMock = { 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' }, 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' } } }), diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index 36729d05..c5641f63 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -31,6 +31,7 @@ export const DEFAULT_APP_SETTINGS = { pythonPath: undefined as string | undefined, gitPath: undefined as string | undefined, githubCLIPath: undefined as string | undefined, + gitlabCLIPath: undefined as string | undefined, autoBuildPath: undefined as string | undefined, autoUpdateAutoBuild: true, autoNameTerminals: true, diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 3730add1..e2e74a47 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -208,6 +208,9 @@ "githubCLIPath": "GitHub CLI Path", "githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)", "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", "claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)", "claudePathPlaceholder": "claude (default)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 71fc1c49..9d9748b7 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -208,6 +208,9 @@ "githubCLIPath": "Chemin GitHub CLI", "githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)", "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", "claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)", "claudePathPlaceholder": "claude (par défaut)", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 8890519e..8e407888 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -355,6 +355,7 @@ export interface ElectronAPI { python: import('./cli').ToolDetectionResult; git: import('./cli').ToolDetectionResult; gh: import('./cli').ToolDetectionResult; + glab: import('./cli').ToolDetectionResult; claude: import('./cli').ToolDetectionResult; }>>; /** Check if Claude Code onboarding is complete (reads ~/.claude.json) */ diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index f7e83e9e..a378c887 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -224,6 +224,7 @@ export interface AppSettings { pythonPath?: string; gitPath?: string; githubCLIPath?: string; + gitlabCLIPath?: string; claudePath?: string; autoBuildPath?: string; autoUpdateAutoBuild: boolean; diff --git a/tests/conftest.py b/tests/conftest.py index 3fa4d432..58e98fd7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,6 +157,8 @@ def pytest_runtest_setup(item): pass + + # ============================================================================= # DIRECTORY FIXTURES # ============================================================================= @@ -1141,3 +1143,108 @@ def temp_project(temp_git_repo: Path): ) 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", + ) diff --git a/tests/test_git_provider.py b/tests/test_git_provider.py new file mode 100644 index 00000000..93fe2c2e --- /dev/null +++ b/tests/test_git_provider.py @@ -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"]) diff --git a/tests/test_github_pr_regression.py b/tests/test_github_pr_regression.py new file mode 100644 index 00000000..e156f4dc --- /dev/null +++ b/tests/test_github_pr_regression.py @@ -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"]) diff --git a/tests/test_gitlab_e2e.py b/tests/test_gitlab_e2e.py new file mode 100644 index 00000000..d353f456 --- /dev/null +++ b/tests/test_gitlab_e2e.py @@ -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) diff --git a/tests/test_gitlab_worktree.py b/tests/test_gitlab_worktree.py new file mode 100644 index 00000000..cef32863 --- /dev/null +++ b/tests/test_gitlab_worktree.py @@ -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"]