diff --git a/.gitea/workflows/ai-comment-reply.yml b/.gitea/workflows/ai-comment-reply.yml index edfa31d..43186ca 100644 --- a/.gitea/workflows/ai-comment-reply.yml +++ b/.gitea/workflows/ai-comment-reply.yml @@ -1,41 +1,50 @@ name: AI Comment Reply on: - issue_comment: - types: [created] + issue_comment: + types: [created] # CUSTOMIZE YOUR BOT NAME: # Change '@ai-bot' below to match your config.yml mention_prefix # Examples: '@bartender', '@uni', '@joey', '@codebot' jobs: - ai-reply: - runs-on: ubuntu-latest - if: contains(github.event.comment.body, '@codebot') # <-- Change this to your bot name - steps: - - uses: actions/checkout@v4 + ai-reply: + runs-on: ubuntu-latest + if: contains(github.event.comment.body, '@codebot') # <-- Change this to your bot name + steps: + - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - repository: Hiddenden/openrabbit - path: .ai-review - token: ${{ secrets.AI_REVIEW_TOKEN }} + - uses: actions/checkout@v4 + with: + repository: Hiddenden/openrabbit + path: .ai-review + token: ${{ secrets.AI_REVIEW_TOKEN }} - - uses: actions/setup-python@v5 - with: - python-version: "3.11" + - uses: actions/setup-python@v5 + with: + python-version: "3.11" - - run: pip install requests pyyaml + - run: pip install requests pyyaml - - name: Run AI Comment Response - env: - AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} - AI_REVIEW_REPO: ${{ gitea.repository }} - AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} - run: | - cd .ai-review/tools/ai-review - python main.py comment ${{ gitea.repository }} ${{ gitea.event.issue.number }} \ - "${{ gitea.event.comment.body }}" + - name: Run AI Comment Response + env: + AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} + AI_REVIEW_REPO: ${{ gitea.repository }} + AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + run: | + cd .ai-review/tools/ai-review + + # Check if this is a PR or an issue + if [ "${{ gitea.event.issue.pull_request }}" != "" ]; then + # This is a PR comment - dispatch as issue_comment event + python main.py dispatch ${{ gitea.repository }} issue_comment \ + '{"action":"created","issue":${{ toJSON(gitea.event.issue) }},"comment":${{ toJSON(gitea.event.comment) }}}' + else + # This is an issue comment - use the comment command + python main.py comment ${{ gitea.repository }} ${{ gitea.event.issue.number }} \ + "${{ gitea.event.comment.body }}" + fi diff --git a/CLAUDE.md b/CLAUDE.md index 918d239..1413001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,7 @@ interaction: - security # Security analysis - summarize # Summarize the issue - triage # Full triage with labeling + - review-again # Re-run PR review (PR comments only) review: fail_on_severity: HIGH # Fail CI if HIGH severity issues found @@ -309,6 +310,49 @@ pytest tests/test_ai_review.py::TestSecurityScanner -v ## Common Development Tasks +### Review-Again Command Implementation + +The `@codebot review-again` command allows manual re-triggering of PR reviews without new commits. + +**Key Features:** +- Detects `@codebot review-again` in PR comments (not issue comments) +- Compares new review with previous review to show resolved/new issues +- Updates existing AI review comment instead of creating duplicates +- Updates PR labels based on new severity assessment + +**Implementation Details:** + +1. **PRAgent.can_handle()** - Handles `issue_comment` events on PRs containing "review-again" +2. **PRAgent._handle_review_again()** - Main handler that: + - Fetches previous review comment + - Re-runs full PR review (security scan + AI analysis) + - Compares findings using `_compare_reviews()` + - Generates diff report with `_format_review_update()` + - Updates comment and labels + +3. **Review Comparison** - Uses finding keys (file:line:description) to match issues: + - **Resolved**: Issues in previous but not in current review + - **New**: Issues in current but not in previous review + - **Still Present**: Issues in both reviews + - **Severity Changed**: Same issue with different severity + +4. **Workflow Integration** - `.gitea/workflows/ai-comment-reply.yml`: + - Detects if comment is on PR or issue + - Uses `dispatch` command for PRs to route to PRAgent + - Preserves backward compatibility with issue commands + +**Usage:** +```bash +# In a PR comment: +@codebot review-again +``` + +**Common Use Cases:** +- Re-evaluate after explaining false positives in comments +- Test new `.ai-review.yml` configuration +- Update severity after code clarification +- Faster iteration without empty commits + ### Adding a New Command to @codebot 1. Add command to `config.yml` under `interaction.commands` @@ -318,9 +362,12 @@ pytest tests/test_ai_review.py::TestSecurityScanner -v 5. Add tests in `tests/test_ai_review.py` Example commands: +- `@codebot help` - Show all available commands with examples - `@codebot triage` - Full issue triage with labeling - `@codebot explain` - Explain the issue - `@codebot suggest` - Suggest solutions +- `@codebot setup-labels` - Automatic label setup (built-in, not in config) +- `@codebot review-again` - Re-run PR review without new commits (PR comments only) ### Changing the Bot Name @@ -338,10 +385,78 @@ Example commands: ## Repository Labels +### Automatic Label Setup (Recommended) + +Use the `@codebot setup-labels` command to automatically configure labels. This command: + +**For repositories with existing labels:** +- Detects naming patterns: `Kind/Bug`, `Priority - High`, `type: bug` +- Maps existing labels to OpenRabbit schema using aliases +- Creates only missing labels following detected pattern +- Zero duplicate labels + +**For fresh repositories:** +- Creates OpenRabbit's default label set +- Uses standard naming: `type:`, `priority:`, status labels + +**Example with existing `Kind/` and `Priority -` labels:** +``` +@codebot setup-labels + +✅ Found 18 existing labels with pattern: prefix_slash + +Proposed Mapping: +| OpenRabbit Expected | Your Existing Label | Status | +|---------------------|---------------------|--------| +| type: bug | Kind/Bug | ✅ Map | +| type: feature | Kind/Feature | ✅ Map | +| priority: high | Priority - High | ✅ Map | +| ai-reviewed | (missing) | ⚠️ Create | + +✅ Created Kind/Question +✅ Created Status - AI Reviewed + +Setup Complete! Auto-labeling will use your existing label schema. +``` + +### Manual Label Setup + The system expects these labels to exist in repositories for auto-labeling: -- `priority: high`, `priority: medium`, `priority: low` -- `type: bug`, `type: feature`, `type: question`, `type: documentation` +- `priority: critical`, `priority: high`, `priority: medium`, `priority: low` +- `type: bug`, `type: feature`, `type: question`, `type: documentation`, `type: security`, `type: testing` - `ai-approved`, `ai-changes-required`, `ai-reviewed` Labels are mapped in `config.yml` under the `labels` section. + +### Label Configuration Format + +Labels support two formats for backwards compatibility: + +**New format (with colors and aliases):** +```yaml +labels: + type: + bug: + name: "type: bug" + color: "d73a4a" # Red + description: "Something isn't working" + aliases: ["Kind/Bug", "bug", "Type: Bug"] # For auto-detection +``` + +**Old format (strings only):** +```yaml +labels: + type: + bug: "type: bug" # Still works, uses default blue color +``` + +### Label Pattern Detection + +The `setup-labels` command detects these patterns (configured in `label_patterns`): + +1. **prefix_slash**: `Kind/Bug`, `Type/Feature`, `Category/X` +2. **prefix_dash**: `Priority - High`, `Status - Blocked` +3. **colon**: `type: bug`, `priority: high` + +When creating missing labels, the bot follows the detected pattern to maintain consistency. diff --git a/README.md b/README.md index e5a0b56..d379370 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,27 @@ jobs: See `.gitea/workflows/` for all workflow examples. -### 3. Create Labels +### 3. Create Labels (Automatic Setup) + +**Option A: Automatic Setup (Recommended)** + +Create an issue and comment: +``` +@codebot setup-labels +``` + +The bot will automatically: +- Detect your existing label schema (e.g., `Kind/Bug`, `Priority - High`) +- Map existing labels to OpenRabbit's auto-labeling system +- Create only the missing labels you need +- Follow your repository's naming convention + +**Option B: Manual Setup** Create these labels in your repository for auto-labeling: -- `priority: high`, `priority: medium`, `priority: low` -- `type: bug`, `type: feature`, `type: question` -- `ai-approved`, `ai-changes-required` +- `priority: critical`, `priority: high`, `priority: medium`, `priority: low` +- `type: bug`, `type: feature`, `type: question`, `type: documentation` +- `ai-approved`, `ai-changes-required`, `ai-reviewed` --- @@ -154,16 +169,85 @@ python main.py chat owner/repo "Find all API endpoints" --issue 789 ## @codebot Commands +### Issue Commands + In any issue comment: | Command | Description | |---------|-------------| +| `@codebot help` | **Help:** Show all available commands with examples | +| `@codebot setup-labels` | **Setup:** Automatically create/map repository labels for auto-labeling | | `@codebot triage` | Full issue triage with auto-labeling and analysis | | `@codebot summarize` | Summarize the issue in 2-3 sentences | | `@codebot explain` | Explain what the issue is about | | `@codebot suggest` | Suggest solutions or next steps | | `@codebot` (any question) | Chat with AI using codebase/web search tools | +### Pull Request Commands + +In any PR comment: + +| Command | Description | +|---------|-------------| +| `@codebot review-again` | Re-run AI code review on current PR state without new commits | + +**Features:** +- ✅ Shows diff from previous review (resolved/new/changed issues) +- 🏷️ Updates labels based on new severity +- ⚡ No need for empty commits to trigger review +- 🔧 Respects latest `.ai-review.yml` configuration + +**When to use:** +- After addressing review feedback in comments +- When AI flagged a false positive and you explained it +- After updating `.ai-review.yml` security rules +- To re-evaluate severity after code clarification + +**Example:** +``` +The hardcoded string at line 45 is a public API URL, not a secret. +@codebot review-again +``` + +**New to OpenRabbit?** Just type `@codebot help` in any issue to see all available commands! + +### Label Setup Command + +The `@codebot setup-labels` command intelligently detects your existing label schema and sets up auto-labeling: + +**For repositories with existing labels (e.g., `Kind/Bug`, `Priority - High`):** +- Detects your naming pattern (prefix/slash, prefix-dash, or colon-style) +- Maps your existing labels to OpenRabbit's schema +- Creates only missing labels following your pattern +- Zero duplicate labels created + +**For fresh repositories:** +- Creates OpenRabbit's default label set +- Uses `type:`, `priority:`, and status labels + +**Example output:** +``` +@codebot setup-labels + +✅ Found 18 existing labels with pattern: prefix_slash + +Detected Categories: +- Kind (7 labels): Bug, Feature, Documentation, Security, Testing +- Priority (4 labels): Critical, High, Medium, Low + +Proposed Mapping: +| OpenRabbit Expected | Your Existing Label | Status | +|---------------------|---------------------|--------| +| type: bug | Kind/Bug | ✅ Map | +| priority: high | Priority - High | ✅ Map | +| ai-reviewed | (missing) | ⚠️ Create | + +✅ Created Kind/Question (#cc317c) +✅ Created Status - AI Reviewed (#1d76db) + +Setup Complete! Auto-labeling will use your existing label schema. +``` + --- ## Interactive Chat diff --git a/docs/feature-ideas.md b/docs/feature-ideas.md new file mode 100644 index 0000000..51e54e4 --- /dev/null +++ b/docs/feature-ideas.md @@ -0,0 +1,440 @@ +# Feature Ideas & Roadmap + +This document outlines recommended feature additions for OpenRabbit, ordered by value/effort ratio. + +--- + +## Quick Reference + +| Feature | Value | Effort | Time Estimate | Status | +|---------|-------|--------|---------------|--------| +| [@codebot help Command](#1-codebot-help-command) | HIGH | LOW | 1-2 hours | ⭐ Recommended | +| [Automatic Label Creator](#2-automatic-label-creator) | HIGH | MEDIUM | 2-3 hours | Planned | +| [PR Changelog Generator](#3-pr-changelog-generator) | MEDIUM | MEDIUM | 3-4 hours | Planned | +| [Code Diff Explainer](#4-code-diff-explainer) | MEDIUM-HIGH | MEDIUM | 2-3 hours | Planned | +| [Smart Test Suggestions](#5-smart-test-suggestions) | HIGH | HIGH | 5-6 hours | Planned | +| [@codebot review-again](#6-codebot-review-again) | MEDIUM | LOW | 1-2 hours | Planned | +| [Dependency Update Advisor](#7-dependency-update-advisor) | VERY HIGH | HIGH | 6-8 hours | Planned | + +--- + +## 1. @codebot help Command + +**⭐ HIGHEST PRIORITY - Quick Win** + +### Problem +Users have no way to discover what commands are available. They don't know what the bot can do without reading documentation. + +### Solution +Add a `@codebot help` command that lists all available commands with descriptions and examples. + +### Implementation +- Add `help` to `config.yml` commands list +- Add `_command_help()` method to IssueAgent +- Format response with all commands + descriptions + +### Example Output +```markdown +@username + +**Available @codebot Commands:** + +**Issue Triage & Analysis:** +- `@codebot triage` - Full issue triage with auto-labeling and priority assignment +- `@codebot summarize` - Generate 2-3 sentence summary +- `@codebot explain` - Detailed explanation of the issue +- `@codebot suggest` - Solution suggestions or next steps + +**Interactive Chat:** +- `@codebot [question]` - Ask questions about the codebase + +**Codebase Analysis:** +- `@codebot codebase` - Trigger full codebase health analysis + +**Utility:** +- `@codebot help` - Show this message + +**Examples:** +- `@codebot explain` - Get detailed explanation +- `@codebot how does authentication work?` - Chat about codebase +``` + +### Impact +- Immediate UX improvement +- Reduces support burden +- Makes all future commands discoverable +- Foundation for growth + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/issue_agent.py` + +--- + +## 2. Automatic Label Creator + +### Problem +Major setup pain point: users must manually create 10+ labels (`priority: high`, `type: bug`, etc.). Bot silently fails to apply labels if they don't exist. + +### Solution +Add `@codebot setup-labels` command that: +1. Checks which required labels are missing +2. Creates them with proper colors +3. Or provides CLI commands for manual creation + +### Implementation +- Add `setup-labels` command +- Query repository labels via Gitea API +- Compare against required labels in config +- Auto-create missing labels or show creation commands + +### Example Output +```markdown +@username + +**Label Setup Analysis:** + +**Missing Labels:** +- `priority: high` (color: #d73a4a) +- `priority: medium` (color: #fbca04) +- `type: bug` (color: #d73a4a) + +**Creating labels...** +✅ Created `priority: high` +✅ Created `priority: medium` +✅ Created `type: bug` + +All required labels are now set up! +``` + +### Impact +- Removes major setup friction +- Ensures auto-labeling works immediately +- Better onboarding experience + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/issue_agent.py` +- `/tools/ai-review/clients/gitea_client.py` (add create_label method) + +--- + +## 3. PR Changelog Generator + +### Problem +Developers spend time writing release notes and changelogs. Bot already analyzes PR content. + +### Solution +Add `@codebot changelog` command that generates human-readable changelog from PR. + +### Implementation +- Add `changelog` command for PRs +- Analyze PR diff + commit messages +- Generate bullet-point summary +- Format for CHANGELOG.md + +### Example Output +```markdown +@username + +**Changelog for PR #123:** + +### Added +- User authentication system with JWT tokens +- Password reset functionality via email + +### Changed +- Updated database schema for user table +- Refactored login endpoint for better error handling + +### Fixed +- Session timeout bug causing premature logouts +- Security vulnerability in password validation + +### Technical Details +- 15 files changed, 450 insertions, 120 deletions +- Main components: auth/, api/users/, database/ +``` + +### Impact +- Saves time on release documentation +- Consistent changelog format +- Can copy-paste into CHANGELOG.md + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/pr_agent.py` + +--- + +## 4. Code Diff Explainer + +### Problem +Complex PRs are hard to understand, especially for: +- Non-expert reviewers +- New team members +- Documentation purposes + +### Solution +Add `@codebot explain-diff` command that breaks down what changed in simple terms. + +### Implementation +- Add `explain-diff` command for PRs +- Analyze PR diff section by section +- Generate plain-language explanations +- Identify architectural changes + +### Example Output +```markdown +@username + +**PR Explanation:** + +**Overview:** +This PR adds user authentication to the application using JWT tokens. + +**Key Changes:** + +1. **auth/jwt.py** (new file) + - Creates JSON Web Tokens for authenticated users + - Validates tokens on protected endpoints + - Handles token expiration (24 hour lifetime) + +2. **api/users.py** (modified) + - Added `/login` endpoint that accepts username/password + - Returns JWT token on successful authentication + - Added `/logout` endpoint to invalidate tokens + +3. **database/schema.sql** (modified) + - Added `users` table with columns: id, username, password_hash, email + - Passwords are hashed using bcrypt (secure) + +**Architecture Impact:** +- Introduces authentication layer across all API endpoints +- Adds dependency on PyJWT library +- Requires database migration to create users table +``` + +### Impact +- Makes code review accessible +- Great for learning/onboarding +- Documentation generation + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/pr_agent.py` +- `/tools/ai-review/prompts/` (add explain_diff.md) + +--- + +## 5. Smart Test Suggestions + +### Problem +Test coverage is critical but developers often miss edge cases or forget to update tests. + +### Solution +Add `@codebot suggest-tests` command that: +1. Analyzes changed functions/classes +2. Identifies what needs testing +3. Suggests specific test cases + +### Implementation +- Add `suggest-tests` command for PRs +- Parse changed code to identify functions +- Use LLM to suggest test scenarios +- Could integrate with coverage reports + +### Example Output +```markdown +@username + +**Test Suggestions for PR #123:** + +### auth/jwt.py - `create_token()` function + +**Recommended Test Cases:** +1. ✅ Valid user creates token successfully +2. ⚠️ **Missing:** Token expiration after 24 hours +3. ⚠️ **Missing:** Invalid user ID handling +4. ⚠️ **Missing:** Token creation with special characters in username + +### api/users.py - `/login` endpoint + +**Recommended Test Cases:** +1. ✅ Successful login with correct credentials +2. ⚠️ **Missing:** Login with wrong password +3. ⚠️ **Missing:** Login with non-existent user +4. ⚠️ **Missing:** SQL injection attempt in username field +5. ⚠️ **Missing:** Rate limiting after failed attempts + +**Coverage Impact:** +- Current coverage: ~60% +- With suggested tests: ~85% +``` + +### Impact +- Improves test coverage +- Catches edge cases +- Reduces production bugs + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/pr_agent.py` +- `/tools/ai-review/prompts/` (add test_suggestions.md) + +--- + +## 6. @codebot review-again + +### Problem +Current workflow: developer fixes issues → pushes commit → bot auto-reviews. Sometimes developers want re-review without creating new commits (e.g., after only changing comments). + +### Solution +Add `@codebot review-again` command that re-runs PR review on current state. + +### Implementation +- Add `review-again` command for PRs +- Re-run PR agent on current diff +- Update existing review comment +- Compare with previous review (show what changed) + +### Example Output +```markdown +@username + +**Re-review Complete:** + +**Previous Review:** 5 issues (2 HIGH, 3 MEDIUM) +**Current Review:** 1 issue (1 MEDIUM) + +✅ Fixed: SQL injection in login endpoint +✅ Fixed: Hardcoded JWT secret +⚠️ Remaining: Missing error handling in password reset + +**Status:** Changes Required → Approved (pending fix) +``` + +### Impact +- Smoother PR workflow +- No unnecessary commits +- Faster feedback loop + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/pr_agent.py` + +--- + +## 7. Dependency Update Advisor + +### Problem +Security vulnerabilities often exist in dependencies. Keeping dependencies up-to-date is critical but tedious. + +### Solution +Add `@codebot check-deps` command that: +1. Parses requirements.txt, package.json, etc. +2. Checks for outdated packages +3. Warns about CVEs +4. Suggests upgrade commands + +### Implementation +- Add `check-deps` command +- Support multiple package formats (pip, npm, cargo, go) +- Integrate with vulnerability databases (CVE, npm audit) +- Generate upgrade instructions + +### Example Output +```markdown +@username + +**Dependency Analysis:** + +### Outdated Packages (5) + +| Package | Current | Latest | Severity | +|---------|---------|--------|----------| +| requests | 2.28.0 | 2.31.0 | �� HIGH - CVE-2023-32681 | +| django | 3.2.0 | 4.2.8 | 🟡 MEDIUM - Multiple CVEs | +| flask | 2.0.0 | 3.0.0 | 🟢 LOW - New features | +| pyyaml | 5.4.1 | 6.0.1 | 🔴 HIGH - CVE-2022-38752 | +| sqlalchemy | 1.4.0 | 2.0.23 | 🟢 LOW - Performance improvements | + +### Recommended Actions + +**Immediate (Security Vulnerabilities):** +```bash +pip install --upgrade requests==2.31.0 +pip install --upgrade pyyaml==6.0.1 +pip install --upgrade django==4.2.8 +``` + +**Optional (Feature Updates):** +```bash +pip install --upgrade flask==3.0.0 +pip install --upgrade sqlalchemy==2.0.23 +``` + +### Breaking Changes to Review +- **Django 4.x:** Requires Python 3.8+, check compatibility +- **Flask 3.x:** Async support added, review async patterns +- **SQLAlchemy 2.x:** ORM API changes, review queries + +### Resources +- [requests CVE-2023-32681](https://nvd.nist.gov/vuln/detail/CVE-2023-32681) +- [pyyaml CVE-2022-38752](https://nvd.nist.gov/vuln/detail/CVE-2022-38752) +``` + +### Impact +- Critical for security +- Keeps projects up-to-date +- Prevents technical debt +- Reduces manual checking + +### Files to Modify +- `/tools/ai-review/config.yml` +- `/tools/ai-review/agents/issue_agent.py` +- Add new module: `/tools/ai-review/dependency_checker.py` + +### External APIs Needed +- PyPI JSON API for Python packages +- npm registry API for JavaScript +- NVD (National Vulnerability Database) for CVEs +- Or use `pip-audit`, `npm audit` CLI tools + +--- + +## Implementation Priority + +### Phase 1: Quick Wins (1-3 hours total) +1. `@codebot help` command +2. `@codebot review-again` command + +### Phase 2: High Impact (5-8 hours total) +3. Automatic Label Creator +4. Code Diff Explainer + +### Phase 3: Strategic Features (10-15 hours total) +5. Smart Test Suggestions +6. PR Changelog Generator +7. Dependency Update Advisor + +--- + +## Contributing + +Have an idea for a new feature? Please: +1. Check if it's already listed here +2. Consider value/effort ratio +3. Open an issue describing: + - Problem it solves + - Proposed solution + - Expected impact + - Example use case + +--- + +## See Also + +- [future_roadmap.md](future_roadmap.md) - Long-term vision (SAST, RAG, etc.) +- [configuration.md](configuration.md) - How to configure existing features +- [agents.md](agents.md) - Current agent capabilities diff --git a/tests/test_ai_review.py b/tests/test_ai_review.py index ba85612..4f16f37 100644 --- a/tests/test_ai_review.py +++ b/tests/test_ai_review.py @@ -20,7 +20,11 @@ class TestPromptFormatting: """Get the full path to a prompt file.""" return os.path.join( os.path.dirname(__file__), - "..", "tools", "ai-review", "prompts", f"{name}.md" + "..", + "tools", + "ai-review", + "prompts", + f"{name}.md", ) def load_prompt(self, name: str) -> str: @@ -32,15 +36,15 @@ class TestPromptFormatting: def test_issue_triage_prompt_formatting(self): """Test that issue_triage.md can be formatted with placeholders.""" prompt = self.load_prompt("issue_triage") - + # This should NOT raise a KeyError formatted = prompt.format( title="Test Issue Title", body="This is the issue body content", author="testuser", - existing_labels="bug, urgent" + existing_labels="bug, urgent", ) - + assert "Test Issue Title" in formatted assert "This is the issue body content" in formatted assert "testuser" in formatted @@ -52,15 +56,15 @@ class TestPromptFormatting: def test_issue_response_prompt_formatting(self): """Test that issue_response.md can be formatted with placeholders.""" prompt = self.load_prompt("issue_response") - + formatted = prompt.format( issue_type="bug", priority="high", title="Bug Report", body="Description of the bug", - triage_analysis="This is a high priority bug" + triage_analysis="This is a high priority bug", ) - + assert "bug" in formatted assert "high" in formatted assert "Bug Report" in formatted @@ -70,7 +74,7 @@ class TestPromptFormatting: def test_base_prompt_no_placeholders(self): """Test that base.md loads correctly (no placeholders needed).""" prompt = self.load_prompt("base") - + # Should contain key elements assert "security" in prompt.lower() assert "JSON" in prompt @@ -80,14 +84,20 @@ class TestPromptFormatting: """Verify JSON examples use double curly braces.""" for prompt_name in ["issue_triage", "issue_response"]: prompt = self.load_prompt(prompt_name) - + # Check that format() doesn't fail try: # Try with minimal placeholders if prompt_name == "issue_triage": prompt.format(title="t", body="b", author="a", existing_labels="l") elif prompt_name == "issue_response": - prompt.format(issue_type="t", priority="p", title="t", body="b", triage_analysis="a") + prompt.format( + issue_type="t", + priority="p", + title="t", + body="b", + triage_analysis="a", + ) except KeyError as e: pytest.fail(f"Prompt {prompt_name} has unescaped curly braces: {e}") @@ -97,11 +107,11 @@ class TestImports: def test_import_agents(self): """Test importing agent classes.""" - from agents.base_agent import BaseAgent, AgentContext, AgentResult + from agents.base_agent import AgentContext, AgentResult, BaseAgent + from agents.codebase_agent import CodebaseAgent from agents.issue_agent import IssueAgent from agents.pr_agent import PRAgent - from agents.codebase_agent import CodebaseAgent - + assert BaseAgent is not None assert IssueAgent is not None assert PRAgent is not None @@ -111,28 +121,28 @@ class TestImports: """Test importing client classes.""" from clients.gitea_client import GiteaClient from clients.llm_client import LLMClient - + assert GiteaClient is not None assert LLMClient is not None def test_import_security(self): """Test importing security scanner.""" from security.security_scanner import SecurityScanner - + assert SecurityScanner is not None def test_import_enterprise(self): """Test importing enterprise features.""" from enterprise.audit_logger import AuditLogger from enterprise.metrics import MetricsCollector - + assert AuditLogger is not None assert MetricsCollector is not None def test_import_dispatcher(self): """Test importing dispatcher.""" from dispatcher import Dispatcher - + assert Dispatcher is not None @@ -142,11 +152,11 @@ class TestSecurityScanner: def test_detects_hardcoded_secret(self): """Test detection of hardcoded secrets.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ API_KEY = "sk-1234567890abcdef" -''' +""" findings = list(scanner.scan_content(code, "test.py")) assert len(findings) >= 1 assert any(f.severity == "HIGH" for f in findings) @@ -154,11 +164,11 @@ API_KEY = "sk-1234567890abcdef" def test_detects_eval(self): """Test detection of eval usage.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ result = eval(user_input) -''' +""" findings = list(scanner.scan_content(code, "test.py")) assert len(findings) >= 1 assert any("eval" in f.rule_name.lower() for f in findings) @@ -166,13 +176,13 @@ result = eval(user_input) def test_no_false_positives_on_clean_code(self): """Test that clean code doesn't trigger false positives.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ def hello(): print("Hello, world!") return 42 -''' +""" findings = list(scanner.scan_content(code, "test.py")) # Should have no HIGH severity issues for clean code high_findings = [f for f in findings if f.severity == "HIGH"] @@ -185,15 +195,15 @@ class TestAgentContext: def test_agent_context_creation(self): """Test creating AgentContext.""" from agents.base_agent import AgentContext - + context = AgentContext( owner="testowner", repo="testrepo", event_type="issues", event_data={"action": "opened"}, - config={} + config={}, ) - + assert context.owner == "testowner" assert context.repo == "testrepo" assert context.event_type == "issues" @@ -201,14 +211,14 @@ class TestAgentContext: def test_agent_result_creation(self): """Test creating AgentResult.""" from agents.base_agent import AgentResult - + result = AgentResult( success=True, message="Test passed", data={"key": "value"}, - actions_taken=["action1", "action2"] + actions_taken=["action1", "action2"], ) - + assert result.success is True assert result.message == "Test passed" assert len(result.actions_taken) == 2 @@ -220,7 +230,7 @@ class TestMetrics: def test_counter_increment(self): """Test counter metrics.""" from enterprise.metrics import Counter - + counter = Counter("test_counter") assert counter.value == 0 counter.inc() @@ -231,27 +241,352 @@ class TestMetrics: def test_histogram_observation(self): """Test histogram metrics.""" from enterprise.metrics import Histogram - + hist = Histogram("test_histogram") hist.observe(0.1) hist.observe(0.5) hist.observe(1.0) - + assert hist.count == 3 assert hist.sum == 1.6 def test_metrics_collector_summary(self): """Test metrics collector summary.""" from enterprise.metrics import MetricsCollector - + collector = MetricsCollector() collector.record_request_start("TestAgent") collector.record_request_end("TestAgent", success=True, duration_seconds=0.5) - + summary = collector.get_summary() assert summary["requests"]["total"] == 1 assert summary["requests"]["success"] == 1 +class TestHelpCommand: + """Test help command functionality.""" + + def test_help_command_returns_text(self): + """Test that help command returns formatted help text.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "interaction": { + "mention_prefix": "@codebot", + "commands": ["help", "triage", "explain"], + } + }, + ) + + help_text = agent._command_help() + + assert help_text is not None + assert len(help_text) > 100 + assert "@codebot" in help_text + assert "help" in help_text.lower() + assert "triage" in help_text.lower() + + def test_help_includes_all_sections(self): + """Test that help text includes all major sections.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={"interaction": {"mention_prefix": "@codebot"}}, + ) + + help_text = agent._command_help() + + # Check for main sections + assert "Issue Triage" in help_text + assert "Interactive Chat" in help_text + assert "Setup & Utility" in help_text + assert "Pull Request" in help_text + assert "Quick Examples" in help_text + + def test_help_uses_custom_bot_name(self): + """Test that help command uses custom bot name from config.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={"interaction": {"mention_prefix": "@mybot"}}, + ) + + help_text = agent._command_help() + + assert "@mybot" in help_text + assert "@codebot" not in help_text + + def test_help_includes_examples(self): + """Test that help text includes usage examples.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={"interaction": {"mention_prefix": "@codebot"}}, + ) + + help_text = agent._command_help() + + # Check for example commands + assert "triage" in help_text + assert "explain" in help_text + assert "setup-labels" in help_text + + +class TestLabelSetup: + """Test label setup and schema detection.""" + + def test_detect_prefix_slash_schema(self): + """Test detection of Kind/Bug style labels.""" + from agents.issue_agent import IssueAgent + + # Create mock agent with config + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "Kind/Bug", "color": "d73a4a"}, + {"name": "Kind/Feature", "color": "1d76db"}, + {"name": "Kind/Documentation", "color": "0075ca"}, + {"name": "Priority - High", "color": "d73a4a"}, + {"name": "Priority - Low", "color": "28a745"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "prefix_slash" + assert "type" in schema["categories"] + assert "priority" in schema["categories"] + assert len(schema["categories"]["type"]) == 3 + + def test_detect_prefix_dash_schema(self): + """Test detection of Priority - High style labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "Priority - Critical", "color": "b60205"}, + {"name": "Priority - High", "color": "d73a4a"}, + {"name": "Status - Blocked", "color": "fef2c0"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "prefix_dash" + assert "priority" in schema["categories"] + assert "status" in schema["categories"] + + def test_detect_colon_schema(self): + """Test detection of type: bug style labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "type: bug", "color": "d73a4a"}, + {"name": "type: feature", "color": "1d76db"}, + {"name": "priority: high", "color": "d73a4a"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "colon" + assert "type" in schema["categories"] + assert "priority" in schema["categories"] + + def test_build_label_mapping(self): + """Test building label mapping from existing labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": {"name": "type: bug", "aliases": ["Kind/Bug", "bug"]}, + "feature": { + "name": "type: feature", + "aliases": ["Kind/Feature", "feature"], + }, + }, + "priority": { + "high": { + "name": "priority: high", + "aliases": ["Priority - High", "P1"], + } + }, + "status": {}, + } + }, + ) + + existing_labels = [ + {"name": "Kind/Bug", "color": "d73a4a"}, + {"name": "Kind/Feature", "color": "1d76db"}, + {"name": "Priority - High", "color": "d73a4a"}, + ] + + schema = { + "pattern": "prefix_slash", + "categories": { + "type": ["Kind/Bug", "Kind/Feature"], + "priority": ["Priority - High"], + }, + } + + mapping = agent._build_label_mapping(existing_labels, schema) + + assert "type" in mapping + assert "bug" in mapping["type"] + assert mapping["type"]["bug"] == "Kind/Bug" + assert "feature" in mapping["type"] + assert mapping["type"]["feature"] == "Kind/Feature" + assert "priority" in mapping + assert "high" in mapping["priority"] + assert mapping["priority"]["high"] == "Priority - High" + + def test_suggest_label_name_prefix_slash(self): + """Test label name suggestion for prefix_slash pattern.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": {"bug": {"name": "type: bug", "color": "d73a4a"}}, + "priority": {}, + "status": { + "ai_approved": {"name": "ai-approved", "color": "28a745"} + }, + } + }, + ) + + # Test type category + suggested = agent._suggest_label_name("type", "bug", "prefix_slash") + assert suggested == "Kind/Bug" + + # Test status category + suggested = agent._suggest_label_name("status", "ai_approved", "prefix_slash") + assert suggested == "Status/Ai Approved" + + def test_suggest_label_name_prefix_dash(self): + """Test label name suggestion for prefix_dash pattern.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": {}, + "priority": {"high": {"name": "priority: high", "color": "d73a4a"}}, + "status": {}, + } + }, + ) + + suggested = agent._suggest_label_name("priority", "high", "prefix_dash") + assert suggested == "Priority - High" + + def test_get_label_config_backwards_compatibility(self): + """Test that old string format still works.""" + from agents.issue_agent import IssueAgent + + # Old config format (strings) + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": "type: bug" # Old format + }, + "priority": {}, + "status": {}, + } + }, + ) + + config = agent._get_label_config("type", "bug") + + assert config["name"] == "type: bug" + assert config["color"] == "1d76db" # Default color + assert config["aliases"] == [] + + def test_get_label_config_new_format(self): + """Test that new dict format works.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": { + "name": "type: bug", + "color": "d73a4a", + "description": "Something isn't working", + "aliases": ["Kind/Bug", "bug"], + } + }, + "priority": {}, + "status": {}, + } + }, + ) + + config = agent._get_label_config("type", "bug") + + assert config["name"] == "type: bug" + assert config["color"] == "d73a4a" + assert config["description"] == "Something isn't working" + assert "Kind/Bug" in config["aliases"] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tools/ai-review/agents/chat_agent.py b/tools/ai-review/agents/chat_agent.py index b7b6f04..a3aeffc 100644 --- a/tools/ai-review/agents/chat_agent.py +++ b/tools/ai-review/agents/chat_agent.py @@ -11,9 +11,9 @@ import re from dataclasses import dataclass import requests +from clients.llm_client import ToolCall from agents.base_agent import AgentContext, AgentResult, BaseAgent -from clients.llm_client import ToolCall @dataclass @@ -114,8 +114,10 @@ Repository context: {owner}/{repo} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._searxng_url = self.config.get("agents", {}).get("chat", {}).get( - "searxng_url", os.environ.get("SEARXNG_URL", "") + self._searxng_url = ( + self.config.get("agents", {}) + .get("chat", {}) + .get("searxng_url", os.environ.get("SEARXNG_URL", "")) ) def can_handle(self, event_type: str, event_data: dict) -> bool: @@ -133,7 +135,13 @@ Repository context: {owner}/{repo} # Check if this is a chat request (any @ai-bot mention that isn't a specific command) if mention_prefix in comment_body: # Check it's not another specific command - specific_commands = ["summarize", "explain", "suggest", "security", "codebase"] + specific_commands = [ + "summarize", + "explain", + "suggest", + "security", + "codebase", + ] body_lower = comment_body.lower() for cmd in specific_commands: if f"{mention_prefix} {cmd}" in body_lower: @@ -150,18 +158,24 @@ Repository context: {owner}/{repo} """Execute the chat agent.""" self.logger.info(f"Starting chat for {context.owner}/{context.repo}") - # Extract user message + # Extract user message and author if context.event_type == "issue_comment": user_message = context.event_data.get("comment", {}).get("body", "") issue_index = context.event_data.get("issue", {}).get("number") - # Remove the @ai-bot prefix + comment_author = ( + context.event_data.get("comment", {}) + .get("user", {}) + .get("login", "user") + ) + # Remove the @codebot prefix mention_prefix = self.config.get("interaction", {}).get( - "mention_prefix", "@ai-bot" + "mention_prefix", "@codebot" ) user_message = user_message.replace(mention_prefix, "").strip() else: user_message = context.event_data.get("message", "") issue_index = context.event_data.get("issue_number") + comment_author = None if not user_message: return AgentResult( @@ -191,13 +205,10 @@ Repository context: {owner}/{repo} # Post response if this is an issue comment if issue_index: - comment_body = self._format_response(response_content) - self.upsert_comment( - context.owner, - context.repo, - issue_index, - comment_body, - marker=self.CHAT_AI_MARKER, + comment_body = self._format_response(response_content, comment_author) + # Create a new comment instead of upserting to make conversation flow better + self.gitea.create_issue_comment( + context.owner, context.repo, issue_index, comment_body ) actions_taken.append("Posted chat response") @@ -230,21 +241,23 @@ Repository context: {owner}/{repo} return response.content, tools_used # Add assistant message with tool calls - messages.append({ - "role": "assistant", - "content": response.content or "", - "tool_calls": [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": str(tc.arguments), - }, - } - for tc in response.tool_calls - ], - }) + messages.append( + { + "role": "assistant", + "content": response.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": str(tc.arguments), + }, + } + for tc in response.tool_calls + ], + } + ) # Execute each tool call for tool_call in response.tool_calls: @@ -252,11 +265,13 @@ Repository context: {owner}/{repo} tools_used.append(tool_call.name) # Add tool result to messages - messages.append({ - "role": "tool", - "tool_call_id": tool_call.id, - "content": tool_result, - }) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": tool_result, + } + ) # If we hit max iterations, make one final call without tools self._rate_limit() @@ -357,15 +372,38 @@ Repository context: {owner}/{repo} # Code extensions to search code_extensions = { - ".py", ".js", ".ts", ".go", ".rs", ".java", ".rb", - ".php", ".c", ".cpp", ".h", ".cs", ".swift", ".kt", - ".md", ".yml", ".yaml", ".json", ".toml", + ".py", + ".js", + ".ts", + ".go", + ".rs", + ".java", + ".rb", + ".php", + ".c", + ".cpp", + ".h", + ".cs", + ".swift", + ".kt", + ".md", + ".yml", + ".yaml", + ".json", + ".toml", } # Patterns to ignore ignore_patterns = [ - "node_modules/", "vendor/", ".git/", "__pycache__/", - ".venv/", "dist/", "build/", ".min.js", ".min.css", + "node_modules/", + "vendor/", + ".git/", + "__pycache__/", + ".venv/", + "dist/", + "build/", + ".min.js", + ".min.css", ] def traverse(path: str = ""): @@ -397,6 +435,7 @@ Repository context: {owner}/{repo} def _match_pattern(self, filepath: str, pattern: str) -> bool: """Check if filepath matches a simple glob pattern.""" import fnmatch + return fnmatch.fnmatch(filepath, pattern) def _tool_read_file(self, context: AgentContext, filepath: str) -> str: @@ -458,13 +497,22 @@ Repository context: {owner}/{repo} except requests.exceptions.RequestException as e: return f"Web search failed: {e}" - def _format_response(self, content: str) -> str: + def _format_response(self, content: str, user: str | None = None) -> str: """Format the chat response with disclaimer.""" - lines = [ - f"{self.AI_DISCLAIMER}", - "", - "---", - "", - content, - ] + lines = [] + + # Add user mention if available + if user: + lines.append(f"@{user}") + lines.append("") + + lines.extend( + [ + f"{self.AI_DISCLAIMER}", + "", + "---", + "", + content, + ] + ) return "\n".join(lines) diff --git a/tools/ai-review/agents/issue_agent.py b/tools/ai-review/agents/issue_agent.py index 1c4e325..3b4418a 100644 --- a/tools/ai-review/agents/issue_agent.py +++ b/tools/ai-review/agents/issue_agent.py @@ -5,6 +5,7 @@ Handles issue.opened, issue.labeled, and issue_comment events. """ import logging +import re from dataclasses import dataclass from agents.base_agent import AgentContext, AgentResult, BaseAgent @@ -153,14 +154,17 @@ class IssueAgent(BaseAgent): comment = context.event_data.get("comment", {}) issue_index = issue.get("number") comment_body = comment.get("body", "") + comment_author = comment.get("user", {}).get("login", "user") # Parse command from mention command = self._parse_command(comment_body) if command: response = self._handle_command(context, issue, command) + # Add user mention at the top + response_with_mention = f"@{comment_author}\n\n{response}" self.gitea.create_issue_comment( - context.owner, context.repo, issue_index, response + context.owner, context.repo, issue_index, response_with_mention ) return AgentResult( success=True, @@ -221,6 +225,52 @@ class IssueAgent(BaseAgent): reasoning="Automatic triage failed, needs human review", ) + def _get_label_name(self, label_config: str | dict) -> str: + """Get label name from config (supports both old string and new dict format). + + Args: + label_config: Either a string (old format) or dict with 'name' key (new format) + + Returns: + Label name as string + """ + if isinstance(label_config, str): + return label_config + elif isinstance(label_config, dict): + return label_config.get("name", "") + return "" + + def _get_label_config(self, category: str, key: str) -> dict: + """Get full label configuration from config. + + Args: + category: Label category (type, priority, status) + key: Label key within category (bug, high, etc.) + + Returns: + Dict with name, color, description, aliases + """ + labels_config = self.config.get("labels", {}) + category_config = labels_config.get(category, {}) + label_config = category_config.get(key, {}) + + # Handle old string format + if isinstance(label_config, str): + return { + "name": label_config, + "color": "1d76db", # Default blue + "description": "", + "aliases": [], + } + + # Handle new dict format + return { + "name": label_config.get("name", ""), + "color": label_config.get("color", "1d76db"), + "description": label_config.get("description", ""), + "aliases": label_config.get("aliases", []), + } + def _apply_labels( self, owner: str, @@ -229,8 +279,6 @@ class IssueAgent(BaseAgent): triage: TriageResult, ) -> list[str]: """Apply labels based on triage result.""" - labels_config = self.config.get("labels", {}) - # Get all repo labels try: repo_labels = self.gitea.get_repo_labels(owner, repo) @@ -241,23 +289,23 @@ class IssueAgent(BaseAgent): labels_to_add = [] - # Map priority - priority_labels = labels_config.get("priority", {}) - priority_label = priority_labels.get(triage.priority) - if priority_label and priority_label in label_map: - labels_to_add.append(label_map[priority_label]) + # Map priority using new helper + priority_config = self._get_label_config("priority", triage.priority) + priority_label_name = priority_config["name"] + if priority_label_name and priority_label_name in label_map: + labels_to_add.append(label_map[priority_label_name]) - # Map type - type_labels = labels_config.get("type", {}) - type_label = type_labels.get(triage.issue_type) - if type_label and type_label in label_map: - labels_to_add.append(label_map[type_label]) + # Map type using new helper + type_config = self._get_label_config("type", triage.issue_type) + type_label_name = type_config["name"] + if type_label_name and type_label_name in label_map: + labels_to_add.append(label_map[type_label_name]) - # Add AI reviewed label - status_labels = labels_config.get("status", {}) - reviewed_label = status_labels.get("ai_reviewed") - if reviewed_label and reviewed_label in label_map: - labels_to_add.append(label_map[reviewed_label]) + # Add AI reviewed label using new helper + reviewed_config = self._get_label_config("status", "ai_reviewed") + reviewed_label_name = reviewed_config["name"] + if reviewed_label_name and reviewed_label_name in label_map: + labels_to_add.append(label_map[reviewed_label_name]) if labels_to_add: try: @@ -314,9 +362,13 @@ class IssueAgent(BaseAgent): "mention_prefix", "@ai-bot" ) commands = self.config.get("interaction", {}).get( - "commands", ["explain", "suggest", "security", "summarize"] + "commands", ["explain", "suggest", "security", "summarize", "triage"] ) + # Also check for setup-labels command (not in config since it's a setup command) + if f"{mention_prefix} setup-labels" in body.lower(): + return "setup-labels" + for command in commands: if f"{mention_prefix} {command}" in body.lower(): return command @@ -328,7 +380,9 @@ class IssueAgent(BaseAgent): title = issue.get("title", "") body = issue.get("body", "") - if command == "summarize": + if command == "help": + return self._command_help() + elif command == "summarize": return self._command_summarize(title, body) elif command == "explain": return self._command_explain(title, body) @@ -336,6 +390,8 @@ class IssueAgent(BaseAgent): return self._command_suggest(title, body) elif command == "triage": return self._command_triage(context, issue) + elif command == "setup-labels": + return self._command_setup_labels(context, issue) return f"{self.AI_DISCLAIMER}\n\nSorry, I don't understand the command `{command}`." @@ -391,6 +447,74 @@ Be practical and concise.""" except Exception as e: return f"{self.AI_DISCLAIMER}\n\nSorry, I was unable to generate suggestions. Error: {e}" + def _command_help(self) -> str: + """Generate help message with all available commands.""" + mention_prefix = self.config.get("interaction", {}).get( + "mention_prefix", "@codebot" + ) + + help_text = f"""{self.AI_DISCLAIMER} + +## Available {mention_prefix} Commands + +### Issue Triage & Analysis +- `{mention_prefix} triage` - Full issue triage with auto-labeling and priority assignment +- `{mention_prefix} summarize` - Generate 2-3 sentence summary of the issue +- `{mention_prefix} explain` - Detailed explanation of what the issue is about +- `{mention_prefix} suggest` - Solution suggestions or next steps +- `{mention_prefix} security` - Security-focused analysis of the issue + +### Interactive Chat +- `{mention_prefix} [question]` - Ask questions about the codebase (uses search & file reading tools) + - Example: `{mention_prefix} how does authentication work?` + - Example: `{mention_prefix} find all API endpoints` + +### Setup & Utility +- `{mention_prefix} help` - Show this help message +- `{mention_prefix} setup-labels` - Auto-create/map repository labels for auto-labeling + +### Pull Request Analysis +PR reviews run automatically when you open or update a pull request. The bot provides: +- Inline code review comments +- Security vulnerability scanning +- Approval or change-request recommendations + +**Manual re-review:** +- `{mention_prefix} review-again` - Re-run AI review on current PR state (in PR comments) + - Shows diff from previous review (resolved/new issues) + - Updates labels and recommendations + - Useful after addressing feedback or updating config + +--- + +### Quick Examples + +**Triage an issue:** +``` +{mention_prefix} triage +``` + +**Get help understanding:** +``` +{mention_prefix} explain +``` + +**Ask about the codebase:** +``` +{mention_prefix} how does the authentication system work? +``` + +**Setup repository labels:** +``` +{mention_prefix} setup-labels +``` + +--- + +*For full documentation, see the [README](https://github.com/YourOrg/OpenRabbit/blob/main/README.md)* +""" + return help_text + def _command_triage(self, context: AgentContext, issue: dict) -> str: """Perform full triage analysis on the issue.""" title = issue.get("title", "") @@ -420,3 +544,313 @@ Be practical and concise.""" return response except Exception as e: return f"{self.AI_DISCLAIMER}\n\nSorry, I was unable to triage this issue. Error: {e}" + + def _command_setup_labels(self, context: AgentContext, issue: dict) -> str: + """Setup repository labels for auto-labeling.""" + owner = context.owner + repo = context.repo + + try: + # Get existing labels + existing_labels = self.gitea.get_repo_labels(owner, repo) + existing_names = { + label["name"].lower(): label["name"] for label in existing_labels + } + + # Detect schema + schema = self._detect_label_schema(existing_labels) + + # Determine mode + if schema and len(existing_labels) >= 5: + # Repository has existing labels, use mapping mode + return self._setup_labels_map_mode( + owner, repo, existing_labels, schema, existing_names + ) + else: + # Fresh repository or few labels, use create mode + return self._setup_labels_create_mode(owner, repo, existing_names) + + except Exception as e: + self.logger.error(f"Label setup failed: {e}") + return f"{self.AI_DISCLAIMER}\n\n**Label Setup Failed**\n\nError: {e}\n\nPlease ensure the bot has write access to this repository." + + def _detect_label_schema(self, labels: list[dict]) -> dict | None: + """Detect the naming pattern used in existing labels. + + Returns: + { + "pattern": "prefix_slash" | "prefix_dash" | "colon", + "categories": { + "type": ["Kind/Bug", "Kind/Feature", ...], + "priority": ["Priority - High", ...], + } + } + """ + patterns_config = self.config.get("label_patterns", {}) + patterns = { + "prefix_slash": re.compile( + patterns_config.get("prefix_slash", r"^(Kind|Type|Category)/(.+)$") + ), + "prefix_dash": re.compile( + patterns_config.get( + "prefix_dash", r"^(Priority|Status|Reviewed) - (.+)$" + ) + ), + "colon": re.compile( + patterns_config.get("colon", r"^(type|priority|status): (.+)$") + ), + } + + categorized = {} + detected_pattern = None + + for label in labels: + name = label["name"] + + for pattern_name, regex in patterns.items(): + match = regex.match(name) + if match: + category = match.group(1).lower() + # Normalize category names + if category == "kind": + category = "type" + elif category == "reviewed": + category = "status" + + if category not in categorized: + categorized[category] = [] + + categorized[category].append(name) + detected_pattern = pattern_name + break + + if not categorized: + return None + + return {"pattern": detected_pattern, "categories": categorized} + + def _build_label_mapping(self, existing_labels: list[dict], schema: dict) -> dict: + """Build mapping from OpenRabbit schema to existing labels. + + Returns: + { + "type": { + "bug": "Kind/Bug", + "feature": "Kind/Feature", + }, + "priority": { + "high": "Priority - High", + } + } + """ + mapping = {} + label_names_lower = { + label["name"].lower(): label["name"] for label in existing_labels + } + + # Get all configured labels with their aliases + labels_config = self.config.get("labels", {}) + + for category in ["type", "priority", "status"]: + category_config = labels_config.get(category, {}) + mapping[category] = {} + + for key, label_def in category_config.items(): + config = self._get_label_config(category, key) + aliases = config.get("aliases", []) + + # Try to find a match using aliases + for alias in aliases: + if alias.lower() in label_names_lower: + mapping[category][key] = label_names_lower[alias.lower()] + break + + return mapping + + def _setup_labels_map_mode( + self, + owner: str, + repo: str, + existing_labels: list[dict], + schema: dict, + existing_names: dict, + ) -> str: + """Map existing labels to OpenRabbit schema.""" + + # Build mapping + mapping = self._build_label_mapping(existing_labels, schema) + + # Get required labels + required_labels = self._get_required_labels() + + # Find missing labels + missing = [] + for category, items in required_labels.items(): + for key in items: + if key not in mapping.get(category, {}): + missing.append((category, key)) + + # Format report + lines = [f"{self.AI_DISCLAIMER}\n"] + lines.append("## Label Schema Detected\n") + lines.append( + f"Found {len(existing_labels)} existing labels with pattern: `{schema['pattern']}`\n" + ) + + lines.append("**Detected Categories:**") + for category, labels in schema["categories"].items(): + lines.append(f"- **{category.title()}** ({len(labels)} labels)") + lines.append("") + + lines.append("**Proposed Mapping:**\n") + lines.append("| OpenRabbit Expected | Your Existing Label | Status |") + lines.append("|---------------------|---------------------|--------|") + + for category, items in required_labels.items(): + for key in items: + openrabbit_config = self._get_label_config(category, key) + openrabbit_name = openrabbit_config["name"] + + if key in mapping.get(category, {}): + existing_name = mapping[category][key] + lines.append( + f"| `{openrabbit_name}` | `{existing_name}` | ✅ Map |" + ) + else: + lines.append(f"| `{openrabbit_name}` | *(missing)* | ⚠️ Create |") + + lines.append("") + + # Create missing labels + if missing: + lines.append(f"**Creating Missing Labels ({len(missing)}):**\n") + created_count = 0 + + for category, key in missing: + config = self._get_label_config(category, key) + suggested_name = self._suggest_label_name( + category, key, schema["pattern"] + ) + + # Check if label already exists (case-insensitive) + if suggested_name.lower() not in existing_names: + try: + self.gitea.create_label( + owner, + repo, + suggested_name, + config["color"], + config["description"], + ) + lines.append( + f"✅ Created `{suggested_name}` (#{config['color']})" + ) + created_count += 1 + except Exception as e: + lines.append(f"❌ Failed to create `{suggested_name}`: {e}") + else: + lines.append(f"⚠️ `{suggested_name}` already exists") + + lines.append("") + if created_count > 0: + lines.append(f"**✅ Created {created_count} new labels!**") + else: + lines.append("**✅ All Required Labels Present!**") + + lines.append("\n**Setup Complete!**") + lines.append("Auto-labeling will use your existing label schema.") + + return "\n".join(lines) + + def _setup_labels_create_mode( + self, owner: str, repo: str, existing_names: dict + ) -> str: + """Create OpenRabbit default labels.""" + + lines = [f"{self.AI_DISCLAIMER}\n"] + lines.append("## Creating OpenRabbit Labels\n") + + # Get all required labels + required_labels = self._get_required_labels() + + created = [] + skipped = [] + failed = [] + + for category, items in required_labels.items(): + for key in items: + config = self._get_label_config(category, key) + label_name = config["name"] + + # Check if already exists (case-insensitive) + if label_name.lower() in existing_names: + skipped.append(label_name) + continue + + try: + self.gitea.create_label( + owner, repo, label_name, config["color"], config["description"] + ) + created.append((label_name, config["color"])) + except Exception as e: + failed.append((label_name, str(e))) + + if created: + lines.append(f"**✅ Created {len(created)} Labels:**\n") + for name, color in created: + lines.append(f"- `{name}` (#{color})") + lines.append("") + + if skipped: + lines.append(f"**⚠️ Skipped {len(skipped)} Existing Labels:**\n") + for name in skipped: + lines.append(f"- `{name}`") + lines.append("") + + if failed: + lines.append(f"**❌ Failed to Create {len(failed)} Labels:**\n") + for name, error in failed: + lines.append(f"- `{name}`: {error}") + lines.append("") + + lines.append("**✅ Setup Complete!**") + lines.append("Auto-labeling is now configured.") + + return "\n".join(lines) + + def _get_required_labels(self) -> dict: + """Get all required label categories and keys. + + Returns: + { + "type": ["bug", "feature", "question", "docs"], + "priority": ["high", "medium", "low"], + "status": ["ai_approved", "ai_changes_required", "ai_reviewed"] + } + """ + labels_config = self.config.get("labels", {}) + required = {} + + for category in ["type", "priority", "status"]: + category_config = labels_config.get(category, {}) + required[category] = list(category_config.keys()) + + return required + + def _suggest_label_name(self, category: str, key: str, pattern: str) -> str: + """Suggest a label name based on detected pattern.""" + + # Get the configured name first + config = self._get_label_config(category, key) + base_name = config["name"] + + if pattern == "prefix_slash": + prefix = "Kind" if category == "type" else category.title() + value = key.replace("_", " ").title() + return f"{prefix}/{value}" + elif pattern == "prefix_dash": + prefix = "Kind" if category == "type" else category.title() + value = key.replace("_", " ").title() + return f"{prefix} - {value}" + else: # colon or unknown + return base_name diff --git a/tools/ai-review/agents/pr_agent.py b/tools/ai-review/agents/pr_agent.py index a2b1f43..e5e164c 100644 --- a/tools/ai-review/agents/pr_agent.py +++ b/tools/ai-review/agents/pr_agent.py @@ -40,6 +40,37 @@ class PRAgent(BaseAgent): # Marker specific to PR reviews PR_AI_MARKER = "" + def _get_label_config(self, category: str, key: str) -> dict: + """Get full label configuration from config. + + Args: + category: Label category (type, priority, status) + key: Label key within category (bug, high, ai_approved, etc.) + + Returns: + Dict with name, color, description, aliases + """ + labels_config = self.config.get("labels", {}) + category_config = labels_config.get(category, {}) + label_config = category_config.get(key, {}) + + # Handle old string format + if isinstance(label_config, str): + return { + "name": label_config, + "color": "1d76db", # Default blue + "description": "", + "aliases": [], + } + + # Handle new dict format + return { + "name": label_config.get("name", ""), + "color": label_config.get("color", "1d76db"), + "description": label_config.get("description", ""), + "aliases": label_config.get("aliases", []), + } + def can_handle(self, event_type: str, event_data: dict) -> bool: """Check if this agent handles the given event.""" # Check if agent is enabled @@ -52,10 +83,30 @@ class PRAgent(BaseAgent): allowed_events = agent_config.get("events", ["opened", "synchronize"]) return action in allowed_events + # Handle issue comments on PRs (for review-again command) + if event_type == "issue_comment": + action = event_data.get("action", "") + if action == "created": + comment_body = event_data.get("comment", {}).get("body", "") + mention_prefix = self.config.get("interaction", {}).get( + "mention_prefix", "@codebot" + ) + # Only handle if this is a PR and contains review-again command + issue = event_data.get("issue", {}) + is_pr = issue.get("pull_request") is not None + has_review_again = ( + f"{mention_prefix} review-again" in comment_body.lower() + ) + return is_pr and has_review_again + return False def execute(self, context: AgentContext) -> AgentResult: """Execute the PR review agent.""" + # Check if this is a review-again command + if context.event_type == "issue_comment": + return self._handle_review_again(context) + pr = context.event_data.get("pull_request", {}) pr_number = pr.get("number") @@ -185,7 +236,7 @@ class PRAgent(BaseAgent): }, { "name": "Hardcoded IP", - "pattern": r'\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b', + "pattern": r"\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b", "severity": "LOW", "category": "Security", "description": "Hardcoded IP address detected", @@ -193,7 +244,7 @@ class PRAgent(BaseAgent): }, { "name": "Eval Usage", - "pattern": r'\beval\s*\(', + "pattern": r"\beval\s*\(", "severity": "HIGH", "category": "Security", "description": "Use of eval() detected - potential code injection risk", @@ -201,7 +252,7 @@ class PRAgent(BaseAgent): }, { "name": "Shell Injection", - "pattern": r'(?i)(?:subprocess\.call|os\.system|shell\s*=\s*True)', + "pattern": r"(?i)(?:subprocess\.call|os\.system|shell\s*=\s*True)", "severity": "MEDIUM", "category": "Security", "description": "Potential shell command execution - verify input is sanitized", @@ -373,7 +424,9 @@ class PRAgent(BaseAgent): lines.append("### Security Issues") lines.append("") for issue in review.security_issues[:5]: - lines.append(f"- **[{issue.severity}]** `{issue.file}:{issue.line}` - {issue.description}") + lines.append( + f"- **[{issue.severity}]** `{issue.file}:{issue.line}` - {issue.description}" + ) lines.append("") # Other issues (limit display) @@ -382,7 +435,9 @@ class PRAgent(BaseAgent): lines.append("### Review Findings") lines.append("") for issue in other_issues[:10]: - loc = f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + loc = ( + f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + ) lines.append(f"- **[{issue.severity}]** {loc} - {issue.description}") if len(other_issues) > 10: lines.append(f"- ...and {len(other_issues) - 10} more issues") @@ -406,8 +461,6 @@ class PRAgent(BaseAgent): review: PRReviewResult, ) -> list[str]: """Apply labels based on review result.""" - labels_config = self.config.get("labels", {}).get("status", {}) - try: repo_labels = self.gitea.get_repo_labels(owner, repo) label_map = {l["name"]: l["id"] for l in repo_labels} @@ -415,15 +468,319 @@ class PRAgent(BaseAgent): self.logger.warning(f"Failed to get repo labels: {e}") return [] + def _handle_review_again(self, context: AgentContext) -> AgentResult: + """Re-run PR review on current state.""" + issue = context.event_data.get("issue", {}) + pr_number = issue.get("number") + comment_author = ( + context.event_data.get("comment", {}).get("user", {}).get("login", "user") + ) + + self.logger.info(f"Re-reviewing PR #{pr_number} at user request") + + # Get previous review comment + previous_comment = self._find_previous_review( + context.owner, context.repo, pr_number + ) + previous_findings = [] + if previous_comment: + previous_findings = self._parse_review_comment(previous_comment) + + # Run new review (reuse existing review logic) + actions_taken = [] + + # Step 1: Get PR diff + diff = self._get_diff(context.owner, context.repo, pr_number) + if not diff.strip(): + response = f"@{comment_author}\n\n{self.AI_DISCLAIMER}\n\n**🔄 Re-review Requested**\n\nPR has no changes to review." + self.gitea.create_issue_comment( + context.owner, context.repo, pr_number, response + ) + return AgentResult( + success=True, + message="PR has no changes to review", + ) + + # Step 2: Parse changed files + changed_files = self._parse_diff_files(diff) + + # Step 3: Run security scan if enabled + security_issues = [] + agent_config = self.config.get("agents", {}).get("pr", {}) + if agent_config.get("security_scan", True): + security_issues = self._run_security_scan(changed_files, diff) + + # Step 4: Run AI review + review_result = self._run_ai_review(diff, context, security_issues) + + # Step 5: Compare with previous review + current_findings = self._extract_findings_from_review(review_result) + diff_result = self._compare_reviews(previous_findings, current_findings) + + # Step 6: Generate updated review with comparison + updated_review = self._format_review_update( + review_result, diff_result, comment_author + ) + + # Step 7: Update existing comment (or create new one) + self.upsert_comment( + context.owner, + context.repo, + pr_number, + updated_review, + marker=self.PR_AI_MARKER, + ) + actions_taken.append("Updated review comment") + + # Step 8: Update PR labels + labels_applied = self._apply_review_labels( + context.owner, context.repo, pr_number, review_result + ) + if labels_applied: + actions_taken.append(f"Updated labels: {labels_applied}") + + return AgentResult( + success=True, + message=f"Re-reviewed PR #{pr_number}: {review_result.overall_severity} severity", + data={ + "severity": review_result.overall_severity, + "approval": review_result.approval, + "issues_count": len(review_result.issues), + "security_issues_count": len(review_result.security_issues), + "resolved_count": len(diff_result.get("resolved", [])), + "new_count": len(diff_result.get("new", [])), + }, + actions_taken=actions_taken, + ) + + def _find_previous_review( + self, owner: str, repo: str, pr_number: int + ) -> str | None: + """Find the previous AI review comment.""" + comment_id = self.find_ai_comment( + owner, repo, pr_number, marker=self.PR_AI_MARKER + ) + if not comment_id: + return None + + # Get the comment content + comments = self.gitea.list_issue_comments(owner, repo, pr_number) + for comment in comments: + if comment.get("id") == comment_id: + return comment.get("body", "") + + return None + + def _parse_review_comment(self, comment_text: str) -> list[dict]: + """Parse previous review comment to extract findings. + + Returns: + List of findings with file, line, severity, description + """ + findings = [] + + if not comment_text: + return findings + + # Look for patterns like: **[HIGH]** `src/file.py:45` - Description + pattern = r"\*\*\[(\w+)\]\*\*\s+`([^:]+):(\d+)`\s+-\s+(.+?)(?:\n|$)" + + for match in re.finditer(pattern, comment_text): + findings.append( + { + "severity": match.group(1), + "file": match.group(2), + "line": int(match.group(3)), + "description": match.group(4).strip(), + } + ) + + return findings + + def _extract_findings_from_review(self, review: PRReviewResult) -> list[dict]: + """Extract findings from PRReviewResult into comparable format.""" + findings = [] + all_issues = review.issues + review.security_issues + + for issue in all_issues: + findings.append( + { + "severity": issue.severity, + "file": issue.file, + "line": issue.line or 0, + "description": issue.description, + "category": issue.category, + } + ) + + return findings + + def _finding_key(self, finding: dict) -> str: + """Create unique key for a finding.""" + file_path = finding.get("file", "unknown") + line = finding.get("line", 0) + # Use first 50 chars of description for matching + desc_key = finding.get("description", "")[:50] + return f"{file_path}:{line}:{desc_key}" + + def _compare_reviews( + self, previous_findings: list[dict], new_findings: list[dict] + ) -> dict: + """Compare previous and new review to show what changed. + + Returns: + { + "resolved": [...], # Issues that disappeared + "new": [...], # New issues found + "still_present": [...], # Issues that remain + "severity_changed": {...} # OLD severity -> NEW severity + } + """ + # Create lookup keys (file:line:description) + prev_keys = {self._finding_key(f): f for f in previous_findings} + new_keys = {self._finding_key(f): f for f in new_findings} + + resolved = [prev_keys[key] for key in prev_keys if key not in new_keys] + + new = [new_keys[key] for key in new_keys if key not in prev_keys] + + still_present = [new_keys[key] for key in new_keys if key in prev_keys] + + severity_changed = {} + for key in prev_keys: + if key in new_keys: + prev_severity = prev_keys[key].get("severity") + new_severity = new_keys[key].get("severity") + if prev_severity != new_severity: + severity_changed[key] = { + "old": prev_severity, + "new": new_severity, + "finding": new_keys[key], + } + + return { + "resolved": resolved, + "new": new, + "still_present": still_present, + "severity_changed": severity_changed, + } + + def _format_review_update( + self, review: PRReviewResult, diff: dict, comment_author: str + ) -> str: + """Format review with comparison to previous run.""" + lines = [f"@{comment_author}\n"] + lines.append(f"{self.AI_DISCLAIMER}\n") + lines.append("**🔄 Re-review Requested**\n") + lines.append("---\n") + lines.append("## AI Code Review (Updated)\n") + + # Summary of changes + prev_total = len(diff["resolved"]) + len(diff["still_present"]) + curr_total = len(diff["new"]) + len(diff["still_present"]) + + if prev_total > 0: + lines.append(f"**Previous Review:** {prev_total} issues") + lines.append(f"**Current Review:** {curr_total} issues\n") + else: + lines.append("**First Review** - No previous review found\n") + + # Changes section (only if there was a previous review) + if prev_total > 0: + lines.append("### Changes from Previous Review\n") + + if diff["resolved"]: + lines.append(f"**✅ Resolved ({len(diff['resolved'])}):**") + for finding in diff["resolved"][:5]: # Show max 5 + lines.append( + f"- **[{finding['severity']}]** `{finding['file']}:{finding['line']}` - {finding['description']}" + ) + if len(diff["resolved"]) > 5: + lines.append(f"- ... and {len(diff['resolved']) - 5} more") + lines.append("") + + if diff["new"]: + lines.append(f"**⚠️ New Issues ({len(diff['new'])}):**") + for finding in diff["new"][:5]: + lines.append( + f"- **[{finding['severity']}]** `{finding['file']}:{finding['line']}` - {finding['description']}" + ) + if len(diff["new"]) > 5: + lines.append(f"- ... and {len(diff['new']) - 5} more") + lines.append("") + + if diff["severity_changed"]: + lines.append( + f"**🔄 Severity Changed ({len(diff['severity_changed'])}):**" + ) + for key, change in list(diff["severity_changed"].items())[:5]: + finding = change["finding"] + lines.append( + f"- `{finding['file']}:{finding['line']}` - {change['old']} → {change['new']}" + ) + lines.append("") + + # Summary table + all_issues = review.issues + review.security_issues + high = sum(1 for i in all_issues if i.severity == "HIGH") + medium = sum(1 for i in all_issues if i.severity == "MEDIUM") + low = sum(1 for i in all_issues if i.severity == "LOW") + + lines.append("### Summary\n") + lines.append("| Severity | Count |") + lines.append("|----------|-------|") + lines.append(f"| HIGH | {high} |") + lines.append(f"| MEDIUM | {medium} |") + lines.append(f"| LOW | {low} |") + lines.append("") + + # Security issues section (if any) + if review.security_issues: + lines.append("### Security Issues\n") + for issue in review.security_issues[:5]: + loc = ( + f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + ) + lines.append(f"- **[{issue.severity}]** {loc} - {issue.description}") + if len(review.security_issues) > 5: + lines.append(f"- ... and {len(review.security_issues) - 5} more") + lines.append("") + + # Other issues (limit display) + other_issues = [i for i in review.issues if i not in review.security_issues] + if other_issues: + lines.append("### Review Findings\n") + for issue in other_issues[:10]: + loc = ( + f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + ) + lines.append(f"- **[{issue.severity}]** {loc} - {issue.description}") + if len(other_issues) > 10: + lines.append(f"- ... and {len(other_issues) - 10} more issues") + lines.append("") + + # Verdict + lines.append("---") + lines.append(f"**Overall Severity:** `{review.overall_severity}`") + if review.approval: + lines.append("**AI Recommendation:** Approved ✅") + else: + lines.append("**AI Recommendation:** Changes Requested ⚠️") + + return "\n".join(lines) + labels_to_add = [] # Add approval/changes required label + # Use helper to support both old string and new dict format if review.approval: - label_name = labels_config.get("ai_approved", "ai-approved") + label_config = self._get_label_config("status", "ai_approved") else: - label_name = labels_config.get("ai_changes_required", "ai-changes-required") + label_config = self._get_label_config("status", "ai_changes_required") - if label_name in label_map: + label_name = label_config.get("name", "") + + if label_name and label_name in label_map: labels_to_add.append(label_map[label_name]) if labels_to_add: diff --git a/tools/ai-review/clients/gitea_client.py b/tools/ai-review/clients/gitea_client.py index 14c6219..33b812e 100644 --- a/tools/ai-review/clients/gitea_client.py +++ b/tools/ai-review/clients/gitea_client.py @@ -72,7 +72,7 @@ class GiteaClient: timeout=self.timeout, ) response.raise_for_status() - + if response.status_code == 204: return {} return response.json() @@ -293,10 +293,45 @@ class GiteaClient: repo: Repository name. Returns: - List of label objects. + List of label objects with 'id', 'name', 'color', 'description' fields. """ return self._request("GET", f"/repos/{owner}/{repo}/labels") + def create_label( + self, + owner: str, + repo: str, + name: str, + color: str, + description: str = "", + ) -> dict: + """Create a new label in the repository. + + Args: + owner: Repository owner. + repo: Repository name. + name: Label name (e.g., "priority: high"). + color: Hex color code without # (e.g., "d73a4a"). + description: Optional label description. + + Returns: + Created label object. + + Raises: + requests.HTTPError: If label creation fails (e.g., already exists). + """ + payload = { + "name": name, + "color": color, + "description": description, + } + + return self._request( + "POST", + f"/repos/{owner}/{repo}/labels", + json=payload, + ) + # ------------------------------------------------------------------------- # Pull Request Operations # ------------------------------------------------------------------------- diff --git a/tools/ai-review/config.yml b/tools/ai-review/config.yml index 801850b..2adccab 100644 --- a/tools/ai-review/config.yml +++ b/tools/ai-review/config.yml @@ -59,11 +59,13 @@ interaction: respond_to_mentions: true mention_prefix: "@codebot" # Change this to customize your bot's name! commands: + - help - explain - suggest - security - summarize - triage + - review-again # Enterprise settings enterprise: @@ -75,20 +77,149 @@ enterprise: max_concurrent: 4 # Label mappings for auto-labeling +# Each label has: +# name: The label name to use/create (string) or full config (dict) +# aliases: Alternative names for auto-detection (optional) +# color: Hex color code without # (optional, for label creation) +# description: Label description (optional, for label creation) labels: priority: - high: "priority: high" - medium: "priority: medium" - low: "priority: low" + critical: + name: "priority: critical" + color: "b60205" # Dark Red + description: "Critical priority - immediate attention required" + aliases: + ["Priority - Critical", "P0", "critical", "Priority/Critical"] + high: + name: "priority: high" + color: "d73a4a" # Red + description: "High priority issue" + aliases: ["Priority - High", "P1", "high", "Priority/High"] + medium: + name: "priority: medium" + color: "fbca04" # Yellow + description: "Medium priority issue" + aliases: ["Priority - Medium", "P2", "medium", "Priority/Medium"] + low: + name: "priority: low" + color: "28a745" # Green + description: "Low priority issue" + aliases: ["Priority - Low", "P3", "low", "Priority/Low"] type: - bug: "type: bug" - feature: "type: feature" - question: "type: question" - docs: "type: documentation" + bug: + name: "type: bug" + color: "d73a4a" # Red + description: "Something isn't working" + aliases: ["Kind/Bug", "bug", "Type: Bug", "Type/Bug", "Kind - Bug"] + feature: + name: "type: feature" + color: "1d76db" # Blue + description: "New feature request" + aliases: + [ + "Kind/Feature", + "feature", + "enhancement", + "Kind/Enhancement", + "Type: Feature", + "Type/Feature", + "Kind - Feature", + ] + question: + name: "type: question" + color: "cc317c" # Purple + description: "Further information is requested" + aliases: + [ + "Kind/Question", + "question", + "Type: Question", + "Type/Question", + "Kind - Question", + ] + docs: + name: "type: documentation" + color: "0075ca" # Light Blue + description: "Documentation improvements" + aliases: + [ + "Kind/Documentation", + "documentation", + "docs", + "Type: Documentation", + "Type/Documentation", + "Kind - Documentation", + ] + security: + name: "type: security" + color: "b60205" # Dark Red + description: "Security vulnerability or concern" + aliases: + [ + "Kind/Security", + "security", + "Type: Security", + "Type/Security", + "Kind - Security", + ] + testing: + name: "type: testing" + color: "0e8a16" # Green + description: "Related to testing" + aliases: + [ + "Kind/Testing", + "testing", + "tests", + "Type: Testing", + "Type/Testing", + "Kind - Testing", + ] status: - ai_approved: "ai-approved" - ai_changes_required: "ai-changes-required" - ai_reviewed: "ai-reviewed" + ai_approved: + name: "ai-approved" + color: "28a745" # Green + description: "AI review approved this PR" + aliases: + [ + "Status - Approved", + "approved", + "Status/Approved", + "Status - AI Approved", + ] + ai_changes_required: + name: "ai-changes-required" + color: "d73a4a" # Red + description: "AI review found issues requiring changes" + aliases: + [ + "Status - Changes Required", + "changes-required", + "Status/Changes Required", + "Status - AI Changes Required", + ] + ai_reviewed: + name: "ai-reviewed" + color: "1d76db" # Blue + description: "This issue/PR has been reviewed by AI" + aliases: + [ + "Reviewed - Confirmed", + "reviewed", + "Status/Reviewed", + "Reviewed/Confirmed", + "Status - Reviewed", + ] + +# Label schema detection patterns +# Used by setup-labels command to detect existing naming conventions +label_patterns: + # Detect prefix-based naming (e.g., Kind/Bug, Type/Feature) + prefix_slash: "^(Kind|Type|Category)/(.+)$" + # Detect dash-separated naming (e.g., Priority - High, Status - Blocked) + prefix_dash: "^(Priority|Status|Reviewed) - (.+)$" + # Detect colon-separated naming (e.g., type: bug, priority: high) + colon: "^(type|priority|status): (.+)$" # Security scanning rules security: