From c801605703b1a0439193f23a34a21543531871dc Mon Sep 17 00:00:00 2001 From: mi6-kiranprajapati Date: Thu, 28 Aug 2025 11:27:17 +0530 Subject: [PATCH 1/5] Create rules info.txt --- .watchflow/rules info.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .watchflow/rules info.txt diff --git a/.watchflow/rules info.txt b/.watchflow/rules info.txt new file mode 100644 index 0000000..a0a99fe --- /dev/null +++ b/.watchflow/rules info.txt @@ -0,0 +1 @@ +op in that chat From 7535d3909c2ce5f400f750398bf6a6c65a55a23a Mon Sep 17 00:00:00 2001 From: mi6-kiranprajapati Date: Thu, 28 Aug 2025 11:41:22 +0530 Subject: [PATCH 2/5] forced fully push --- .watchflow/rules.yaml | 37 ++++---------------- .watchflow/rules_info.txt | 30 +++++++++++++++++ .watchflow/testing.ipynb | 71 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 .watchflow/rules_info.txt create mode 100644 .watchflow/testing.ipynb diff --git a/.watchflow/rules.yaml b/.watchflow/rules.yaml index 5cbc692..716c07e 100644 --- a/.watchflow/rules.yaml +++ b/.watchflow/rules.yaml @@ -1,35 +1,12 @@ rules: - # Essential Open Source Rules - - description: "Pull requests must have descriptive titles following conventional commit format" + - id: pr-approval-required + name: PR Approval Required + description: All pull requests must have at least 2 approvals enabled: true - severity: "medium" - event_types: ["pull_request"] + severity: high + event_types: [pull_request] parameters: - title_pattern: "^feat|^fix|^docs|^style|^refactor|^test|^chore|^perf|^ci|^build|^revert" + min_approvals: 2 + message: "Pull requests require at least 2 approvals" - - description: "New contributors require approval from at least one past contributor" - enabled: true - severity: "medium" - event_types: ["pull_request"] - parameters: - min_past_contributors: 1 - - - description: "Code changes must include corresponding tests" - enabled: true - severity: "medium" - event_types: ["pull_request"] - parameters: - pattern: "tests/.*\\.py$|test_.*\\.py$" - condition_type: "files_match_pattern" - - description: "Changes to critical files require review from code owners" - enabled: true - severity: "high" - event_types: ["pull_request"] - - - description: "No direct pushes to main branch - all changes must go through PRs" - enabled: true - severity: "critical" - event_types: ["push"] - parameters: - allow_force_push: false diff --git a/.watchflow/rules_info.txt b/.watchflow/rules_info.txt new file mode 100644 index 0000000..cab9ec4 --- /dev/null +++ b/.watchflow/rules_info.txt @@ -0,0 +1,30 @@ +op in that chat + + - id: pr-creation-title-rule + name: PR Title date Required + description: Selected PR Title must have date included in that + enabled: true + serverity: high + event_types: [pull_request] + actions: + - type: comment + message: "This is a title without date mentioned. PR TItle must with date." + + - id: push-comment-rule + name: Push Comment Rule + description: in commit message date is required. + enabled: true + serverity: high + event_types: [push] + actions: + - type: comment + message: "their must be date in the commit message" + + - id: commit-message-date + name: Commit message must include date + description: "Every commit message in a push must include a date in format YYYY-MM-DD." + enabled: true + severity: high + event_types: [push] + parameters: + date_regex: "\\b\\d{4}-\\d{2}-\\d{2}\\b" \ No newline at end of file diff --git a/.watchflow/testing.ipynb b/.watchflow/testing.ipynb new file mode 100644 index 0000000..a8f5afc --- /dev/null +++ b/.watchflow/testing.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "7e207d09", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"supported\": true,\n", + " \"snippet\": \"rules:\n", + " - description: \"When a pull request is created, the title must include a date; if not, take action\"\n", + " enabled: true\n", + " severity: medium\n", + " event_types: [\"pull_request\"]\n", + " parameters:\n", + " title_pattern: '^.*\\d{4}-\\d{2}-\\d{2}.*$'\",\n", + " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", + "}\n" + ] + } + ], + "source": [ + "print('''{\n", + " \"supported\": true,\n", + " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: true\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", + " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", + "}''')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "190a26d4", + "metadata": {}, + "outputs": [], + "source": [ + "{\n", + " \"supported\": true,\n", + " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: true\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", + " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", + "}" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 49df4ec65f97fb6972855f6bd7cad8dbaf6601c7 Mon Sep 17 00:00:00 2001 From: mi6-kiranprajapati Date: Thu, 28 Aug 2025 11:46:51 +0530 Subject: [PATCH 3/5] now change the things --- .watchflow/rules info.txt | 1 - .watchflow/rules.yaml | 2 -- .watchflow/rules_info.txt | 2 +- .watchflow/testing.ipynb | 18 +++++++++--------- 4 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 .watchflow/rules info.txt diff --git a/.watchflow/rules info.txt b/.watchflow/rules info.txt deleted file mode 100644 index a0a99fe..0000000 --- a/.watchflow/rules info.txt +++ /dev/null @@ -1 +0,0 @@ -op in that chat diff --git a/.watchflow/rules.yaml b/.watchflow/rules.yaml index 716c07e..70f3430 100644 --- a/.watchflow/rules.yaml +++ b/.watchflow/rules.yaml @@ -8,5 +8,3 @@ rules: parameters: min_approvals: 2 message: "Pull requests require at least 2 approvals" - - diff --git a/.watchflow/rules_info.txt b/.watchflow/rules_info.txt index cab9ec4..ef9273b 100644 --- a/.watchflow/rules_info.txt +++ b/.watchflow/rules_info.txt @@ -27,4 +27,4 @@ op in that chat severity: high event_types: [push] parameters: - date_regex: "\\b\\d{4}-\\d{2}-\\d{2}\\b" \ No newline at end of file + date_regex: "\\b\\d{4}-\\d{2}-\\d{2}\\b" diff --git a/.watchflow/testing.ipynb b/.watchflow/testing.ipynb index a8f5afc..72ff7a2 100644 --- a/.watchflow/testing.ipynb +++ b/.watchflow/testing.ipynb @@ -11,10 +11,10 @@ "output_type": "stream", "text": [ "{\n", - " \"supported\": true,\n", + " \"supported\": "True",\n", " \"snippet\": \"rules:\n", " - description: \"When a pull request is created, the title must include a date; if not, take action\"\n", - " enabled: true\n", + " enabled: True\n", " severity: medium\n", " event_types: [\"pull_request\"]\n", " parameters:\n", @@ -25,11 +25,11 @@ } ], "source": [ - "print('''{\n", - " \"supported\": true,\n", - " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: true\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", + "print(\"\"\"{\n", + " \"supported\": "True",\n", + " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: True\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", - "}''')" + "}\"\"\")" ] }, { @@ -40,9 +40,9 @@ "outputs": [], "source": [ "{\n", - " \"supported\": true,\n", - " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: true\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", - " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", + " \"supported\": True,\n", + " \"snippet\": 'rules:\\n - description: \"When a pull request is created, the title must include a date; if not, take action\"\\n enabled: True\\n severity: medium\\n event_types: [\"pull_request\"]\\n parameters:\\n title_pattern: \\'^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$\\'',\n", + " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\",\n", "}" ] } From 4f0097772f245a0aef0de31922e5a82e735252d3 Mon Sep 17 00:00:00 2001 From: mi6-kiranprajapati Date: Mon, 1 Sep 2025 17:19:35 +0530 Subject: [PATCH 4/5] feat: impliment force push prevention and commit message validation --- .watchflow/rules.yaml | 32 +++++ .watchflow/rules_info.txt | 30 ----- .watchflow/testing.ipynb | 71 ---------- src/agents/engine_agent/agent.py | 12 +- src/event_processors/push.py | 19 ++- src/rules/validators.py | 217 ++++++++++++++++++++++++++++--- src/webhooks/handlers/push.py | 7 + src/webhooks/router.py | 9 ++ start-dev.sh | 36 +++++ test_feasibility_agent.py | 86 ++++++++++++ test_feasibility_interactive.py | 124 ++++++++++++++++++ 11 files changed, 519 insertions(+), 124 deletions(-) delete mode 100644 .watchflow/rules_info.txt delete mode 100644 .watchflow/testing.ipynb create mode 100755 start-dev.sh create mode 100644 test_feasibility_agent.py create mode 100644 test_feasibility_interactive.py diff --git a/.watchflow/rules.yaml b/.watchflow/rules.yaml index 70f3430..553772e 100644 --- a/.watchflow/rules.yaml +++ b/.watchflow/rules.yaml @@ -8,3 +8,35 @@ rules: parameters: min_approvals: 2 message: "Pull requests require at least 2 approvals" + + - id: push-author-team-required + name: Push Author Must Be In DevOps Team + description: Require the user who pushed to be a member of the devops team + enabled: true + severity: high + event_types: [push] + parameters: + team: devops + org: kp45 + + - id: force-push-prevention + name: Force Push Prevention + description: Prevent force pushes to protected branches + enabled: true + severity: critical + event_types: [push] + parameters: + allow_force_push: false + protected_branches: [main, development, force-demo] + detect_non_ff: false + message: "Force pushes are not allowed to protected branches" + + - id: commit-count-limit + name: Commit Count Limit + description: Limit number of commits per push + enabled: true + severity: medium + event_types: [push] + parameters: + max_commits: 10 + message: "Too many commits in a single push" diff --git a/.watchflow/rules_info.txt b/.watchflow/rules_info.txt deleted file mode 100644 index ef9273b..0000000 --- a/.watchflow/rules_info.txt +++ /dev/null @@ -1,30 +0,0 @@ -op in that chat - - - id: pr-creation-title-rule - name: PR Title date Required - description: Selected PR Title must have date included in that - enabled: true - serverity: high - event_types: [pull_request] - actions: - - type: comment - message: "This is a title without date mentioned. PR TItle must with date." - - - id: push-comment-rule - name: Push Comment Rule - description: in commit message date is required. - enabled: true - serverity: high - event_types: [push] - actions: - - type: comment - message: "their must be date in the commit message" - - - id: commit-message-date - name: Commit message must include date - description: "Every commit message in a push must include a date in format YYYY-MM-DD." - enabled: true - severity: high - event_types: [push] - parameters: - date_regex: "\\b\\d{4}-\\d{2}-\\d{2}\\b" diff --git a/.watchflow/testing.ipynb b/.watchflow/testing.ipynb deleted file mode 100644 index 72ff7a2..0000000 --- a/.watchflow/testing.ipynb +++ /dev/null @@ -1,71 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "id": "7e207d09", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{\n", - " \"supported\": "True",\n", - " \"snippet\": \"rules:\n", - " - description: \"When a pull request is created, the title must include a date; if not, take action\"\n", - " enabled: True\n", - " severity: medium\n", - " event_types: [\"pull_request\"]\n", - " parameters:\n", - " title_pattern: '^.*\\d{4}-\\d{2}-\\d{2}.*$'\",\n", - " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", - "}\n" - ] - } - ], - "source": [ - "print(\"\"\"{\n", - " \"supported\": "True",\n", - " \"snippet\": \"rules:\\n - description: \\\"When a pull request is created, the title must include a date; if not, take action\\\"\\n enabled: True\\n severity: medium\\n event_types: [\\\"pull_request\\\"]\\n parameters:\\n title_pattern: '^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$'\",\n", - " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\"\n", - "}\"\"\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "190a26d4", - "metadata": {}, - "outputs": [], - "source": [ - "{\n", - " \"supported\": True,\n", - " \"snippet\": 'rules:\\n - description: \"When a pull request is created, the title must include a date; if not, take action\"\\n enabled: True\\n severity: medium\\n event_types: [\"pull_request\"]\\n parameters:\\n title_pattern: \\'^.*\\\\d{4}-\\\\d{2}-\\\\d{2}.*$\\'',\n", - " \"feedback\": \"This rule requires PR titles to include a date when a pull request is created. Implementing this as a title_pattern rule is feasible by defining a regex that matches date formats (e.g., YYYY-MM-DD). Enforcement can be set to block merges or require corrections before approval. Consider specifying acceptable date formats clearly to avoid false negatives. Severity should be medium to high if the date is critical for tracking. Watchflow supports triggering actions on PR creation, making this practical.\",\n", - "}" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/src/agents/engine_agent/agent.py b/src/agents/engine_agent/agent.py index f64c307..86e6237 100644 --- a/src/agents/engine_agent/agent.py +++ b/src/agents/engine_agent/agent.py @@ -110,10 +110,14 @@ async def execute(self, event_type: str, event_data: dict[str, Any], rules: list execution_time = time.time() - start_time logger.info(f"๐Ÿ”ง Rule Engine evaluation completed in {execution_time:.2f}s") - # Extract violations from result - violations = result.violations if hasattr(result, "violations") else [] - - logger.info(f"๐Ÿ”ง Rule Engine extracted {len(violations)} violations") + # Extract violations from result (EngineState) + violations = [] + if hasattr(result, "violations"): + violations = result.violations + elif isinstance(result, dict) and "violations" in result: + violations = result["violations"] + + logger.info(f"๐Ÿ”ง Rule Engine extracted {len(violations)} violations from state") # Convert violations to RuleViolation objects rule_violations = [] diff --git a/src/event_processors/push.py b/src/event_processors/push.py index 92d67f3..60d31ff 100644 --- a/src/event_processors/push.py +++ b/src/event_processors/push.py @@ -43,12 +43,17 @@ async def process(self, task: Task) -> ProcessingResult: "head_commit": payload.get("head_commit", {}), "before": payload.get("before"), "after": payload.get("after"), + "forced": payload.get("forced", False), }, + # Provide sender for validators that expect GitHub's standard field + "sender": payload.get("sender", {}) or {"login": payload.get("pusher", {}).get("name")}, "triggering_user": {"login": payload.get("pusher", {}).get("name")}, "repository": payload.get("repository", {}), "organization": payload.get("organization", {}), "event_id": payload.get("event_id"), "timestamp": payload.get("timestamp"), + # Provide installation for validators needing API calls + "installation": {"id": task.installation_id}, } # Get rules for the repository @@ -68,7 +73,14 @@ async def process(self, task: Task) -> ProcessingResult: # Run agentic analysis using the instance result = await self.engine_agent.execute(event_type="push", event_data=event_data, rules=formatted_rules) - violations = result.data.get("violations", []) + # Extract violations from evaluation_result + violations: list[dict[str, Any]] = [] + try: + eval_result = result.data.get("evaluation_result") if result and result.data else None + if eval_result and hasattr(eval_result, "violations"): + violations = [v.__dict__ if hasattr(v, "__dict__") else v for v in eval_result.violations] + except Exception as e: + logger.error(f"Error extracting violations from engine result: {e}") processing_time = int((time.time() - start_time) * 1000) @@ -88,7 +100,8 @@ async def process(self, task: Task) -> ProcessingResult: if violations: logger.warning("๐Ÿšจ VIOLATION SUMMARY:") for i, violation in enumerate(violations, 1): - logger.warning(f" {i}. {violation.get('rule', 'Unknown')} ({violation.get('severity', 'medium')})") + rule_name = violation.get("rule", violation.get("rule_description", "Unknown")) + logger.warning(f" {i}. {rule_name} ({violation.get('severity', 'medium')})") logger.warning(f" {violation.get('message', '')}") else: logger.info("โœ… All rules passed - no violations detected!") @@ -124,6 +137,8 @@ def _convert_rules_to_new_format(self, rules: list[Any]) -> list[dict[str, Any]] async def prepare_webhook_data(self, task: Task) -> dict[str, Any]: """Prepare data from webhook payload.""" + + logger.info(f"forced: {task.payload['forced']}") return { "event_type": "push", "repo_full_name": task.repo_full_name, diff --git a/src/rules/validators.py b/src/rules/validators.py index b04ac19..ba34534 100644 --- a/src/rules/validators.py +++ b/src/rules/validators.py @@ -52,27 +52,73 @@ class AuthorTeamCondition(Condition): examples = [{"team": "devops"}, {"team": "codeowners"}] async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: + """Validate membership using GitHub API via installation token. + + Parameters expected: + - team: team slug (required), e.g., "devops" + - org: organization login (optional). If omitted, inferred from repo owner + """ team_name = parameters.get("team") if not team_name: logger.warning("AuthorTeamCondition: No team specified in parameters") return False - # Get author from event author_login = event.get("sender", {}).get("login", "") if not author_login: logger.warning("AuthorTeamCondition: No sender login found in event") return False - # Placeholder logic - replace with actual GitHub API call - logger.debug(f"Checking if {author_login} is in team {team_name}") + # Get repo/org context and installation id + repository = event.get("repository", {}) + repo_full_name = repository.get("full_name", "") + installation_id = event.get("installation", {}).get("id") + if not installation_id and event.get("event_type") == "push": + # PushProcessor passes installation_id only in Task; engine event may not have it. + # Best-effort: try reading from repository owner but we need installation_id for token. + logger.warning("AuthorTeamCondition: Missing installation_id; cannot query GitHub API. Allowing.") + return True - # For testing purposes, let's assume certain users are in certain teams - team_memberships = { - "devops": ["devops-user", "admin-user"], - "codeowners": ["senior-dev", "tech-lead"], - } + # Infer org + org_login = parameters.get("org") or (repo_full_name.split("/")[0] if repo_full_name else None) + if not org_login: + logger.warning("AuthorTeamCondition: Unable to infer org from repository context") + return True - return author_login in team_memberships.get(team_name, []) + try: + from src.integrations.github_api import github_client + + # GitHub API: GET /orgs/{org}/teams/{team_slug}/memberships/{username} + token = await github_client.get_installation_access_token(int(installation_id)) + if not token: + logger.warning("AuthorTeamCondition: Could not obtain installation token") + return True + + import httpx + + url = f"https://api.github.com/orgs/{org_login}/teams/{team_name}/memberships/{author_login}" + headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + state = resp.json().get("state") + is_active = state == "active" + logger.debug( + f"AuthorTeamCondition: membership lookup user={author_login} team={team_name} state={state}" + ) + return is_active + elif resp.status_code == 404: + logger.debug(f"AuthorTeamCondition: user={author_login} is not a member of team={team_name} (404)") + return False + else: + logger.warning( + f"AuthorTeamCondition: Team membership lookup failed {resp.status_code}: {resp.text}" + ) + # On API error, do not block + return True + except Exception as e: + logger.error(f"AuthorTeamCondition: Error checking team membership: {e}") + # Fail-open to avoid blocking pushes due to infra issues + return True class FilePatternCondition(Condition): @@ -444,21 +490,157 @@ async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> b return True # No violation if pattern is invalid +class CommitCountLimitCondition(Condition): + """Validates that a push does not exceed a maximum number of commits.""" + + name = "commit_count_limit" + description = "Validates that a push does not exceed a maximum number of commits" + parameter_patterns = ["max_commits"] + event_types = ["push"] + examples = [{"max_commits": 5}, {"max_commits": 10}] + + async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: + min_length = int(parameters.get("max_commits", 10)) # Using max_commits as min_length for now + + push_data = event.get("push", {}) + commits = push_data.get("commits", []) + + if not commits: + logger.debug("CommitCountLimitCondition: No commits found") + return True + + # Check each commit message length + for i, commit in enumerate(commits): + message = commit.get("message", "") + message_length = len(message.strip()) + + print("#########################################################", message_length, min_length) + logger.debug( + f"CommitCountLimitCondition: commit {i + 1} message length={message_length}, min_length={min_length}" + ) + + if message_length < min_length: + logger.debug( + f"CommitCountLimitCondition: VIOLATION - commit {i + 1} message too short: '{message[:50]}...'" + ) + return False + + logger.debug(f"CommitCountLimitCondition: PASS - all {len(commits)} commits meet minimum length") + return True + + class AllowForcePushCondition(Condition): - """Validates if force pushes are allowed.""" + """Validates if force pushes are allowed on specific branches.""" name = "allow_force_push" - description = "Validates if force pushes are allowed" - parameter_patterns = ["allow_force_push"] + description = "Validates if force pushes are allowed on specific branches" + parameter_patterns = ["allow_force_push", "protected_branches"] event_types = ["push"] - examples = [{"allow_force_push": False}, {"allow_force_push": True}] + examples = [ + {"allow_force_push": False, "protected_branches": ["main", "development", "force-demo"]}, + {"allow_force_push": False}, + {"allow_force_push": False, "protected_branches": ["main", "development", "master", "force-demo"]}, + ] async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: - # allow_force_push = parameters.get("allow_force_push", False) + print("#########################################################", parameters) + """ + Validate force push restrictions. - # This would typically check if the push was a force push - # For now, return True (no violation) as placeholder - return True + Parameters: + - allow_force_push: bool (default False) - whether force pushes are allowed + - protected_branches: list[str] (optional) - branches where force push is restricted + """ + allow_force_push = parameters.get("allow_force_push", False) + protected_branches = parameters.get("protected_branches", []) + detect_non_ff = parameters.get("detect_non_ff", True) + + # Get push data + push_data = event.get("push", {}) + if not push_data: + logger.debug("AllowForcePushCondition: No push data found in event") + return True # No violation if we can't check + + # Check if this is a force push (webhook flag) + is_force_push = bool(push_data.get("forced", False)) + if not is_force_push: + # If configured to NOT detect non-FF, allow immediately + if not detect_non_ff: + return True + + # Fallback: detect non-fast-forward by comparing before..after with GitHub compare API + before = push_data.get("before") + after = push_data.get("after") + created = bool(push_data.get("created", False)) + deleted = bool(push_data.get("deleted", False)) + + # Only attempt compare if we have both SHAs and it's not a create/delete + if before and after and not created and not deleted: + repository = event.get("repository", {}) + repo_full_name = repository.get("full_name", "") + installation_id = event.get("installation", {}).get("id") + try: + from src.integrations.github_api import github_client + + token = ( + await github_client.get_installation_access_token(int(installation_id)) + if installation_id + else None + ) + if token and repo_full_name: + import httpx + + url = f"https://api.github.com/repos/{repo_full_name}/compare/{before}...{after}" + headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} + async with httpx.AsyncClient() as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + status = resp.json().get("status") # ahead | behind | identical | diverged + # Fast-forward cases: ahead/identical โ†’ not force; others imply non-FF + if status not in ("ahead", "identical"): + is_force_push = True + logger.debug( + f"AllowForcePushCondition: Non-FF detected via compare API (status={status})" + ) + else: + logger.debug( + f"AllowForcePushCondition: Compare API failed {resp.status_code}: {resp.text[:200]}" + ) + except Exception as e: + logger.debug(f"AllowForcePushCondition: Compare API error: {e}") + + if not is_force_push: + return True # Not a force push, so no violation + + # If force pushes are globally allowed, allow it + if allow_force_push: + logger.debug("AllowForcePushCondition: Force pushes are globally allowed") + return True + + # Get the target branch + ref = push_data.get("ref") + if not ref: + logger.debug("AllowForcePushCondition: No ref found in push data") + return True # No violation if we can't determine branch + + # Extract branch name from ref (e.g., "refs/heads/main" -> "main") + branch_name = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref + + # If no protected branches specified, block all force pushes + if not protected_branches: + logger.debug( + f"AllowForcePushCondition: Force push to {branch_name} blocked (no protected branches specified)" + ) + return False + + # Check if this branch is protected + is_protected_branch = branch_name in protected_branches + if is_protected_branch: + logger.debug(f"AllowForcePushCondition: Force push to protected branch {branch_name} blocked") + return False + else: + logger.debug(f"AllowForcePushCondition: Force push to unprotected branch {branch_name} allowed") + return True class ProtectedBranchesCondition(Condition): @@ -762,6 +944,7 @@ def _is_new_contributor(self, username: str) -> bool: "required_checks": RequiredChecksCondition(), "code_owners": CodeOwnersCondition(), "past_contributor_approval": PastContributorApprovalCondition(), + "commit_count_limit": CommitCountLimitCondition(), } diff --git a/src/webhooks/handlers/push.py b/src/webhooks/handlers/push.py index 02c3914..362ad21 100644 --- a/src/webhooks/handlers/push.py +++ b/src/webhooks/handlers/push.py @@ -15,6 +15,13 @@ async def can_handle(self, event: WebhookEvent) -> bool: async def handle(self, event: WebhookEvent): """Handle push events by enqueuing them for background processing.""" + try: + forced_flag = event.payload.get("forced", None) + ref_value = event.payload.get("ref", "") + logger.info(f"Push handler: forced={forced_flag}, ref={ref_value}") + except Exception: + pass + logger.info(f"๐Ÿ”„ Enqueuing push event for {event.repo_full_name}") task_id = await task_queue.enqueue( diff --git a/src/webhooks/router.py b/src/webhooks/router.py index 0a2bf07..af6ea3c 100644 --- a/src/webhooks/router.py +++ b/src/webhooks/router.py @@ -59,6 +59,15 @@ async def github_webhook_endpoint( payload = await request.json() event_name = request.headers.get("X-GitHub-Event") + # Log raw forced flag early for push events + try: + if event_name and event_name.split(".")[0] == "push": + forced_flag = payload.get("forced", None) + ref_value = payload.get("ref", "") + logger.info(f"Webhook push received: forced={forced_flag}, ref={ref_value}") + except Exception: + pass + try: event = _create_event_from_request(event_name, payload) result = await dispatcher_instance.dispatch(event) diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 0000000..1292eea --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Watchflow Local Development Startup Script + +# Set the PATH to include uv +export PATH="$HOME/.local/bin:$PATH" + +# Activate virtual environment +source .venv/bin/activate + +# Check if .env file exists +if [ ! -f .env ]; then + echo "Error: .env file not found!" + echo "Please copy .env.example to .env and configure your environment variables." + exit 1 +fi + +# Check if required environment variables are set +if [ -z "$OPENAI_API_KEY" ] && [ -z "$(grep '^OPENAI_API_KEY=' .env | cut -d'=' -f2)" ]; then + echo "Warning: OPENAI_API_KEY not set. AI features will not work." +fi + +if [ -z "$APP_NAME_GITHUB" ] && [ -z "$(grep '^APP_NAME_GITHUB=' .env | cut -d'=' -f2)" ]; then + echo "Warning: GitHub App configuration not complete. GitHub integration will not work." +fi + +echo "Starting Watchflow API server..." +echo "API will be available at: http://localhost:8000" +echo "API documentation at: http://localhost:8000/docs" +echo "Health check at: http://localhost:8000/health" +echo "" +echo "Press Ctrl+C to stop the server" +echo "" + +# Start the API server +uvicorn src.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/test_feasibility_agent.py b/test_feasibility_agent.py new file mode 100644 index 0000000..32c66ec --- /dev/null +++ b/test_feasibility_agent.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Test script for RuleFeasibilityAgent +Run this to test the agent's feasibility analysis and YAML generation capabilities. +""" + +import asyncio +import os +import sys + +# Add src to path +sys.path.append("src") + +from dotenv import load_dotenv + +from agents.feasibility_agent.agent import RuleFeasibilityAgent + +# Load environment variables +load_dotenv() + + +async def test_feasibility_agent(): + """Test the RuleFeasibilityAgent with various rule descriptions.""" + + # Initialize the agent + agent = RuleFeasibilityAgent() + + # Test cases + test_rules = [ + "All pull requests must have at least 3 approvals from senior developers", + "No commits should be pushed directly to main branch", + "All commit messages must follow Conventional Commits format", + "Files larger than 10MB should not be committed", + "Pull request titles must start with feat:, fix:, or docs:", + "Only allow deployments during business hours (9 AM - 6 PM)", + "Require code review from at least one team lead for security-related changes", + ] + + print("๐Ÿงช Testing RuleFeasibilityAgent\n") + print("=" * 60) + + for i, rule_description in enumerate(test_rules, 1): + print(f"\n๐Ÿ“‹ Test Case {i}: {rule_description}") + print("-" * 50) + + try: + # Execute the agent + result = await agent.execute(rule_description) + + print(f"โœ… Status: {result.status}") + print(f"๐Ÿ“Š Feasibility: {result.data.get('is_feasible', 'Unknown')}") + + if result.data.get("yaml_config"): + print("๐Ÿ“ Generated YAML:") + print(result.data["yaml_config"]) + else: + print("โŒ No YAML generated") + + if result.data.get("reasoning"): + print(f"๐Ÿ’ญ Reasoning: {result.data['reasoning']}") + + except Exception as e: + print(f"โŒ Error: {str(e)}") + print(f"๐Ÿ” Error type: {type(e).__name__}") + + print() + + +async def main(): + """Main function to run the tests.""" + print("๐Ÿš€ Starting RuleFeasibilityAgent tests...") + print(f"๐Ÿ”‘ OpenAI API Key: {'โœ… Set' if os.getenv('OPENAI_API_KEY') else 'โŒ Missing'}") + print(f"๐Ÿค– Model: {os.getenv('OPENAI_MODEL', 'gpt-4o-mini')}") + + try: + await test_feasibility_agent() + print("\n๐ŸŽ‰ All tests completed!") + except Exception as e: + print(f"\n๐Ÿ’ฅ Test suite failed: {str(e)}") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test_feasibility_interactive.py b/test_feasibility_interactive.py new file mode 100644 index 0000000..1c771c5 --- /dev/null +++ b/test_feasibility_interactive.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Interactive test script for RuleFeasibilityAgent +Input your own rule descriptions and see how the agent evaluates them. +""" + +import asyncio +import os +import sys + +# Add src to path +sys.path.append("src") + +from dotenv import load_dotenv + +from agents.feasibility_agent.agent import RuleFeasibilityAgent + +# Load environment variables +load_dotenv() + + +async def test_single_rule(rule_description: str): + """Test a single rule description.""" + print(f"\n๐Ÿงช Testing: {rule_description}") + print("=" * 60) + + try: + # Initialize the agent + agent = RuleFeasibilityAgent() + + # Execute the agent + result = await agent.execute(rule_description) + + print(f"โœ… Status: {result.status}") + print(f"๐Ÿ“Š Feasibility: {result.data.get('is_feasible', 'Unknown')}") + + if result.data.get("reasoning"): + print("\n๐Ÿ’ญ Feasibility Analysis:") + print(result.data["reasoning"]) + + if result.data.get("yaml_config"): + print("\n๐Ÿ“ Generated YAML Configuration:") + print("```yaml") + print(result.data["yaml_config"]) + print("```") + else: + print("\nโŒ No YAML configuration generated") + + if result.data.get("confidence_score"): + print(f"\n๐ŸŽฏ Confidence Score: {result.data['confidence_score']}") + + except Exception as e: + print(f"โŒ Error: {str(e)}") + print(f"๐Ÿ” Error type: {type(e).__name__}") + import traceback + + traceback.print_exc() + + +async def interactive_mode(): + """Run in interactive mode for custom rule testing.""" + print("๐ŸŽฏ Interactive RuleFeasibilityAgent Tester") + print("=" * 50) + print("Enter rule descriptions to test feasibility analysis") + print("Type 'quit' to exit, 'examples' for sample rules") + print() + + while True: + try: + rule_input = input("\n๐Ÿ“ Enter rule description: ").strip() + + if rule_input.lower() == "quit": + print("๐Ÿ‘‹ Goodbye!") + break + + elif rule_input.lower() == "examples": + print("\n๐Ÿ“š Example rules to test:") + examples = [ + "All PRs must have at least 2 approvals", + "No direct pushes to main branch", + "Commit messages must follow Conventional Commits", + "Files larger than 5MB should not be committed", + "Only allow deployments during business hours", + "Require security review for sensitive files", + ] + for i, example in enumerate(examples, 1): + print(f" {i}. {example}") + continue + + elif not rule_input: + print("โš ๏ธ Please enter a rule description") + continue + + await test_single_rule(rule_input) + + except KeyboardInterrupt: + print("\n\n๐Ÿ‘‹ Goodbye!") + break + except EOFError: + print("\n\n๐Ÿ‘‹ Goodbye!") + break + + +async def main(): + """Main function.""" + print("๐Ÿš€ RuleFeasibilityAgent Interactive Tester") + print(f"๐Ÿ”‘ OpenAI API Key: {'โœ… Set' if os.getenv('OPENAI_API_KEY') else 'โŒ Missing'}") + print(f"๐Ÿค– Model: {os.getenv('OPENAI_MODEL', 'gpt-4o-mini')}") + + if not os.getenv("OPENAI_API_KEY"): + print("\nโŒ Please set OPENAI_API_KEY in your .env file") + return + + try: + await interactive_mode() + except Exception as e: + print(f"\n๐Ÿ’ฅ Tester failed: {str(e)}") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) From cb4b536faedfd4d5cb258ceb4e313fb9c1b47055 Mon Sep 17 00:00:00 2001 From: mi6-kiranprajapati Date: Tue, 9 Sep 2025 17:29:37 +0530 Subject: [PATCH 5/5] updated all final things --- .watchflow/rules.yaml | 53 ++++--- src/agents/engine_agent/agent.py | 5 + src/event_processors/push.py | 9 +- src/rules/validators.py | 228 +++++++------------------------ src/webhooks/handlers/push.py | 7 - src/webhooks/router.py | 9 -- start-dev.sh | 36 ----- test_feasibility_agent.py | 86 ------------ test_feasibility_interactive.py | 124 ----------------- 9 files changed, 82 insertions(+), 475 deletions(-) delete mode 100755 start-dev.sh delete mode 100644 test_feasibility_agent.py delete mode 100644 test_feasibility_interactive.py diff --git a/.watchflow/rules.yaml b/.watchflow/rules.yaml index 553772e..5cbc692 100644 --- a/.watchflow/rules.yaml +++ b/.watchflow/rules.yaml @@ -1,42 +1,35 @@ rules: - - id: pr-approval-required - name: PR Approval Required - description: All pull requests must have at least 2 approvals + # Essential Open Source Rules + - description: "Pull requests must have descriptive titles following conventional commit format" enabled: true - severity: high - event_types: [pull_request] + severity: "medium" + event_types: ["pull_request"] parameters: - min_approvals: 2 - message: "Pull requests require at least 2 approvals" + title_pattern: "^feat|^fix|^docs|^style|^refactor|^test|^chore|^perf|^ci|^build|^revert" - - id: push-author-team-required - name: Push Author Must Be In DevOps Team - description: Require the user who pushed to be a member of the devops team + - description: "New contributors require approval from at least one past contributor" enabled: true - severity: high - event_types: [push] + severity: "medium" + event_types: ["pull_request"] parameters: - team: devops - org: kp45 + min_past_contributors: 1 - - id: force-push-prevention - name: Force Push Prevention - description: Prevent force pushes to protected branches + - description: "Code changes must include corresponding tests" enabled: true - severity: critical - event_types: [push] + severity: "medium" + event_types: ["pull_request"] parameters: - allow_force_push: false - protected_branches: [main, development, force-demo] - detect_non_ff: false - message: "Force pushes are not allowed to protected branches" + pattern: "tests/.*\\.py$|test_.*\\.py$" + condition_type: "files_match_pattern" + + - description: "Changes to critical files require review from code owners" + enabled: true + severity: "high" + event_types: ["pull_request"] - - id: commit-count-limit - name: Commit Count Limit - description: Limit number of commits per push + - description: "No direct pushes to main branch - all changes must go through PRs" enabled: true - severity: medium - event_types: [push] + severity: "critical" + event_types: ["push"] parameters: - max_commits: 10 - message: "Too many commits in a single push" + allow_force_push: false diff --git a/src/agents/engine_agent/agent.py b/src/agents/engine_agent/agent.py index 86e6237..5fffa97 100644 --- a/src/agents/engine_agent/agent.py +++ b/src/agents/engine_agent/agent.py @@ -110,6 +110,9 @@ async def execute(self, event_type: str, event_data: dict[str, Any], rules: list execution_time = time.time() - start_time logger.info(f"๐Ÿ”ง Rule Engine evaluation completed in {execution_time:.2f}s") + # Extract violations from result + # violations = result.violations if hasattr(result, "violations") else [] + # Extract violations from result (EngineState) violations = [] if hasattr(result, "violations"): @@ -119,6 +122,8 @@ async def execute(self, event_type: str, event_data: dict[str, Any], rules: list logger.info(f"๐Ÿ”ง Rule Engine extracted {len(violations)} violations from state") + logger.info(f"๐Ÿ”ง Rule Engine extracted {len(violations)} violations") + # Convert violations to RuleViolation objects rule_violations = [] for violation in violations: diff --git a/src/event_processors/push.py b/src/event_processors/push.py index 60d31ff..b197b38 100644 --- a/src/event_processors/push.py +++ b/src/event_processors/push.py @@ -45,15 +45,11 @@ async def process(self, task: Task) -> ProcessingResult: "after": payload.get("after"), "forced": payload.get("forced", False), }, - # Provide sender for validators that expect GitHub's standard field - "sender": payload.get("sender", {}) or {"login": payload.get("pusher", {}).get("name")}, "triggering_user": {"login": payload.get("pusher", {}).get("name")}, "repository": payload.get("repository", {}), "organization": payload.get("organization", {}), "event_id": payload.get("event_id"), "timestamp": payload.get("timestamp"), - # Provide installation for validators needing API calls - "installation": {"id": task.installation_id}, } # Get rules for the repository @@ -73,8 +69,8 @@ async def process(self, task: Task) -> ProcessingResult: # Run agentic analysis using the instance result = await self.engine_agent.execute(event_type="push", event_data=event_data, rules=formatted_rules) - # Extract violations from evaluation_result violations: list[dict[str, Any]] = [] + try: eval_result = result.data.get("evaluation_result") if result and result.data else None if eval_result and hasattr(eval_result, "violations"): @@ -100,6 +96,7 @@ async def process(self, task: Task) -> ProcessingResult: if violations: logger.warning("๐Ÿšจ VIOLATION SUMMARY:") for i, violation in enumerate(violations, 1): + # logger.warning(f" {i}. {violation.get('rule', 'Unknown')} ({violation.get('severity', 'medium')})") rule_name = violation.get("rule", violation.get("rule_description", "Unknown")) logger.warning(f" {i}. {rule_name} ({violation.get('severity', 'medium')})") logger.warning(f" {violation.get('message', '')}") @@ -137,8 +134,6 @@ def _convert_rules_to_new_format(self, rules: list[Any]) -> list[dict[str, Any]] async def prepare_webhook_data(self, task: Task) -> dict[str, Any]: """Prepare data from webhook payload.""" - - logger.info(f"forced: {task.payload['forced']}") return { "event_type": "push", "repo_full_name": task.repo_full_name, diff --git a/src/rules/validators.py b/src/rules/validators.py index ba34534..1fd83fc 100644 --- a/src/rules/validators.py +++ b/src/rules/validators.py @@ -52,73 +52,52 @@ class AuthorTeamCondition(Condition): examples = [{"team": "devops"}, {"team": "codeowners"}] async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: - """Validate membership using GitHub API via installation token. - - Parameters expected: - - team: team slug (required), e.g., "devops" - - org: organization login (optional). If omitted, inferred from repo owner - """ team_name = parameters.get("team") if not team_name: logger.warning("AuthorTeamCondition: No team specified in parameters") return False + # Get author from event author_login = event.get("sender", {}).get("login", "") if not author_login: logger.warning("AuthorTeamCondition: No sender login found in event") return False - # Get repo/org context and installation id - repository = event.get("repository", {}) - repo_full_name = repository.get("full_name", "") - installation_id = event.get("installation", {}).get("id") - if not installation_id and event.get("event_type") == "push": - # PushProcessor passes installation_id only in Task; engine event may not have it. - # Best-effort: try reading from repository owner but we need installation_id for token. - logger.warning("AuthorTeamCondition: Missing installation_id; cannot query GitHub API. Allowing.") - return True + # Placeholder logic - replace with actual GitHub API call + logger.debug(f"Checking if {author_login} is in team {team_name}") - # Infer org - org_login = parameters.get("org") or (repo_full_name.split("/")[0] if repo_full_name else None) - if not org_login: - logger.warning("AuthorTeamCondition: Unable to infer org from repository context") - return True + # For testing purposes, let's assume certain users are in certain teams + team_memberships = { + "devops": ["devops-user", "admin-user"], + "codeowners": ["senior-dev", "tech-lead"], + } - try: - from src.integrations.github_api import github_client - - # GitHub API: GET /orgs/{org}/teams/{team_slug}/memberships/{username} - token = await github_client.get_installation_access_token(int(installation_id)) - if not token: - logger.warning("AuthorTeamCondition: Could not obtain installation token") - return True - - import httpx - - url = f"https://api.github.com/orgs/{org_login}/teams/{team_name}/memberships/{author_login}" - headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers=headers) - if resp.status_code == 200: - state = resp.json().get("state") - is_active = state == "active" - logger.debug( - f"AuthorTeamCondition: membership lookup user={author_login} team={team_name} state={state}" - ) - return is_active - elif resp.status_code == 404: - logger.debug(f"AuthorTeamCondition: user={author_login} is not a member of team={team_name} (404)") - return False - else: - logger.warning( - f"AuthorTeamCondition: Team membership lookup failed {resp.status_code}: {resp.text}" - ) - # On API error, do not block - return True - except Exception as e: - logger.error(f"AuthorTeamCondition: Error checking team membership: {e}") - # Fail-open to avoid blocking pushes due to infra issues - return True + return author_login in team_memberships.get(team_name, []) + + +class CommitCountLimitCondition(Condition): + """Validates that a push does not exceed a maximum number of commits.""" + + name = "commit_count_limit" + description = "Validates that a push does not exceed a maximum number of commits" + parameter_patterns = ["max_commits"] + event_types = ["push"] + examples = [{"max_commits": 5}, {"max_commits": 2}] + + async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: + max_commits = int(parameters.get("max_commits", 2)) + push_data = event.get("push", {}) + commits = push_data.get("commits", []) + + commit_count = len(commits) + logger.debug(f"CommitCountLimitCondition: found {commit_count} commits, max allowed={max_commits}") + + if commit_count > max_commits: + logger.debug("CommitCountLimitCondition: VIOLATION - too many commits in push") + return False + + logger.debug("CommitCountLimitCondition: PASS - within commit limit") + return True class FilePatternCondition(Condition): @@ -490,157 +469,54 @@ async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> b return True # No violation if pattern is invalid -class CommitCountLimitCondition(Condition): - """Validates that a push does not exceed a maximum number of commits.""" - - name = "commit_count_limit" - description = "Validates that a push does not exceed a maximum number of commits" - parameter_patterns = ["max_commits"] - event_types = ["push"] - examples = [{"max_commits": 5}, {"max_commits": 10}] - - async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: - min_length = int(parameters.get("max_commits", 10)) # Using max_commits as min_length for now - - push_data = event.get("push", {}) - commits = push_data.get("commits", []) - - if not commits: - logger.debug("CommitCountLimitCondition: No commits found") - return True - - # Check each commit message length - for i, commit in enumerate(commits): - message = commit.get("message", "") - message_length = len(message.strip()) - - print("#########################################################", message_length, min_length) - logger.debug( - f"CommitCountLimitCondition: commit {i + 1} message length={message_length}, min_length={min_length}" - ) - - if message_length < min_length: - logger.debug( - f"CommitCountLimitCondition: VIOLATION - commit {i + 1} message too short: '{message[:50]}...'" - ) - return False - - logger.debug(f"CommitCountLimitCondition: PASS - all {len(commits)} commits meet minimum length") - return True - - class AllowForcePushCondition(Condition): - """Validates if force pushes are allowed on specific branches.""" + """Validates if force pushes are allowed.""" name = "allow_force_push" - description = "Validates if force pushes are allowed on specific branches" - parameter_patterns = ["allow_force_push", "protected_branches"] + description = "Validates if force pushes are allowed" + parameter_patterns = ["allow_force_push"] event_types = ["push"] - examples = [ - {"allow_force_push": False, "protected_branches": ["main", "development", "force-demo"]}, - {"allow_force_push": False}, - {"allow_force_push": False, "protected_branches": ["main", "development", "master", "force-demo"]}, - ] + examples = [{"allow_force_push": False}, {"allow_force_push": True}] async def validate(self, parameters: dict[str, Any], event: dict[str, Any]) -> bool: - print("#########################################################", parameters) - """ - Validate force push restrictions. - - Parameters: - - allow_force_push: bool (default False) - whether force pushes are allowed - - protected_branches: list[str] (optional) - branches where force push is restricted - """ - allow_force_push = parameters.get("allow_force_push", False) - protected_branches = parameters.get("protected_branches", []) - detect_non_ff = parameters.get("detect_non_ff", True) - - # Get push data push_data = event.get("push", {}) if not push_data: - logger.debug("AllowForcePushCondition: No push data found in event") return True # No violation if we can't check - # Check if this is a force push (webhook flag) - is_force_push = bool(push_data.get("forced", False)) - if not is_force_push: - # If configured to NOT detect non-FF, allow immediately - if not detect_non_ff: - return True - - # Fallback: detect non-fast-forward by comparing before..after with GitHub compare API - before = push_data.get("before") - after = push_data.get("after") - created = bool(push_data.get("created", False)) - deleted = bool(push_data.get("deleted", False)) - - # Only attempt compare if we have both SHAs and it's not a create/delete - if before and after and not created and not deleted: - repository = event.get("repository", {}) - repo_full_name = repository.get("full_name", "") - installation_id = event.get("installation", {}).get("id") - try: - from src.integrations.github_api import github_client - - token = ( - await github_client.get_installation_access_token(int(installation_id)) - if installation_id - else None - ) - if token and repo_full_name: - import httpx - - url = f"https://api.github.com/repos/{repo_full_name}/compare/{before}...{after}" - headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers=headers) - if resp.status_code == 200: - status = resp.json().get("status") # ahead | behind | identical | diverged - # Fast-forward cases: ahead/identical โ†’ not force; others imply non-FF - if status not in ("ahead", "identical"): - is_force_push = True - logger.debug( - f"AllowForcePushCondition: Non-FF detected via compare API (status={status})" - ) - else: - logger.debug( - f"AllowForcePushCondition: Compare API failed {resp.status_code}: {resp.text[:200]}" - ) - except Exception as e: - logger.debug(f"AllowForcePushCondition: Compare API error: {e}") + allow_force_push = parameters.get("allow_force_push", False) + if allow_force_push: + return True # No violation if force pushes are allowed + is_force_push = bool(push_data.get("forced", False)) if not is_force_push: - return True # Not a force push, so no violation + return True # No violation if not a force push - # If force pushes are globally allowed, allow it - if allow_force_push: - logger.debug("AllowForcePushCondition: Force pushes are globally allowed") - return True - - # Get the target branch ref = push_data.get("ref") if not ref: logger.debug("AllowForcePushCondition: No ref found in push data") return True # No violation if we can't determine branch + is_force_push = bool(push_data.get("forced", False)) + if not is_force_push: + return True + # Extract branch name from ref (e.g., "refs/heads/main" -> "main") branch_name = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref - # If no protected branches specified, block all force pushes + protected_branches = parameters.get("protected_branches", []) if not protected_branches: logger.debug( - f"AllowForcePushCondition: Force push to {branch_name} blocked (no protected branches specified)" + f"AllowForcePushCondition: Force push to {branch_name} allowed (no protected branches specified so we select all branches as protected)" ) - return False + return False # No protected branches means all branches are protected - # Check if this branch is protected is_protected_branch = branch_name in protected_branches if is_protected_branch: logger.debug(f"AllowForcePushCondition: Force push to protected branch {branch_name} blocked") - return False + return False # Violation else: logger.debug(f"AllowForcePushCondition: Force push to unprotected branch {branch_name} allowed") - return True + return True # return True (no violation) as placeholder class ProtectedBranchesCondition(Condition): diff --git a/src/webhooks/handlers/push.py b/src/webhooks/handlers/push.py index 362ad21..02c3914 100644 --- a/src/webhooks/handlers/push.py +++ b/src/webhooks/handlers/push.py @@ -15,13 +15,6 @@ async def can_handle(self, event: WebhookEvent) -> bool: async def handle(self, event: WebhookEvent): """Handle push events by enqueuing them for background processing.""" - try: - forced_flag = event.payload.get("forced", None) - ref_value = event.payload.get("ref", "") - logger.info(f"Push handler: forced={forced_flag}, ref={ref_value}") - except Exception: - pass - logger.info(f"๐Ÿ”„ Enqueuing push event for {event.repo_full_name}") task_id = await task_queue.enqueue( diff --git a/src/webhooks/router.py b/src/webhooks/router.py index af6ea3c..0a2bf07 100644 --- a/src/webhooks/router.py +++ b/src/webhooks/router.py @@ -59,15 +59,6 @@ async def github_webhook_endpoint( payload = await request.json() event_name = request.headers.get("X-GitHub-Event") - # Log raw forced flag early for push events - try: - if event_name and event_name.split(".")[0] == "push": - forced_flag = payload.get("forced", None) - ref_value = payload.get("ref", "") - logger.info(f"Webhook push received: forced={forced_flag}, ref={ref_value}") - except Exception: - pass - try: event = _create_event_from_request(event_name, payload) result = await dispatcher_instance.dispatch(event) diff --git a/start-dev.sh b/start-dev.sh deleted file mode 100755 index 1292eea..0000000 --- a/start-dev.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Watchflow Local Development Startup Script - -# Set the PATH to include uv -export PATH="$HOME/.local/bin:$PATH" - -# Activate virtual environment -source .venv/bin/activate - -# Check if .env file exists -if [ ! -f .env ]; then - echo "Error: .env file not found!" - echo "Please copy .env.example to .env and configure your environment variables." - exit 1 -fi - -# Check if required environment variables are set -if [ -z "$OPENAI_API_KEY" ] && [ -z "$(grep '^OPENAI_API_KEY=' .env | cut -d'=' -f2)" ]; then - echo "Warning: OPENAI_API_KEY not set. AI features will not work." -fi - -if [ -z "$APP_NAME_GITHUB" ] && [ -z "$(grep '^APP_NAME_GITHUB=' .env | cut -d'=' -f2)" ]; then - echo "Warning: GitHub App configuration not complete. GitHub integration will not work." -fi - -echo "Starting Watchflow API server..." -echo "API will be available at: http://localhost:8000" -echo "API documentation at: http://localhost:8000/docs" -echo "Health check at: http://localhost:8000/health" -echo "" -echo "Press Ctrl+C to stop the server" -echo "" - -# Start the API server -uvicorn src.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/test_feasibility_agent.py b/test_feasibility_agent.py deleted file mode 100644 index 32c66ec..0000000 --- a/test_feasibility_agent.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for RuleFeasibilityAgent -Run this to test the agent's feasibility analysis and YAML generation capabilities. -""" - -import asyncio -import os -import sys - -# Add src to path -sys.path.append("src") - -from dotenv import load_dotenv - -from agents.feasibility_agent.agent import RuleFeasibilityAgent - -# Load environment variables -load_dotenv() - - -async def test_feasibility_agent(): - """Test the RuleFeasibilityAgent with various rule descriptions.""" - - # Initialize the agent - agent = RuleFeasibilityAgent() - - # Test cases - test_rules = [ - "All pull requests must have at least 3 approvals from senior developers", - "No commits should be pushed directly to main branch", - "All commit messages must follow Conventional Commits format", - "Files larger than 10MB should not be committed", - "Pull request titles must start with feat:, fix:, or docs:", - "Only allow deployments during business hours (9 AM - 6 PM)", - "Require code review from at least one team lead for security-related changes", - ] - - print("๐Ÿงช Testing RuleFeasibilityAgent\n") - print("=" * 60) - - for i, rule_description in enumerate(test_rules, 1): - print(f"\n๐Ÿ“‹ Test Case {i}: {rule_description}") - print("-" * 50) - - try: - # Execute the agent - result = await agent.execute(rule_description) - - print(f"โœ… Status: {result.status}") - print(f"๐Ÿ“Š Feasibility: {result.data.get('is_feasible', 'Unknown')}") - - if result.data.get("yaml_config"): - print("๐Ÿ“ Generated YAML:") - print(result.data["yaml_config"]) - else: - print("โŒ No YAML generated") - - if result.data.get("reasoning"): - print(f"๐Ÿ’ญ Reasoning: {result.data['reasoning']}") - - except Exception as e: - print(f"โŒ Error: {str(e)}") - print(f"๐Ÿ” Error type: {type(e).__name__}") - - print() - - -async def main(): - """Main function to run the tests.""" - print("๐Ÿš€ Starting RuleFeasibilityAgent tests...") - print(f"๐Ÿ”‘ OpenAI API Key: {'โœ… Set' if os.getenv('OPENAI_API_KEY') else 'โŒ Missing'}") - print(f"๐Ÿค– Model: {os.getenv('OPENAI_MODEL', 'gpt-4o-mini')}") - - try: - await test_feasibility_agent() - print("\n๐ŸŽ‰ All tests completed!") - except Exception as e: - print(f"\n๐Ÿ’ฅ Test suite failed: {str(e)}") - import traceback - - traceback.print_exc() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_feasibility_interactive.py b/test_feasibility_interactive.py deleted file mode 100644 index 1c771c5..0000000 --- a/test_feasibility_interactive.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Interactive test script for RuleFeasibilityAgent -Input your own rule descriptions and see how the agent evaluates them. -""" - -import asyncio -import os -import sys - -# Add src to path -sys.path.append("src") - -from dotenv import load_dotenv - -from agents.feasibility_agent.agent import RuleFeasibilityAgent - -# Load environment variables -load_dotenv() - - -async def test_single_rule(rule_description: str): - """Test a single rule description.""" - print(f"\n๐Ÿงช Testing: {rule_description}") - print("=" * 60) - - try: - # Initialize the agent - agent = RuleFeasibilityAgent() - - # Execute the agent - result = await agent.execute(rule_description) - - print(f"โœ… Status: {result.status}") - print(f"๐Ÿ“Š Feasibility: {result.data.get('is_feasible', 'Unknown')}") - - if result.data.get("reasoning"): - print("\n๐Ÿ’ญ Feasibility Analysis:") - print(result.data["reasoning"]) - - if result.data.get("yaml_config"): - print("\n๐Ÿ“ Generated YAML Configuration:") - print("```yaml") - print(result.data["yaml_config"]) - print("```") - else: - print("\nโŒ No YAML configuration generated") - - if result.data.get("confidence_score"): - print(f"\n๐ŸŽฏ Confidence Score: {result.data['confidence_score']}") - - except Exception as e: - print(f"โŒ Error: {str(e)}") - print(f"๐Ÿ” Error type: {type(e).__name__}") - import traceback - - traceback.print_exc() - - -async def interactive_mode(): - """Run in interactive mode for custom rule testing.""" - print("๐ŸŽฏ Interactive RuleFeasibilityAgent Tester") - print("=" * 50) - print("Enter rule descriptions to test feasibility analysis") - print("Type 'quit' to exit, 'examples' for sample rules") - print() - - while True: - try: - rule_input = input("\n๐Ÿ“ Enter rule description: ").strip() - - if rule_input.lower() == "quit": - print("๐Ÿ‘‹ Goodbye!") - break - - elif rule_input.lower() == "examples": - print("\n๐Ÿ“š Example rules to test:") - examples = [ - "All PRs must have at least 2 approvals", - "No direct pushes to main branch", - "Commit messages must follow Conventional Commits", - "Files larger than 5MB should not be committed", - "Only allow deployments during business hours", - "Require security review for sensitive files", - ] - for i, example in enumerate(examples, 1): - print(f" {i}. {example}") - continue - - elif not rule_input: - print("โš ๏ธ Please enter a rule description") - continue - - await test_single_rule(rule_input) - - except KeyboardInterrupt: - print("\n\n๐Ÿ‘‹ Goodbye!") - break - except EOFError: - print("\n\n๐Ÿ‘‹ Goodbye!") - break - - -async def main(): - """Main function.""" - print("๐Ÿš€ RuleFeasibilityAgent Interactive Tester") - print(f"๐Ÿ”‘ OpenAI API Key: {'โœ… Set' if os.getenv('OPENAI_API_KEY') else 'โŒ Missing'}") - print(f"๐Ÿค– Model: {os.getenv('OPENAI_MODEL', 'gpt-4o-mini')}") - - if not os.getenv("OPENAI_API_KEY"): - print("\nโŒ Please set OPENAI_API_KEY in your .env file") - return - - try: - await interactive_mode() - except Exception as e: - print(f"\n๐Ÿ’ฅ Tester failed: {str(e)}") - import traceback - - traceback.print_exc() - - -if __name__ == "__main__": - asyncio.run(main())