* Fix GitLab Merged MRs Not Displaying Fixes https://github.com/AndyMik90/Auto-Claude/issues/1521 - Added UseGitLabMRsOptions interface with stateFilter parameter - Updated hook signature to accept optional options parameter - Removed hardcoded useState for stateFilter - Defaults to 'opened' for backward compatibility - Pass stateFilter state to useGitLabMRs hook via options parameter - Enables proper filtering of MRs by state (opened/merged/closed/all) - Completes frontend implementation for GitLab MR state filtering Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * GitLab Support for Create PR Button (#2) Title: feat: add GitLab support for Create PR button (#2) Fixes: https://github.com/AndyMik90/Auto-Claude/issues/1519 Body: Add automatic git remote detection to route GitLab repositories to the `glab` CLI for creating merge requests, while preserving existing GitHub functionality. ## Changes - Add `git_provider.py` for detecting GitHub vs GitLab from remote URLs - Supports SSH and HTTPS formats - Supports self-hosted GitLab instances (detects "gitlab" in hostname) - Add `glab_executable.py` for finding GitLab CLI with platform-specific fallbacks - Update `WorktreeManager.push_and_create_pr()` to detect provider and route to either `create_pull_request()` (GitHub) or `create_merge_request()` (GitLab) - Add `create_merge_request()` method for GitLab MR creation via glab CLI - Update error messages to include provider-specific installation instructions ## Testing - Unit tests for `git_provider.py` detection logic - Integration tests for WorktreeManager PR/MR creation - Manual E2E tests for GitLab remote repositories - Regression tests to ensure GitHub PR creation still works Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add GitLab CLI (glab) Path Configuration to Settings (#3) feat(frontend): add GitLab CLI (glab) path configuration to Settings Add support for configuring the GitLab CLI (glab) path in the Settings page, consistent with existing GitHub CLI (gh) path configuration. Changes: - Add 'glab' to CLITool type union and gitlabCLIPath to ToolConfig - Implement detectGitLabCLI() and validateGitLabCLI() with multi-level detection (user config, Homebrew, system PATH, Windows Program Files) - Implement async variants for non-blocking detection - Add gitlabCLIPath to AppSettings interface and DEFAULT_APP_SETTINGS - Add glab to getCliToolsInfo IPC handler return type - Add gitlabCLIPath to pathFields array and configureTools calls - Add English and French translation keys for GitLab CLI path - Add GitLab CLI path input field to GeneralSettings component - Fix glab version regex to match actual output format ("glab X.Y.Z" instead of "glab version X.Y.Z") - Add augmented env to all sync CLI validators (Python, Git, gh, glab) for consistency with validateClaude and async validators Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * Fix GitLab bugs from CodeRabbitAI review comments Address actionable comments reported by CodeRabbitAI: - Fix SSH URL parsing in git_provider.py to support ssh:// URLs and arbitrary usernames (not just git@) - Fix Windows glab paths to use correct installation directory (glab\glab.exe instead of GitLab CLI\glab.exe) - Fix regex for GitLab MR URLs to correctly match both /merge_requests/ and /-/merge_requests/ patterns - Move inline json import to top-level in worktree.py - Fix incorrect mock paths in test_worktree_gitlab.py - Remove unused imports and f-strings without placeholders - Add WINDOWS_GLAB_PATHS constant to frontend for centralized path management - Replace fragile monkeypatch with unittest.mock.patch in manual tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Consolidate GitLab test files and move tests to tests/ directory - Remove duplicate test_gitlab_pr_manual.py, consolidate into test_gitlab_e2e.py - Expand provider detection to test 8 URL patterns (GitHub/GitLab variants) - Add WorktreeManager method signature verification test - Improve error message test to use unittest.mock.patch - Move all GitLab test files from apps/backend/core/ to tests/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Convert GitLab E2E tests to pytest-style assertions Rename test functions to _check_* helpers and create proper test_* pytest functions with assertions. This fixes PytestReturnNotNoneWarning warnings and ensures tests actually fail when checks return False. Fixes: https://github.com/AndyMik90/Auto-Claude/pull/1544#discussion_r2729260291 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix GitHub Enterprise detection in git_provider Broaden the hostname check in _classify_hostname to also detect GitHub Enterprise hostnames (e.g., github.company.com) by checking for "github" substring, matching the pattern already used for GitLab. Addresses CodeRabbitAI review comment on PR #1544. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CI failures: Ruff formatting and test isolation issues - Split long regex line in worktree.py for Ruff compliance - Fix test isolation issue caused by worktree.py importlib shim - Convert patch() calls to patch.object() pattern in test files - Add fixtures to test_github_pr_regression.py for consistency The importlib shim in apps/backend/worktree.py causes module-level patches to fail when tests run after test_agent_flow.py. Using patch.object() on the imported module directly resolves this. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Skip glab detection test when glab CLI is not installed Address CodeRabbit recommendation: add pytest import and guard test_glab_detection with get_glab_executable() check, using pytest.skip() when glab is not available on the system. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Disable GPG signing in test git repos to prevent CI hangs Tests may hang if the runner has global GPG signing enabled. Explicitly disable commit.gpgsign in create_test_git_repo. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix glab CLI path not passed to backend subprocess The frontend detected glab but never set GITLAB_CLI_PATH env var. Added 'glab' to CliTool type and CLI_TOOL_ENV_MAP, and call detectAndSetCliPath('glab') in setupProcessEnvironment. This ensures GitLab MR creation works when the app is launched from Finder/Dock and glab is in a non-standard PATH location. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix glab CLI flags and JSON field name in _get_existing_mr_url glab uses --output json (not --json fieldName like gh CLI) and returns snake_case field names (web_url instead of webUrl). Verified with actual glab mr view command on lcoffice repo. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Extract shared test fixtures to conftest.py Move temp_project_dir and worktree_manager fixtures from test_gitlab_worktree.py and test_github_pr_regression.py to a shared conftest.py file. Also adds GPG signing disable to the shared fixture for CI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Use throwaway variable for unused WorktreeManager instance Replace `manager` with `_` to indicate the variable is intentionally unused - the test only verifies the constructor doesn't raise. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove unused imports in test_gitlab_worktree.py Remove pytest and WorktreeManager imports that are not used in the file. Fixtures are provided by conftest.py which handles the imports. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Address PR review findings: cleanup and improve hostname matching - Remove unused MergeRequestResult TypedDict (dead code) - Rename GH_CLI_TIMEOUT/GH_QUERY_TIMEOUT to provider-neutral CLI_TIMEOUT/CLI_QUERY_TIMEOUT - Improve hostname classification to use precise domain segment matching (rejects edge cases like attacker-github.com while still matching github-enterprise.local) - Add test coverage for GitHub/GitLab hostname detection edge cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "Extract shared test fixtures to conftest.py" This reverts commit 22ab6662ee2710b86651dd630ee613e2d73ce395. * Extract shared test fixtures to conftest.py - Move temp_project_dir and worktree_manager fixtures to conftest.py (shared across GitLab and GitHub test suites) - Remove duplicate fixtures from test_github_pr_regression.py and test_gitlab_worktree.py - Remove unused subprocess import from test_gitlab_worktree.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Use consistent shim imports in GitLab/GitHub tests Switch to shim imports (worktree instead of core.worktree) to match other test files and avoid module aliasing issues caused by Python's module caching when tests use different import paths for the same module. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Isolate git environment in temp_project_dir fixture Pass sanitized environment to subprocess.run calls to prevent git operations from leaking into parent repos when tests run inside git worktrees (e.g., during pre-commit hooks). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CodeQL findings in test files - Remove URL substring check that triggered py/incomplete-url-substring-sanitization (redundant - error message check is sufficient) - Remove unused pytest import in test_gitlab_worktree.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use pushed remote for provider detection in multi-remote repos detect_git_provider() now accepts an optional remote_name parameter so push_and_create_pr() can pass the actual pushed remote instead of always checking 'origin'. This fixes incorrect PR/MR creation when repos have multiple remotes pointing to different providers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GitLab CLI Executable Finder
|
||||||
|
============================
|
||||||
|
|
||||||
|
Utility to find the glab (GitLab CLI) executable, with platform-specific fallbacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_cached_glab_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_glab_cache() -> None:
|
||||||
|
"""Invalidate the cached glab executable path.
|
||||||
|
|
||||||
|
Useful when glab may have been uninstalled, updated, or when
|
||||||
|
GITLAB_CLI_PATH environment variable has changed.
|
||||||
|
"""
|
||||||
|
global _cached_glab_path
|
||||||
|
_cached_glab_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_glab_executable(path: str) -> bool:
|
||||||
|
"""Verify that a path is a valid glab executable by checking version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the potential glab executable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the path points to a valid glab executable, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_where_command() -> str | None:
|
||||||
|
"""Run Windows 'where glab' command to find glab executable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
First path found, or None if command failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
"where glab",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=5,
|
||||||
|
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||||
|
if (
|
||||||
|
found_path
|
||||||
|
and os.path.isfile(found_path)
|
||||||
|
and _verify_glab_executable(found_path)
|
||||||
|
):
|
||||||
|
return found_path
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
# 'where' command failed or timed out - fall through to return None
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_glab_executable() -> str | None:
|
||||||
|
"""Find the glab executable, with platform-specific fallbacks.
|
||||||
|
|
||||||
|
Returns the path to glab executable, or None if not found.
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. GITLAB_CLI_PATH env var (user-configured path from frontend)
|
||||||
|
2. shutil.which (if glab is in PATH)
|
||||||
|
3. Homebrew paths on macOS
|
||||||
|
4. Windows Program Files paths
|
||||||
|
5. Windows 'where' command
|
||||||
|
|
||||||
|
Caches the result after first successful find. Use invalidate_glab_cache()
|
||||||
|
to force re-detection (e.g., after glab installation/uninstallation).
|
||||||
|
"""
|
||||||
|
global _cached_glab_path
|
||||||
|
|
||||||
|
# Return cached result if available AND still exists
|
||||||
|
if _cached_glab_path is not None and os.path.isfile(_cached_glab_path):
|
||||||
|
return _cached_glab_path
|
||||||
|
|
||||||
|
_cached_glab_path = _find_glab_executable()
|
||||||
|
return _cached_glab_path
|
||||||
|
|
||||||
|
|
||||||
|
def _find_glab_executable() -> str | None:
|
||||||
|
"""Internal function to find glab executable."""
|
||||||
|
# 1. Check GITLAB_CLI_PATH env var (set by Electron frontend)
|
||||||
|
env_path = os.environ.get("GITLAB_CLI_PATH")
|
||||||
|
if env_path and os.path.isfile(env_path) and _verify_glab_executable(env_path):
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
# 2. Try shutil.which (works if glab is in PATH)
|
||||||
|
glab_path = shutil.which("glab")
|
||||||
|
if glab_path and _verify_glab_executable(glab_path):
|
||||||
|
return glab_path
|
||||||
|
|
||||||
|
# 3. macOS-specific: check Homebrew paths
|
||||||
|
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
||||||
|
homebrew_paths = [
|
||||||
|
"/opt/homebrew/bin/glab", # Apple Silicon
|
||||||
|
"/usr/local/bin/glab", # Intel Mac
|
||||||
|
"/home/linuxbrew/.linuxbrew/bin/glab", # Linux Homebrew
|
||||||
|
]
|
||||||
|
for path in homebrew_paths:
|
||||||
|
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# 4. Windows-specific: check Program Files paths
|
||||||
|
# glab uses Inno Setup with DefaultDirName={autopf}\glab
|
||||||
|
if os.name == "nt":
|
||||||
|
windows_paths = [
|
||||||
|
os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"),
|
||||||
|
os.path.expandvars(r"%PROGRAMFILES(X86)%\glab\glab.exe"),
|
||||||
|
os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"),
|
||||||
|
]
|
||||||
|
for path in windows_paths:
|
||||||
|
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||||
|
return _run_where_command()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_glab(
|
||||||
|
args: list[str],
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
input_data: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Run a glab command with proper executable finding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: glab command arguments (without 'glab' prefix)
|
||||||
|
cwd: Working directory for the command
|
||||||
|
timeout: Command timeout in seconds (default: 60)
|
||||||
|
input_data: Optional string data to pass to stdin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompletedProcess with command results.
|
||||||
|
"""
|
||||||
|
glab = get_glab_executable()
|
||||||
|
if not glab:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=["glab"] + args,
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
[glab] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
input=input_data,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=[glab] + args,
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr=f"Command timed out after {timeout} seconds",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=[glab] + args,
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr="GitLab CLI (glab) executable not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||||
|
)
|
||||||
+255
-16
@@ -15,6 +15,7 @@ This allows:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -29,6 +30,8 @@ from typing import TypedDict, TypeVar
|
|||||||
|
|
||||||
from core.gh_executable import get_gh_executable, invalidate_gh_cache
|
from core.gh_executable import get_gh_executable, invalidate_gh_cache
|
||||||
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
|
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
|
||||||
|
from core.git_provider import detect_git_provider
|
||||||
|
from core.glab_executable import get_glab_executable, invalidate_glab_cache
|
||||||
from core.model_config import get_utility_model_config
|
from core.model_config import get_utility_model_config
|
||||||
from debug import debug_warning
|
from debug import debug_warning
|
||||||
|
|
||||||
@@ -140,6 +143,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
|
|||||||
pushed: bool
|
pushed: bool
|
||||||
remote: str
|
remote: str
|
||||||
branch: str
|
branch: str
|
||||||
|
provider: str # 'github', 'gitlab', or 'unknown'
|
||||||
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
||||||
already_exists: bool
|
already_exists: bool
|
||||||
error: str
|
error: str
|
||||||
@@ -179,8 +183,8 @@ class WorktreeManager:
|
|||||||
|
|
||||||
# Timeout constants for subprocess operations
|
# Timeout constants for subprocess operations
|
||||||
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
|
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
|
||||||
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
|
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
|
||||||
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
|
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
|
||||||
|
|
||||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||||
self.project_dir = project_dir
|
self.project_dir = project_dir
|
||||||
@@ -1254,7 +1258,7 @@ class WorktreeManager:
|
|||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
timeout=self.GH_CLI_TIMEOUT,
|
timeout=self.CLI_TIMEOUT,
|
||||||
env=get_isolated_git_env(),
|
env=get_isolated_git_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1330,7 +1334,165 @@ class WorktreeManager:
|
|||||||
invalidate_gh_cache()
|
invalidate_gh_cache()
|
||||||
return PullRequestResult(
|
return PullRequestResult(
|
||||||
success=False,
|
success=False,
|
||||||
error="gh CLI not found. Install from https://cli.github.com/",
|
error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_merge_request(
|
||||||
|
self,
|
||||||
|
spec_name: str,
|
||||||
|
target_branch: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
draft: bool = False,
|
||||||
|
) -> PullRequestResult:
|
||||||
|
"""
|
||||||
|
Create a GitLab merge request for a spec's branch using glab CLI with retry logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec_name: The spec folder name
|
||||||
|
target_branch: Target branch for MR (defaults to base_branch)
|
||||||
|
title: MR title (defaults to spec name)
|
||||||
|
draft: Whether to create as draft MR
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PullRequestResult with keys:
|
||||||
|
- success: bool
|
||||||
|
- pr_url: str (if created)
|
||||||
|
- already_exists: bool (if MR already exists)
|
||||||
|
- error: str (if failed)
|
||||||
|
"""
|
||||||
|
info = self.get_worktree_info(spec_name)
|
||||||
|
if not info:
|
||||||
|
return PullRequestResult(
|
||||||
|
success=False,
|
||||||
|
error=f"No worktree found for spec: {spec_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
target = target_branch or self.base_branch
|
||||||
|
mr_title = title or f"auto-claude: {spec_name}"
|
||||||
|
|
||||||
|
# Get MR body from spec.md if available
|
||||||
|
mr_body = self._extract_spec_summary(spec_name)
|
||||||
|
|
||||||
|
# Find glab executable before attempting MR creation
|
||||||
|
glab_executable = get_glab_executable()
|
||||||
|
if not glab_executable:
|
||||||
|
return PullRequestResult(
|
||||||
|
success=False,
|
||||||
|
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build glab mr create command
|
||||||
|
glab_args = [
|
||||||
|
glab_executable,
|
||||||
|
"mr",
|
||||||
|
"create",
|
||||||
|
"--target-branch",
|
||||||
|
target,
|
||||||
|
"--source-branch",
|
||||||
|
info.branch,
|
||||||
|
"--title",
|
||||||
|
mr_title,
|
||||||
|
"--description",
|
||||||
|
mr_body,
|
||||||
|
]
|
||||||
|
if draft:
|
||||||
|
glab_args.append("--draft")
|
||||||
|
|
||||||
|
def is_mr_retryable(stderr: str) -> bool:
|
||||||
|
"""Check if MR creation error is retryable (network or HTTP 5xx)."""
|
||||||
|
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
|
||||||
|
stderr
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_create_mr() -> tuple[bool, PullRequestResult | None, str]:
|
||||||
|
"""Execute MR creation for retry wrapper."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
glab_args,
|
||||||
|
cwd=info.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=self.CLI_TIMEOUT,
|
||||||
|
env=get_isolated_git_env(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for "already exists" case (success, no retry needed)
|
||||||
|
if result.returncode != 0 and "already exists" in result.stderr.lower():
|
||||||
|
existing_url = self._get_existing_mr_url(spec_name, target)
|
||||||
|
result_dict = PullRequestResult(
|
||||||
|
success=True,
|
||||||
|
pr_url=existing_url,
|
||||||
|
already_exists=True,
|
||||||
|
)
|
||||||
|
if existing_url is None:
|
||||||
|
result_dict["message"] = (
|
||||||
|
"MR already exists but URL could not be retrieved"
|
||||||
|
)
|
||||||
|
return (True, result_dict, "")
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Extract MR URL from output
|
||||||
|
mr_url: str | None = result.stdout.strip()
|
||||||
|
if not mr_url.startswith("http"):
|
||||||
|
# Try to find URL in output
|
||||||
|
# GitLab URL pattern: matches any HTTPS URL with /merge_requests/<number> or /-/merge_requests/<number> path
|
||||||
|
match = re.search(
|
||||||
|
r"https://[^\s]+(?:/merge_requests/|/-/merge_requests/)\d+",
|
||||||
|
result.stdout,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
mr_url = match.group(0)
|
||||||
|
else:
|
||||||
|
# Invalid output - no valid URL found
|
||||||
|
mr_url = None
|
||||||
|
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
PullRequestResult(
|
||||||
|
success=True,
|
||||||
|
pr_url=mr_url,
|
||||||
|
already_exists=False,
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
return (False, None, result.stderr)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# glab CLI not installed - not retryable, raise to exit retry loop
|
||||||
|
raise
|
||||||
|
|
||||||
|
max_retries = 3
|
||||||
|
try:
|
||||||
|
result, last_error = _with_retry(
|
||||||
|
operation=do_create_mr,
|
||||||
|
max_retries=max_retries,
|
||||||
|
is_retryable=is_mr_retryable,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Handle timeout error message
|
||||||
|
if last_error == "Operation timed out":
|
||||||
|
return PullRequestResult(
|
||||||
|
success=False,
|
||||||
|
error=f"MR creation timed out after {max_retries} attempts.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PullRequestResult(
|
||||||
|
success=False,
|
||||||
|
error=f"Failed to create MR: {last_error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Cached glab path became invalid - clear cache so next call re-discovers
|
||||||
|
invalidate_glab_cache()
|
||||||
|
return PullRequestResult(
|
||||||
|
success=False,
|
||||||
|
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]:
|
def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]:
|
||||||
@@ -1568,7 +1730,7 @@ class WorktreeManager:
|
|||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
timeout=self.GH_QUERY_TIMEOUT,
|
timeout=self.CLI_QUERY_TIMEOUT,
|
||||||
env=get_isolated_git_env(),
|
env=get_isolated_git_env(),
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
@@ -1587,6 +1749,57 @@ class WorktreeManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_existing_mr_url(self, spec_name: str, target_branch: str) -> str | None:
|
||||||
|
"""Get the URL of an existing MR for this branch."""
|
||||||
|
info = self.get_worktree_info(spec_name)
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
glab_executable = get_glab_executable()
|
||||||
|
if not glab_executable:
|
||||||
|
# glab CLI not found - return None and let caller handle it
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
glab_executable,
|
||||||
|
"mr",
|
||||||
|
"view",
|
||||||
|
info.branch,
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
cwd=info.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=self.CLI_QUERY_TIMEOUT,
|
||||||
|
env=get_isolated_git_env(),
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
# Parse JSON output to extract web_url (glab uses snake_case)
|
||||||
|
try:
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
return data.get("web_url")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# If JSON parsing fails, return None
|
||||||
|
pass
|
||||||
|
except (
|
||||||
|
subprocess.TimeoutExpired,
|
||||||
|
FileNotFoundError,
|
||||||
|
subprocess.SubprocessError,
|
||||||
|
) as e:
|
||||||
|
# Silently ignore errors when fetching existing MR URL - this is a best-effort
|
||||||
|
# lookup that may fail due to network issues, missing glab CLI, or auth problems.
|
||||||
|
# Returning None allows the caller to handle missing URLs gracefully.
|
||||||
|
if isinstance(e, FileNotFoundError):
|
||||||
|
invalidate_glab_cache()
|
||||||
|
debug_warning("worktree", f"Could not get existing MR URL: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def push_and_create_pr(
|
def push_and_create_pr(
|
||||||
self,
|
self,
|
||||||
spec_name: str,
|
spec_name: str,
|
||||||
@@ -1596,13 +1809,14 @@ class WorktreeManager:
|
|||||||
force_push: bool = False,
|
force_push: bool = False,
|
||||||
) -> PushAndCreatePRResult:
|
) -> PushAndCreatePRResult:
|
||||||
"""
|
"""
|
||||||
Push branch and create a pull request in one operation.
|
Push branch and create a pull request/merge request in one operation.
|
||||||
|
Automatically detects git provider (GitHub or GitLab) and routes to the appropriate CLI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
spec_name: The spec folder name
|
spec_name: The spec folder name
|
||||||
target_branch: Target branch for PR (defaults to base_branch)
|
target_branch: Target branch for PR/MR (defaults to base_branch)
|
||||||
title: PR title (defaults to spec name)
|
title: PR/MR title (defaults to spec name)
|
||||||
draft: Whether to create as draft PR
|
draft: Whether to create as draft PR/MR
|
||||||
force_push: Whether to force push the branch
|
force_push: Whether to force push the branch
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1610,7 +1824,8 @@ class WorktreeManager:
|
|||||||
- success: bool
|
- success: bool
|
||||||
- pr_url: str (if created)
|
- pr_url: str (if created)
|
||||||
- pushed: bool (if push succeeded)
|
- pushed: bool (if push succeeded)
|
||||||
- already_exists: bool (if PR already exists)
|
- provider: str ('github', 'gitlab', or 'unknown')
|
||||||
|
- already_exists: bool (if PR/MR already exists)
|
||||||
- error: str (if failed)
|
- error: str (if failed)
|
||||||
"""
|
"""
|
||||||
# Step 1: Push the branch
|
# Step 1: Push the branch
|
||||||
@@ -1624,20 +1839,44 @@ class WorktreeManager:
|
|||||||
error=push_result.get("error", "Push failed"),
|
error=push_result.get("error", "Push failed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Create the PR
|
# Step 2: Detect git provider (use the remote that was pushed to)
|
||||||
pr_result = self.create_pull_request(
|
provider = detect_git_provider(
|
||||||
spec_name=spec_name,
|
self.project_dir, remote_name=push_result.get("remote")
|
||||||
target_branch=target_branch,
|
|
||||||
title=title,
|
|
||||||
draft=draft,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step 3: Create the PR/MR based on provider
|
||||||
|
if provider == "github":
|
||||||
|
pr_result = self.create_pull_request(
|
||||||
|
spec_name=spec_name,
|
||||||
|
target_branch=target_branch,
|
||||||
|
title=title,
|
||||||
|
draft=draft,
|
||||||
|
)
|
||||||
|
elif provider == "gitlab":
|
||||||
|
pr_result = self.create_merge_request(
|
||||||
|
spec_name=spec_name,
|
||||||
|
target_branch=target_branch,
|
||||||
|
title=title,
|
||||||
|
draft=draft,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Unknown provider
|
||||||
|
return PushAndCreatePRResult(
|
||||||
|
success=False,
|
||||||
|
pushed=True,
|
||||||
|
remote=push_result.get("remote"),
|
||||||
|
branch=push_result.get("branch"),
|
||||||
|
provider=provider,
|
||||||
|
error="Unable to determine git hosting provider. Supported: GitHub, GitLab.",
|
||||||
|
)
|
||||||
|
|
||||||
# Combine results
|
# Combine results
|
||||||
return PushAndCreatePRResult(
|
return PushAndCreatePRResult(
|
||||||
success=pr_result.get("success", False),
|
success=pr_result.get("success", False),
|
||||||
pushed=True,
|
pushed=True,
|
||||||
remote=push_result.get("remote"),
|
remote=push_result.get("remote"),
|
||||||
branch=push_result.get("branch"),
|
branch=push_result.get("branch"),
|
||||||
|
provider=provider,
|
||||||
pr_url=pr_result.get("pr_url"),
|
pr_url=pr_result.get("pr_url"),
|
||||||
already_exists=pr_result.get("already_exists", False),
|
already_exists=pr_result.get("already_exists", False),
|
||||||
error=pr_result.get("error"),
|
error=pr_result.get("error"),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { killProcessGracefully, isWindows } from '../platform';
|
|||||||
/**
|
/**
|
||||||
* Type for supported CLI tools
|
* Type for supported CLI tools
|
||||||
*/
|
*/
|
||||||
type CliTool = 'claude' | 'gh';
|
type CliTool = 'claude' | 'gh' | 'glab';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of CLI tools to their environment variable names
|
* Mapping of CLI tools to their environment variable names
|
||||||
@@ -37,7 +37,8 @@ type CliTool = 'claude' | 'gh';
|
|||||||
*/
|
*/
|
||||||
const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = {
|
const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = {
|
||||||
claude: 'CLAUDE_CLI_PATH',
|
claude: 'CLAUDE_CLI_PATH',
|
||||||
gh: 'GITHUB_CLI_PATH'
|
gh: 'GITHUB_CLI_PATH',
|
||||||
|
glab: 'GITLAB_CLI_PATH'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
@@ -201,12 +202,14 @@ export class AgentProcessManager {
|
|||||||
// Detect and pass CLI tool paths to Python backend
|
// Detect and pass CLI tool paths to Python backend
|
||||||
const claudeCliEnv = this.detectAndSetCliPath('claude');
|
const claudeCliEnv = this.detectAndSetCliPath('claude');
|
||||||
const ghCliEnv = this.detectAndSetCliPath('gh');
|
const ghCliEnv = this.detectAndSetCliPath('gh');
|
||||||
|
const glabCliEnv = this.detectAndSetCliPath('glab');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...augmentedEnv,
|
...augmentedEnv,
|
||||||
...gitBashEnv,
|
...gitBashEnv,
|
||||||
...claudeCliEnv,
|
...claudeCliEnv,
|
||||||
...ghCliEnv,
|
...ghCliEnv,
|
||||||
|
...glabCliEnv,
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
...profileEnv,
|
...profileEnv,
|
||||||
PYTHONUNBUFFERED: '1',
|
PYTHONUNBUFFERED: '1',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
getWindowsExecutablePaths,
|
getWindowsExecutablePaths,
|
||||||
getWindowsExecutablePathsAsync,
|
getWindowsExecutablePathsAsync,
|
||||||
WINDOWS_GIT_PATHS,
|
WINDOWS_GIT_PATHS,
|
||||||
|
WINDOWS_GLAB_PATHS,
|
||||||
findWindowsExecutableViaWhere,
|
findWindowsExecutableViaWhere,
|
||||||
findWindowsExecutableViaWhereAsync,
|
findWindowsExecutableViaWhereAsync,
|
||||||
isSecurePath,
|
isSecurePath,
|
||||||
@@ -54,7 +55,7 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Supported CLI tools managed by this system
|
* Supported CLI tools managed by this system
|
||||||
*/
|
*/
|
||||||
export type CLITool = 'python' | 'git' | 'gh' | 'claude';
|
export type CLITool = 'python' | 'git' | 'gh' | 'glab' | 'claude';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User configuration for CLI tool paths
|
* User configuration for CLI tool paths
|
||||||
@@ -64,6 +65,7 @@ export interface ToolConfig {
|
|||||||
pythonPath?: string;
|
pythonPath?: string;
|
||||||
gitPath?: string;
|
gitPath?: string;
|
||||||
githubCLIPath?: string;
|
githubCLIPath?: string;
|
||||||
|
gitlabCLIPath?: string;
|
||||||
claudePath?: string;
|
claudePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +372,8 @@ class CLIToolManager {
|
|||||||
return this.detectGit();
|
return this.detectGit();
|
||||||
case 'gh':
|
case 'gh':
|
||||||
return this.detectGitHubCLI();
|
return this.detectGitHubCLI();
|
||||||
|
case 'glab':
|
||||||
|
return this.detectGitLabCLI();
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return this.detectClaude();
|
return this.detectClaude();
|
||||||
default:
|
default:
|
||||||
@@ -719,6 +723,105 @@ class CLIToolManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect GitLab CLI with multi-level priority
|
||||||
|
*
|
||||||
|
* Priority order:
|
||||||
|
* 1. User configuration (if valid for current platform)
|
||||||
|
* 2. Homebrew glab (macOS)
|
||||||
|
* 3. System PATH
|
||||||
|
* 4. Windows Program Files
|
||||||
|
*
|
||||||
|
* @returns Detection result for GitLab CLI
|
||||||
|
*/
|
||||||
|
private detectGitLabCLI(): ToolDetectionResult {
|
||||||
|
// 1. User configuration
|
||||||
|
if (this.userConfig.gitlabCLIPath) {
|
||||||
|
// Check if path is from wrong platform (e.g., Windows path on macOS)
|
||||||
|
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
|
||||||
|
console.warn(
|
||||||
|
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const validation = this.validateGitLabCLI(this.userConfig.gitlabCLIPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: this.userConfig.gitlabCLIPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'user-config',
|
||||||
|
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
`[GitLab CLI] User-configured path invalid: ${validation.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Homebrew (macOS)
|
||||||
|
if (isMacOS()) {
|
||||||
|
const homebrewPaths = [
|
||||||
|
'/opt/homebrew/bin/glab', // Apple Silicon
|
||||||
|
'/usr/local/bin/glab', // Intel Mac
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const glabPath of homebrewPaths) {
|
||||||
|
if (existsSync(glabPath)) {
|
||||||
|
const validation = this.validateGitLabCLI(glabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: glabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'homebrew',
|
||||||
|
message: `Using Homebrew GitLab CLI: ${glabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System PATH (augmented)
|
||||||
|
const glabPath = findExecutable('glab');
|
||||||
|
if (glabPath) {
|
||||||
|
const validation = this.validateGitLabCLI(glabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: glabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'system-path',
|
||||||
|
message: `Using system GitLab CLI: ${glabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Windows Program Files
|
||||||
|
if (isWindows()) {
|
||||||
|
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
|
||||||
|
for (const glabPath of windowsPaths) {
|
||||||
|
const validation = this.validateGitLabCLI(glabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: glabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'system-path',
|
||||||
|
message: `Using Windows GitLab CLI: ${glabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Not found
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
source: 'fallback',
|
||||||
|
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect Claude CLI with multi-level priority
|
* Detect Claude CLI with multi-level priority
|
||||||
*
|
*
|
||||||
@@ -846,6 +949,7 @@ class CLIToolManager {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: getAugmentedEnv(),
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
const match = version.match(/Python (\d+\.\d+\.\d+)/);
|
const match = version.match(/Python (\d+\.\d+\.\d+)/);
|
||||||
@@ -896,6 +1000,7 @@ class CLIToolManager {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: getAugmentedEnv(),
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
const match = version.match(/git version (\d+\.\d+\.\d+)/);
|
const match = version.match(/git version (\d+\.\d+\.\d+)/);
|
||||||
@@ -926,6 +1031,7 @@ class CLIToolManager {
|
|||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: getAugmentedEnv(),
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
const match = version.match(/gh version (\d+\.\d+\.\d+)/);
|
const match = version.match(/gh version (\d+\.\d+\.\d+)/);
|
||||||
@@ -944,6 +1050,38 @@ class CLIToolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate GitLab CLI availability and version
|
||||||
|
*
|
||||||
|
* @param glabCmd - The GitLab CLI command to validate
|
||||||
|
* @returns Validation result with version information
|
||||||
|
*/
|
||||||
|
private validateGitLabCLI(glabCmd: string): ToolValidation {
|
||||||
|
try {
|
||||||
|
const version = execFileSync(glabCmd, ['--version'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: true,
|
||||||
|
env: getAugmentedEnv(),
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
|
||||||
|
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
|
||||||
|
const versionStr = match ? match[1] : version.split('\n')[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
version: versionStr,
|
||||||
|
message: `GitLab CLI ${versionStr} is available`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate Claude CLI availability and version
|
* Validate Claude CLI availability and version
|
||||||
*
|
*
|
||||||
@@ -1097,6 +1235,8 @@ class CLIToolManager {
|
|||||||
return this.detectGitAsync();
|
return this.detectGitAsync();
|
||||||
case 'gh':
|
case 'gh':
|
||||||
return this.detectGitHubCLIAsync();
|
return this.detectGitHubCLIAsync();
|
||||||
|
case 'glab':
|
||||||
|
return this.detectGitLabCLIAsync();
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
found: false,
|
found: false,
|
||||||
@@ -1298,6 +1438,39 @@ class CLIToolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate GitLab CLI availability and version asynchronously (non-blocking)
|
||||||
|
*
|
||||||
|
* @param glabCmd - The GitLab CLI command to validate
|
||||||
|
* @returns Promise resolving to validation result
|
||||||
|
*/
|
||||||
|
private async validateGitLabCLIAsync(glabCmd: string): Promise<ToolValidation> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(glabCmd, ['--version'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: true,
|
||||||
|
env: await getAugmentedEnvAsync(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const version = stdout.trim();
|
||||||
|
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
|
||||||
|
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
|
||||||
|
const versionStr = match ? match[1] : version.split('\n')[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
version: versionStr,
|
||||||
|
message: `GitLab CLI ${versionStr} is available`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect Claude CLI asynchronously (non-blocking)
|
* Detect Claude CLI asynchronously (non-blocking)
|
||||||
*
|
*
|
||||||
@@ -1724,6 +1897,98 @@ class CLIToolManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect GitLab CLI asynchronously (non-blocking)
|
||||||
|
*
|
||||||
|
* Same detection logic as detectGitLabCLI but uses async validation.
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to detection result
|
||||||
|
*/
|
||||||
|
private async detectGitLabCLIAsync(): Promise<ToolDetectionResult> {
|
||||||
|
// 1. User configuration
|
||||||
|
if (this.userConfig.gitlabCLIPath) {
|
||||||
|
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
|
||||||
|
console.warn(
|
||||||
|
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const validation = await this.validateGitLabCLIAsync(this.userConfig.gitlabCLIPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: this.userConfig.gitlabCLIPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'user-config',
|
||||||
|
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.warn(`[GitLab CLI] User-configured path invalid: ${validation.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Homebrew (macOS)
|
||||||
|
if (isMacOS()) {
|
||||||
|
const homebrewPaths = [
|
||||||
|
'/opt/homebrew/bin/glab',
|
||||||
|
'/usr/local/bin/glab',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const glabPath of homebrewPaths) {
|
||||||
|
if (await existsAsync(glabPath)) {
|
||||||
|
const validation = await this.validateGitLabCLIAsync(glabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: glabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'homebrew',
|
||||||
|
message: `Using Homebrew GitLab CLI: ${glabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System PATH (augmented)
|
||||||
|
const glabPath = await findExecutableAsync('glab');
|
||||||
|
if (glabPath) {
|
||||||
|
const validation = await this.validateGitLabCLIAsync(glabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: glabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'system-path',
|
||||||
|
message: `Using system GitLab CLI: ${glabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Windows Program Files
|
||||||
|
if (isWindows()) {
|
||||||
|
const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
|
||||||
|
for (const winGlabPath of windowsPaths) {
|
||||||
|
const validation = await this.validateGitLabCLIAsync(winGlabPath);
|
||||||
|
if (validation.valid) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
path: winGlabPath,
|
||||||
|
version: validation.version,
|
||||||
|
source: 'system-path',
|
||||||
|
message: `Using Windows GitLab CLI: ${winGlabPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Not found
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
source: 'fallback',
|
||||||
|
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bundled Python path for packaged apps
|
* Get bundled Python path for packaged apps
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function registerSettingsHandlers(
|
|||||||
// Fixes issue where Windows paths persisted on macOS (and vice versa)
|
// Fixes issue where Windows paths persisted on macOS (and vice versa)
|
||||||
// when settings were synced/transferred between platforms
|
// when settings were synced/transferred between platforms
|
||||||
// See: https://github.com/AndyMik90/Auto-Claude/issues/XXX
|
// See: https://github.com/AndyMik90/Auto-Claude/issues/XXX
|
||||||
const pathFields = ['pythonPath', 'gitPath', 'githubCLIPath', 'claudePath', 'autoBuildPath'] as const;
|
const pathFields = ['pythonPath', 'gitPath', 'githubCLIPath', 'gitlabCLIPath', 'claudePath', 'autoBuildPath'] as const;
|
||||||
for (const field of pathFields) {
|
for (const field of pathFields) {
|
||||||
const pathValue = settings[field];
|
const pathValue = settings[field];
|
||||||
if (pathValue && isPathFromWrongPlatform(pathValue)) {
|
if (pathValue && isPathFromWrongPlatform(pathValue)) {
|
||||||
@@ -174,6 +174,7 @@ export function registerSettingsHandlers(
|
|||||||
pythonPath: settings.pythonPath,
|
pythonPath: settings.pythonPath,
|
||||||
gitPath: settings.gitPath,
|
gitPath: settings.gitPath,
|
||||||
githubCLIPath: settings.githubCLIPath,
|
githubCLIPath: settings.githubCLIPath,
|
||||||
|
gitlabCLIPath: settings.gitlabCLIPath,
|
||||||
claudePath: settings.claudePath,
|
claudePath: settings.claudePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,12 +216,14 @@ export function registerSettingsHandlers(
|
|||||||
settings.pythonPath !== undefined ||
|
settings.pythonPath !== undefined ||
|
||||||
settings.gitPath !== undefined ||
|
settings.gitPath !== undefined ||
|
||||||
settings.githubCLIPath !== undefined ||
|
settings.githubCLIPath !== undefined ||
|
||||||
|
settings.gitlabCLIPath !== undefined ||
|
||||||
settings.claudePath !== undefined
|
settings.claudePath !== undefined
|
||||||
) {
|
) {
|
||||||
configureTools({
|
configureTools({
|
||||||
pythonPath: newSettings.pythonPath,
|
pythonPath: newSettings.pythonPath,
|
||||||
gitPath: newSettings.gitPath,
|
gitPath: newSettings.gitPath,
|
||||||
githubCLIPath: newSettings.githubCLIPath,
|
githubCLIPath: newSettings.githubCLIPath,
|
||||||
|
gitlabCLIPath: newSettings.gitlabCLIPath,
|
||||||
claudePath: newSettings.claudePath,
|
claudePath: newSettings.claudePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,6 +263,7 @@ export function registerSettingsHandlers(
|
|||||||
python: ReturnType<typeof getToolInfo>;
|
python: ReturnType<typeof getToolInfo>;
|
||||||
git: ReturnType<typeof getToolInfo>;
|
git: ReturnType<typeof getToolInfo>;
|
||||||
gh: ReturnType<typeof getToolInfo>;
|
gh: ReturnType<typeof getToolInfo>;
|
||||||
|
glab: ReturnType<typeof getToolInfo>;
|
||||||
claude: ReturnType<typeof getToolInfo>;
|
claude: ReturnType<typeof getToolInfo>;
|
||||||
}>> => {
|
}>> => {
|
||||||
try {
|
try {
|
||||||
@@ -269,6 +273,7 @@ export function registerSettingsHandlers(
|
|||||||
python: getToolInfo('python'),
|
python: getToolInfo('python'),
|
||||||
git: getToolInfo('git'),
|
git: getToolInfo('git'),
|
||||||
gh: getToolInfo('gh'),
|
gh: getToolInfo('gh'),
|
||||||
|
glab: getToolInfo('glab'),
|
||||||
claude: getToolInfo('claude'),
|
claude: getToolInfo('claude'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export const WINDOWS_GIT_PATHS: WindowsToolPaths = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WINDOWS_GLAB_PATHS: WindowsToolPaths = {
|
||||||
|
toolName: 'GitLab CLI',
|
||||||
|
executable: 'glab.exe',
|
||||||
|
patterns: [
|
||||||
|
// Official Inno Setup installer path (DefaultDirName={autopf}\glab)
|
||||||
|
'%PROGRAMFILES%\\glab',
|
||||||
|
'%PROGRAMFILES(X86)%\\glab',
|
||||||
|
'%LOCALAPPDATA%\\Programs\\glab',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export function isSecurePath(pathStr: string): boolean {
|
export function isSecurePath(pathStr: string): boolean {
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [
|
||||||
/[;&|`${}[\]<>!"^]/, // Shell metacharacters (parentheses removed - safe when quoted)
|
/[;&|`${}[\]<>!"^]/, // Shell metacharacters (parentheses removed - safe when quoted)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function GitLabMergeRequests({ projectId, onOpenSettings }: GitLabMergeRe
|
|||||||
mergeMR,
|
mergeMR,
|
||||||
assignMR,
|
assignMR,
|
||||||
approveMR,
|
approveMR,
|
||||||
} = useGitLabMRs(projectId);
|
} = useGitLabMRs(projectId, { stateFilter });
|
||||||
|
|
||||||
const handleCreateSuccess = async (mrIid: number) => {
|
const handleCreateSuccess = async (mrIid: number) => {
|
||||||
// Refresh the list and select the newly created MR
|
// Refresh the list and select the newly created MR
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
export type { GitLabMergeRequest, GitLabMRReviewResult, GitLabMRReviewProgress };
|
export type { GitLabMergeRequest, GitLabMRReviewResult, GitLabMRReviewProgress };
|
||||||
export type { GitLabMRReviewFinding } from '../../../../shared/types';
|
export type { GitLabMRReviewFinding } from '../../../../shared/types';
|
||||||
|
|
||||||
|
interface UseGitLabMRsOptions {
|
||||||
|
/** Filter MRs by state */
|
||||||
|
stateFilter?: 'opened' | 'closed' | 'merged' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
interface UseGitLabMRsResult {
|
interface UseGitLabMRsResult {
|
||||||
mergeRequests: GitLabMergeRequest[];
|
mergeRequests: GitLabMergeRequest[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -47,14 +52,14 @@ interface UseGitLabMRsResult {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitLabMRs(projectId?: string): UseGitLabMRsResult {
|
export function useGitLabMRs(projectId?: string, options: UseGitLabMRsOptions = {}): UseGitLabMRsResult {
|
||||||
|
const { stateFilter = 'opened' } = options;
|
||||||
const [mergeRequests, setMergeRequests] = useState<GitLabMergeRequest[]>([]);
|
const [mergeRequests, setMergeRequests] = useState<GitLabMergeRequest[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMRIid, setSelectedMRIid] = useState<number | null>(null);
|
const [selectedMRIid, setSelectedMRIid] = useState<number | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||||
const [stateFilter] = useState<'opened' | 'closed' | 'merged' | 'all'>('opened');
|
|
||||||
|
|
||||||
// Get MR review state from the global store
|
// Get MR review state from the global store
|
||||||
const mrReviews = useMRReviewStore((state) => state.mrReviews);
|
const mrReviews = useMRReviewStore((state) => state.mrReviews);
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
|
|||||||
python: ToolDetectionResult;
|
python: ToolDetectionResult;
|
||||||
git: ToolDetectionResult;
|
git: ToolDetectionResult;
|
||||||
gh: ToolDetectionResult;
|
gh: ToolDetectionResult;
|
||||||
|
glab: ToolDetectionResult;
|
||||||
claude: ToolDetectionResult;
|
claude: ToolDetectionResult;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isLoadingTools, setIsLoadingTools] = useState(false);
|
const [isLoadingTools, setIsLoadingTools] = useState(false);
|
||||||
@@ -106,7 +107,7 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
|
|||||||
setIsLoadingTools(true);
|
setIsLoadingTools(true);
|
||||||
window.electronAPI
|
window.electronAPI
|
||||||
.getCliToolsInfo()
|
.getCliToolsInfo()
|
||||||
.then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; claude: ToolDetectionResult } }) => {
|
.then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult; glab: ToolDetectionResult; claude: ToolDetectionResult } }) => {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setToolsInfo(result.data);
|
setToolsInfo(result.data);
|
||||||
}
|
}
|
||||||
@@ -305,6 +306,24 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="gitlabCLIPath" className="text-sm font-medium text-foreground">{t('general.gitlabCLIPath')}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('general.gitlabCLIPathDescription')}</p>
|
||||||
|
<Input
|
||||||
|
id="gitlabCLIPath"
|
||||||
|
placeholder={t('general.gitlabCLIPathPlaceholder')}
|
||||||
|
className="w-full max-w-lg"
|
||||||
|
value={settings.gitlabCLIPath || ''}
|
||||||
|
onChange={(e) => onSettingsChange({ ...settings, gitlabCLIPath: e.target.value })}
|
||||||
|
/>
|
||||||
|
{!settings.gitlabCLIPath && (
|
||||||
|
<ToolDetectionDisplay
|
||||||
|
info={toolsInfo?.glab || null}
|
||||||
|
isLoading={isLoadingTools}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="claudePath" className="text-sm font-medium text-foreground">{t('general.claudePath')}</Label>
|
<Label htmlFor="claudePath" className="text-sm font-medium text-foreground">{t('general.claudePath')}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t('general.claudePathDescription')}</p>
|
<p className="text-sm text-muted-foreground">{t('general.claudePathDescription')}</p>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const settingsMock = {
|
|||||||
python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
||||||
git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
||||||
gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
||||||
|
glab: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' },
|
||||||
claude: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }
|
claude: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const DEFAULT_APP_SETTINGS = {
|
|||||||
pythonPath: undefined as string | undefined,
|
pythonPath: undefined as string | undefined,
|
||||||
gitPath: undefined as string | undefined,
|
gitPath: undefined as string | undefined,
|
||||||
githubCLIPath: undefined as string | undefined,
|
githubCLIPath: undefined as string | undefined,
|
||||||
|
gitlabCLIPath: undefined as string | undefined,
|
||||||
autoBuildPath: undefined as string | undefined,
|
autoBuildPath: undefined as string | undefined,
|
||||||
autoUpdateAutoBuild: true,
|
autoUpdateAutoBuild: true,
|
||||||
autoNameTerminals: true,
|
autoNameTerminals: true,
|
||||||
|
|||||||
@@ -208,6 +208,9 @@
|
|||||||
"githubCLIPath": "GitHub CLI Path",
|
"githubCLIPath": "GitHub CLI Path",
|
||||||
"githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)",
|
"githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)",
|
||||||
"githubCLIPathPlaceholder": "gh (default)",
|
"githubCLIPathPlaceholder": "gh (default)",
|
||||||
|
"gitlabCLIPath": "GitLab CLI Path",
|
||||||
|
"gitlabCLIPathDescription": "Path to GitLab CLI (glab) executable (leave empty for auto-detection)",
|
||||||
|
"gitlabCLIPathPlaceholder": "glab (default)",
|
||||||
"claudePath": "Claude CLI Path",
|
"claudePath": "Claude CLI Path",
|
||||||
"claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)",
|
"claudePathDescription": "Path to Claude CLI executable (leave empty for auto-detection)",
|
||||||
"claudePathPlaceholder": "claude (default)",
|
"claudePathPlaceholder": "claude (default)",
|
||||||
|
|||||||
@@ -208,6 +208,9 @@
|
|||||||
"githubCLIPath": "Chemin GitHub CLI",
|
"githubCLIPath": "Chemin GitHub CLI",
|
||||||
"githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)",
|
"githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)",
|
||||||
"githubCLIPathPlaceholder": "gh (par défaut)",
|
"githubCLIPathPlaceholder": "gh (par défaut)",
|
||||||
|
"gitlabCLIPath": "Chemin GitLab CLI",
|
||||||
|
"gitlabCLIPathDescription": "Chemin vers l'exécutable GitLab CLI (glab) (laisser vide pour détection automatique)",
|
||||||
|
"gitlabCLIPathPlaceholder": "glab (par défaut)",
|
||||||
"claudePath": "Chemin Claude CLI",
|
"claudePath": "Chemin Claude CLI",
|
||||||
"claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)",
|
"claudePathDescription": "Chemin vers l'exécutable Claude CLI (laisser vide pour détection automatique)",
|
||||||
"claudePathPlaceholder": "claude (par défaut)",
|
"claudePathPlaceholder": "claude (par défaut)",
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export interface ElectronAPI {
|
|||||||
python: import('./cli').ToolDetectionResult;
|
python: import('./cli').ToolDetectionResult;
|
||||||
git: import('./cli').ToolDetectionResult;
|
git: import('./cli').ToolDetectionResult;
|
||||||
gh: import('./cli').ToolDetectionResult;
|
gh: import('./cli').ToolDetectionResult;
|
||||||
|
glab: import('./cli').ToolDetectionResult;
|
||||||
claude: import('./cli').ToolDetectionResult;
|
claude: import('./cli').ToolDetectionResult;
|
||||||
}>>;
|
}>>;
|
||||||
/** Check if Claude Code onboarding is complete (reads ~/.claude.json) */
|
/** Check if Claude Code onboarding is complete (reads ~/.claude.json) */
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface AppSettings {
|
|||||||
pythonPath?: string;
|
pythonPath?: string;
|
||||||
gitPath?: string;
|
gitPath?: string;
|
||||||
githubCLIPath?: string;
|
githubCLIPath?: string;
|
||||||
|
gitlabCLIPath?: string;
|
||||||
claudePath?: string;
|
claudePath?: string;
|
||||||
autoBuildPath?: string;
|
autoBuildPath?: string;
|
||||||
autoUpdateAutoBuild: boolean;
|
autoUpdateAutoBuild: boolean;
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ def pytest_runtest_setup(item):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DIRECTORY FIXTURES
|
# DIRECTORY FIXTURES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1141,3 +1143,108 @@ def temp_project(temp_git_repo: Path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return temp_git_repo
|
return temp_git_repo
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WORKTREE MANAGER FIXTURES - For GitLab/GitHub integration tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_project_dir(tmp_path):
|
||||||
|
"""Create a temporary project directory with proper git setup.
|
||||||
|
|
||||||
|
IMPORTANT: This fixture properly isolates git operations by passing
|
||||||
|
a sanitized environment to subprocess.run calls, clearing git environment
|
||||||
|
variables that may be set by pre-commit hooks. Without this isolation,
|
||||||
|
git operations could affect the parent repository when tests run inside
|
||||||
|
a git worktree (e.g., during pre-commit validation).
|
||||||
|
|
||||||
|
See: https://git-scm.com/docs/git#_environment_variables
|
||||||
|
"""
|
||||||
|
project_dir = tmp_path / "test-project"
|
||||||
|
project_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a sanitized environment for git commands to prevent leaking
|
||||||
|
# into parent repos when running inside git worktrees (e.g., pre-commit)
|
||||||
|
git_env = os.environ.copy()
|
||||||
|
git_vars_to_clear = [
|
||||||
|
"GIT_DIR",
|
||||||
|
"GIT_WORK_TREE",
|
||||||
|
"GIT_INDEX_FILE",
|
||||||
|
"GIT_OBJECT_DIRECTORY",
|
||||||
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
|
]
|
||||||
|
for var in git_vars_to_clear:
|
||||||
|
git_env.pop(var, None)
|
||||||
|
|
||||||
|
# Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
|
||||||
|
git_env["GIT_CEILING_DIRECTORIES"] = str(tmp_path.parent)
|
||||||
|
|
||||||
|
# Initialize git repo
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.name", "Test User"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.email", "test@example.com"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disable GPG signing to prevent hangs in CI
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "commit.gpgsign", "false"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial commit
|
||||||
|
readme = project_dir / "README.md"
|
||||||
|
readme.write_text("# Test Project\n")
|
||||||
|
subprocess.run(
|
||||||
|
["git", "add", "README.md"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", "Initial commit"],
|
||||||
|
cwd=project_dir,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
env=git_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
return project_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worktree_manager(temp_project_dir):
|
||||||
|
"""Create a WorktreeManager instance."""
|
||||||
|
from core.worktree import WorktreeManager
|
||||||
|
|
||||||
|
# Create .auto-claude directories
|
||||||
|
auto_claude_dir = temp_project_dir / ".auto-claude"
|
||||||
|
auto_claude_dir.mkdir(exist_ok=True)
|
||||||
|
(auto_claude_dir / "specs").mkdir(exist_ok=True)
|
||||||
|
(auto_claude_dir / "worktrees" / "tasks").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return WorktreeManager(
|
||||||
|
project_dir=temp_project_dir,
|
||||||
|
base_branch="main",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -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"])
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user