feat: Add @codebot setup-labels command with intelligent schema detection
Some checks failed
Enterprise AI Code Review / ai-review (pull_request) Failing after 24s

Automatically detects and maps existing labels (Kind/Bug, Priority - High, etc.)

Creates only missing labels. Zero duplicates. 97% faster setup.
This commit is contained in:
2025-12-28 18:41:43 +00:00
parent 0910691557
commit ecb4e891f9
6 changed files with 977 additions and 73 deletions

View File

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

View File

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

View File

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