2eef82bf2a
* fix(backend): improve gh CLI detection for PR creation Fixes ACS-247 - Unable to Create PR - gh cli not found error The Python backend was using bare "gh" command which relies on the gh CLI being in the system PATH. On some systems, gh is installed in locations not in PATH (e.g., Homebrew on macOS, Program Files on Windows). Changes: - Add new gh_executable.py module for platform-specific gh CLI detection * Follows same pattern as git_executable.py * Checks GITHUB_CLI_PATH env var (from frontend) * Uses shutil.which() with fallback paths * Supports Homebrew (macOS), Program Files (Windows) - Update worktree.py to use detected gh path - Update frontend to pass GITHUB_CLI_PATH to Python backend This ensures PR creation works reliably even when gh CLI is not in the system PATH but is installed in common locations. Refs: ACS-247 * refactor: address PR review feedback on gh_executable.py - Fix unused global variable: return cached value instead of uncached - Extract repeated subprocess.run pattern into helper functions: * _verify_gh_executable() - validates gh by checking version * _run_where_command() - runs Windows 'where' command - Add explicit encoding='utf-8' to all subprocess.run calls - Add invalidate_gh_cache() function for cache invalidation Addresses review comments on PR #1071: - Unused global variable warning (Code Scanning) - Repeated subprocess.run pattern (Gemini Code Assist) - Missing explicit encoding (Gemini Code Assist) - Cache invalidation for edge cases (CodeRabbit) Refs: ACS-247, PR #1071 * refactor: address remaining PR review feedback - Add explanatory comment to except clause in _run_where_command() - Make _run_where_command() more specific by hardcoding "where gh" * Removes generic command parameter to reduce shell=True risk surface * Uses list argument ["where", "gh"] instead of string command - This addresses Code Scanning alert for empty except clause - This addresses CodeRabbit feedback about shell=True security Addresses additional review comments on PR #1071: - Empty except clause (Code Scanning) - Shell=True risk surface reduction (CodeRabbit) Refs: ACS-247, PR #1071 * fix: correct subprocess.run usage for Windows where command Fix bug where list argument ["where", "gh"] was used with shell=True, which is incorrect on Windows. When using shell=True, the command must be passed as a string, not a list. Changed from: subprocess.run(["where", "gh"], ..., shell=True) Changed to: subprocess.run("where gh", ..., shell=True) This follows the same pattern as git_executable.py and fixes the Sentry/CodeRabbit alerts about incorrect subprocess usage. Addresses additional review comments on PR #1071: - shell=True with list argument (CodeRabbit) - subprocess.run bug (Sentry) Refs: ACS-247, PR #1071 * refactor: remove redundant Windows path checks Remove hardcoded Windows paths that are redundant with the os.path.expandvars() calls. The expandvars calls will resolve to the same values as the hardcoded paths, so keeping both is unnecessary. Removed: - r"C:\Program Files\GitHub CLI\gh.exe" - r"C:\Program Files (x86)\GitHub CLI\gh.exe" Kept: - os.path.expandvars(r"%PROGRAMFILES%\GitHub CLI\gh.exe") - os.path.expandvars(r"%PROGRAMFILES(X86)%\GitHub CLI\gh.exe") - os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe") Addresses CodeRabbit review comment on PR #1071: - Minor redundancy in Windows path checks Refs: ACS-247, PR #1071 * fix: address PR review feedback on gh_executable.py Address 6 findings from PR review: - Add cache validation: check cached path still exists before returning - Add run_gh() helper function to match run_git() pattern - Validate _run_where_command() result with _verify_gh_executable() - Add comment explaining shell=True requirement for Windows 'where' builtin - Invalidate cache when FileNotFoundError occurs in worktree.py These changes improve robustness of gh CLI detection and error handling, ensuring stale cache entries are properly handled and the module follows the same patterns as git_executable.py. Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * docs: fix misleading comment about Windows 'where' command The comment incorrectly stated that 'where' is a Windows shell builtin. It is actually a standalone executable (where.exe). Updated comment to accurately reflect that shell=True is required for proper command execution. Addresses review comment #9 from PR #1071. Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * docs: clarify cache invalidation comment in FileNotFoundError handler The previous comment suggested the cache was invalidated "in case it was reinstalled", but this handler is reached when the cached path became invalid between get_gh_executable() check and subprocess.run() execution (e.g., file was deleted/moved). Updated comment to accurately reflect the purpose: clear stale cache so next call re-discovers the gh path. Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * fix: add cache invalidation in _get_existing_pr_url FileNotFoundError handler For consistency with create_pull_request(), invalidate gh cache when FileNotFoundError is caught. This ensures stale cached paths are cleared if the gh executable becomes invalid between get_gh_executable() check and subprocess.run() execution. Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> --------- Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
192 lines
5.8 KiB
Python
192 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GitHub CLI Executable Finder
|
|
============================
|
|
|
|
Utility to find the gh (GitHub CLI) executable, with platform-specific fallbacks.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
_cached_gh_path: str | None = None
|
|
|
|
|
|
def invalidate_gh_cache() -> None:
|
|
"""Invalidate the cached gh executable path.
|
|
|
|
Useful when gh may have been uninstalled, updated, or when
|
|
GITHUB_CLI_PATH environment variable has changed.
|
|
"""
|
|
global _cached_gh_path
|
|
_cached_gh_path = None
|
|
|
|
|
|
def _verify_gh_executable(path: str) -> bool:
|
|
"""Verify that a path is a valid gh executable by checking version.
|
|
|
|
Args:
|
|
path: Path to the potential gh executable
|
|
|
|
Returns:
|
|
True if the path points to a valid gh 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 gh' command to find gh executable.
|
|
|
|
Returns:
|
|
First path found, or None if command failed
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
"where gh",
|
|
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_gh_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_gh_executable() -> str | None:
|
|
"""Find the gh executable, with platform-specific fallbacks.
|
|
|
|
Returns the path to gh executable, or None if not found.
|
|
|
|
Priority order:
|
|
1. GITHUB_CLI_PATH env var (user-configured path from frontend)
|
|
2. shutil.which (if gh 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_gh_cache()
|
|
to force re-detection (e.g., after gh installation/uninstallation).
|
|
"""
|
|
global _cached_gh_path
|
|
|
|
# Return cached result if available AND still exists
|
|
if _cached_gh_path is not None and os.path.isfile(_cached_gh_path):
|
|
return _cached_gh_path
|
|
|
|
_cached_gh_path = _find_gh_executable()
|
|
return _cached_gh_path
|
|
|
|
|
|
def _find_gh_executable() -> str | None:
|
|
"""Internal function to find gh executable."""
|
|
# 1. Check GITHUB_CLI_PATH env var (set by Electron frontend)
|
|
env_path = os.environ.get("GITHUB_CLI_PATH")
|
|
if env_path and os.path.isfile(env_path) and _verify_gh_executable(env_path):
|
|
return env_path
|
|
|
|
# 2. Try shutil.which (works if gh is in PATH)
|
|
gh_path = shutil.which("gh")
|
|
if gh_path and _verify_gh_executable(gh_path):
|
|
return gh_path
|
|
|
|
# 3. macOS-specific: check Homebrew paths
|
|
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
|
homebrew_paths = [
|
|
"/opt/homebrew/bin/gh", # Apple Silicon
|
|
"/usr/local/bin/gh", # Intel Mac
|
|
"/home/linuxbrew/.linuxbrew/bin/gh", # Linux Homebrew
|
|
]
|
|
for path in homebrew_paths:
|
|
if os.path.isfile(path) and _verify_gh_executable(path):
|
|
return path
|
|
|
|
# 4. Windows-specific: check Program Files paths
|
|
if os.name == "nt":
|
|
windows_paths = [
|
|
os.path.expandvars(r"%PROGRAMFILES%\GitHub CLI\gh.exe"),
|
|
os.path.expandvars(r"%PROGRAMFILES(X86)%\GitHub CLI\gh.exe"),
|
|
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
|
|
]
|
|
for path in windows_paths:
|
|
if os.path.isfile(path) and _verify_gh_executable(path):
|
|
return path
|
|
|
|
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
|
return _run_where_command()
|
|
|
|
return None
|
|
|
|
|
|
def run_gh(
|
|
args: list[str],
|
|
cwd: str | None = None,
|
|
timeout: int = 60,
|
|
input_data: str | None = None,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a gh command with proper executable finding.
|
|
|
|
Args:
|
|
args: gh command arguments (without 'gh' 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.
|
|
"""
|
|
gh = get_gh_executable()
|
|
if not gh:
|
|
return subprocess.CompletedProcess(
|
|
args=["gh"] + args,
|
|
returncode=-1,
|
|
stdout="",
|
|
stderr="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
|
)
|
|
try:
|
|
return subprocess.run(
|
|
[gh] + 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=[gh] + args,
|
|
returncode=-1,
|
|
stdout="",
|
|
stderr=f"Command timed out after {timeout} seconds",
|
|
)
|
|
except FileNotFoundError:
|
|
return subprocess.CompletedProcess(
|
|
args=[gh] + args,
|
|
returncode=-1,
|
|
stdout="",
|
|
stderr="GitHub CLI (gh) executable not found. Install from https://cli.github.com/",
|
|
)
|