Merge pull request 'dev' (#11) from dev into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2025-12-28 19:16:58 +00:00
10 changed files with 2148 additions and 160 deletions

View File

@@ -1,41 +1,50 @@
name: AI Comment Reply name: AI Comment Reply
on: on:
issue_comment: issue_comment:
types: [created] types: [created]
# CUSTOMIZE YOUR BOT NAME: # CUSTOMIZE YOUR BOT NAME:
# Change '@ai-bot' below to match your config.yml mention_prefix # Change '@ai-bot' below to match your config.yml mention_prefix
# Examples: '@bartender', '@uni', '@joey', '@codebot' # Examples: '@bartender', '@uni', '@joey', '@codebot'
jobs: jobs:
ai-reply: ai-reply:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: contains(github.event.comment.body, '@codebot') # <-- Change this to your bot name if: contains(github.event.comment.body, '@codebot') # <-- Change this to your bot name
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
repository: Hiddenden/openrabbit repository: Hiddenden/openrabbit
path: .ai-review path: .ai-review
token: ${{ secrets.AI_REVIEW_TOKEN }} token: ${{ secrets.AI_REVIEW_TOKEN }}
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"
- run: pip install requests pyyaml - run: pip install requests pyyaml
- name: Run AI Comment Response - name: Run AI Comment Response
env: env:
AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }}
AI_REVIEW_REPO: ${{ gitea.repository }} AI_REVIEW_REPO: ${{ gitea.repository }}
AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }}
run: | run: |
cd .ai-review/tools/ai-review cd .ai-review/tools/ai-review
python main.py comment ${{ gitea.repository }} ${{ gitea.event.issue.number }} \
"${{ gitea.event.comment.body }}" # 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

119
CLAUDE.md
View File

@@ -145,6 +145,7 @@ interaction:
- security # Security analysis - security # Security analysis
- summarize # Summarize the issue - summarize # Summarize the issue
- triage # Full triage with labeling - triage # Full triage with labeling
- review-again # Re-run PR review (PR comments only)
review: review:
fail_on_severity: HIGH # Fail CI if HIGH severity issues found 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 ## 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 ### Adding a New Command to @codebot
1. Add command to `config.yml` under `interaction.commands` 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` 5. Add tests in `tests/test_ai_review.py`
Example commands: Example commands:
- `@codebot help` - Show all available commands with examples
- `@codebot triage` - Full issue triage with labeling - `@codebot triage` - Full issue triage with labeling
- `@codebot explain` - Explain the issue - `@codebot explain` - Explain the issue
- `@codebot suggest` - Suggest solutions - `@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 ### Changing the Bot Name
@@ -338,10 +385,78 @@ Example commands:
## Repository Labels ## 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: The system expects these labels to exist in repositories for auto-labeling:
- `priority: high`, `priority: medium`, `priority: low` - `priority: critical`, `priority: high`, `priority: medium`, `priority: low`
- `type: bug`, `type: feature`, `type: question`, `type: documentation` - `type: bug`, `type: feature`, `type: question`, `type: documentation`, `type: security`, `type: testing`
- `ai-approved`, `ai-changes-required`, `ai-reviewed` - `ai-approved`, `ai-changes-required`, `ai-reviewed`
Labels are mapped in `config.yml` under the `labels` section. 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.

View File

@@ -82,12 +82,27 @@ jobs:
See `.gitea/workflows/` for all workflow examples. 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: Create these labels in your repository for auto-labeling:
- `priority: high`, `priority: medium`, `priority: low` - `priority: critical`, `priority: high`, `priority: medium`, `priority: low`
- `type: bug`, `type: feature`, `type: question` - `type: bug`, `type: feature`, `type: question`, `type: documentation`
- `ai-approved`, `ai-changes-required` - `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 ## @codebot Commands
### Issue Commands
In any issue comment: In any issue comment:
| Command | Description | | 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 triage` | Full issue triage with auto-labeling and analysis |
| `@codebot summarize` | Summarize the issue in 2-3 sentences | | `@codebot summarize` | Summarize the issue in 2-3 sentences |
| `@codebot explain` | Explain what the issue is about | | `@codebot explain` | Explain what the issue is about |
| `@codebot suggest` | Suggest solutions or next steps | | `@codebot suggest` | Suggest solutions or next steps |
| `@codebot` (any question) | Chat with AI using codebase/web search tools | | `@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 ## Interactive Chat

440
docs/feature-ideas.md Normal file
View File

@@ -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 | <20><> 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

View File

@@ -20,7 +20,11 @@ class TestPromptFormatting:
"""Get the full path to a prompt file.""" """Get the full path to a prompt file."""
return os.path.join( return os.path.join(
os.path.dirname(__file__), 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: def load_prompt(self, name: str) -> str:
@@ -32,15 +36,15 @@ class TestPromptFormatting:
def test_issue_triage_prompt_formatting(self): def test_issue_triage_prompt_formatting(self):
"""Test that issue_triage.md can be formatted with placeholders.""" """Test that issue_triage.md can be formatted with placeholders."""
prompt = self.load_prompt("issue_triage") prompt = self.load_prompt("issue_triage")
# This should NOT raise a KeyError # This should NOT raise a KeyError
formatted = prompt.format( formatted = prompt.format(
title="Test Issue Title", title="Test Issue Title",
body="This is the issue body content", body="This is the issue body content",
author="testuser", author="testuser",
existing_labels="bug, urgent" existing_labels="bug, urgent",
) )
assert "Test Issue Title" in formatted assert "Test Issue Title" in formatted
assert "This is the issue body content" in formatted assert "This is the issue body content" in formatted
assert "testuser" in formatted assert "testuser" in formatted
@@ -52,15 +56,15 @@ class TestPromptFormatting:
def test_issue_response_prompt_formatting(self): def test_issue_response_prompt_formatting(self):
"""Test that issue_response.md can be formatted with placeholders.""" """Test that issue_response.md can be formatted with placeholders."""
prompt = self.load_prompt("issue_response") prompt = self.load_prompt("issue_response")
formatted = prompt.format( formatted = prompt.format(
issue_type="bug", issue_type="bug",
priority="high", priority="high",
title="Bug Report", title="Bug Report",
body="Description of the bug", 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 "bug" in formatted
assert "high" in formatted assert "high" in formatted
assert "Bug Report" in formatted assert "Bug Report" in formatted
@@ -70,7 +74,7 @@ class TestPromptFormatting:
def test_base_prompt_no_placeholders(self): def test_base_prompt_no_placeholders(self):
"""Test that base.md loads correctly (no placeholders needed).""" """Test that base.md loads correctly (no placeholders needed)."""
prompt = self.load_prompt("base") prompt = self.load_prompt("base")
# Should contain key elements # Should contain key elements
assert "security" in prompt.lower() assert "security" in prompt.lower()
assert "JSON" in prompt assert "JSON" in prompt
@@ -80,14 +84,20 @@ class TestPromptFormatting:
"""Verify JSON examples use double curly braces.""" """Verify JSON examples use double curly braces."""
for prompt_name in ["issue_triage", "issue_response"]: for prompt_name in ["issue_triage", "issue_response"]:
prompt = self.load_prompt(prompt_name) prompt = self.load_prompt(prompt_name)
# Check that format() doesn't fail # Check that format() doesn't fail
try: try:
# Try with minimal placeholders # Try with minimal placeholders
if prompt_name == "issue_triage": if prompt_name == "issue_triage":
prompt.format(title="t", body="b", author="a", existing_labels="l") prompt.format(title="t", body="b", author="a", existing_labels="l")
elif prompt_name == "issue_response": 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: except KeyError as e:
pytest.fail(f"Prompt {prompt_name} has unescaped curly braces: {e}") pytest.fail(f"Prompt {prompt_name} has unescaped curly braces: {e}")
@@ -97,11 +107,11 @@ class TestImports:
def test_import_agents(self): def test_import_agents(self):
"""Test importing agent classes.""" """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.issue_agent import IssueAgent
from agents.pr_agent import PRAgent from agents.pr_agent import PRAgent
from agents.codebase_agent import CodebaseAgent
assert BaseAgent is not None assert BaseAgent is not None
assert IssueAgent is not None assert IssueAgent is not None
assert PRAgent is not None assert PRAgent is not None
@@ -111,28 +121,28 @@ class TestImports:
"""Test importing client classes.""" """Test importing client classes."""
from clients.gitea_client import GiteaClient from clients.gitea_client import GiteaClient
from clients.llm_client import LLMClient from clients.llm_client import LLMClient
assert GiteaClient is not None assert GiteaClient is not None
assert LLMClient is not None assert LLMClient is not None
def test_import_security(self): def test_import_security(self):
"""Test importing security scanner.""" """Test importing security scanner."""
from security.security_scanner import SecurityScanner from security.security_scanner import SecurityScanner
assert SecurityScanner is not None assert SecurityScanner is not None
def test_import_enterprise(self): def test_import_enterprise(self):
"""Test importing enterprise features.""" """Test importing enterprise features."""
from enterprise.audit_logger import AuditLogger from enterprise.audit_logger import AuditLogger
from enterprise.metrics import MetricsCollector from enterprise.metrics import MetricsCollector
assert AuditLogger is not None assert AuditLogger is not None
assert MetricsCollector is not None assert MetricsCollector is not None
def test_import_dispatcher(self): def test_import_dispatcher(self):
"""Test importing dispatcher.""" """Test importing dispatcher."""
from dispatcher import Dispatcher from dispatcher import Dispatcher
assert Dispatcher is not None assert Dispatcher is not None
@@ -142,11 +152,11 @@ class TestSecurityScanner:
def test_detects_hardcoded_secret(self): def test_detects_hardcoded_secret(self):
"""Test detection of hardcoded secrets.""" """Test detection of hardcoded secrets."""
from security.security_scanner import SecurityScanner from security.security_scanner import SecurityScanner
scanner = SecurityScanner() scanner = SecurityScanner()
code = ''' code = """
API_KEY = "sk-1234567890abcdef" API_KEY = "sk-1234567890abcdef"
''' """
findings = list(scanner.scan_content(code, "test.py")) findings = list(scanner.scan_content(code, "test.py"))
assert len(findings) >= 1 assert len(findings) >= 1
assert any(f.severity == "HIGH" for f in findings) assert any(f.severity == "HIGH" for f in findings)
@@ -154,11 +164,11 @@ API_KEY = "sk-1234567890abcdef"
def test_detects_eval(self): def test_detects_eval(self):
"""Test detection of eval usage.""" """Test detection of eval usage."""
from security.security_scanner import SecurityScanner from security.security_scanner import SecurityScanner
scanner = SecurityScanner() scanner = SecurityScanner()
code = ''' code = """
result = eval(user_input) result = eval(user_input)
''' """
findings = list(scanner.scan_content(code, "test.py")) findings = list(scanner.scan_content(code, "test.py"))
assert len(findings) >= 1 assert len(findings) >= 1
assert any("eval" in f.rule_name.lower() for f in findings) 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): def test_no_false_positives_on_clean_code(self):
"""Test that clean code doesn't trigger false positives.""" """Test that clean code doesn't trigger false positives."""
from security.security_scanner import SecurityScanner from security.security_scanner import SecurityScanner
scanner = SecurityScanner() scanner = SecurityScanner()
code = ''' code = """
def hello(): def hello():
print("Hello, world!") print("Hello, world!")
return 42 return 42
''' """
findings = list(scanner.scan_content(code, "test.py")) findings = list(scanner.scan_content(code, "test.py"))
# Should have no HIGH severity issues for clean code # Should have no HIGH severity issues for clean code
high_findings = [f for f in findings if f.severity == "HIGH"] high_findings = [f for f in findings if f.severity == "HIGH"]
@@ -185,15 +195,15 @@ class TestAgentContext:
def test_agent_context_creation(self): def test_agent_context_creation(self):
"""Test creating AgentContext.""" """Test creating AgentContext."""
from agents.base_agent import AgentContext from agents.base_agent import AgentContext
context = AgentContext( context = AgentContext(
owner="testowner", owner="testowner",
repo="testrepo", repo="testrepo",
event_type="issues", event_type="issues",
event_data={"action": "opened"}, event_data={"action": "opened"},
config={} config={},
) )
assert context.owner == "testowner" assert context.owner == "testowner"
assert context.repo == "testrepo" assert context.repo == "testrepo"
assert context.event_type == "issues" assert context.event_type == "issues"
@@ -201,14 +211,14 @@ class TestAgentContext:
def test_agent_result_creation(self): def test_agent_result_creation(self):
"""Test creating AgentResult.""" """Test creating AgentResult."""
from agents.base_agent import AgentResult from agents.base_agent import AgentResult
result = AgentResult( result = AgentResult(
success=True, success=True,
message="Test passed", message="Test passed",
data={"key": "value"}, data={"key": "value"},
actions_taken=["action1", "action2"] actions_taken=["action1", "action2"],
) )
assert result.success is True assert result.success is True
assert result.message == "Test passed" assert result.message == "Test passed"
assert len(result.actions_taken) == 2 assert len(result.actions_taken) == 2
@@ -220,7 +230,7 @@ class TestMetrics:
def test_counter_increment(self): def test_counter_increment(self):
"""Test counter metrics.""" """Test counter metrics."""
from enterprise.metrics import Counter from enterprise.metrics import Counter
counter = Counter("test_counter") counter = Counter("test_counter")
assert counter.value == 0 assert counter.value == 0
counter.inc() counter.inc()
@@ -231,27 +241,352 @@ class TestMetrics:
def test_histogram_observation(self): def test_histogram_observation(self):
"""Test histogram metrics.""" """Test histogram metrics."""
from enterprise.metrics import Histogram from enterprise.metrics import Histogram
hist = Histogram("test_histogram") hist = Histogram("test_histogram")
hist.observe(0.1) hist.observe(0.1)
hist.observe(0.5) hist.observe(0.5)
hist.observe(1.0) hist.observe(1.0)
assert hist.count == 3 assert hist.count == 3
assert hist.sum == 1.6 assert hist.sum == 1.6
def test_metrics_collector_summary(self): def test_metrics_collector_summary(self):
"""Test metrics collector summary.""" """Test metrics collector summary."""
from enterprise.metrics import MetricsCollector from enterprise.metrics import MetricsCollector
collector = MetricsCollector() collector = MetricsCollector()
collector.record_request_start("TestAgent") collector.record_request_start("TestAgent")
collector.record_request_end("TestAgent", success=True, duration_seconds=0.5) collector.record_request_end("TestAgent", success=True, duration_seconds=0.5)
summary = collector.get_summary() summary = collector.get_summary()
assert summary["requests"]["total"] == 1 assert summary["requests"]["total"] == 1
assert summary["requests"]["success"] == 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__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])

View File

@@ -11,9 +11,9 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
import requests import requests
from clients.llm_client import ToolCall
from agents.base_agent import AgentContext, AgentResult, BaseAgent from agents.base_agent import AgentContext, AgentResult, BaseAgent
from clients.llm_client import ToolCall
@dataclass @dataclass
@@ -114,8 +114,10 @@ Repository context: {owner}/{repo}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._searxng_url = self.config.get("agents", {}).get("chat", {}).get( self._searxng_url = (
"searxng_url", os.environ.get("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: 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) # Check if this is a chat request (any @ai-bot mention that isn't a specific command)
if mention_prefix in comment_body: if mention_prefix in comment_body:
# Check it's not another specific command # 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() body_lower = comment_body.lower()
for cmd in specific_commands: for cmd in specific_commands:
if f"{mention_prefix} {cmd}" in body_lower: if f"{mention_prefix} {cmd}" in body_lower:
@@ -150,18 +158,24 @@ Repository context: {owner}/{repo}
"""Execute the chat agent.""" """Execute the chat agent."""
self.logger.info(f"Starting chat for {context.owner}/{context.repo}") 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": if context.event_type == "issue_comment":
user_message = context.event_data.get("comment", {}).get("body", "") user_message = context.event_data.get("comment", {}).get("body", "")
issue_index = context.event_data.get("issue", {}).get("number") 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 = self.config.get("interaction", {}).get(
"mention_prefix", "@ai-bot" "mention_prefix", "@codebot"
) )
user_message = user_message.replace(mention_prefix, "").strip() user_message = user_message.replace(mention_prefix, "").strip()
else: else:
user_message = context.event_data.get("message", "") user_message = context.event_data.get("message", "")
issue_index = context.event_data.get("issue_number") issue_index = context.event_data.get("issue_number")
comment_author = None
if not user_message: if not user_message:
return AgentResult( return AgentResult(
@@ -191,13 +205,10 @@ Repository context: {owner}/{repo}
# Post response if this is an issue comment # Post response if this is an issue comment
if issue_index: if issue_index:
comment_body = self._format_response(response_content) comment_body = self._format_response(response_content, comment_author)
self.upsert_comment( # Create a new comment instead of upserting to make conversation flow better
context.owner, self.gitea.create_issue_comment(
context.repo, context.owner, context.repo, issue_index, comment_body
issue_index,
comment_body,
marker=self.CHAT_AI_MARKER,
) )
actions_taken.append("Posted chat response") actions_taken.append("Posted chat response")
@@ -230,21 +241,23 @@ Repository context: {owner}/{repo}
return response.content, tools_used return response.content, tools_used
# Add assistant message with tool calls # Add assistant message with tool calls
messages.append({ messages.append(
"role": "assistant", {
"content": response.content or "", "role": "assistant",
"tool_calls": [ "content": response.content or "",
{ "tool_calls": [
"id": tc.id, {
"type": "function", "id": tc.id,
"function": { "type": "function",
"name": tc.name, "function": {
"arguments": str(tc.arguments), "name": tc.name,
}, "arguments": str(tc.arguments),
} },
for tc in response.tool_calls }
], for tc in response.tool_calls
}) ],
}
)
# Execute each tool call # Execute each tool call
for tool_call in response.tool_calls: for tool_call in response.tool_calls:
@@ -252,11 +265,13 @@ Repository context: {owner}/{repo}
tools_used.append(tool_call.name) tools_used.append(tool_call.name)
# Add tool result to messages # Add tool result to messages
messages.append({ messages.append(
"role": "tool", {
"tool_call_id": tool_call.id, "role": "tool",
"content": tool_result, "tool_call_id": tool_call.id,
}) "content": tool_result,
}
)
# If we hit max iterations, make one final call without tools # If we hit max iterations, make one final call without tools
self._rate_limit() self._rate_limit()
@@ -357,15 +372,38 @@ Repository context: {owner}/{repo}
# Code extensions to search # Code extensions to search
code_extensions = { code_extensions = {
".py", ".js", ".ts", ".go", ".rs", ".java", ".rb", ".py",
".php", ".c", ".cpp", ".h", ".cs", ".swift", ".kt", ".js",
".md", ".yml", ".yaml", ".json", ".toml", ".ts",
".go",
".rs",
".java",
".rb",
".php",
".c",
".cpp",
".h",
".cs",
".swift",
".kt",
".md",
".yml",
".yaml",
".json",
".toml",
} }
# Patterns to ignore # Patterns to ignore
ignore_patterns = [ ignore_patterns = [
"node_modules/", "vendor/", ".git/", "__pycache__/", "node_modules/",
".venv/", "dist/", "build/", ".min.js", ".min.css", "vendor/",
".git/",
"__pycache__/",
".venv/",
"dist/",
"build/",
".min.js",
".min.css",
] ]
def traverse(path: str = ""): def traverse(path: str = ""):
@@ -397,6 +435,7 @@ Repository context: {owner}/{repo}
def _match_pattern(self, filepath: str, pattern: str) -> bool: def _match_pattern(self, filepath: str, pattern: str) -> bool:
"""Check if filepath matches a simple glob pattern.""" """Check if filepath matches a simple glob pattern."""
import fnmatch import fnmatch
return fnmatch.fnmatch(filepath, pattern) return fnmatch.fnmatch(filepath, pattern)
def _tool_read_file(self, context: AgentContext, filepath: str) -> str: def _tool_read_file(self, context: AgentContext, filepath: str) -> str:
@@ -458,13 +497,22 @@ Repository context: {owner}/{repo}
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
return f"Web search failed: {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.""" """Format the chat response with disclaimer."""
lines = [ lines = []
f"{self.AI_DISCLAIMER}",
"", # Add user mention if available
"---", if user:
"", lines.append(f"@{user}")
content, lines.append("")
]
lines.extend(
[
f"{self.AI_DISCLAIMER}",
"",
"---",
"",
content,
]
)
return "\n".join(lines) return "\n".join(lines)

View File

@@ -5,6 +5,7 @@ Handles issue.opened, issue.labeled, and issue_comment events.
""" """
import logging import logging
import re
from dataclasses import dataclass from dataclasses import dataclass
from agents.base_agent import AgentContext, AgentResult, BaseAgent from agents.base_agent import AgentContext, AgentResult, BaseAgent
@@ -153,14 +154,17 @@ class IssueAgent(BaseAgent):
comment = context.event_data.get("comment", {}) comment = context.event_data.get("comment", {})
issue_index = issue.get("number") issue_index = issue.get("number")
comment_body = comment.get("body", "") comment_body = comment.get("body", "")
comment_author = comment.get("user", {}).get("login", "user")
# Parse command from mention # Parse command from mention
command = self._parse_command(comment_body) command = self._parse_command(comment_body)
if command: if command:
response = self._handle_command(context, issue, 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( self.gitea.create_issue_comment(
context.owner, context.repo, issue_index, response context.owner, context.repo, issue_index, response_with_mention
) )
return AgentResult( return AgentResult(
success=True, success=True,
@@ -221,6 +225,52 @@ class IssueAgent(BaseAgent):
reasoning="Automatic triage failed, needs human review", 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( def _apply_labels(
self, self,
owner: str, owner: str,
@@ -229,8 +279,6 @@ class IssueAgent(BaseAgent):
triage: TriageResult, triage: TriageResult,
) -> list[str]: ) -> list[str]:
"""Apply labels based on triage result.""" """Apply labels based on triage result."""
labels_config = self.config.get("labels", {})
# Get all repo labels # Get all repo labels
try: try:
repo_labels = self.gitea.get_repo_labels(owner, repo) repo_labels = self.gitea.get_repo_labels(owner, repo)
@@ -241,23 +289,23 @@ class IssueAgent(BaseAgent):
labels_to_add = [] labels_to_add = []
# Map priority # Map priority using new helper
priority_labels = labels_config.get("priority", {}) priority_config = self._get_label_config("priority", triage.priority)
priority_label = priority_labels.get(triage.priority) priority_label_name = priority_config["name"]
if priority_label and priority_label in label_map: if priority_label_name and priority_label_name in label_map:
labels_to_add.append(label_map[priority_label]) labels_to_add.append(label_map[priority_label_name])
# Map type # Map type using new helper
type_labels = labels_config.get("type", {}) type_config = self._get_label_config("type", triage.issue_type)
type_label = type_labels.get(triage.issue_type) type_label_name = type_config["name"]
if type_label and type_label in label_map: if type_label_name and type_label_name in label_map:
labels_to_add.append(label_map[type_label]) labels_to_add.append(label_map[type_label_name])
# Add AI reviewed label # Add AI reviewed label using new helper
status_labels = labels_config.get("status", {}) reviewed_config = self._get_label_config("status", "ai_reviewed")
reviewed_label = status_labels.get("ai_reviewed") reviewed_label_name = reviewed_config["name"]
if reviewed_label and reviewed_label in label_map: if reviewed_label_name and reviewed_label_name in label_map:
labels_to_add.append(label_map[reviewed_label]) labels_to_add.append(label_map[reviewed_label_name])
if labels_to_add: if labels_to_add:
try: try:
@@ -314,9 +362,13 @@ class IssueAgent(BaseAgent):
"mention_prefix", "@ai-bot" "mention_prefix", "@ai-bot"
) )
commands = self.config.get("interaction", {}).get( 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: for command in commands:
if f"{mention_prefix} {command}" in body.lower(): if f"{mention_prefix} {command}" in body.lower():
return command return command
@@ -328,7 +380,9 @@ class IssueAgent(BaseAgent):
title = issue.get("title", "") title = issue.get("title", "")
body = issue.get("body", "") body = issue.get("body", "")
if command == "summarize": if command == "help":
return self._command_help()
elif command == "summarize":
return self._command_summarize(title, body) return self._command_summarize(title, body)
elif command == "explain": elif command == "explain":
return self._command_explain(title, body) return self._command_explain(title, body)
@@ -336,6 +390,8 @@ class IssueAgent(BaseAgent):
return self._command_suggest(title, body) return self._command_suggest(title, body)
elif command == "triage": elif command == "triage":
return self._command_triage(context, issue) 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}`." 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: except Exception as e:
return f"{self.AI_DISCLAIMER}\n\nSorry, I was unable to generate suggestions. Error: {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: def _command_triage(self, context: AgentContext, issue: dict) -> str:
"""Perform full triage analysis on the issue.""" """Perform full triage analysis on the issue."""
title = issue.get("title", "") title = issue.get("title", "")
@@ -420,3 +544,313 @@ Be practical and concise."""
return response return response
except Exception as e: except Exception as e:
return f"{self.AI_DISCLAIMER}\n\nSorry, I was unable to triage this issue. Error: {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

View File

@@ -40,6 +40,37 @@ class PRAgent(BaseAgent):
# Marker specific to PR reviews # Marker specific to PR reviews
PR_AI_MARKER = "<!-- AI_PR_REVIEW -->" PR_AI_MARKER = "<!-- AI_PR_REVIEW -->"
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: def can_handle(self, event_type: str, event_data: dict) -> bool:
"""Check if this agent handles the given event.""" """Check if this agent handles the given event."""
# Check if agent is enabled # Check if agent is enabled
@@ -52,10 +83,30 @@ class PRAgent(BaseAgent):
allowed_events = agent_config.get("events", ["opened", "synchronize"]) allowed_events = agent_config.get("events", ["opened", "synchronize"])
return action in allowed_events 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 return False
def execute(self, context: AgentContext) -> AgentResult: def execute(self, context: AgentContext) -> AgentResult:
"""Execute the PR review agent.""" """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 = context.event_data.get("pull_request", {})
pr_number = pr.get("number") pr_number = pr.get("number")
@@ -185,7 +236,7 @@ class PRAgent(BaseAgent):
}, },
{ {
"name": "Hardcoded IP", "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", "severity": "LOW",
"category": "Security", "category": "Security",
"description": "Hardcoded IP address detected", "description": "Hardcoded IP address detected",
@@ -193,7 +244,7 @@ class PRAgent(BaseAgent):
}, },
{ {
"name": "Eval Usage", "name": "Eval Usage",
"pattern": r'\beval\s*\(', "pattern": r"\beval\s*\(",
"severity": "HIGH", "severity": "HIGH",
"category": "Security", "category": "Security",
"description": "Use of eval() detected - potential code injection risk", "description": "Use of eval() detected - potential code injection risk",
@@ -201,7 +252,7 @@ class PRAgent(BaseAgent):
}, },
{ {
"name": "Shell Injection", "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", "severity": "MEDIUM",
"category": "Security", "category": "Security",
"description": "Potential shell command execution - verify input is sanitized", "description": "Potential shell command execution - verify input is sanitized",
@@ -373,7 +424,9 @@ class PRAgent(BaseAgent):
lines.append("### Security Issues") lines.append("### Security Issues")
lines.append("") lines.append("")
for issue in review.security_issues[:5]: 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("") lines.append("")
# Other issues (limit display) # Other issues (limit display)
@@ -382,7 +435,9 @@ class PRAgent(BaseAgent):
lines.append("### Review Findings") lines.append("### Review Findings")
lines.append("") lines.append("")
for issue in other_issues[:10]: 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}") lines.append(f"- **[{issue.severity}]** {loc} - {issue.description}")
if len(other_issues) > 10: if len(other_issues) > 10:
lines.append(f"- ...and {len(other_issues) - 10} more issues") lines.append(f"- ...and {len(other_issues) - 10} more issues")
@@ -406,8 +461,6 @@ class PRAgent(BaseAgent):
review: PRReviewResult, review: PRReviewResult,
) -> list[str]: ) -> list[str]:
"""Apply labels based on review result.""" """Apply labels based on review result."""
labels_config = self.config.get("labels", {}).get("status", {})
try: try:
repo_labels = self.gitea.get_repo_labels(owner, repo) repo_labels = self.gitea.get_repo_labels(owner, repo)
label_map = {l["name"]: l["id"] for l in repo_labels} 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}") self.logger.warning(f"Failed to get repo labels: {e}")
return [] 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 = [] labels_to_add = []
# Add approval/changes required label # Add approval/changes required label
# Use helper to support both old string and new dict format
if review.approval: if review.approval:
label_name = labels_config.get("ai_approved", "ai-approved") label_config = self._get_label_config("status", "ai_approved")
else: 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]) labels_to_add.append(label_map[label_name])
if labels_to_add: if labels_to_add:

View File

@@ -72,7 +72,7 @@ class GiteaClient:
timeout=self.timeout, timeout=self.timeout,
) )
response.raise_for_status() response.raise_for_status()
if response.status_code == 204: if response.status_code == 204:
return {} return {}
return response.json() return response.json()
@@ -293,10 +293,45 @@ class GiteaClient:
repo: Repository name. repo: Repository name.
Returns: Returns:
List of label objects. List of label objects with 'id', 'name', 'color', 'description' fields.
""" """
return self._request("GET", f"/repos/{owner}/{repo}/labels") 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 # Pull Request Operations
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

View File

@@ -59,11 +59,13 @@ interaction:
respond_to_mentions: true respond_to_mentions: true
mention_prefix: "@codebot" # Change this to customize your bot's name! mention_prefix: "@codebot" # Change this to customize your bot's name!
commands: commands:
- help
- explain - explain
- suggest - suggest
- security - security
- summarize - summarize
- triage - triage
- review-again
# Enterprise settings # Enterprise settings
enterprise: enterprise:
@@ -75,20 +77,149 @@ enterprise:
max_concurrent: 4 max_concurrent: 4
# Label mappings for auto-labeling # 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: labels:
priority: priority:
high: "priority: high" critical:
medium: "priority: medium" name: "priority: critical"
low: "priority: low" 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: type:
bug: "type: bug" bug:
feature: "type: feature" name: "type: bug"
question: "type: question" color: "d73a4a" # Red
docs: "type: documentation" 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: status:
ai_approved: "ai-approved" ai_approved:
ai_changes_required: "ai-changes-required" name: "ai-approved"
ai_reviewed: "ai-reviewed" 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 scanning rules
security: security: