diff --git a/CLAUDE.md b/CLAUDE.md index 918d239..e52a28c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,7 @@ Example commands: - `@codebot triage` - Full issue triage with labeling - `@codebot explain` - Explain the issue - `@codebot suggest` - Suggest solutions +- `@codebot setup-labels` - Automatic label setup (built-in, not in config) ### Changing the Bot Name @@ -338,10 +339,78 @@ Example commands: ## Repository Labels +### Automatic Label Setup (Recommended) + +Use the `@codebot setup-labels` command to automatically configure labels. This command: + +**For repositories with existing labels:** +- Detects naming patterns: `Kind/Bug`, `Priority - High`, `type: bug` +- Maps existing labels to OpenRabbit schema using aliases +- Creates only missing labels following detected pattern +- Zero duplicate labels + +**For fresh repositories:** +- Creates OpenRabbit's default label set +- Uses standard naming: `type:`, `priority:`, status labels + +**Example with existing `Kind/` and `Priority -` labels:** +``` +@codebot setup-labels + +✅ Found 18 existing labels with pattern: prefix_slash + +Proposed Mapping: +| OpenRabbit Expected | Your Existing Label | Status | +|---------------------|---------------------|--------| +| type: bug | Kind/Bug | ✅ Map | +| type: feature | Kind/Feature | ✅ Map | +| priority: high | Priority - High | ✅ Map | +| ai-reviewed | (missing) | ⚠️ Create | + +✅ Created Kind/Question +✅ Created Status - AI Reviewed + +Setup Complete! Auto-labeling will use your existing label schema. +``` + +### Manual Label Setup + The system expects these labels to exist in repositories for auto-labeling: -- `priority: high`, `priority: medium`, `priority: low` -- `type: bug`, `type: feature`, `type: question`, `type: documentation` +- `priority: critical`, `priority: high`, `priority: medium`, `priority: low` +- `type: bug`, `type: feature`, `type: question`, `type: documentation`, `type: security`, `type: testing` - `ai-approved`, `ai-changes-required`, `ai-reviewed` Labels are mapped in `config.yml` under the `labels` section. + +### Label Configuration Format + +Labels support two formats for backwards compatibility: + +**New format (with colors and aliases):** +```yaml +labels: + type: + bug: + name: "type: bug" + color: "d73a4a" # Red + description: "Something isn't working" + aliases: ["Kind/Bug", "bug", "Type: Bug"] # For auto-detection +``` + +**Old format (strings only):** +```yaml +labels: + type: + bug: "type: bug" # Still works, uses default blue color +``` + +### Label Pattern Detection + +The `setup-labels` command detects these patterns (configured in `label_patterns`): + +1. **prefix_slash**: `Kind/Bug`, `Type/Feature`, `Category/X` +2. **prefix_dash**: `Priority - High`, `Status - Blocked` +3. **colon**: `type: bug`, `priority: high` + +When creating missing labels, the bot follows the detected pattern to maintain consistency. diff --git a/README.md b/README.md index e5a0b56..6bc8be8 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,27 @@ jobs: See `.gitea/workflows/` for all workflow examples. -### 3. Create Labels +### 3. Create Labels (Automatic Setup) + +**Option A: Automatic Setup (Recommended)** + +Create an issue and comment: +``` +@codebot setup-labels +``` + +The bot will automatically: +- Detect your existing label schema (e.g., `Kind/Bug`, `Priority - High`) +- Map existing labels to OpenRabbit's auto-labeling system +- Create only the missing labels you need +- Follow your repository's naming convention + +**Option B: Manual Setup** Create these labels in your repository for auto-labeling: -- `priority: high`, `priority: medium`, `priority: low` -- `type: bug`, `type: feature`, `type: question` -- `ai-approved`, `ai-changes-required` +- `priority: critical`, `priority: high`, `priority: medium`, `priority: low` +- `type: bug`, `type: feature`, `type: question`, `type: documentation` +- `ai-approved`, `ai-changes-required`, `ai-reviewed` --- @@ -158,12 +173,50 @@ In any issue comment: | 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 summarize` | Summarize the issue in 2-3 sentences | | `@codebot explain` | Explain what the issue is about | | `@codebot suggest` | Suggest solutions or next steps | | `@codebot` (any question) | Chat with AI using codebase/web search tools | +### Label Setup Command + +The `@codebot setup-labels` command intelligently detects your existing label schema and sets up auto-labeling: + +**For repositories with existing labels (e.g., `Kind/Bug`, `Priority - High`):** +- Detects your naming pattern (prefix/slash, prefix-dash, or colon-style) +- Maps your existing labels to OpenRabbit's schema +- Creates only missing labels following your pattern +- Zero duplicate labels created + +**For fresh repositories:** +- Creates OpenRabbit's default label set +- Uses `type:`, `priority:`, and status labels + +**Example output:** +``` +@codebot setup-labels + +✅ Found 18 existing labels with pattern: prefix_slash + +Detected Categories: +- Kind (7 labels): Bug, Feature, Documentation, Security, Testing +- Priority (4 labels): Critical, High, Medium, Low + +Proposed Mapping: +| OpenRabbit Expected | Your Existing Label | Status | +|---------------------|---------------------|--------| +| type: bug | Kind/Bug | ✅ Map | +| priority: high | Priority - High | ✅ Map | +| ai-reviewed | (missing) | ⚠️ Create | + +✅ Created Kind/Question (#cc317c) +✅ Created Status - AI Reviewed (#1d76db) + +Setup Complete! Auto-labeling will use your existing label schema. +``` + --- ## Interactive Chat diff --git a/tests/test_ai_review.py b/tests/test_ai_review.py index ba85612..a12f728 100644 --- a/tests/test_ai_review.py +++ b/tests/test_ai_review.py @@ -20,7 +20,11 @@ class TestPromptFormatting: """Get the full path to a prompt file.""" return os.path.join( os.path.dirname(__file__), - "..", "tools", "ai-review", "prompts", f"{name}.md" + "..", + "tools", + "ai-review", + "prompts", + f"{name}.md", ) def load_prompt(self, name: str) -> str: @@ -32,15 +36,15 @@ class TestPromptFormatting: def test_issue_triage_prompt_formatting(self): """Test that issue_triage.md can be formatted with placeholders.""" prompt = self.load_prompt("issue_triage") - + # This should NOT raise a KeyError formatted = prompt.format( title="Test Issue Title", body="This is the issue body content", author="testuser", - existing_labels="bug, urgent" + existing_labels="bug, urgent", ) - + assert "Test Issue Title" in formatted assert "This is the issue body content" in formatted assert "testuser" in formatted @@ -52,15 +56,15 @@ class TestPromptFormatting: def test_issue_response_prompt_formatting(self): """Test that issue_response.md can be formatted with placeholders.""" prompt = self.load_prompt("issue_response") - + formatted = prompt.format( issue_type="bug", priority="high", title="Bug Report", body="Description of the bug", - triage_analysis="This is a high priority bug" + triage_analysis="This is a high priority bug", ) - + assert "bug" in formatted assert "high" in formatted assert "Bug Report" in formatted @@ -70,7 +74,7 @@ class TestPromptFormatting: def test_base_prompt_no_placeholders(self): """Test that base.md loads correctly (no placeholders needed).""" prompt = self.load_prompt("base") - + # Should contain key elements assert "security" in prompt.lower() assert "JSON" in prompt @@ -80,14 +84,20 @@ class TestPromptFormatting: """Verify JSON examples use double curly braces.""" for prompt_name in ["issue_triage", "issue_response"]: prompt = self.load_prompt(prompt_name) - + # Check that format() doesn't fail try: # Try with minimal placeholders if prompt_name == "issue_triage": prompt.format(title="t", body="b", author="a", existing_labels="l") elif prompt_name == "issue_response": - prompt.format(issue_type="t", priority="p", title="t", body="b", triage_analysis="a") + prompt.format( + issue_type="t", + priority="p", + title="t", + body="b", + triage_analysis="a", + ) except KeyError as e: pytest.fail(f"Prompt {prompt_name} has unescaped curly braces: {e}") @@ -97,11 +107,11 @@ class TestImports: def test_import_agents(self): """Test importing agent classes.""" - from agents.base_agent import BaseAgent, AgentContext, AgentResult + from agents.base_agent import AgentContext, AgentResult, BaseAgent + from agents.codebase_agent import CodebaseAgent from agents.issue_agent import IssueAgent from agents.pr_agent import PRAgent - from agents.codebase_agent import CodebaseAgent - + assert BaseAgent is not None assert IssueAgent is not None assert PRAgent is not None @@ -111,28 +121,28 @@ class TestImports: """Test importing client classes.""" from clients.gitea_client import GiteaClient from clients.llm_client import LLMClient - + assert GiteaClient is not None assert LLMClient is not None def test_import_security(self): """Test importing security scanner.""" from security.security_scanner import SecurityScanner - + assert SecurityScanner is not None def test_import_enterprise(self): """Test importing enterprise features.""" from enterprise.audit_logger import AuditLogger from enterprise.metrics import MetricsCollector - + assert AuditLogger is not None assert MetricsCollector is not None def test_import_dispatcher(self): """Test importing dispatcher.""" from dispatcher import Dispatcher - + assert Dispatcher is not None @@ -142,11 +152,11 @@ class TestSecurityScanner: def test_detects_hardcoded_secret(self): """Test detection of hardcoded secrets.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ API_KEY = "sk-1234567890abcdef" -''' +""" findings = list(scanner.scan_content(code, "test.py")) assert len(findings) >= 1 assert any(f.severity == "HIGH" for f in findings) @@ -154,11 +164,11 @@ API_KEY = "sk-1234567890abcdef" def test_detects_eval(self): """Test detection of eval usage.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ result = eval(user_input) -''' +""" findings = list(scanner.scan_content(code, "test.py")) assert len(findings) >= 1 assert any("eval" in f.rule_name.lower() for f in findings) @@ -166,13 +176,13 @@ result = eval(user_input) def test_no_false_positives_on_clean_code(self): """Test that clean code doesn't trigger false positives.""" from security.security_scanner import SecurityScanner - + scanner = SecurityScanner() - code = ''' + code = """ def hello(): print("Hello, world!") return 42 -''' +""" findings = list(scanner.scan_content(code, "test.py")) # Should have no HIGH severity issues for clean code high_findings = [f for f in findings if f.severity == "HIGH"] @@ -185,15 +195,15 @@ class TestAgentContext: def test_agent_context_creation(self): """Test creating AgentContext.""" from agents.base_agent import AgentContext - + context = AgentContext( owner="testowner", repo="testrepo", event_type="issues", event_data={"action": "opened"}, - config={} + config={}, ) - + assert context.owner == "testowner" assert context.repo == "testrepo" assert context.event_type == "issues" @@ -201,14 +211,14 @@ class TestAgentContext: def test_agent_result_creation(self): """Test creating AgentResult.""" from agents.base_agent import AgentResult - + result = AgentResult( success=True, message="Test passed", data={"key": "value"}, - actions_taken=["action1", "action2"] + actions_taken=["action1", "action2"], ) - + assert result.success is True assert result.message == "Test passed" assert len(result.actions_taken) == 2 @@ -220,7 +230,7 @@ class TestMetrics: def test_counter_increment(self): """Test counter metrics.""" from enterprise.metrics import Counter - + counter = Counter("test_counter") assert counter.value == 0 counter.inc() @@ -231,27 +241,274 @@ class TestMetrics: def test_histogram_observation(self): """Test histogram metrics.""" from enterprise.metrics import Histogram - + hist = Histogram("test_histogram") hist.observe(0.1) hist.observe(0.5) hist.observe(1.0) - + assert hist.count == 3 assert hist.sum == 1.6 def test_metrics_collector_summary(self): """Test metrics collector summary.""" from enterprise.metrics import MetricsCollector - + collector = MetricsCollector() collector.record_request_start("TestAgent") collector.record_request_end("TestAgent", success=True, duration_seconds=0.5) - + summary = collector.get_summary() assert summary["requests"]["total"] == 1 assert summary["requests"]["success"] == 1 +class TestLabelSetup: + """Test label setup and schema detection.""" + + def test_detect_prefix_slash_schema(self): + """Test detection of Kind/Bug style labels.""" + from agents.issue_agent import IssueAgent + + # Create mock agent with config + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "Kind/Bug", "color": "d73a4a"}, + {"name": "Kind/Feature", "color": "1d76db"}, + {"name": "Kind/Documentation", "color": "0075ca"}, + {"name": "Priority - High", "color": "d73a4a"}, + {"name": "Priority - Low", "color": "28a745"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "prefix_slash" + assert "type" in schema["categories"] + assert "priority" in schema["categories"] + assert len(schema["categories"]["type"]) == 3 + + def test_detect_prefix_dash_schema(self): + """Test detection of Priority - High style labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "Priority - Critical", "color": "b60205"}, + {"name": "Priority - High", "color": "d73a4a"}, + {"name": "Status - Blocked", "color": "fef2c0"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "prefix_dash" + assert "priority" in schema["categories"] + assert "status" in schema["categories"] + + def test_detect_colon_schema(self): + """Test detection of type: bug style labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "label_patterns": { + "prefix_slash": r"^(Kind|Type|Category)/(.+)$", + "prefix_dash": r"^(Priority|Status|Reviewed) - (.+)$", + "colon": r"^(type|priority|status): (.+)$", + } + }, + ) + + labels = [ + {"name": "type: bug", "color": "d73a4a"}, + {"name": "type: feature", "color": "1d76db"}, + {"name": "priority: high", "color": "d73a4a"}, + ] + + schema = agent._detect_label_schema(labels) + + assert schema is not None + assert schema["pattern"] == "colon" + assert "type" in schema["categories"] + assert "priority" in schema["categories"] + + def test_build_label_mapping(self): + """Test building label mapping from existing labels.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": {"name": "type: bug", "aliases": ["Kind/Bug", "bug"]}, + "feature": { + "name": "type: feature", + "aliases": ["Kind/Feature", "feature"], + }, + }, + "priority": { + "high": { + "name": "priority: high", + "aliases": ["Priority - High", "P1"], + } + }, + "status": {}, + } + }, + ) + + existing_labels = [ + {"name": "Kind/Bug", "color": "d73a4a"}, + {"name": "Kind/Feature", "color": "1d76db"}, + {"name": "Priority - High", "color": "d73a4a"}, + ] + + schema = { + "pattern": "prefix_slash", + "categories": { + "type": ["Kind/Bug", "Kind/Feature"], + "priority": ["Priority - High"], + }, + } + + mapping = agent._build_label_mapping(existing_labels, schema) + + assert "type" in mapping + assert "bug" in mapping["type"] + assert mapping["type"]["bug"] == "Kind/Bug" + assert "feature" in mapping["type"] + assert mapping["type"]["feature"] == "Kind/Feature" + assert "priority" in mapping + assert "high" in mapping["priority"] + assert mapping["priority"]["high"] == "Priority - High" + + def test_suggest_label_name_prefix_slash(self): + """Test label name suggestion for prefix_slash pattern.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": {"bug": {"name": "type: bug", "color": "d73a4a"}}, + "priority": {}, + "status": { + "ai_approved": {"name": "ai-approved", "color": "28a745"} + }, + } + }, + ) + + # Test type category + suggested = agent._suggest_label_name("type", "bug", "prefix_slash") + assert suggested == "Kind/Bug" + + # Test status category + suggested = agent._suggest_label_name("status", "ai_approved", "prefix_slash") + assert suggested == "Status/Ai Approved" + + def test_suggest_label_name_prefix_dash(self): + """Test label name suggestion for prefix_dash pattern.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": {}, + "priority": {"high": {"name": "priority: high", "color": "d73a4a"}}, + "status": {}, + } + }, + ) + + suggested = agent._suggest_label_name("priority", "high", "prefix_dash") + assert suggested == "Priority - High" + + def test_get_label_config_backwards_compatibility(self): + """Test that old string format still works.""" + from agents.issue_agent import IssueAgent + + # Old config format (strings) + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": "type: bug" # Old format + }, + "priority": {}, + "status": {}, + } + }, + ) + + config = agent._get_label_config("type", "bug") + + assert config["name"] == "type: bug" + assert config["color"] == "1d76db" # Default color + assert config["aliases"] == [] + + def test_get_label_config_new_format(self): + """Test that new dict format works.""" + from agents.issue_agent import IssueAgent + + agent = IssueAgent( + gitea_client=None, + llm_client=None, + config={ + "labels": { + "type": { + "bug": { + "name": "type: bug", + "color": "d73a4a", + "description": "Something isn't working", + "aliases": ["Kind/Bug", "bug"], + } + }, + "priority": {}, + "status": {}, + } + }, + ) + + config = agent._get_label_config("type", "bug") + + assert config["name"] == "type: bug" + assert config["color"] == "d73a4a" + assert config["description"] == "Something isn't working" + assert "Kind/Bug" in config["aliases"] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tools/ai-review/agents/issue_agent.py b/tools/ai-review/agents/issue_agent.py index 4490022..3c6e5e9 100644 --- a/tools/ai-review/agents/issue_agent.py +++ b/tools/ai-review/agents/issue_agent.py @@ -5,6 +5,7 @@ Handles issue.opened, issue.labeled, and issue_comment events. """ import logging +import re from dataclasses import dataclass from agents.base_agent import AgentContext, AgentResult, BaseAgent @@ -224,6 +225,52 @@ class IssueAgent(BaseAgent): reasoning="Automatic triage failed, needs human review", ) + def _get_label_name(self, label_config: str | dict) -> str: + """Get label name from config (supports both old string and new dict format). + + Args: + label_config: Either a string (old format) or dict with 'name' key (new format) + + Returns: + Label name as string + """ + if isinstance(label_config, str): + return label_config + elif isinstance(label_config, dict): + return label_config.get("name", "") + return "" + + def _get_label_config(self, category: str, key: str) -> dict: + """Get full label configuration from config. + + Args: + category: Label category (type, priority, status) + key: Label key within category (bug, high, etc.) + + Returns: + Dict with name, color, description, aliases + """ + labels_config = self.config.get("labels", {}) + category_config = labels_config.get(category, {}) + label_config = category_config.get(key, {}) + + # Handle old string format + if isinstance(label_config, str): + return { + "name": label_config, + "color": "1d76db", # Default blue + "description": "", + "aliases": [], + } + + # Handle new dict format + return { + "name": label_config.get("name", ""), + "color": label_config.get("color", "1d76db"), + "description": label_config.get("description", ""), + "aliases": label_config.get("aliases", []), + } + def _apply_labels( self, owner: str, @@ -232,8 +279,6 @@ class IssueAgent(BaseAgent): triage: TriageResult, ) -> list[str]: """Apply labels based on triage result.""" - labels_config = self.config.get("labels", {}) - # Get all repo labels try: repo_labels = self.gitea.get_repo_labels(owner, repo) @@ -244,23 +289,23 @@ class IssueAgent(BaseAgent): labels_to_add = [] - # Map priority - priority_labels = labels_config.get("priority", {}) - priority_label = priority_labels.get(triage.priority) - if priority_label and priority_label in label_map: - labels_to_add.append(label_map[priority_label]) + # Map priority using new helper + priority_config = self._get_label_config("priority", triage.priority) + priority_label_name = priority_config["name"] + if priority_label_name and priority_label_name in label_map: + labels_to_add.append(label_map[priority_label_name]) - # Map type - type_labels = labels_config.get("type", {}) - type_label = type_labels.get(triage.issue_type) - if type_label and type_label in label_map: - labels_to_add.append(label_map[type_label]) + # Map type using new helper + type_config = self._get_label_config("type", triage.issue_type) + type_label_name = type_config["name"] + if type_label_name and type_label_name in label_map: + labels_to_add.append(label_map[type_label_name]) - # Add AI reviewed label - status_labels = labels_config.get("status", {}) - reviewed_label = status_labels.get("ai_reviewed") - if reviewed_label and reviewed_label in label_map: - labels_to_add.append(label_map[reviewed_label]) + # Add AI reviewed label using new helper + reviewed_config = self._get_label_config("status", "ai_reviewed") + reviewed_label_name = reviewed_config["name"] + if reviewed_label_name and reviewed_label_name in label_map: + labels_to_add.append(label_map[reviewed_label_name]) if labels_to_add: try: @@ -317,9 +362,13 @@ class IssueAgent(BaseAgent): "mention_prefix", "@ai-bot" ) commands = self.config.get("interaction", {}).get( - "commands", ["explain", "suggest", "security", "summarize"] + "commands", ["explain", "suggest", "security", "summarize", "triage"] ) + # Also check for setup-labels command (not in config since it's a setup command) + if f"{mention_prefix} setup-labels" in body.lower(): + return "setup-labels" + for command in commands: if f"{mention_prefix} {command}" in body.lower(): return command @@ -339,6 +388,8 @@ class IssueAgent(BaseAgent): return self._command_suggest(title, body) elif command == "triage": return self._command_triage(context, issue) + elif command == "setup-labels": + return self._command_setup_labels(context, issue) return f"{self.AI_DISCLAIMER}\n\nSorry, I don't understand the command `{command}`." @@ -423,3 +474,313 @@ Be practical and concise.""" return response except Exception as e: return f"{self.AI_DISCLAIMER}\n\nSorry, I was unable to triage this issue. Error: {e}" + + def _command_setup_labels(self, context: AgentContext, issue: dict) -> str: + """Setup repository labels for auto-labeling.""" + owner = context.owner + repo = context.repo + + try: + # Get existing labels + existing_labels = self.gitea.get_repo_labels(owner, repo) + existing_names = { + label["name"].lower(): label["name"] for label in existing_labels + } + + # Detect schema + schema = self._detect_label_schema(existing_labels) + + # Determine mode + if schema and len(existing_labels) >= 5: + # Repository has existing labels, use mapping mode + return self._setup_labels_map_mode( + owner, repo, existing_labels, schema, existing_names + ) + else: + # Fresh repository or few labels, use create mode + return self._setup_labels_create_mode(owner, repo, existing_names) + + except Exception as e: + self.logger.error(f"Label setup failed: {e}") + return f"{self.AI_DISCLAIMER}\n\n**Label Setup Failed**\n\nError: {e}\n\nPlease ensure the bot has write access to this repository." + + def _detect_label_schema(self, labels: list[dict]) -> dict | None: + """Detect the naming pattern used in existing labels. + + Returns: + { + "pattern": "prefix_slash" | "prefix_dash" | "colon", + "categories": { + "type": ["Kind/Bug", "Kind/Feature", ...], + "priority": ["Priority - High", ...], + } + } + """ + patterns_config = self.config.get("label_patterns", {}) + patterns = { + "prefix_slash": re.compile( + patterns_config.get("prefix_slash", r"^(Kind|Type|Category)/(.+)$") + ), + "prefix_dash": re.compile( + patterns_config.get( + "prefix_dash", r"^(Priority|Status|Reviewed) - (.+)$" + ) + ), + "colon": re.compile( + patterns_config.get("colon", r"^(type|priority|status): (.+)$") + ), + } + + categorized = {} + detected_pattern = None + + for label in labels: + name = label["name"] + + for pattern_name, regex in patterns.items(): + match = regex.match(name) + if match: + category = match.group(1).lower() + # Normalize category names + if category == "kind": + category = "type" + elif category == "reviewed": + category = "status" + + if category not in categorized: + categorized[category] = [] + + categorized[category].append(name) + detected_pattern = pattern_name + break + + if not categorized: + return None + + return {"pattern": detected_pattern, "categories": categorized} + + def _build_label_mapping(self, existing_labels: list[dict], schema: dict) -> dict: + """Build mapping from OpenRabbit schema to existing labels. + + Returns: + { + "type": { + "bug": "Kind/Bug", + "feature": "Kind/Feature", + }, + "priority": { + "high": "Priority - High", + } + } + """ + mapping = {} + label_names_lower = { + label["name"].lower(): label["name"] for label in existing_labels + } + + # Get all configured labels with their aliases + labels_config = self.config.get("labels", {}) + + for category in ["type", "priority", "status"]: + category_config = labels_config.get(category, {}) + mapping[category] = {} + + for key, label_def in category_config.items(): + config = self._get_label_config(category, key) + aliases = config.get("aliases", []) + + # Try to find a match using aliases + for alias in aliases: + if alias.lower() in label_names_lower: + mapping[category][key] = label_names_lower[alias.lower()] + break + + return mapping + + def _setup_labels_map_mode( + self, + owner: str, + repo: str, + existing_labels: list[dict], + schema: dict, + existing_names: dict, + ) -> str: + """Map existing labels to OpenRabbit schema.""" + + # Build mapping + mapping = self._build_label_mapping(existing_labels, schema) + + # Get required labels + required_labels = self._get_required_labels() + + # Find missing labels + missing = [] + for category, items in required_labels.items(): + for key in items: + if key not in mapping.get(category, {}): + missing.append((category, key)) + + # Format report + lines = [f"{self.AI_DISCLAIMER}\n"] + lines.append("## Label Schema Detected\n") + lines.append( + f"Found {len(existing_labels)} existing labels with pattern: `{schema['pattern']}`\n" + ) + + lines.append("**Detected Categories:**") + for category, labels in schema["categories"].items(): + lines.append(f"- **{category.title()}** ({len(labels)} labels)") + lines.append("") + + lines.append("**Proposed Mapping:**\n") + lines.append("| OpenRabbit Expected | Your Existing Label | Status |") + lines.append("|---------------------|---------------------|--------|") + + for category, items in required_labels.items(): + for key in items: + openrabbit_config = self._get_label_config(category, key) + openrabbit_name = openrabbit_config["name"] + + if key in mapping.get(category, {}): + existing_name = mapping[category][key] + lines.append( + f"| `{openrabbit_name}` | `{existing_name}` | ✅ Map |" + ) + else: + lines.append(f"| `{openrabbit_name}` | *(missing)* | ⚠️ Create |") + + lines.append("") + + # Create missing labels + if missing: + lines.append(f"**Creating Missing Labels ({len(missing)}):**\n") + created_count = 0 + + for category, key in missing: + config = self._get_label_config(category, key) + suggested_name = self._suggest_label_name( + category, key, schema["pattern"] + ) + + # Check if label already exists (case-insensitive) + if suggested_name.lower() not in existing_names: + try: + self.gitea.create_label( + owner, + repo, + suggested_name, + config["color"], + config["description"], + ) + lines.append( + f"✅ Created `{suggested_name}` (#{config['color']})" + ) + created_count += 1 + except Exception as e: + lines.append(f"❌ Failed to create `{suggested_name}`: {e}") + else: + lines.append(f"⚠️ `{suggested_name}` already exists") + + lines.append("") + if created_count > 0: + lines.append(f"**✅ Created {created_count} new labels!**") + else: + lines.append("**✅ All Required Labels Present!**") + + lines.append("\n**Setup Complete!**") + lines.append("Auto-labeling will use your existing label schema.") + + return "\n".join(lines) + + def _setup_labels_create_mode( + self, owner: str, repo: str, existing_names: dict + ) -> str: + """Create OpenRabbit default labels.""" + + lines = [f"{self.AI_DISCLAIMER}\n"] + lines.append("## Creating OpenRabbit Labels\n") + + # Get all required labels + required_labels = self._get_required_labels() + + created = [] + skipped = [] + failed = [] + + for category, items in required_labels.items(): + for key in items: + config = self._get_label_config(category, key) + label_name = config["name"] + + # Check if already exists (case-insensitive) + if label_name.lower() in existing_names: + skipped.append(label_name) + continue + + try: + self.gitea.create_label( + owner, repo, label_name, config["color"], config["description"] + ) + created.append((label_name, config["color"])) + except Exception as e: + failed.append((label_name, str(e))) + + if created: + lines.append(f"**✅ Created {len(created)} Labels:**\n") + for name, color in created: + lines.append(f"- `{name}` (#{color})") + lines.append("") + + if skipped: + lines.append(f"**⚠️ Skipped {len(skipped)} Existing Labels:**\n") + for name in skipped: + lines.append(f"- `{name}`") + lines.append("") + + if failed: + lines.append(f"**❌ Failed to Create {len(failed)} Labels:**\n") + for name, error in failed: + lines.append(f"- `{name}`: {error}") + lines.append("") + + lines.append("**✅ Setup Complete!**") + lines.append("Auto-labeling is now configured.") + + return "\n".join(lines) + + def _get_required_labels(self) -> dict: + """Get all required label categories and keys. + + Returns: + { + "type": ["bug", "feature", "question", "docs"], + "priority": ["high", "medium", "low"], + "status": ["ai_approved", "ai_changes_required", "ai_reviewed"] + } + """ + labels_config = self.config.get("labels", {}) + required = {} + + for category in ["type", "priority", "status"]: + category_config = labels_config.get(category, {}) + required[category] = list(category_config.keys()) + + return required + + def _suggest_label_name(self, category: str, key: str, pattern: str) -> str: + """Suggest a label name based on detected pattern.""" + + # Get the configured name first + config = self._get_label_config(category, key) + base_name = config["name"] + + if pattern == "prefix_slash": + prefix = "Kind" if category == "type" else category.title() + value = key.replace("_", " ").title() + return f"{prefix}/{value}" + elif pattern == "prefix_dash": + prefix = "Kind" if category == "type" else category.title() + value = key.replace("_", " ").title() + return f"{prefix} - {value}" + else: # colon or unknown + return base_name diff --git a/tools/ai-review/agents/pr_agent.py b/tools/ai-review/agents/pr_agent.py index a2b1f43..a431cf8 100644 --- a/tools/ai-review/agents/pr_agent.py +++ b/tools/ai-review/agents/pr_agent.py @@ -40,6 +40,37 @@ class PRAgent(BaseAgent): # Marker specific to PR reviews PR_AI_MARKER = "" + def _get_label_config(self, category: str, key: str) -> dict: + """Get full label configuration from config. + + Args: + category: Label category (type, priority, status) + key: Label key within category (bug, high, ai_approved, etc.) + + Returns: + Dict with name, color, description, aliases + """ + labels_config = self.config.get("labels", {}) + category_config = labels_config.get(category, {}) + label_config = category_config.get(key, {}) + + # Handle old string format + if isinstance(label_config, str): + return { + "name": label_config, + "color": "1d76db", # Default blue + "description": "", + "aliases": [], + } + + # Handle new dict format + return { + "name": label_config.get("name", ""), + "color": label_config.get("color", "1d76db"), + "description": label_config.get("description", ""), + "aliases": label_config.get("aliases", []), + } + def can_handle(self, event_type: str, event_data: dict) -> bool: """Check if this agent handles the given event.""" # Check if agent is enabled @@ -185,7 +216,7 @@ class PRAgent(BaseAgent): }, { "name": "Hardcoded IP", - "pattern": r'\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b', + "pattern": r"\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b", "severity": "LOW", "category": "Security", "description": "Hardcoded IP address detected", @@ -193,7 +224,7 @@ class PRAgent(BaseAgent): }, { "name": "Eval Usage", - "pattern": r'\beval\s*\(', + "pattern": r"\beval\s*\(", "severity": "HIGH", "category": "Security", "description": "Use of eval() detected - potential code injection risk", @@ -201,7 +232,7 @@ class PRAgent(BaseAgent): }, { "name": "Shell Injection", - "pattern": r'(?i)(?:subprocess\.call|os\.system|shell\s*=\s*True)', + "pattern": r"(?i)(?:subprocess\.call|os\.system|shell\s*=\s*True)", "severity": "MEDIUM", "category": "Security", "description": "Potential shell command execution - verify input is sanitized", @@ -373,7 +404,9 @@ class PRAgent(BaseAgent): lines.append("### Security Issues") lines.append("") for issue in review.security_issues[:5]: - lines.append(f"- **[{issue.severity}]** `{issue.file}:{issue.line}` - {issue.description}") + lines.append( + f"- **[{issue.severity}]** `{issue.file}:{issue.line}` - {issue.description}" + ) lines.append("") # Other issues (limit display) @@ -382,7 +415,9 @@ class PRAgent(BaseAgent): lines.append("### Review Findings") lines.append("") for issue in other_issues[:10]: - loc = f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + loc = ( + f"`{issue.file}:{issue.line}`" if issue.line else f"`{issue.file}`" + ) lines.append(f"- **[{issue.severity}]** {loc} - {issue.description}") if len(other_issues) > 10: lines.append(f"- ...and {len(other_issues) - 10} more issues") @@ -406,8 +441,6 @@ class PRAgent(BaseAgent): review: PRReviewResult, ) -> list[str]: """Apply labels based on review result.""" - labels_config = self.config.get("labels", {}).get("status", {}) - try: repo_labels = self.gitea.get_repo_labels(owner, repo) label_map = {l["name"]: l["id"] for l in repo_labels} @@ -418,12 +451,15 @@ class PRAgent(BaseAgent): labels_to_add = [] # Add approval/changes required label + # Use helper to support both old string and new dict format if review.approval: - label_name = labels_config.get("ai_approved", "ai-approved") + label_config = self._get_label_config("status", "ai_approved") else: - label_name = labels_config.get("ai_changes_required", "ai-changes-required") + label_config = self._get_label_config("status", "ai_changes_required") - if label_name in label_map: + label_name = label_config.get("name", "") + + if label_name and label_name in label_map: labels_to_add.append(label_map[label_name]) if labels_to_add: diff --git a/tools/ai-review/clients/gitea_client.py b/tools/ai-review/clients/gitea_client.py index 14c6219..33b812e 100644 --- a/tools/ai-review/clients/gitea_client.py +++ b/tools/ai-review/clients/gitea_client.py @@ -72,7 +72,7 @@ class GiteaClient: timeout=self.timeout, ) response.raise_for_status() - + if response.status_code == 204: return {} return response.json() @@ -293,10 +293,45 @@ class GiteaClient: repo: Repository name. Returns: - List of label objects. + List of label objects with 'id', 'name', 'color', 'description' fields. """ return self._request("GET", f"/repos/{owner}/{repo}/labels") + def create_label( + self, + owner: str, + repo: str, + name: str, + color: str, + description: str = "", + ) -> dict: + """Create a new label in the repository. + + Args: + owner: Repository owner. + repo: Repository name. + name: Label name (e.g., "priority: high"). + color: Hex color code without # (e.g., "d73a4a"). + description: Optional label description. + + Returns: + Created label object. + + Raises: + requests.HTTPError: If label creation fails (e.g., already exists). + """ + payload = { + "name": name, + "color": color, + "description": description, + } + + return self._request( + "POST", + f"/repos/{owner}/{repo}/labels", + json=payload, + ) + # ------------------------------------------------------------------------- # Pull Request Operations # ------------------------------------------------------------------------- diff --git a/tools/ai-review/config.yml b/tools/ai-review/config.yml index 801850b..969d1cd 100644 --- a/tools/ai-review/config.yml +++ b/tools/ai-review/config.yml @@ -75,20 +75,149 @@ enterprise: max_concurrent: 4 # Label mappings for auto-labeling +# Each label has: +# name: The label name to use/create (string) or full config (dict) +# aliases: Alternative names for auto-detection (optional) +# color: Hex color code without # (optional, for label creation) +# description: Label description (optional, for label creation) labels: priority: - high: "priority: high" - medium: "priority: medium" - low: "priority: low" + critical: + name: "priority: critical" + color: "b60205" # Dark Red + description: "Critical priority - immediate attention required" + aliases: + ["Priority - Critical", "P0", "critical", "Priority/Critical"] + high: + name: "priority: high" + color: "d73a4a" # Red + description: "High priority issue" + aliases: ["Priority - High", "P1", "high", "Priority/High"] + medium: + name: "priority: medium" + color: "fbca04" # Yellow + description: "Medium priority issue" + aliases: ["Priority - Medium", "P2", "medium", "Priority/Medium"] + low: + name: "priority: low" + color: "28a745" # Green + description: "Low priority issue" + aliases: ["Priority - Low", "P3", "low", "Priority/Low"] type: - bug: "type: bug" - feature: "type: feature" - question: "type: question" - docs: "type: documentation" + bug: + name: "type: bug" + color: "d73a4a" # Red + description: "Something isn't working" + aliases: ["Kind/Bug", "bug", "Type: Bug", "Type/Bug", "Kind - Bug"] + feature: + name: "type: feature" + color: "1d76db" # Blue + description: "New feature request" + aliases: + [ + "Kind/Feature", + "feature", + "enhancement", + "Kind/Enhancement", + "Type: Feature", + "Type/Feature", + "Kind - Feature", + ] + question: + name: "type: question" + color: "cc317c" # Purple + description: "Further information is requested" + aliases: + [ + "Kind/Question", + "question", + "Type: Question", + "Type/Question", + "Kind - Question", + ] + docs: + name: "type: documentation" + color: "0075ca" # Light Blue + description: "Documentation improvements" + aliases: + [ + "Kind/Documentation", + "documentation", + "docs", + "Type: Documentation", + "Type/Documentation", + "Kind - Documentation", + ] + security: + name: "type: security" + color: "b60205" # Dark Red + description: "Security vulnerability or concern" + aliases: + [ + "Kind/Security", + "security", + "Type: Security", + "Type/Security", + "Kind - Security", + ] + testing: + name: "type: testing" + color: "0e8a16" # Green + description: "Related to testing" + aliases: + [ + "Kind/Testing", + "testing", + "tests", + "Type: Testing", + "Type/Testing", + "Kind - Testing", + ] status: - ai_approved: "ai-approved" - ai_changes_required: "ai-changes-required" - ai_reviewed: "ai-reviewed" + ai_approved: + name: "ai-approved" + color: "28a745" # Green + description: "AI review approved this PR" + aliases: + [ + "Status - Approved", + "approved", + "Status/Approved", + "Status - AI Approved", + ] + ai_changes_required: + name: "ai-changes-required" + color: "d73a4a" # Red + description: "AI review found issues requiring changes" + aliases: + [ + "Status - Changes Required", + "changes-required", + "Status/Changes Required", + "Status - AI Changes Required", + ] + ai_reviewed: + name: "ai-reviewed" + color: "1d76db" # Blue + description: "This issue/PR has been reviewed by AI" + aliases: + [ + "Reviewed - Confirmed", + "reviewed", + "Status/Reviewed", + "Reviewed/Confirmed", + "Status - Reviewed", + ] + +# Label schema detection patterns +# Used by setup-labels command to detect existing naming conventions +label_patterns: + # Detect prefix-based naming (e.g., Kind/Bug, Type/Feature) + prefix_slash: "^(Kind|Type|Category)/(.+)$" + # Detect dash-separated naming (e.g., Priority - High, Status - Blocked) + prefix_dash: "^(Priority|Status|Reviewed) - (.+)$" + # Detect colon-separated naming (e.g., type: bug, priority: high) + colon: "^(type|priority|status): (.+)$" # Security scanning rules security: