Files
Aperant/apps/backend/runners/gitlab/runner.py
T
StillKnotKnown 71a9fc848f fix(windows): prevent pywintypes import errors before dependency validation (ACS-253) (#1057)
* fix(windows): prevent pywintypes import errors before dependency validation

This fixes ACS-253 where Windows users on Python 3.12+ encounter
ModuleNotFoundError for pywintypes when importing mcp.client.stdio.

The issue occurred because graphiti_config was imported at module level
in cli/utils.py, which triggered the import chain:
  graphiti_config → graphiti_core → real_ladybug → pywintypes

This happened BEFORE validate_platform_dependencies() could check for
pywin32 and provide helpful installation instructions.

Changes:
- cli/utils.py: Made graphiti_config import lazy (moved into
  validate_environment() function where it's actually used)
- run.py: Added early validate_platform_dependencies() call before
  importing cli.main
- runners/spec_runner.py: Added early validate_platform_dependencies()
  call before importing cli.utils

Users now get a clear error message with installation instructions
when pywin32 is missing, rather than a cryptic pywintypes import error.

Refs: ACS-253

* test(windows): add comprehensive tests for dependency validator

This adds test coverage for the ACS-253 fix preventing pywintypes import
errors on Windows Python 3.12+.

Test Coverage:
- TestValidatePlatformDependencies (7 tests):
  - Windows + Python 3.12+ with pywin32 missing → exits with error
  - Windows + Python 3.12+ with pywin32 installed → continues
  - Windows + Python < 3.12 → skips validation
  - Linux/macOS → skips validation
  - Windows + Python 3.13+ → validates
  - Windows + Python 3.10 → skips validation

- TestExitWithPywin32Error (3 tests):
  - Error message contains helpful instructions
  - Error message contains venv path
  - Error message contains Python executable

- TestImportOrderPreventsEarlyFailure (3 tests):
  - validate_platform_dependencies doesn't import graphiti
  - cli/utils.py imports graphiti_config lazily
  - Entry points validate before CLI imports

- TestCliUtilsFindSpec (4 tests):
  - Find spec by number prefix
  - Find spec by full name
  - Return None when not found
  - Require spec.md to exist

- TestCliUtilsGetProjectDir (2 tests):
  - Return provided directory
  - Auto-detect from apps/backend directory

- TestCliUtilsSetupEnvironment (2 tests):
  - Returns apps/backend directory
  - Adds to sys.path

Total: 21 tests, all passing

Refs: ACS-253

* refactor(tests): improve test robustness with AST and fix assertions

Improvements made to test_dependency_validator.py:

1. AST-based function detection: Replace fragile string parsing with
   ast.parse() to find the first module-level function, avoiding false
   matches in docstrings or multi-line strings.

2. Fix setup_environment test: Remove unused temp_dir fixture and
   misleading assertion. Split into two focused tests:
   - test_setup_environment_returns_backend_dir: Verifies directory structure
   - test_setup_environment_adds_to_path: Verifies sys.path behavior

3. Remove redundant imports: Consolidate builtins imports to module-level,
   removing duplicate inner imports that shadow the top-level import.

4. Selective mock for pywintypes: Use selective_mock that returns
   MagicMock for pywintypes only, delegating all other imports to the
   original __import__ for more realistic test environment.

5. Strengthen venv path assertion: Require both "/path/to/venv" AND
   "Scripts" to be present in the error message, not just one or the other.

6. Add ast import: Add AST module import for robust parsing.

All 21 tests pass.

Refs: ACS-253

* fix(tests): address CodeQL and CodeRabbit review feedback

- Remove unused Mock import from unittest.mock
- Remove unused ast import from module level (kept local import in function)
- Initialize validate_env_end_lineno before loop to prevent potential
  uninitialized variable use

All 21 tests pass.

Addresses review comments on PR #1057
Refs: ACS-253

* feat(windows): add dependency validation to all entry points for consistency

Add validate_platform_dependencies() to all runner entry points for
consistency with run.py and spec_runner.py. This provides defense-in-depth
and ensures all entry points validate pywin32 on Windows Python 3.12+
before importing from cli.utils.

Changes:
- roadmap_runner.py: Added validate_platform_dependencies() before cli.utils import
- ideation_runner.py: Added validate_platform_dependencies() before cli.utils import
- insights_runner.py: Added validate_platform_dependencies() before cli.utils import
- github/runner.py: Added validate_platform_dependencies() before cli.utils import
- gitlab/runner.py: Added validate_platform_dependencies() before cli.utils import

This completes the consistency improvements suggested by the Auto Claude PR Review.

Refs: ACS-253

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-14 21:13:42 +02:00

330 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""
GitLab Automation Runner
========================
CLI interface for GitLab automation features:
- MR Review: AI-powered merge request review
- Follow-up Review: Review changes since last review
Usage:
# Review a specific MR
python runner.py review-mr 123
# Follow-up review after new commits
python runner.py followup-review-mr 123
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Validate platform-specific dependencies BEFORE any imports that might
# trigger graphiti_core -> real_ladybug -> pywintypes import chain (ACS-253)
from core.dependency_validator import validate_platform_dependencies
validate_platform_dependencies()
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
# Add gitlab runner directory to path for direct imports
sys.path.insert(0, str(Path(__file__).parent))
from core.io_utils import safe_print
from models import GitLabRunnerConfig
from orchestrator import GitLabOrchestrator, ProgressCallback
def print_progress(callback: ProgressCallback) -> None:
"""Print progress updates to console."""
prefix = ""
if callback.mr_iid:
prefix = f"[MR !{callback.mr_iid}] "
safe_print(f"{prefix}[{callback.progress:3d}%] {callback.message}")
def get_config(args) -> GitLabRunnerConfig:
"""Build config from CLI args and environment."""
token = args.token or os.environ.get("GITLAB_TOKEN", "")
project = args.project or os.environ.get("GITLAB_PROJECT", "")
instance_url = args.instance or os.environ.get(
"GITLAB_INSTANCE_URL", "https://gitlab.com"
)
if not token:
# Try to get from glab CLI
import subprocess
try:
result = subprocess.run(
["glab", "auth", "status", "-t"],
capture_output=True,
text=True,
)
except FileNotFoundError:
result = None
if result and result.returncode == 0:
# Parse token from output
for line in result.stdout.split("\n"):
if "Token:" in line:
token = line.split("Token:")[-1].strip()
break
if not project:
# Try to detect from .auto-claude/gitlab/config.json
config_path = Path(args.project_dir) / ".auto-claude" / "gitlab" / "config.json"
if config_path.exists():
try:
with open(config_path) as f:
data = json.load(f)
project = data.get("project", "")
instance_url = data.get("instance_url", instance_url)
if not token:
token = data.get("token", "")
except Exception as exc:
print(f"Warning: Failed to read GitLab config: {exc}", file=sys.stderr)
if not token:
print(
"Error: No GitLab token found. Set GITLAB_TOKEN or configure in project settings."
)
sys.exit(1)
if not project:
print(
"Error: No GitLab project found. Set GITLAB_PROJECT or configure in project settings."
)
sys.exit(1)
return GitLabRunnerConfig(
token=token,
project=project,
instance_url=instance_url,
model=args.model,
thinking_level=args.thinking_level,
)
async def cmd_review_mr(args) -> int:
"""Review a merge request."""
import sys
# Force unbuffered output so Electron sees it in real-time
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
safe_print(f"[DEBUG] Starting MR review for MR !{args.mr_iid}")
safe_print(f"[DEBUG] Project directory: {args.project_dir}")
safe_print("[DEBUG] Building config...")
config = get_config(args)
safe_print(f"[DEBUG] Config built: project={config.project}, model={config.model}")
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitLabOrchestrator(
project_dir=args.project_dir,
config=config,
progress_callback=print_progress,
)
safe_print("[DEBUG] Orchestrator created")
safe_print(f"[DEBUG] Calling orchestrator.review_mr({args.mr_iid})...")
result = await orchestrator.review_mr(args.mr_iid)
safe_print(f"[DEBUG] review_mr returned, success={result.success}")
if result.success:
print(f"\n{'=' * 60}")
print(f"MR !{result.mr_iid} Review Complete")
print(f"{'=' * 60}")
print(f"Status: {result.overall_status}")
print(f"Verdict: {result.verdict.value}")
print(f"Findings: {len(result.findings)}")
if result.findings:
print("\nFindings by severity:")
for f in result.findings:
emoji = {"critical": "!", "high": "*", "medium": "-", "low": "."}
print(
f" {emoji.get(f.severity.value, '?')} [{f.severity.value.upper()}] {f.title}"
)
print(f" File: {f.file}:{f.line}")
return 0
else:
print(f"\nReview failed: {result.error}")
return 1
async def cmd_followup_review_mr(args) -> int:
"""Perform a follow-up review of a merge request."""
import sys
# Force unbuffered output
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
safe_print(f"[DEBUG] Starting follow-up review for MR !{args.mr_iid}")
safe_print(f"[DEBUG] Project directory: {args.project_dir}")
safe_print("[DEBUG] Building config...")
config = get_config(args)
safe_print(f"[DEBUG] Config built: project={config.project}, model={config.model}")
safe_print("[DEBUG] Creating orchestrator...")
orchestrator = GitLabOrchestrator(
project_dir=args.project_dir,
config=config,
progress_callback=print_progress,
)
safe_print("[DEBUG] Orchestrator created")
safe_print(f"[DEBUG] Calling orchestrator.followup_review_mr({args.mr_iid})...")
try:
result = await orchestrator.followup_review_mr(args.mr_iid)
except ValueError as e:
print(f"\nFollow-up review failed: {e}")
return 1
safe_print(f"[DEBUG] followup_review_mr returned, success={result.success}")
if result.success:
print(f"\n{'=' * 60}")
print(f"MR !{result.mr_iid} Follow-up Review Complete")
print(f"{'=' * 60}")
print(f"Status: {result.overall_status}")
print(f"Is Follow-up: {result.is_followup_review}")
if result.resolved_findings:
print(f"Resolved: {len(result.resolved_findings)} finding(s)")
if result.unresolved_findings:
print(f"Still Open: {len(result.unresolved_findings)} finding(s)")
if result.new_findings_since_last_review:
print(
f"New Issues: {len(result.new_findings_since_last_review)} finding(s)"
)
print(f"\nSummary:\n{result.summary[:500]}...")
if result.findings:
print("\nRemaining Findings:")
for f in result.findings:
emoji = {"critical": "!", "high": "*", "medium": "-", "low": "."}
print(
f" {emoji.get(f.severity.value, '?')} [{f.severity.value.upper()}] {f.title}"
)
print(f" File: {f.file}:{f.line}")
return 0
else:
print(f"\nFollow-up review failed: {result.error}")
return 1
def main():
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description="GitLab automation CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Global options
parser.add_argument(
"--project-dir",
type=Path,
default=Path.cwd(),
help="Project directory (default: current)",
)
parser.add_argument(
"--token",
type=str,
help="GitLab token (or set GITLAB_TOKEN)",
)
parser.add_argument(
"--project",
type=str,
help="GitLab project (namespace/name) or auto-detect",
)
parser.add_argument(
"--instance",
type=str,
default="https://gitlab.com",
help="GitLab instance URL (default: https://gitlab.com)",
)
parser.add_argument(
"--model",
type=str,
default="claude-sonnet-4-20250514",
help="AI model to use",
)
parser.add_argument(
"--thinking-level",
type=str,
default="medium",
choices=["none", "low", "medium", "high"],
help="Thinking level for extended reasoning",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# review-mr command
review_parser = subparsers.add_parser("review-mr", help="Review a merge request")
review_parser.add_argument("mr_iid", type=int, help="MR IID to review")
# followup-review-mr command
followup_parser = subparsers.add_parser(
"followup-review-mr",
help="Follow-up review of an MR (after new commits)",
)
followup_parser.add_argument("mr_iid", type=int, help="MR IID to review")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
# Route to command handler
commands = {
"review-mr": cmd_review_mr,
"followup-review-mr": cmd_followup_review_mr,
}
handler = commands.get(args.command)
if not handler:
print(f"Unknown command: {args.command}")
sys.exit(1)
try:
exit_code = asyncio.run(handler(args))
sys.exit(exit_code)
except KeyboardInterrupt:
print("\nInterrupted.")
sys.exit(1)
except Exception as e:
import traceback
print(f"Error: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()