71a9fc848f
* 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>
330 lines
9.9 KiB
Python
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()
|