Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions src/event_processors/pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from src.agents.engine_agent.agent import RuleEngineAgent
from src.event_processors.base import BaseEventProcessor, ProcessingResult
from src.rules.github_provider import RulesFileNotFoundError
from src.tasks.task_queue import Task

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -45,8 +46,25 @@ async def process(self, task: Task) -> ProcessingResult:
api_calls += 1

# Fetch rules
rules = await self.rule_provider.get_rules(task.repo_full_name, task.installation_id)
api_calls += 1
try:
rules = await self.rule_provider.get_rules(task.repo_full_name, task.installation_id)
api_calls += 1
except RulesFileNotFoundError as e:
logger.warning(f"Rules file not found: {e}")
# Create a neutral check run for missing rules file with helpful guidance
await self._create_check_run(
task,
[],
conclusion="neutral",
error="Rules not configured. Please create `.watchflow/rules.yaml` in your repository.",
)
return ProcessingResult(
success=True, # Not a failure, just needs setup
violations=[],
api_calls_made=api_calls,
processing_time_ms=int((time.time() - start_time) * 1000),
error="Rules not configured",
)

# Convert rules to the new format expected by the agent
formatted_rules = self._convert_rules_to_new_format(rules)
Expand Down Expand Up @@ -242,7 +260,8 @@ async def _create_check_run(
# Determine check run status
if error:
status = "completed"
conclusion = "failure"
# Use provided conclusion or default to failure
conclusion = conclusion or "failure"
elif violations:
status = "completed"
conclusion = "failure"
Expand Down Expand Up @@ -274,11 +293,29 @@ async def _create_check_run(
def _format_check_run_output(self, violations: list[dict[str, Any]], error: str | None = None) -> dict[str, Any]:
"""Format violations for check run output."""
if error:
return {
"title": "Error processing rules",
"summary": f"❌ Error: {error}",
"text": f"An error occurred while processing rules:\n\n```\n{error}\n```\n\nPlease check the logs for more details.",
}
# Check if it's a missing rules file error
if "rules not configured" in error.lower() or "rules file not found" in error.lower():
return {
"title": "Rules not configured",
"summary": "⚙️ Watchflow rules setup required",
"text": (
"**Watchflow rules not configured**\n\n"
"No rules file found in your repository. Watchflow can help enforce governance rules for your team.\n\n"
"**How to set up rules:**\n"
"1. Create a file at `.watchflow/rules.yaml` in your repository root\n"
"2. Add your rules in the following format:\n"
" ```yaml\n rules:\n - id: pr-approval-required\n name: PR Approval Required\n description: All pull requests must have at least 2 approvals\n enabled: true\n severity: high\n event_types: [pull_request]\n parameters:\n min_approvals: 2\n ```\n\n"
"**Note:** Rules are currently read from the main branch only.\n\n"
"📖 [Read the documentation for more examples](https://github.com/warestack/watchflow/blob/main/docs/getting-started/configuration.md)\n\n"
"After adding the file, push your changes to re-run validation."
),
}
else:
return {
"title": "Error processing rules",
"summary": f"❌ Error: {error}",
"text": f"An error occurred while processing rules:\n\n```\n{error}\n```\n\nPlease check the logs for more details.",
}

if not violations:
return {
Expand Down
14 changes: 13 additions & 1 deletion src/rules/github_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
logger = logging.getLogger(__name__)


class RulesFileNotFoundError(Exception):
"""Raised when the rules file is not found in the repository."""

pass


class GitHubRuleLoader(RuleLoader):
"""
Loads rules from a GitHub repository's rules yaml file.
Expand All @@ -30,11 +36,13 @@ async def get_rules(self, repository: str, installation_id: int) -> list[Rule]:
content = await self.github_client.get_file_content(repository, rules_file_path, installation_id)
if not content:
logger.warning(f"No rules.yaml file found in {repository}")
return []
raise RulesFileNotFoundError(f"Rules file not found: {rules_file_path}")

rules_data = yaml.safe_load(content)
if not rules_data or "rules" not in rules_data:
logger.warning(f"No rules found in {repository}/{rules_file_path}")
return []

rules = []
for rule_data in rules_data["rules"]:
try:
Expand All @@ -44,8 +52,12 @@ async def get_rules(self, repository: str, installation_id: int) -> list[Rule]:
except Exception as e:
logger.error(f"Error parsing rule {rule_data.get('id', 'unknown')}: {e}")
continue

logger.info(f"Successfully loaded {len(rules)} rules from {repository}")
return rules
except RulesFileNotFoundError:
# Re-raise this specific exception
raise
except Exception as e:
logger.error(f"Error fetching rules for {repository}: {e}")
raise
Expand Down
124 changes: 75 additions & 49 deletions src/rules/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Any

import yaml

Expand All @@ -12,74 +13,99 @@

async def validate_rules_yaml_from_repo(repo_full_name: str, installation_id: int, pr_number: int):
validation_result = await _validate_rules_yaml(repo_full_name, installation_id)
# Only post a comment if the result is not a success (i.e., does not start with the green checkmark)
if not validation_result.strip().startswith("✅"):
# Only post a comment if the result is not a success
if not validation_result["success"]:
await github_client.create_pull_request_comment(
repo=repo_full_name,
pr_number=pr_number,
comment=validation_result,
comment=validation_result["message"],
installation_id=installation_id,
)
logger.info(f"Posted validation result to PR #{pr_number} in {repo_full_name}")


async def _validate_rules_yaml(repo: str, installation_id: int) -> str:
async def _validate_rules_yaml(repo: str, installation_id: int) -> dict[str, Any]:
try:
file_content = await github_client.get_file_content(repo, ".watchflow/rules.yaml", installation_id)
if file_content is None:
return (
"❌ **Watchflow rules file not found**\n\n"
"The file `.watchflow/rules.yaml` is missing from your repository.\n\n"
"**How to fix:**\n"
"1. Create a file at `.watchflow/rules.yaml` in your repository root.\n"
"2. Add your rules in the following format:\n"
" ```yaml\n rules:\n - id: example-rule\n description: Example rule description\n ...\n ```\n"
f"3. [Read the documentation for more details.]({DOCS_URL})\n\n"
"After adding the file, push your changes to re-run validation."
)
return {
"success": False,
"message": (
"⚙️ **Watchflow rules not configured**\n\n"
"No rules file found in your repository. Watchflow can help enforce governance rules for your team.\n\n"
"**How to set up rules:**\n"
"1. Create a file at `.watchflow/rules.yaml` in your repository root\n"
"2. Add your rules in the following format:\n"
" ```yaml\n rules:\n - id: pr-approval-required\n name: PR Approval Required\n description: All pull requests must have at least 2 approvals\n enabled: true\n severity: high\n event_types: [pull_request]\n parameters:\n min_approvals: 2\n ```\n\n"
"**Note:** Rules are currently read from the main branch only.\n\n"
"📖 [Read the documentation for more examples](https://github.com/warestack/watchflow/blob/main/docs/getting-started/configuration.md)\n\n"
"After adding the file, push your changes to re-run validation."
),
}
try:
rules_data = yaml.safe_load(file_content)
except Exception as e:
return (
"❌ **Failed to parse `.watchflow/rules.yaml`**\n\n"
f"Error details: `{e}`\n\n"
"**How to fix:**\n"
"- Ensure your YAML is valid. You can use an online YAML validator.\n"
"- Check for indentation, missing colons, or invalid syntax.\n\n"
f"[See configuration docs.]({DOCS_URL})"
)
return {
"success": False,
"message": (
"❌ **Failed to parse `.watchflow/rules.yaml`**\n\n"
f"Error details: `{e}`\n\n"
"**How to fix:**\n"
"- Ensure your YAML is valid. You can use an online YAML validator.\n"
"- Check for indentation, missing colons, or invalid syntax.\n\n"
f"[See configuration docs.]({DOCS_URL})"
),
}
if not isinstance(rules_data, dict) or "rules" not in rules_data:
return (
"❌ **Invalid `.watchflow/rules.yaml`: missing top-level `rules:` key**\n\n"
"Your file must start with a `rules:` key, like:\n"
"```yaml\nrules:\n - id: ...\n```\n"
f"[See configuration docs.]({DOCS_URL})"
)
return {
"success": False,
"message": (
"❌ **Invalid `.watchflow/rules.yaml`: missing top-level `rules:` key**\n\n"
"Your file must start with a `rules:` key, like:\n"
"```yaml\nrules:\n - id: ...\n```\n"
f"[See configuration docs.]({DOCS_URL})"
),
}
if not isinstance(rules_data["rules"], list):
return (
"❌ **Invalid `.watchflow/rules.yaml`: `rules` must be a list**\n\n"
"Example:\n"
"```yaml\nrules:\n - id: my-rule\n description: ...\n```\n"
f"[See configuration docs.]({DOCS_URL})"
)
return {
"success": False,
"message": (
"❌ **Invalid `.watchflow/rules.yaml`: `rules` must be a list**\n\n"
"Example:\n"
"```yaml\nrules:\n - id: my-rule\n description: ...\n```\n"
f"[See configuration docs.]({DOCS_URL})"
),
}
if not rules_data["rules"]:
return (
"✅ **`.watchflow/rules.yaml` is valid but contains no rules.**\n\n"
"You can add rules at any time. [See documentation for examples.]"
f"({DOCS_URL})"
)
return {
"success": True,
"message": (
"✅ **`.watchflow/rules.yaml` is valid but contains no rules.**\n\n"
"You can add rules at any time. [See documentation for examples.]"
f"({DOCS_URL})"
),
}
for i, rule_data in enumerate(rules_data["rules"]):
try:
Rule.model_validate(rule_data)
except Exception as e:
return (
f"❌ **Rule #{i + 1} (`{rule_data.get('id', 'N/A')}`) failed validation**\n\n"
f"Error: `{e}`\n\n"
"Please check your rule definition and fix the error above.\n\n"
f"[See rule schema docs.]({DOCS_URL})"
)
return f"✅ **`.watchflow/rules.yaml` is valid and contains {len(rules_data['rules'])} rules.**\n\nNo action needed."
return {
"success": False,
"message": (
f"❌ **Rule #{i + 1} (`{rule_data.get('id', 'N/A')}`) failed validation**\n\n"
f"Error: `{e}`\n\n"
"Please check your rule definition and fix the error above.\n\n"
f"[See rule schema docs.]({DOCS_URL})"
),
}
return {
"success": True,
"message": f"✅ **`.watchflow/rules.yaml` is valid and contains {len(rules_data['rules'])} rules.**\n\nNo action needed.",
}
except Exception as e:
return (
f"❌ **Error validating `.watchflow/rules.yaml`**\n\nError: `{e}`\n\n[See configuration docs.]({DOCS_URL})"
)
return {
"success": False,
"message": (
f"❌ **Error validating `.watchflow/rules.yaml`**\n\nError: `{e}`\n\n[See configuration docs.]({DOCS_URL})"
),
}