claude_ci.py
1 #!/usr/bin/env python3 2 """ 3 Claude CI Integration for Alpha/Delta Protocol 4 CSPEC-2026-001 5 6 AI-powered code review integrated into Forgejo CI pipeline. 7 Provides automated PR review, security analysis, architecture validation, 8 and documentation drift detection. 9 """ 10 11 import argparse 12 import json 13 import os 14 import sys 15 import hashlib 16 import time 17 from dataclasses import dataclass, field 18 from pathlib import Path 19 from typing import Optional, List, Dict, Any 20 from datetime import datetime, timedelta 21 22 # Optional imports with graceful degradation 23 try: 24 import anthropic 25 HAS_ANTHROPIC = True 26 except ImportError: 27 HAS_ANTHROPIC = False 28 29 try: 30 import requests 31 HAS_REQUESTS = True 32 except ImportError: 33 HAS_REQUESTS = False 34 35 try: 36 import yaml 37 HAS_YAML = True 38 except ImportError: 39 HAS_YAML = False 40 41 42 # ============================================================================ 43 # Configuration 44 # ============================================================================ 45 46 @dataclass 47 class CIConfig: 48 """Configuration for Claude CI integration.""" 49 50 # API Settings - supports both direct API and OAuth 51 anthropic_api_key: str = "" 52 claude_session_key: str = "" # OAuth session token for claude.ai 53 claude_org_id: str = "" # Organization ID for claude.ai 54 auth_mode: str = "auto" # auto, api, or oauth 55 56 forgejo_url: str = "https://source.ac-dc.network" 57 forgejo_token: str = "" 58 59 # Context Settings 60 context_repo_path: str = "" 61 max_context_tokens: int = 150000 62 63 # Model Settings 64 model_pr_review: str = "claude-sonnet-4-20250514" 65 model_security: str = "claude-sonnet-4-20250514" 66 model_architecture: str = "claude-opus-4-20250514" 67 max_output_tokens: int = 8192 68 69 # Cost Controls (only applies to API mode) 70 max_cost_per_pr: float = 1.00 71 max_cost_per_day: float = 20.00 72 73 # Behavior 74 post_comments: bool = True 75 security_gate_enabled: bool = True 76 arch_gate_enabled: bool = True 77 78 # Cache Settings 79 cache_dir: str = "/tmp/claude-ci-cache" 80 cache_ttl_seconds: int = 3600 81 82 @classmethod 83 def from_env(cls) -> "CIConfig": 84 """Load configuration from environment variables.""" 85 return cls( 86 anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""), 87 claude_session_key=os.getenv("CLAUDE_SESSION_KEY", ""), 88 claude_org_id=os.getenv("CLAUDE_ORG_ID", ""), 89 auth_mode=os.getenv("CLAUDE_AUTH_MODE", "auto"), 90 forgejo_url=os.getenv("FORGEJO_URL", "https://source.ac-dc.network"), 91 forgejo_token=os.getenv("FORGEJO_TOKEN", ""), 92 context_repo_path=os.getenv("CONTEXT_REPO_PATH", ""), 93 max_context_tokens=int(os.getenv("MAX_CONTEXT_TOKENS", "150000")), 94 model_pr_review=os.getenv("MODEL_PR_REVIEW", "claude-sonnet-4-20250514"), 95 model_security=os.getenv("MODEL_SECURITY", "claude-sonnet-4-20250514"), 96 model_architecture=os.getenv("MODEL_ARCHITECTURE", "claude-opus-4-20250514"), 97 max_output_tokens=int(os.getenv("MAX_OUTPUT_TOKENS", "8192")), 98 max_cost_per_pr=float(os.getenv("MAX_COST_PER_PR", "1.00")), 99 max_cost_per_day=float(os.getenv("MAX_COST_PER_DAY", "20.00")), 100 post_comments=os.getenv("POST_COMMENTS", "true").lower() == "true", 101 security_gate_enabled=os.getenv("SECURITY_GATE", "true").lower() == "true", 102 arch_gate_enabled=os.getenv("ARCH_GATE", "true").lower() == "true", 103 cache_dir=os.getenv("CACHE_DIR", "/tmp/claude-ci-cache"), 104 cache_ttl_seconds=int(os.getenv("CACHE_TTL", "3600")), 105 ) 106 107 def get_auth_mode(self) -> str: 108 """Determine which authentication mode to use.""" 109 if self.auth_mode != "auto": 110 return self.auth_mode 111 # Auto-detect: prefer OAuth if session key is set 112 if self.claude_session_key: 113 return "oauth" 114 if self.anthropic_api_key: 115 return "api" 116 return "none" 117 118 @classmethod 119 def from_yaml(cls, path: str) -> "CIConfig": 120 """Load configuration from YAML file.""" 121 if not HAS_YAML: 122 raise ImportError("PyYAML required: pip install pyyaml") 123 124 with open(path) as f: 125 data = yaml.safe_load(f) 126 127 return cls(**{k: v for k, v in data.items() if hasattr(cls, k)}) 128 129 def validate(self) -> List[str]: 130 """Validate configuration, return list of errors.""" 131 errors = [] 132 133 auth_mode = self.get_auth_mode() 134 if auth_mode == "none": 135 errors.append("No authentication configured. Set ANTHROPIC_API_KEY or CLAUDE_SESSION_KEY") 136 elif auth_mode == "api" and not self.anthropic_api_key: 137 errors.append("ANTHROPIC_API_KEY not set (required for API mode)") 138 elif auth_mode == "oauth" and not self.claude_session_key: 139 errors.append("CLAUDE_SESSION_KEY not set (required for OAuth mode)") 140 141 if not self.context_repo_path: 142 errors.append("CONTEXT_REPO_PATH not set") 143 elif not Path(self.context_repo_path).exists(): 144 errors.append(f"Context repo path does not exist: {self.context_repo_path}") 145 146 return errors 147 148 149 # ============================================================================ 150 # Context Loader 151 # ============================================================================ 152 153 class ContextLoader: 154 """ 155 Loads and manages project context from alpha-delta-context repository. 156 Provides token-efficient context for Claude API calls. 157 """ 158 159 # File patterns to load for context 160 CONTEXT_PATTERNS = { 161 "architecture": [ 162 "project/architecture/machine/*.cspec", 163 ], 164 "governance": [ 165 "project/governance/*.md", 166 ], 167 "security": [ 168 "infra/machine/security.cspec", 169 "infra/machine/incident-response.cspec", 170 ], 171 "api": [ 172 "project/architecture/machine/api-specification.cspec", 173 ], 174 "testing": [ 175 "project/architecture/machine/testing-strategy.cspec", 176 ], 177 "decisions": [ 178 "project/decisions.md", 179 ], 180 } 181 182 def __init__(self, config: CIConfig): 183 self.config = config 184 self.repo_path = Path(config.context_repo_path) 185 self._context_cache: Optional[str] = None 186 self._cache_time: Optional[datetime] = None 187 188 def load_context_files(self, categories: Optional[List[str]] = None) -> Dict[str, str]: 189 """ 190 Load context files from repository. 191 192 Args: 193 categories: Optional list of categories to load. 194 If None, loads all categories. 195 196 Returns: 197 Dict mapping file paths to contents. 198 """ 199 if categories is None: 200 categories = list(self.CONTEXT_PATTERNS.keys()) 201 202 files = {} 203 204 for category in categories: 205 patterns = self.CONTEXT_PATTERNS.get(category, []) 206 for pattern in patterns: 207 for file_path in self.repo_path.glob(pattern): 208 if file_path.is_file(): 209 try: 210 content = file_path.read_text(encoding="utf-8") 211 rel_path = str(file_path.relative_to(self.repo_path)) 212 files[rel_path] = content 213 except Exception as e: 214 print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) 215 216 return files 217 218 def build_context_prompt(self, categories: Optional[List[str]] = None) -> str: 219 """ 220 Build a context prompt for Claude API calls. 221 Uses caching to avoid repeated file reads. 222 223 Returns: 224 Formatted context string for system prompt. 225 """ 226 # Check cache 227 if (self._context_cache is not None and 228 self._cache_time is not None and 229 datetime.now() - self._cache_time < timedelta(seconds=self.config.cache_ttl_seconds)): 230 return self._context_cache 231 232 files = self.load_context_files(categories) 233 234 sections = [] 235 sections.append("# Alpha/Delta Protocol Context") 236 sections.append("") 237 sections.append("You have access to the following project documentation:") 238 sections.append("") 239 240 for file_path, content in sorted(files.items()): 241 sections.append(f"## File: {file_path}") 242 sections.append("```") 243 sections.append(content) 244 sections.append("```") 245 sections.append("") 246 247 context = "\n".join(sections) 248 249 # Cache the result 250 self._context_cache = context 251 self._cache_time = datetime.now() 252 253 return context 254 255 def estimate_tokens(self, text: str) -> int: 256 """Rough token estimation (4 chars per token).""" 257 return len(text) // 4 258 259 def get_context_info(self) -> Dict[str, Any]: 260 """Get information about loaded context.""" 261 files = self.load_context_files() 262 total_chars = sum(len(content) for content in files.values()) 263 264 return { 265 "repo_path": str(self.repo_path), 266 "file_count": len(files), 267 "total_chars": total_chars, 268 "estimated_tokens": self.estimate_tokens( 269 self.build_context_prompt() 270 ), 271 "files": list(files.keys()), 272 } 273 274 275 # ============================================================================ 276 # Forgejo API Client 277 # ============================================================================ 278 279 class ForgejoClient: 280 """Client for Forgejo/Gitea API interactions.""" 281 282 def __init__(self, config: CIConfig): 283 self.config = config 284 self.base_url = config.forgejo_url.rstrip("/") 285 self.headers = {} 286 if config.forgejo_token: 287 self.headers["Authorization"] = f"token {config.forgejo_token}" 288 289 def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: 290 """Make API request.""" 291 if not HAS_REQUESTS: 292 raise ImportError("requests required: pip install requests") 293 294 url = f"{self.base_url}/api/v1{endpoint}" 295 response = requests.request(method, url, headers=self.headers, **kwargs) 296 response.raise_for_status() 297 298 if response.content: 299 return response.json() 300 return {} 301 302 def get_pr(self, owner: str, repo: str, pr_number: int) -> Dict[str, Any]: 303 """Get pull request details.""" 304 return self._request("GET", f"/repos/{owner}/{repo}/pulls/{pr_number}") 305 306 def get_pr_diff(self, owner: str, repo: str, pr_number: int) -> str: 307 """Get pull request diff.""" 308 if not HAS_REQUESTS: 309 raise ImportError("requests required: pip install requests") 310 311 url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}.diff" 312 response = requests.get(url, headers=self.headers) 313 response.raise_for_status() 314 return response.text 315 316 def get_pr_files(self, owner: str, repo: str, pr_number: int) -> List[Dict[str, Any]]: 317 """Get list of files changed in PR.""" 318 return self._request("GET", f"/repos/{owner}/{repo}/pulls/{pr_number}/files") 319 320 def post_review_comment( 321 self, 322 owner: str, 323 repo: str, 324 pr_number: int, 325 body: str, 326 event: str = "COMMENT" 327 ) -> Dict[str, Any]: 328 """ 329 Post a review comment on a PR. 330 331 Args: 332 event: APPROVE, REQUEST_CHANGES, or COMMENT 333 """ 334 return self._request( 335 "POST", 336 f"/repos/{owner}/{repo}/pulls/{pr_number}/reviews", 337 json={"body": body, "event": event} 338 ) 339 340 def set_commit_status( 341 self, 342 owner: str, 343 repo: str, 344 sha: str, 345 state: str, 346 context: str, 347 description: str, 348 target_url: Optional[str] = None 349 ) -> Dict[str, Any]: 350 """ 351 Set commit status. 352 353 Args: 354 state: pending, success, error, failure 355 """ 356 data = { 357 "state": state, 358 "context": context, 359 "description": description[:140], # Forgejo limit 360 } 361 if target_url: 362 data["target_url"] = target_url 363 364 return self._request( 365 "POST", 366 f"/repos/{owner}/{repo}/statuses/{sha}", 367 json=data 368 ) 369 370 371 # ============================================================================ 372 # Claude OAuth Client (claude.ai web API) 373 # ============================================================================ 374 375 class ClaudeCLIClient: 376 """ 377 Client that uses the Claude Code CLI for inference. 378 Leverages existing OAuth authentication from Claude Code. 379 """ 380 381 def __init__(self, config: CIConfig): 382 self.config = config 383 self._check_cli() 384 385 def _check_cli(self): 386 """Verify Claude CLI is available.""" 387 import shutil 388 if not shutil.which("claude"): 389 raise RuntimeError("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code") 390 391 def chat(self, system_prompt: str, user_message: str) -> str: 392 """ 393 Send a chat message using Claude Code CLI. 394 Uses -p flag for non-interactive output. 395 """ 396 import subprocess 397 398 # Combine prompts 399 full_prompt = f"{user_message}" 400 401 # Run claude CLI with print mode 402 cmd = [ 403 "claude", 404 "-p", # Print mode (non-interactive) 405 "--output-format", "text", 406 "--system-prompt", system_prompt, 407 # Note: --dangerously-skip-permissions not used (doesn't work with root) 408 # CI runner should set up appropriate permissions 409 full_prompt 410 ] 411 412 try: 413 result = subprocess.run( 414 cmd, 415 capture_output=True, 416 text=True, 417 timeout=300, # 5 minute timeout 418 ) 419 420 if result.returncode != 0: 421 print(f"Claude CLI error: {result.stderr}", file=sys.stderr) 422 return "" 423 424 return result.stdout.strip() 425 426 except subprocess.TimeoutExpired: 427 print("Claude CLI timed out", file=sys.stderr) 428 return "" 429 except Exception as e: 430 print(f"Claude CLI error: {e}", file=sys.stderr) 431 return "" 432 433 434 class ClaudeOAuthClient: 435 """ 436 DEPRECATED: Use ClaudeCLIClient instead. 437 The claude.ai web API doesn't accept OAuth tokens from Claude Code. 438 This class is kept for potential future use with session cookies. 439 """ 440 441 BASE_URL = "https://claude.ai/api" 442 443 def __init__(self, config: CIConfig): 444 self.config = config 445 self.session_key = config.claude_session_key 446 self.org_id = config.claude_org_id 447 448 if not HAS_REQUESTS: 449 raise ImportError("requests required: pip install requests") 450 451 # Try CLI first - raise if not available (caller should fall back to API) 452 import shutil 453 if shutil.which("claude"): 454 print("Using Claude CLI for OAuth mode", file=sys.stderr) 455 self._cli_client = ClaudeCLIClient(config) 456 else: 457 # CLI not found - raise so caller can fall back to API key if available 458 raise RuntimeError("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code") 459 460 def _get_headers(self) -> Dict[str, str]: 461 """Get headers for claude.ai API requests.""" 462 headers = { 463 "Content-Type": "application/json", 464 "Accept": "application/json", 465 "User-Agent": "Claude-CI/1.0", 466 } 467 # Support both OAuth bearer token and session cookie 468 if self.session_key.startswith("sk-") or len(self.session_key) > 50: 469 headers["Authorization"] = f"Bearer {self.session_key}" 470 else: 471 headers["Cookie"] = f"sessionKey={self.session_key}" 472 return headers 473 474 def chat(self, system_prompt: str, user_message: str) -> str: 475 """ 476 Send a chat message. 477 Uses CLI if available, otherwise attempts web API (may fail). 478 """ 479 if self._cli_client: 480 return self._cli_client.chat(system_prompt, user_message) 481 482 # Fall back to web API (likely to fail with OAuth token) 483 print("Warning: Claude CLI not found, attempting web API", file=sys.stderr) 484 try: 485 # Get org ID 486 response = requests.get( 487 f"{self.BASE_URL}/organizations", 488 headers=self._get_headers(), 489 ) 490 response.raise_for_status() 491 orgs = response.json() 492 if not orgs: 493 raise ValueError("No organization found") 494 org_id = orgs[0].get("uuid", "") 495 496 # Create conversation 497 response = requests.post( 498 f"{self.BASE_URL}/organizations/{org_id}/chat_conversations", 499 headers=self._get_headers(), 500 json={"uuid": "", "name": "CI Review"}, 501 ) 502 response.raise_for_status() 503 conv_id = response.json().get("uuid", "") 504 505 # Send message 506 full_message = f"{system_prompt}\n\n---\n\n{user_message}" 507 response = requests.post( 508 f"{self.BASE_URL}/organizations/{org_id}/chat_conversations/{conv_id}/completion", 509 headers=self._get_headers(), 510 json={"prompt": full_message, "timezone": "UTC", "attachments": []}, 511 stream=True, 512 ) 513 response.raise_for_status() 514 515 # Parse streaming response 516 full_response = "" 517 for line in response.iter_lines(): 518 if line: 519 line_str = line.decode("utf-8") 520 if line_str.startswith("data: "): 521 try: 522 data = json.loads(line_str[6:]) 523 if "completion" in data: 524 full_response += data["completion"] 525 except json.JSONDecodeError: 526 continue 527 return full_response 528 529 except Exception as e: 530 print(f"Web API error: {e}", file=sys.stderr) 531 return "" 532 533 534 # ============================================================================ 535 # Claude CI Client 536 # ============================================================================ 537 538 @dataclass 539 class ReviewResult: 540 """Result of a Claude review.""" 541 recommendation: str # APPROVE, REQUEST_CHANGES, COMMENT 542 summary: str 543 findings: List[Dict[str, Any]] 544 raw_response: str 545 model: str 546 input_tokens: int 547 output_tokens: int 548 cost_estimate: float 549 550 551 class ClaudeCIClient: 552 """ 553 Claude API client for CI code review tasks. 554 Provides PR review, security analysis, architecture validation, 555 and documentation sync checking. 556 557 Supports both direct API (ANTHROPIC_API_KEY) and OAuth (CLAUDE_SESSION_KEY). 558 """ 559 560 # Pricing per 1K tokens (as of 2025) - only applies to API mode 561 PRICING = { 562 "claude-sonnet-4-20250514": {"input": 0.003, "output": 0.015}, 563 "claude-opus-4-20250514": {"input": 0.015, "output": 0.075}, 564 } 565 566 def __init__(self, config: CIConfig, context_loader: ContextLoader): 567 self.config = config 568 self.context_loader = context_loader 569 self.auth_mode = config.get_auth_mode() 570 571 if self.auth_mode == "api": 572 if not HAS_ANTHROPIC: 573 raise ImportError("anthropic required: pip install anthropic") 574 self.client = anthropic.Anthropic(api_key=config.anthropic_api_key) 575 self.oauth_client = None 576 elif self.auth_mode == "oauth": 577 # Try OAuth, but fall back to API if Claude CLI not available 578 try: 579 self.client = None 580 self.oauth_client = ClaudeOAuthClient(config) 581 except RuntimeError as e: 582 # Claude CLI not found - fall back to API if available 583 if config.anthropic_api_key and HAS_ANTHROPIC: 584 print(f"OAuth failed ({e}), falling back to API key", file=sys.stderr) 585 self.auth_mode = "api" 586 self.client = anthropic.Anthropic(api_key=config.anthropic_api_key) 587 self.oauth_client = None 588 else: 589 raise 590 else: 591 raise ValueError("No authentication configured") 592 593 def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float: 594 """Calculate cost estimate for API call.""" 595 pricing = self.PRICING.get(model, {"input": 0.003, "output": 0.015}) 596 return (input_tokens / 1000 * pricing["input"] + 597 output_tokens / 1000 * pricing["output"]) 598 599 def _build_system_prompt(self, task_type: str) -> str: 600 """Build system prompt with context and task instructions.""" 601 context = self.context_loader.build_context_prompt() 602 603 base_prompt = """You are an expert code reviewer for the Alpha/Delta Protocol, 604 a dual-chain blockchain system with privacy-preserving features. 605 606 Your expertise includes: 607 - Rust programming and blockchain development 608 - Zero-knowledge proofs and privacy-preserving computation 609 - DeFi protocols, DEX design, and perpetual futures 610 - Security best practices and vulnerability detection 611 - Cross-chain bridge security 612 613 """ 614 615 task_prompts = { 616 "pr_review": """ 617 TASK: Review the provided pull request diff. 618 619 Analyze for: 620 1. Code quality and Rust best practices 621 2. Potential bugs or logic errors 622 3. Security concerns 623 4. Alignment with project architecture 624 5. Missing tests or documentation 625 626 Respond with JSON: 627 { 628 "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT", 629 "summary": "Brief summary of review", 630 "findings": [ 631 { 632 "severity": "critical" | "high" | "medium" | "low" | "info", 633 "category": "security" | "bug" | "style" | "architecture" | "docs", 634 "file": "path/to/file.rs", 635 "line": 42, 636 "title": "Finding title", 637 "description": "Detailed description", 638 "suggestion": "How to fix" 639 } 640 ] 641 } 642 """, 643 "security_review": """ 644 TASK: Perform a security-focused review of the provided code changes. 645 646 Focus on: 647 1. Authentication and authorization issues 648 2. Input validation and sanitization 649 3. Cryptographic implementation correctness 650 4. Privacy leaks in zero-knowledge circuits 651 5. Cross-chain bridge vulnerabilities 652 6. Reentrancy and state manipulation 653 7. Integer overflow/underflow 654 8. Denial of service vectors 655 656 For each finding, include CWE ID if applicable. 657 658 Respond with JSON: 659 { 660 "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT", 661 "summary": "Security assessment summary", 662 "risk_level": "critical" | "high" | "medium" | "low" | "none", 663 "findings": [ 664 { 665 "severity": "critical" | "high" | "medium" | "low", 666 "cwe_id": "CWE-XXX", 667 "title": "Vulnerability title", 668 "description": "Detailed description", 669 "file": "path/to/file.rs", 670 "line": 42, 671 "remediation": "How to fix" 672 } 673 ] 674 } 675 """, 676 "architecture_validation": """ 677 TASK: Validate the code changes against the Alpha/Delta Protocol specifications. 678 679 Check for: 680 1. Compliance with Technical Specification 3.0 681 2. Correct use of protocol primitives (AX/DX tokens, validators, etc.) 682 3. Governance rule adherence 683 4. Cross-chain bridge protocol compliance 684 5. Privacy circuit specifications 685 686 Reference specific sections of the loaded specifications in your findings. 687 688 Respond with JSON: 689 { 690 "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT", 691 "summary": "Architecture compliance summary", 692 "compliant": true | false, 693 "findings": [ 694 { 695 "severity": "critical" | "high" | "medium" | "low", 696 "spec_section": "Reference to spec section", 697 "title": "Violation title", 698 "description": "How the code violates the spec", 699 "suggestion": "How to make compliant" 700 } 701 ] 702 } 703 """, 704 "docs_sync": """ 705 TASK: Check if the code changes require documentation updates. 706 707 Analyze: 708 1. New public APIs that need documentation 709 2. Changed behavior that affects existing docs 710 3. Removed features that should be removed from docs 711 4. Configuration changes 712 5. Migration requirements 713 714 Respond with JSON: 715 { 716 "recommendation": "APPROVE" | "REQUEST_CHANGES" | "COMMENT", 717 "summary": "Documentation sync summary", 718 "updates_needed": true | false, 719 "findings": [ 720 { 721 "category": "new_api" | "changed_behavior" | "removed" | "config" | "migration", 722 "title": "Update needed", 723 "description": "What documentation needs updating", 724 "suggested_doc_path": "Where to add/update docs", 725 "suggested_content": "Draft documentation text" 726 } 727 ] 728 } 729 """ 730 } 731 732 return base_prompt + context + "\n\n" + task_prompts.get(task_type, "") 733 734 def _call_api(self, system_prompt: str, user_prompt: str, model: str) -> ReviewResult: 735 """Make API call to Claude using configured authentication.""" 736 if self.auth_mode == "oauth": 737 # Use OAuth client (claude.ai web API) 738 raw_response = self.oauth_client.chat(system_prompt, user_prompt) 739 input_tokens = 0 # Not available via OAuth 740 output_tokens = 0 741 cost = 0.0 # No direct cost for Max Plan 742 else: 743 # Use direct API 744 response = self.client.messages.create( 745 model=model, 746 max_tokens=self.config.max_output_tokens, 747 system=system_prompt, 748 messages=[{"role": "user", "content": user_prompt}] 749 ) 750 raw_response = response.content[0].text 751 input_tokens = response.usage.input_tokens 752 output_tokens = response.usage.output_tokens 753 cost = self._calculate_cost(model, input_tokens, output_tokens) 754 755 # Parse JSON response 756 try: 757 # Try to extract JSON from response 758 json_start = raw_response.find("{") 759 json_end = raw_response.rfind("}") + 1 760 if json_start >= 0 and json_end > json_start: 761 parsed = json.loads(raw_response[json_start:json_end]) 762 else: 763 parsed = {"recommendation": "COMMENT", "summary": raw_response, "findings": []} 764 except json.JSONDecodeError: 765 parsed = {"recommendation": "COMMENT", "summary": raw_response, "findings": []} 766 767 return ReviewResult( 768 recommendation=parsed.get("recommendation", "COMMENT"), 769 summary=parsed.get("summary", ""), 770 findings=parsed.get("findings", []), 771 raw_response=raw_response, 772 model=model, 773 input_tokens=input_tokens, 774 output_tokens=output_tokens, 775 cost_estimate=cost, 776 ) 777 778 def review_pull_request(self, diff: str, pr_info: Optional[Dict] = None) -> ReviewResult: 779 """ 780 Review a pull request. 781 782 Args: 783 diff: The PR diff content 784 pr_info: Optional PR metadata (title, description, etc.) 785 """ 786 system_prompt = self._build_system_prompt("pr_review") 787 788 user_prompt = "Please review the following pull request:\n\n" 789 if pr_info: 790 user_prompt += f"**Title:** {pr_info.get('title', 'N/A')}\n" 791 user_prompt += f"**Description:** {pr_info.get('body', 'N/A')}\n\n" 792 user_prompt += f"**Diff:**\n```diff\n{diff}\n```" 793 794 return self._call_api(system_prompt, user_prompt, self.config.model_pr_review) 795 796 def security_review(self, diff: str) -> ReviewResult: 797 """Perform security-focused review.""" 798 system_prompt = self._build_system_prompt("security_review") 799 user_prompt = f"Please perform a security review of the following changes:\n\n```diff\n{diff}\n```" 800 801 return self._call_api(system_prompt, user_prompt, self.config.model_security) 802 803 def validate_architecture(self, diff: str, commit_message: Optional[str] = None) -> ReviewResult: 804 """Validate changes against architecture specifications.""" 805 system_prompt = self._build_system_prompt("architecture_validation") 806 807 user_prompt = "Please validate the following changes against the protocol specifications:\n\n" 808 if commit_message: 809 user_prompt += f"**Commit Message:** {commit_message}\n\n" 810 user_prompt += f"**Changes:**\n```diff\n{diff}\n```" 811 812 return self._call_api(system_prompt, user_prompt, self.config.model_architecture) 813 814 def check_documentation_sync(self, diff: str) -> ReviewResult: 815 """Check if documentation needs updating.""" 816 system_prompt = self._build_system_prompt("docs_sync") 817 user_prompt = f"Please check if the following changes require documentation updates:\n\n```diff\n{diff}\n```" 818 819 return self._call_api(system_prompt, user_prompt, self.config.model_pr_review) 820 821 822 # ============================================================================ 823 # CLI 824 # ============================================================================ 825 826 def cmd_context_info(args, config: CIConfig): 827 """Show context repository information.""" 828 loader = ContextLoader(config) 829 info = loader.get_context_info() 830 831 print("š Context Repository Information") 832 print(f" Path: {info['repo_path']}") 833 print(f" Files loaded: {info['file_count']}") 834 print(f" Total characters: {info['total_chars']:,}") 835 print(f" Estimated tokens: {info['estimated_tokens']:,}") 836 print("\n Files:") 837 for f in info['files']: 838 print(f" - {f}") 839 840 841 def cmd_review(args, config: CIConfig): 842 """Review a pull request.""" 843 loader = ContextLoader(config) 844 client = ClaudeCIClient(config, loader) 845 forgejo = ForgejoClient(config) 846 847 # Get PR info and diff 848 owner, repo = args.repo.split("/") 849 pr_info = forgejo.get_pr(owner, repo, args.pr) 850 diff = forgejo.get_pr_diff(owner, repo, args.pr) 851 852 print(f"š Reviewing PR #{args.pr}: {pr_info.get('title', 'N/A')}") 853 854 result = client.review_pull_request(diff, pr_info) 855 856 print(f"\nš Review Result: {result.recommendation}") 857 print(f" Summary: {result.summary}") 858 print(f" Findings: {len(result.findings)}") 859 print(f" Cost: ${result.cost_estimate:.4f}") 860 861 if result.findings: 862 print("\n Findings:") 863 for f in result.findings: 864 print(f" - [{f.get('severity', 'info')}] {f.get('title', 'No title')}") 865 866 # Post comment if enabled 867 if config.post_comments and config.forgejo_token: 868 comment = format_review_comment(result) 869 forgejo.post_review_comment(owner, repo, args.pr, comment, result.recommendation) 870 print("\nā Posted review comment to PR") 871 872 # Output JSON if requested 873 if args.json: 874 print("\n" + json.dumps({ 875 "recommendation": result.recommendation, 876 "summary": result.summary, 877 "findings": result.findings, 878 "cost": result.cost_estimate, 879 }, indent=2)) 880 881 882 def cmd_pr_review(args, config: CIConfig): 883 """Review PR using local diff file.""" 884 # Debug output (to stderr to not corrupt JSON output) 885 print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr) 886 print(f"Debug: diff arg = {args.diff}", file=sys.stderr) 887 888 loader = ContextLoader(config) 889 client = ClaudeCIClient(config, loader) 890 891 # Read diff from file or stdin 892 if args.diff == "-": 893 diff = sys.stdin.read() 894 else: 895 diff_path = Path(args.diff) 896 print(f"Debug: diff_path = {diff_path}", file=sys.stderr) 897 print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr) 898 print(f"Debug: diff_path.absolute() = {diff_path.absolute()}", file=sys.stderr) 899 if not diff_path.exists(): 900 print(f"ā Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr) 901 sys.exit(1) 902 diff = diff_path.read_text() 903 print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr) 904 905 pr_info = {"title": args.title or "N/A", "body": ""} 906 907 # Human-readable output goes to stderr when JSON mode is enabled 908 output = sys.stderr if args.json else sys.stdout 909 910 print(f"š Reviewing PR: {args.title or 'Unknown'}", file=output) 911 print(f" Diff size: {len(diff)} bytes", file=output) 912 913 result = client.review_pull_request(diff, pr_info) 914 915 print(f"\nš Review Result: {result.recommendation}", file=output) 916 print(f" Summary: {result.summary}", file=output) 917 print(f" Findings: {len(result.findings)}", file=output) 918 print(f" Cost: ${result.cost_estimate:.4f}", file=output) 919 920 if result.findings: 921 print("\n Findings:", file=output) 922 for f in result.findings: 923 print(f" - [{f.get('severity', 'info')}] {f.get('title', 'No title')}", file=output) 924 925 # Output JSON to stdout when requested 926 if args.json: 927 print(json.dumps({ 928 "recommendation": result.recommendation, 929 "summary": result.summary, 930 "findings": result.findings, 931 "cost": result.cost_estimate, 932 }, indent=2)) 933 934 935 def cmd_security_review(args, config: CIConfig): 936 """Perform security review.""" 937 # Debug output (to stderr to not corrupt JSON output) 938 print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr) 939 print(f"Debug: diff arg = {args.diff}", file=sys.stderr) 940 941 loader = ContextLoader(config) 942 client = ClaudeCIClient(config, loader) 943 944 # Read diff from file or stdin 945 if args.diff == "-": 946 diff = sys.stdin.read() 947 else: 948 diff_path = Path(args.diff) 949 print(f"Debug: diff_path = {diff_path}", file=sys.stderr) 950 print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr) 951 print(f"Debug: diff_path.absolute() = {diff_path.absolute()}", file=sys.stderr) 952 if not diff_path.exists(): 953 print(f"ā Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr) 954 sys.exit(1) 955 diff = diff_path.read_text() 956 print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr) 957 958 # Human-readable output goes to stderr when JSON mode is enabled 959 output = sys.stderr if args.json else sys.stdout 960 961 print("š Performing security review...", file=output) 962 963 result = client.security_review(diff) 964 965 print(f"\nš Security Review Result: {result.recommendation}", file=output) 966 print(f" Summary: {result.summary}", file=output) 967 print(f" Findings: {len(result.findings)}", file=output) 968 print(f" Cost: ${result.cost_estimate:.4f}", file=output) 969 970 if result.findings: 971 print("\n Security Findings:", file=output) 972 for f in result.findings: 973 cwe = f.get("cwe_id", "") 974 cwe_str = f" ({cwe})" if cwe else "" 975 print(f" - [{f.get('severity', 'info')}]{cwe_str} {f.get('title', 'No title')}", file=output) 976 977 # Output JSON to stdout when requested 978 if args.json: 979 print(json.dumps({ 980 "recommendation": result.recommendation, 981 "summary": result.summary, 982 "findings": result.findings, 983 "cost": result.cost_estimate, 984 }, indent=2)) 985 986 # Exit with error if critical findings 987 if config.security_gate_enabled: 988 critical = [f for f in result.findings if f.get("severity") == "critical"] 989 if critical: 990 print(f"\nā Security gate failed: {len(critical)} critical finding(s)", file=sys.stderr) 991 sys.exit(1) 992 993 994 def cmd_validate_arch(args, config: CIConfig): 995 """Validate architecture compliance.""" 996 loader = ContextLoader(config) 997 client = ClaudeCIClient(config, loader) 998 999 # Get diff from git 1000 import subprocess 1001 if args.commit: 1002 diff = subprocess.check_output( 1003 ["git", "show", args.commit, "--format="], 1004 cwd=args.repo_path or "." 1005 ).decode() 1006 commit_msg = subprocess.check_output( 1007 ["git", "log", "-1", "--format=%s", args.commit], 1008 cwd=args.repo_path or "." 1009 ).decode().strip() 1010 else: 1011 diff = subprocess.check_output( 1012 ["git", "diff", "HEAD~1"], 1013 cwd=args.repo_path or "." 1014 ).decode() 1015 commit_msg = None 1016 1017 # Human-readable output goes to stderr when JSON mode is enabled 1018 output = sys.stderr if args.json else sys.stdout 1019 1020 print("š Validating architecture compliance...", file=output) 1021 1022 result = client.validate_architecture(diff, commit_msg) 1023 1024 print(f"\nš Architecture Validation: {result.recommendation}", file=output) 1025 print(f" Summary: {result.summary}", file=output) 1026 print(f" Findings: {len(result.findings)}", file=output) 1027 print(f" Cost: ${result.cost_estimate:.4f}", file=output) 1028 1029 if result.findings: 1030 print("\n Architecture Findings:", file=output) 1031 for f in result.findings: 1032 print(f" - [{f.get('severity', 'info')}] {f.get('title', 'No title')}", file=output) 1033 if f.get("spec_section"): 1034 print(f" Spec: {f.get('spec_section')}", file=output) 1035 1036 # Output JSON to stdout when requested 1037 if args.json: 1038 print(json.dumps({ 1039 "recommendation": result.recommendation, 1040 "summary": result.summary, 1041 "findings": result.findings, 1042 "cost": result.cost_estimate, 1043 }, indent=2)) 1044 1045 1046 def cmd_docs_sync(args, config: CIConfig): 1047 """Check documentation sync.""" 1048 # Debug output (to stderr) 1049 print(f"Debug: cwd = {os.getcwd()}", file=sys.stderr) 1050 print(f"Debug: diff arg = {args.diff}", file=sys.stderr) 1051 1052 loader = ContextLoader(config) 1053 client = ClaudeCIClient(config, loader) 1054 1055 # Read diff 1056 if args.diff == "-": 1057 diff = sys.stdin.read() 1058 else: 1059 diff_path = Path(args.diff) 1060 print(f"Debug: diff_path.exists() = {diff_path.exists()}", file=sys.stderr) 1061 if not diff_path.exists(): 1062 print(f"ā Error: Diff file not found: {diff_path.absolute()}", file=sys.stderr) 1063 sys.exit(1) 1064 diff = diff_path.read_text() 1065 print(f"Debug: diff length = {len(diff)} chars", file=sys.stderr) 1066 1067 # Human-readable output goes to stderr when JSON mode is enabled 1068 output = sys.stderr if args.json else sys.stdout 1069 1070 print("š Checking documentation sync...", file=output) 1071 1072 result = client.check_documentation_sync(diff) 1073 1074 print(f"\nš Documentation Sync: {result.recommendation}", file=output) 1075 print(f" Summary: {result.summary}", file=output) 1076 print(f" Updates needed: {len(result.findings)}", file=output) 1077 print(f" Cost: ${result.cost_estimate:.4f}", file=output) 1078 1079 if result.findings: 1080 print("\n Documentation Updates Needed:", file=output) 1081 for f in result.findings: 1082 print(f" - [{f.get('category', 'other')}] {f.get('title', 'No title')}", file=output) 1083 1084 # Output JSON to stdout when requested 1085 if args.json: 1086 print(json.dumps({ 1087 "recommendation": result.recommendation, 1088 "summary": result.summary, 1089 "findings": result.findings, 1090 "cost": result.cost_estimate, 1091 }, indent=2)) 1092 1093 1094 def cmd_post_comment(args, config: CIConfig): 1095 """Post a combined review comment to PR.""" 1096 if not HAS_REQUESTS: 1097 print("ā Error: requests required: pip install requests", file=sys.stderr) 1098 sys.exit(1) 1099 1100 comment_parts = ["## š¤ Claude CI Review Summary", ""] 1101 1102 # PR Review 1103 if args.review and Path(args.review).exists(): 1104 try: 1105 with open(args.review) as f: 1106 data = json.load(f) 1107 rec = data.get('recommendation', 'N/A') 1108 summary = data.get('summary', 'No summary')[:300] 1109 comment_parts.append(f"### PR Review: {rec}") 1110 comment_parts.append(summary) 1111 comment_parts.append("") 1112 except Exception as e: 1113 print(f"PR review not available: {e}", file=sys.stderr) 1114 1115 # Security Review 1116 if args.security and Path(args.security).exists(): 1117 try: 1118 with open(args.security) as f: 1119 data = json.load(f) 1120 rec = data.get('recommendation', 'N/A') 1121 summary = data.get('summary', 'No summary')[:300] 1122 comment_parts.append(f"### Security Review: {rec}") 1123 comment_parts.append(summary) 1124 comment_parts.append("") 1125 except Exception as e: 1126 print(f"Security review not available: {e}", file=sys.stderr) 1127 1128 # Architecture 1129 if args.arch and Path(args.arch).exists(): 1130 try: 1131 with open(args.arch) as f: 1132 data = json.load(f) 1133 rec = data.get('recommendation', 'N/A') 1134 comment_parts.append(f"### Architecture: {rec}") 1135 comment_parts.append("") 1136 except Exception as e: 1137 print(f"Arch review not available: {e}", file=sys.stderr) 1138 1139 # Docs 1140 if args.docs and Path(args.docs).exists(): 1141 try: 1142 with open(args.docs) as f: 1143 data = json.load(f) 1144 rec = data.get('recommendation', 'N/A') 1145 comment_parts.append(f"### Documentation: {rec}") 1146 comment_parts.append("") 1147 except Exception as e: 1148 print(f"Docs review not available: {e}", file=sys.stderr) 1149 1150 comment_parts.append("---") 1151 comment_parts.append("*Powered by Claude CI (CSPEC-2026-001)*") 1152 1153 comment_body = "\n".join(comment_parts) 1154 print(f"Comment body ({len(comment_body)} chars):", file=sys.stderr) 1155 print(comment_body, file=sys.stderr) 1156 1157 # Post to Forgejo 1158 forgejo_url = config.forgejo_url.rstrip("/") 1159 forgejo_token = config.forgejo_token 1160 1161 if not forgejo_token: 1162 print("ā Error: FORGEJO_TOKEN not set", file=sys.stderr) 1163 sys.exit(1) 1164 1165 url = f"{forgejo_url}/api/v1/repos/{args.repo}/issues/{args.pr}/comments" 1166 headers = { 1167 "Authorization": f"token {forgejo_token}", 1168 "Content-Type": "application/json", 1169 } 1170 payload = {"body": comment_body} 1171 1172 print(f"Posting to: {url}", file=sys.stderr) 1173 response = requests.post(url, headers=headers, json=payload) 1174 1175 if response.status_code in (200, 201): 1176 print(f"ā Comment posted successfully", file=sys.stderr) 1177 else: 1178 print(f"ā Failed to post comment: {response.status_code}", file=sys.stderr) 1179 print(response.text, file=sys.stderr) 1180 sys.exit(1) 1181 1182 1183 def format_review_comment(result: ReviewResult) -> str: 1184 """Format review result as Markdown comment for PR.""" 1185 emoji_map = { 1186 "APPROVE": "ā ", 1187 "REQUEST_CHANGES": "ā ļø", 1188 "COMMENT": "š¬", 1189 } 1190 severity_emoji = { 1191 "critical": "š“", 1192 "high": "š ", 1193 "medium": "š”", 1194 "low": "šµ", 1195 "info": "ā¹ļø", 1196 } 1197 1198 lines = [] 1199 lines.append(f"## {emoji_map.get(result.recommendation, 'š¬')} Claude CI Review") 1200 lines.append("") 1201 lines.append(f"**Recommendation:** {result.recommendation}") 1202 lines.append("") 1203 lines.append(f"**Summary:** {result.summary}") 1204 lines.append("") 1205 1206 if result.findings: 1207 lines.append("### Findings") 1208 lines.append("") 1209 for f in result.findings: 1210 sev = f.get("severity", "info") 1211 emoji = severity_emoji.get(sev, "ā¹ļø") 1212 lines.append(f"#### {emoji} {f.get('title', 'Finding')}") 1213 lines.append("") 1214 lines.append(f"**Severity:** {sev}") 1215 if f.get("file"): 1216 lines.append(f"**File:** `{f.get('file')}`") 1217 if f.get("line"): 1218 lines.append(f"**Line:** {f.get('line')}") 1219 if f.get("cwe_id"): 1220 lines.append(f"**CWE:** {f.get('cwe_id')}") 1221 lines.append("") 1222 lines.append(f.get("description", "")) 1223 lines.append("") 1224 if f.get("suggestion") or f.get("remediation"): 1225 lines.append(f"**Suggestion:** {f.get('suggestion') or f.get('remediation')}") 1226 lines.append("") 1227 1228 lines.append("---") 1229 lines.append(f"*š¤ Automated review by Claude CI | Model: {result.model} | " 1230 f"Cost: ${result.cost_estimate:.4f}*") 1231 1232 return "\n".join(lines) 1233 1234 1235 def main(): 1236 parser = argparse.ArgumentParser( 1237 description="Claude CI Integration for Alpha/Delta Protocol", 1238 formatter_class=argparse.RawDescriptionHelpFormatter, 1239 ) 1240 parser.add_argument( 1241 "--config", "-c", 1242 help="Path to YAML config file", 1243 ) 1244 1245 subparsers = parser.add_subparsers(dest="command", help="Commands") 1246 1247 # context-info 1248 ctx_parser = subparsers.add_parser("context-info", help="Show context information") 1249 1250 # review (via API) 1251 review_parser = subparsers.add_parser("review", help="Review a pull request via API") 1252 review_parser.add_argument("--repo", "-r", required=True, help="Repository (owner/repo)") 1253 review_parser.add_argument("--pr", "-p", type=int, required=True, help="PR number") 1254 review_parser.add_argument("--json", action="store_true", help="Output JSON") 1255 1256 # pr-review (via local diff file) 1257 pr_review_parser = subparsers.add_parser("pr-review", help="Review PR using local diff file") 1258 pr_review_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin") 1259 pr_review_parser.add_argument("--title", "-t", help="PR title for context") 1260 pr_review_parser.add_argument("--json", action="store_true", help="Output JSON") 1261 1262 # security-review 1263 sec_parser = subparsers.add_parser("security-review", help="Security-focused review") 1264 sec_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin") 1265 sec_parser.add_argument("--json", action="store_true", help="Output JSON") 1266 1267 # validate-arch 1268 arch_parser = subparsers.add_parser("validate-arch", help="Validate architecture") 1269 arch_parser.add_argument("--commit", help="Commit SHA to validate") 1270 arch_parser.add_argument("--repo-path", help="Path to repository") 1271 arch_parser.add_argument("--json", action="store_true", help="Output JSON") 1272 1273 # docs-sync 1274 docs_parser = subparsers.add_parser("docs-sync", help="Check documentation sync") 1275 docs_parser.add_argument("--diff", "-d", required=True, help="Path to diff file or - for stdin") 1276 docs_parser.add_argument("--json", action="store_true", help="Output JSON") 1277 1278 # post-comment 1279 comment_parser = subparsers.add_parser("post-comment", help="Post combined review comment to PR") 1280 comment_parser.add_argument("--pr", required=True, help="PR number") 1281 comment_parser.add_argument("--repo", required=True, help="Repository (owner/repo)") 1282 comment_parser.add_argument("--review", help="Path to review-result.json") 1283 comment_parser.add_argument("--security", help="Path to security-result.json") 1284 comment_parser.add_argument("--arch", help="Path to arch-result.json") 1285 comment_parser.add_argument("--docs", help="Path to docs-result.json") 1286 1287 args = parser.parse_args() 1288 1289 if not args.command: 1290 parser.print_help() 1291 sys.exit(1) 1292 1293 # Load config 1294 if args.config: 1295 config = CIConfig.from_yaml(args.config) 1296 else: 1297 config = CIConfig.from_env() 1298 1299 # Validate config (skip for commands that don't need full config) 1300 skip_validation_commands = {"context-info", "post-comment"} 1301 errors = config.validate() 1302 if errors and args.command not in skip_validation_commands: 1303 for error in errors: 1304 print(f"ā Config error: {error}", file=sys.stderr) 1305 sys.exit(1) 1306 1307 # Dispatch command 1308 commands = { 1309 "context-info": cmd_context_info, 1310 "review": cmd_review, 1311 "pr-review": cmd_pr_review, 1312 "security-review": cmd_security_review, 1313 "validate-arch": cmd_validate_arch, 1314 "docs-sync": cmd_docs_sync, 1315 "post-comment": cmd_post_comment, 1316 } 1317 1318 cmd_func = commands.get(args.command) 1319 if cmd_func: 1320 try: 1321 cmd_func(args, config) 1322 except Exception as e: 1323 print(f"ā Error: {e}", file=sys.stderr) 1324 if os.getenv("DEBUG"): 1325 raise 1326 sys.exit(1) 1327 else: 1328 parser.print_help() 1329 sys.exit(1) 1330 1331 1332 if __name__ == "__main__": 1333 main() 1334 # Test