diff --git a/src/event_processors/pull_request.py b/src/event_processors/pull_request.py index fcb2f9d..ee6538a 100644 --- a/src/event_processors/pull_request.py +++ b/src/event_processors/pull_request.py @@ -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__) @@ -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) @@ -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" @@ -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 { diff --git a/src/rules/github_provider.py b/src/rules/github_provider.py index b2a7127..cbdbfa2 100644 --- a/src/rules/github_provider.py +++ b/src/rules/github_provider.py @@ -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. @@ -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: @@ -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 diff --git a/src/rules/utils.py b/src/rules/utils.py index b55ee1c..d7e204d 100644 --- a/src/rules/utils.py +++ b/src/rules/utils.py @@ -1,4 +1,5 @@ import logging +from typing import Any import yaml @@ -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})" + ), + }