From 827815628f032fb16a50d564b5f70b16c3b4c8e9 Mon Sep 17 00:00:00 2001 From: Sagar Paul Date: Fri, 24 Apr 2026 14:38:10 +0530 Subject: [PATCH 1/2] chore: add gstack skill routing rules to CLAUDE.md --- CLAUDE.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22fd77e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# Ansieyes + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. The +skill has multi-step workflows, checklists, and quality gates that produce better +results than an ad-hoc answer. When in doubt, invoke the skill. A false positive is +cheaper than a false negative. + +Key routing rules: +- Product ideas, "is this worth building", brainstorming → invoke /office-hours +- Strategy, scope, "think bigger", "what should we build" → invoke /plan-ceo-review +- Architecture, "does this design make sense" → invoke /plan-eng-review +- Design system, brand, "how should this look" → invoke /design-consultation +- Design review of a plan → invoke /plan-design-review +- Developer experience of a plan → invoke /plan-devex-review +- "Review everything", full review pipeline → invoke /autoplan +- Bugs, errors, "why is this broken", "wtf", "this doesn't work" → invoke /investigate +- Test the site, find bugs, "does this work" → invoke /qa (or /qa-only for report only) +- Code review, check the diff, "look at my changes" → invoke /review +- Visual polish, design audit, "this looks off" → invoke /design-review +- Developer experience audit, try onboarding → invoke /devex-review +- Ship, deploy, create a PR, "send it" → invoke /ship +- Merge + deploy + verify → invoke /land-and-deploy +- Configure deployment → invoke /setup-deploy +- Post-deploy monitoring → invoke /canary +- Update docs after shipping → invoke /document-release +- Weekly retro, "how'd we do" → invoke /retro +- Second opinion, codex review → invoke /codex +- Safety mode, careful mode, lock it down → invoke /careful or /guard +- Restrict edits to a directory → invoke /freeze or /unfreeze +- Upgrade gstack → invoke /gstack-upgrade +- Save progress, "save my work" → invoke /context-save +- Resume, restore, "where was I" → invoke /context-restore +- Security audit, OWASP, "is this secure" → invoke /cso +- Make a PDF, document, publication → invoke /make-pdf +- Launch real browser for QA → invoke /open-gstack-browser +- Import cookies for authenticated testing → invoke /setup-browser-cookies +- Performance regression, page speed, benchmarks → invoke /benchmark +- Review what gstack has learned → invoke /learn +- Tune question sensitivity → invoke /plan-tune +- Code quality dashboard → invoke /health From 330571365b8f6f5d1c1f7d84633efff618f22121 Mon Sep 17 00:00:00 2001 From: Sagar Paul Date: Fri, 24 Apr 2026 15:01:14 +0530 Subject: [PATCH 2/2] Fixes for gunicorn deployment --- .gitignore | 1 + app.py | 106 +++++++++++++++++++++++++++++++++++++---------- issue_triager.py | 95 +++++++++++++++++++++++++++++------------- pr_reviewer.py | 19 ++++++--- 4 files changed, 163 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 4e99b20..020d4cf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ ENV/ .DS_Store *.log +.gstack/ diff --git a/app.py b/app.py index 2ffd273..77927be 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,7 @@ GITHUB_APP_ID = os.getenv('GITHUB_APP_ID') GITHUB_PRIVATE_KEY_PATH = os.getenv('GITHUB_PRIVATE_KEY_PATH') GITHUB_WEBHOOK_SECRET = os.getenv('GITHUB_WEBHOOK_SECRET') -AI_TRIAGE_PATH = os.getenv('AI_TRIAGE_PATH', '/Users/shvenkat/Documents/AI/AI-Issue-Triage') +AI_TRIAGE_PATH = os.getenv('AI_TRIAGE_PATH', os.path.expanduser('~/AI-Issue-Triage')) PORT = int(os.getenv('PORT', 3000)) HOST = os.getenv('HOST', '0.0.0.0') @@ -98,8 +98,8 @@ def get_label_color(label_name): def verify_webhook_signature(payload_body, signature_header): """Verify GitHub webhook signature""" if not GITHUB_WEBHOOK_SECRET: - logger.warning("GITHUB_WEBHOOK_SECRET not set. Skipping signature verification.") - return True + logger.error("GITHUB_WEBHOOK_SECRET not set. Webhook signature verification required for security.") + return False if not signature_header: return False @@ -610,7 +610,10 @@ def handle_triage_mention(payload, installation_id): "Please try again later or contact support.\n\n" "---\n*Powered by Ansieyes*" ) - shutil.rmtree(temp_dir, ignore_errors=True) + try: + shutil.rmtree(temp_dir, ignore_errors=False) + except Exception as cleanup_error: + logger.warning(f"Failed to cleanup temp_dir {temp_dir}: {cleanup_error}") return except Exception as e: logger.error(f"Failed to clone repository: {e}") @@ -620,20 +623,41 @@ def handle_triage_mention(payload, installation_id): f"Error: {str(e)}\n\n" "---\n*Powered by Ansieyes*" ) - shutil.rmtree(temp_dir, ignore_errors=True) + try: + shutil.rmtree(temp_dir, ignore_errors=False) + except Exception as cleanup_error: + logger.warning(f"Failed to cleanup temp_dir {temp_dir}: {cleanup_error}") return # Fetch existing issues for duplicate detection - # Only fetch OPEN issues that were created BEFORE the current issue + # Only fetch recent OPEN issues (last 90 days) that were created BEFORE the current issue + # Limit to 200 issues max to avoid rate limiting and performance issues logger.info("Fetching existing issues for duplicate detection...") existing_issues = [] try: + from datetime import datetime, timedelta, timezone current_issue_created_at = issue.created_at - for existing_issue in repo.get_issues(state='open'): + # Only check issues from last 90 days + ninety_days_ago = datetime.now(timezone.utc) - timedelta(days=90) + + issue_count = 0 + max_issues = 200 # Limit to prevent rate limiting + + for existing_issue in repo.get_issues(state='open', sort='created', direction='desc'): + # Stop if we've checked enough issues + if issue_count >= max_issues: + logger.info(f"Reached max issue limit ({max_issues}), stopping duplicate check") + break + + # Stop if we've gone back 90 days + if existing_issue.created_at < ninety_days_ago: + logger.info("Reached 90-day threshold, stopping duplicate check") + break + # Skip the current issue if existing_issue.number == issue_number: continue - + # Only include issues created BEFORE the current issue # This ensures issue A (older) won't be marked as duplicate of issue B (newer) if existing_issue.created_at < current_issue_created_at: @@ -645,8 +669,10 @@ def handle_triage_mention(payload, installation_id): 'created_date': existing_issue.created_at.isoformat(), 'url': existing_issue.html_url }) - - logger.info(f"Found {len(existing_issues)} older open issues for duplicate check") + + issue_count += 1 + + logger.info(f"Found {len(existing_issues)} older open issues for duplicate check (checked {issue_count} total)") except Exception as e: logger.warning(f"Could not fetch existing issues: {e}") @@ -662,11 +688,17 @@ def handle_triage_mention(payload, installation_id): # Clean up cloned repository (CRITICAL: Always cleanup) try: - shutil.rmtree(temp_dir, ignore_errors=True) + shutil.rmtree(temp_dir, ignore_errors=False) # Don't ignore errors logger.info(f"Cleaned up temp directory: {temp_dir}") except Exception as e: - logger.error(f"Failed to clean up temp directory: {e}") - # Log this for monitoring - disk space issues can be serious + logger.error(f"CRITICAL: Failed to clean up temp directory {temp_dir}: {e}") + # Disk space issues are serious - try fallback cleanup + try: + subprocess.run(['rm', '-rf', temp_dir], timeout=30, check=False) + logger.info(f"Fallback cleanup succeeded for {temp_dir}") + except Exception as fallback_error: + logger.error(f"Fallback cleanup also failed for {temp_dir}: {fallback_error}") + # This is a critical issue - log for monitoring/alerting # Check if triage_result is valid if not triage_result: @@ -710,21 +742,31 @@ def handle_triage_mention(payload, installation_id): except Exception as e: logger.warning(f"Could not delete processing comment: {e}") - # Simple label management: Remove ALL existing labels, then add new ones + # Smart label management: Only remove bot-added labels, preserve user labels logger.info("Starting label management...") labels_to_add = [] - - # Remove ALL existing labels + + # Define labels that are managed by the bot (safe to remove) + bot_managed_label_prefixes = ['Type : ', 'Severity : ', 'ai-triaged', 'duplicate', 'Prompt injection blocked', 'ai-reviewed'] + + # Remove only bot-managed labels try: existing_labels = [label.name for label in issue.labels] - if existing_labels: - for label in existing_labels: + labels_to_remove = [] + for label_name in existing_labels: + # Check if this label was added by the bot + is_bot_label = any(label_name.startswith(prefix) or label_name == prefix for prefix in bot_managed_label_prefixes) + if is_bot_label: + labels_to_remove.append(label_name) + + if labels_to_remove: + for label in labels_to_remove: issue.remove_from_labels(label) - logger.info(f"Removed all old labels: {existing_labels}") + logger.info(f"Removed bot-managed labels: {labels_to_remove}") else: - logger.info("No existing labels to remove") + logger.info("No bot-managed labels to remove") except Exception as e: - logger.warning(f"Could not remove old labels: {e}") + logger.warning(f"Could not remove bot-managed labels: {e}") import traceback traceback.print_exc() @@ -835,13 +877,15 @@ def handle_triage_mention(payload, installation_id): logger.warning("No labels to add") logger.info(f"Triage completed for issue #{issue_number}") - + except GithubException as e: logger.error(f"GitHub API error: {e}") + raise # Re-raise so webhook handler can return 500 except Exception as e: logger.error(f"Error handling triage mention: {e}") import traceback traceback.print_exc() + raise # Re-raise so webhook handler can return 500 def handle_pr_review_mention(payload, installation_id): @@ -942,13 +986,15 @@ def handle_pr_review_mention(payload, installation_id): pass logger.info(f"PR review completed for PR #{issue_number}") - + except GithubException as e: logger.error(f"GitHub API error: {e}") + raise # Re-raise so webhook handler can return 500 except Exception as e: logger.error(f"Error handling PR review mention: {e}") import traceback traceback.print_exc() + raise # Re-raise so webhook handler can return 500 if __name__ == '__main__': @@ -956,6 +1002,20 @@ def handle_pr_review_mention(payload, installation_id): logger.error("GEMINI_API_KEY environment variable is required") exit(1) + # Production deployment warning + logger.warning("=" * 80) + logger.warning("⚠️ PRODUCTION WARNING") + logger.warning("=" * 80) + logger.warning("Flask's built-in server is NOT production-ready!") + logger.warning("For production, use a WSGI server like gunicorn or uvicorn:") + logger.warning("") + logger.warning(" gunicorn -w 4 -b 0.0.0.0:3000 app:app") + logger.warning(" or") + logger.warning(" uvicorn app:app --host 0.0.0.0 --port 3000 --workers 4") + logger.warning("") + logger.warning("Flask dev server is single-threaded and crashes on uncaught exceptions.") + logger.warning("=" * 80) + logger.info(f"Starting GitHub PR Review Bot on {HOST}:{PORT}") app.run(host=HOST, port=PORT, debug=False) diff --git a/issue_triager.py b/issue_triager.py index 0462956..75be00e 100644 --- a/issue_triager.py +++ b/issue_triager.py @@ -18,7 +18,7 @@ class IssueTriager: """Handle AI-powered issue triage using two-pass architecture""" - def __init__(self, api_key: Optional[str] = None, ai_triage_path: str = "/Users/shvenkat/Documents/AI/AI-Issue-Triage"): + def __init__(self, api_key: Optional[str] = None, ai_triage_path: str = None): """Initialize the issue triager Args: @@ -26,7 +26,9 @@ def __init__(self, api_key: Optional[str] = None, ai_triage_path: str = "/Users/ ai_triage_path: Path to AI-Issue-Triage repository """ self.api_key = api_key or os.getenv('GEMINI_API_KEY') - self.ai_triage_path = Path(ai_triage_path) + # Use provided path, env var, or default to ~/AI-Issue-Triage + triage_path = ai_triage_path or os.getenv('AI_TRIAGE_PATH') or os.path.expanduser('~/AI-Issue-Triage') + self.ai_triage_path = Path(triage_path) # Try to load prompt injection detector from AI-Issue-Triage self.detect_prompt_injection_func = None @@ -150,7 +152,10 @@ def check_for_duplicates( ) # Clean up temp file - os.unlink(issues_file) + try: + os.unlink(issues_file) + except Exception as e: + logger.warning(f"Failed to cleanup issues file {issues_file}: {e}") if result.returncode == 0: return json.loads(result.stdout) @@ -217,11 +222,18 @@ def run_librarian( if result.returncode == 0 and os.path.exists(output_file): with open(output_file, 'r') as f: librarian_result = json.load(f) - os.unlink(output_file) + try: + os.unlink(output_file) + except Exception as e: + logger.warning(f"Failed to cleanup librarian output file {output_file}: {e}") return librarian_result else: logger.error(f"Librarian failed: {result.stderr}") - os.unlink(output_file) + try: + if os.path.exists(output_file): + os.unlink(output_file) + except Exception as e: + logger.warning(f"Failed to cleanup librarian output file {output_file}: {e}") return {"relevant_files": [], "error": result.stderr} except Exception as e: @@ -302,12 +314,18 @@ def run_surgeon( if result.returncode == 0 and os.path.exists(output_file): with open(output_file, 'r') as f: surgeon_result = f.read() # Read as text, not JSON - os.unlink(output_file) + try: + os.unlink(output_file) + except Exception as e: + logger.warning(f"Failed to cleanup surgeon output file {output_file}: {e}") return {"formatted_output": surgeon_result} # Return formatted text else: logger.error(f"Surgeon failed: {result.stderr}") - if os.path.exists(output_file): - os.unlink(output_file) + try: + if os.path.exists(output_file): + os.unlink(output_file) + except Exception as e: + logger.warning(f"Failed to cleanup surgeon output file {output_file}: {e}") return {"error": result.stderr} except Exception as e: @@ -527,14 +545,20 @@ def triage_issue( result["error"] = f"Failed to clone repository: {e}" if cleanup_needed and local_temp_dir: import shutil - shutil.rmtree(local_temp_dir, ignore_errors=True) + try: + shutil.rmtree(local_temp_dir, ignore_errors=False) + except Exception as cleanup_error: + logger.warning(f"Failed to cleanup {local_temp_dir}: {cleanup_error}") return result except subprocess.TimeoutExpired: logger.error("Git clone timeout") result["error"] = "Repository clone timeout" if cleanup_needed and local_temp_dir: import shutil - shutil.rmtree(local_temp_dir, ignore_errors=True) + try: + shutil.rmtree(local_temp_dir, ignore_errors=False) + except Exception as cleanup_error: + logger.warning(f"Failed to cleanup {local_temp_dir}: {cleanup_error}") return result else: logger.info(f"Using existing repo path: {repo_path}") @@ -551,33 +575,40 @@ def triage_issue( logger.warning("No relevant files identified") if cleanup_needed and local_temp_dir: import shutil - shutil.rmtree(local_temp_dir, ignore_errors=True) + try: + shutil.rmtree(local_temp_dir, ignore_errors=False) + except Exception as cleanup_error: + logger.warning(f"Failed to cleanup {local_temp_dir}: {cleanup_error}") return result # Step 3: Generate targeted repomix and run Surgeon logger.info("Generating targeted repomix...") - + # Create a temp file for targeted repomix (will be cleaned up) targeted_repomix_fd, targeted_repomix_path = tempfile.mkstemp(suffix='.txt') os.close(targeted_repomix_fd) # Close file descriptor - - # Generate repomix with identified files + + # Generate repomix with identified files from already-cloned repo + # This avoids double-cloning the repository file_list = result["librarian"]["relevant_files"] include_args = [] for file in file_list: include_args.extend(['--include', file]) - - cmd = ['repomix', '--remote', repo_url, '--style', 'plain', - '--output', targeted_repomix_path] + include_args - + + # Find repomix command + repomix_cmd = self._find_repomix() + + # Run repomix from within the cloned repo (no --remote, uses local files) + cmd = repomix_cmd + ['--style', 'plain', '--output', targeted_repomix_path] + include_args + try: - subprocess.run(cmd, capture_output=True, timeout=300, check=True) + subprocess.run(cmd, cwd=repo_path, capture_output=True, timeout=300, check=True) + logger.info(f"Generated targeted repomix from local repo at {repo_path}") except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - logger.warning(f"Targeted repomix failed: {e}, trying fallback") - # Fallback: use full repo - subprocess.run(['repomix', '--remote', repo_url, '--style', 'plain', - '--output', targeted_repomix_path], - capture_output=True, timeout=300) + logger.warning(f"Targeted repomix failed: {e}, trying fallback without filters") + # Fallback: use full repo without filters + subprocess.run(repomix_cmd + ['--style', 'plain', '--output', targeted_repomix_path], + cwd=repo_path, capture_output=True, timeout=300) if os.path.exists(targeted_repomix_path) and os.path.getsize(targeted_repomix_path) > 0: # Run Surgeon with config and repo_path @@ -607,10 +638,16 @@ def triage_issue( if cleanup_needed and local_temp_dir: try: import shutil - shutil.rmtree(local_temp_dir, ignore_errors=True) + shutil.rmtree(local_temp_dir, ignore_errors=False) # Don't ignore errors logger.debug(f"Cleaned up temp directory: {local_temp_dir}") except Exception as e: - logger.warning(f"Failed to clean up temp directory: {e}") + logger.error(f"CRITICAL: Failed to clean up temp directory {local_temp_dir}: {e}") + # Try fallback cleanup + try: + subprocess.run(['rm', '-rf', local_temp_dir], timeout=30, check=False) + logger.info(f"Fallback cleanup succeeded for {local_temp_dir}") + except Exception as fallback_error: + logger.error(f"Fallback cleanup also failed for {local_temp_dir}: {fallback_error}") return result @@ -624,7 +661,7 @@ def format_triage_comment(self, triage_result: Dict) -> str: Returns: Formatted markdown comment """ - from datetime import datetime + from datetime import datetime, timezone # Prompt injection check - HIGH/CRITICAL blocks (formatted like AI-Issue-Triage) if triage_result.get("prompt_injection_check"): @@ -649,7 +686,7 @@ def format_triage_comment(self, triage_result: Dict) -> str: confidence_percent = int(injection.get("confidence", 0) * 100) comment += f"🔴 **Risk Level:** `{risk_level.upper()}` \n" comment += f"📊 **Confidence:** `{confidence_percent}%` \n" - comment += f"⏰ **Generated:** `{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}`\n\n" + comment += f"⏰ **Generated:** `{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}`\n\n" comment += "---\n\n" if injection.get("detected_patterns"): @@ -684,7 +721,7 @@ def format_triage_comment(self, triage_result: Dict) -> str: comment += f"📊 **Similarity Score:** `{similarity_percent}%` \n" comment += f"🎯 **Confidence:** `{confidence_percent}%` \n" - comment += f"⏰ **Generated:** `{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}`\n\n" + comment += f"⏰ **Generated:** `{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}`\n\n" comment += "---\n\n" # Show why it's considered duplicate diff --git a/pr_reviewer.py b/pr_reviewer.py index ccf930f..c9b2d15 100644 --- a/pr_reviewer.py +++ b/pr_reviewer.py @@ -23,7 +23,7 @@ def __init__(self, api_key: Optional[str] = None, ai_triage_path: Optional[str] logger.warning("Gemini API key not provided") # Path to AI-Issue-Triage installation - self.ai_triage_path = ai_triage_path or os.getenv("AI_TRIAGE_PATH", "/root/AI-Issue-Triage") + self.ai_triage_path = ai_triage_path or os.getenv("AI_TRIAGE_PATH", os.path.expanduser("~/AI-Issue-Triage")) if not os.path.exists(self.ai_triage_path): raise ValueError(f"AI-Issue-Triage not found at {self.ai_triage_path}") @@ -102,11 +102,18 @@ def review_pr(self, title: str, body: str, file_changes: List[Dict], repo_url: O return f"❌ **PR Review Failed**\n\n```\n{result.stderr}\n```" finally: - # Cleanup temporary files - if os.path.exists(pr_file_path): - os.unlink(pr_file_path) - if os.path.exists(output_file_path): - os.unlink(output_file_path) + # Cleanup temporary files (robust error handling) + try: + if os.path.exists(pr_file_path): + os.unlink(pr_file_path) + except Exception as e: + logger.warning(f"Failed to cleanup PR file {pr_file_path}: {e}") + + try: + if os.path.exists(output_file_path): + os.unlink(output_file_path) + except Exception as e: + logger.warning(f"Failed to cleanup output file {output_file_path}: {e}") except subprocess.TimeoutExpired: logger.error("PR review timed out")