Merge pull request 'feat: Add @codebot setup-labels command with intelligent schema detection' (#8) from feature/auto-label-setup into dev

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2025-12-28 18:49:47 +00:00
7 changed files with 1023 additions and 83 deletions

View File

@@ -321,6 +321,7 @@ Example commands:
- `@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)
### Changing the Bot Name ### Changing the Bot Name
@@ -338,10 +339,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`
--- ---
@@ -158,12 +173,50 @@ In any issue comment:
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `@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 |
### 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

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,274 @@ 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 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

@@ -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
@@ -224,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,
@@ -232,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)
@@ -244,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:
@@ -317,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
@@ -339,6 +388,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}`."
@@ -423,3 +474,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
@@ -185,7 +216,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 +224,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 +232,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 +404,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 +415,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 +441,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}
@@ -418,12 +451,15 @@ class PRAgent(BaseAgent):
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

@@ -75,20 +75,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: