Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0e08dfc
feat: refactor CODEOWNERS integration to fetch dynamically from API
dkargatzis Feb 27, 2026
9656ffd
feat: implement DiffPatternCondition and SecurityPatternCondition
dkargatzis Feb 27, 2026
f2c19a4
style: fix pre-commit issues with line lengths and whitespace
dkargatzis Feb 27, 2026
84d4566
feat: implement UnresolvedCommentsCondition via GraphQL reviewThreads…
dkargatzis Feb 27, 2026
5a699ca
refactor: consolidate GraphQL clients and enforce strong typing with …
dkargatzis Feb 27, 2026
61d5721
feat: implement TestCoverageCondition to enforce test inclusion on so…
dkargatzis Feb 27, 2026
042db61
feat: implement CommentResponseTimeCondition for SLAs
dkargatzis Feb 27, 2026
cba1dfd
feat: add pull_request_review webhook handlers and fix mypy type errors
dkargatzis Feb 27, 2026
5c5da29
docs: add enterprise rules roadmap
dkargatzis Feb 27, 2026
7240244
feat: implement enterprise compliance conditions (Changelog and Signe…
dkargatzis Feb 27, 2026
985a325
feat: implement advanced access control rules for enterprise environm…
dkargatzis Feb 27, 2026
3c93e41
fix: resolve mypy errors and complete missing tests for enterprise rules
dkargatzis Feb 27, 2026
797e531
docs: enhance enterprise rules roadmap with github ecosystem and OSS …
dkargatzis Feb 28, 2026
981a2f2
fix: resolve import errors in webhook handlers and add test coverage
dkargatzis Feb 28, 2026
6d93e65
fix: resolve CI failures and address CodeRabbit review feedback
dkargatzis Mar 1, 2026
1991986
feat: wire enterprise conditions into rule evaluation pipeline
dkargatzis Mar 1, 2026
92140b4
docs: add CHANGELOG and update all docs with complete ruleset reference
dkargatzis Mar 1, 2026
b74bd5a
fix: paginate PR files and reviews API calls to fetch complete data
dkargatzis Mar 1, 2026
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
92 changes: 92 additions & 0 deletions docs/enterprise-rules-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Enterprise & Regulated Industry Guardrails

To level up Watchflow for large engineering teams and highly regulated industries (FinTech, HealthTech, Enterprise SaaS), we should expand our rule engine to support strict compliance, auditability, and advanced access control.

## 1. Compliance & Security Verification Rules

### `SignedCommitsCondition`
**Purpose:** Ensure all commits in a PR are signed (GPG/SSH/S/MIME).
**Why:** Required by SOC2, FedRAMP, and most enterprise security teams to prevent impersonation.
**Parameters:** `require_signed_commits: true`

### `SecretScanningCondition` (Enhanced)
**Purpose:** Integrate with GitHub Advanced Security or detect specific sensitive file extensions.
**Why:** Catching hardcoded secrets before they merge is a massive pain point. We built regex parsing, but we can add native hooks to check if GitHub's native secret scanner triggered alerts on the branch.
**Parameters:** `block_on_secret_alerts: true`

### `BannedDependenciesCondition`
**Purpose:** Parse `package.json`, `requirements.txt`, or `go.mod` diffs to block banned licenses (e.g., AGPL) or deprecated libraries.
**Why:** Open-source license compliance and CVE prevention.
**Parameters:** `banned_licenses: ["AGPL", "GPL"]`, `banned_packages: ["requests<2.0.0"]`

## 2. Advanced Access Control (Separation of Duties)

### `CrossTeamApprovalCondition`
**Purpose:** Require approvals from at least two different GitHub Teams.
**Why:** Regulated environments require "Separation of Duties" (e.g., a dev from `backend-team` and a dev from `qa-team` must both approve).
**Parameters:** `required_team_approvals: ["@org/backend", "@org/qa"]`

### `NoSelfApprovalCondition`
**Purpose:** Explicitly block PR authors from approving their own PRs (or using a secondary admin account to do so).
**Why:** Strict SOX/SOC2 requirement.
**Parameters:** `block_self_approval: true`

## 3. Operations & Reliability

### `MigrationSafetyCondition`
**Purpose:** If a PR modifies database schemas/migrations (e.g., `alembic/`, `prisma/migrations/`), enforce that it does *not* contain destructive operations like `DROP TABLE` or `DROP COLUMN`.
**Why:** Prevents junior devs from accidentally wiping production data.
**Parameters:** `safe_migrations_only: true`

### `FeatureFlagRequiredCondition`
**Purpose:** If a PR exceeds a certain size or modifies core routing, ensure a feature flag is added.
**Why:** Enables safe rollbacks and trunk-based development.
**Parameters:** `require_feature_flags_for_large_prs: true`

## 4. Documentation & Traceability

### `JiraTicketStatusCondition`
**Purpose:** Instead of just checking if a Jira ticket *exists* in the title, make an API call to Jira to ensure the ticket is in the "In Progress" or "In Review" state.
**Why:** Prevents devs from linking to closed, backlog, or fake tickets just to bypass the basic `RequireLinkedIssue` rule.
**Parameters:** `require_active_jira_ticket: true`

### `ChangelogRequiredCondition`
**Purpose:** If `src/` files change, require an addition to `CHANGELOG.md` or a `.changeset/` file.
**Why:** Maintains release notes for compliance audits automatically.
**Parameters:** `require_changelog_update: true`

## 5. Potential GitHub Ecosystem Integrations

To make Watchflow a true "single pane of glass" for governance, we can build custom condition handlers that hook directly into GitHub's native ecosystem.

### `CodeQLAnalysisCondition`
**Purpose:** Block merges if CodeQL (or other static analysis tools) has detected critical vulnerabilities in the PR diff.
**How to build:** Call the GitHub `code-scanning/alerts` API for the current `head_sha`.
**Why:** Instead of developers having to check multiple tabs, Watchflow summarizes the CodeQL alerts and makes them enforceable via YAML.
**Parameters:** `block_on_critical_codeql: true`

### `DependabotAlertsCondition`
**Purpose:** Ensure developers do not merge PRs that introduce new dependencies with known CVEs.
**How to build:** Hook into the `dependabot/alerts` REST API for the repository, filtering by the PR's branch.
**Why:** Shifting security left.
**Parameters:** `max_dependabot_severity: "high"`

## 6. Open-Source Ecosystem Integrations

We can leverage popular open-source Python SDKs directly within our rule engine to parse specific file types during the event evaluation.

### Open Policy Agent (OPA) / Rego Validation
**Purpose:** If a PR modifies `.rego` files or Kubernetes manifests, validate them against the OPA engine.
**How to build:** Embed the `opa` CLI or use the `PyOPA` library to evaluate the diff.
**Why:** Infrastructure-as-Code (IaC) teams need a way to ensure PRs don't introduce misconfigurations.

### Pydantic Schema Breakage Detection
**Purpose:** Detect backward-incompatible changes to REST API models.
**How to build:** If `models.py` changes, parse the old and new AST (Abstract Syntax Tree) to see if a required field was deleted or changed types.
**Why:** Breaking API contracts is a massive incident vector in enterprise microservices.

### Ruff / Black / ESLint Override Detection
**Purpose:** Flag PRs that introduce new `# noqa`, `# type: ignore`, or `// eslint-disable` comments.
**How to build:** Use our existing diff/patch parser to explicitly hunt for suppression comments in the added lines.
**Why:** Keeps technical debt from quietly slipping into the codebase.
**Parameters:** `allow_linter_suppressions: false`
Comment on lines +1 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add runnable rule examples and migration cross-links.

This roadmap is useful, but for user-facing rule additions it should include at least a few executable YAML examples and links to the canonical README/docs sections describing migration/usage.

As per coding guidelines, "docs/**: Update README and docs for user-visible changes and migrations" and "Provide runnable examples for new/changed rules; keep cross-links current".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/enterprise-rules-roadmap.md` around lines 1 - 92, The docs file lists
many new rule types (e.g., SignedCommitsCondition, SecretScanningCondition,
BannedDependenciesCondition, CrossTeamApprovalCondition,
NoSelfApprovalCondition, MigrationSafetyCondition, FeatureFlagRequiredCondition,
JiraTicketStatusCondition, CodeQLAnalysisCondition, DependabotAlertsCondition)
but lacks runnable YAML examples and migration cross-links; add 1–2 minimal,
executable YAML snippets per highlighted rule (showing parameters like
require_signed_commits: true, block_on_secret_alerts: true,
banned_licenses/banned_packages, required_team_approvals, safe_migrations_only,
require_active_jira_ticket, block_on_critical_codeql, max_dependabot_severity),
and add explicit cross-links from each rule header to the canonical README/docs
migration/usage sections and an examples directory, plus update the docs README
to list these new example files and a short “migration notes” section; ensure
symbols above are used as section headings and link targets so users can run the
examples directly.

2 changes: 2 additions & 0 deletions src/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class EventType(str, Enum):
PUSH = "push"
ISSUE_COMMENT = "issue_comment"
PULL_REQUEST = "pull_request"
PULL_REQUEST_REVIEW = "pull_request_review"
PULL_REQUEST_REVIEW_THREAD = "pull_request_review_thread"
CHECK_RUN = "check_run"
DEPLOYMENT = "deployment"
DEPLOYMENT_STATUS = "deployment_status"
Expand Down
10 changes: 10 additions & 0 deletions src/event_processors/pull_request/enricher.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ async def fetch_api_data(self, repo_full_name: str, pr_number: int, installation
reviews = await self.github_client.get_pull_request_reviews(repo_full_name, pr_number, installation_id)
api_data["reviews"] = reviews or []

# Fetch review threads using GraphQL
if hasattr(self.github_client, "get_pull_request_review_threads"):
threads = await self.github_client.get_pull_request_review_threads(
repo_full_name, pr_number, installation_id
)
api_data["review_threads"] = threads or []
else:
api_data["review_threads"] = []

# Fetch files
files = await self.github_client.get_pull_request_files(repo_full_name, pr_number, installation_id)
api_data["files"] = files or []
Expand Down Expand Up @@ -71,6 +80,7 @@ async def enrich_event_data(self, task: Any, github_token: str) -> dict[str, Any
"status": f.get("status"),
"additions": f.get("additions"),
"deletions": f.get("deletions"),
"patch": f.get("patch", ""),
}
for f in files
]
Expand Down
58 changes: 57 additions & 1 deletion src/integrations/github/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,62 @@ async def get_pull_request_reviews(self, repo: str, pr_number: int, installation
logger.error(f"Error getting reviews for PR #{pr_number} in {repo}: {e}")
return []

async def get_pull_request_review_threads(
self, repo: str, pr_number: int, installation_id: int
) -> list[dict[str, Any]]:
"""Get review threads for a pull request using the GraphQL API."""
try:
token = await self.get_installation_access_token(installation_id)
if not token:
logger.error(f"Failed to get installation token for {installation_id}")
return []

from src.integrations.github.graphql import GitHubGraphQLClient

client = GitHubGraphQLClient(token)

owner, repo_name = repo.split("/", 1)
query = """
query PRReviewThreads($owner: String!, $repo: String!, $pr_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr_number) {
reviewThreads(first: 50) {
nodes {
isResolved
isOutdated
comments(first: 10) {
nodes {
body
createdAt
author {
login
}
}
}
}
}
}
}
}
"""
Comment on lines +534 to +556
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded pagination limits may truncate data on large PRs.

The GraphQL query uses reviewThreads(first: 50) and comments(first: 10) without cursor-based pagination. PRs with more than 50 review threads or threads with more than 10 comments will have silently truncated results.

This could cause UnresolvedCommentsCondition to miss unresolved threads on large PRs, leading to incorrect pass results.

Consider either:

  1. Implementing cursor-based pagination for completeness, or
  2. Documenting this as a known limitation and increasing the limits to reasonable maximums (e.g., first: 100)
🔧 Quick fix to increase limits
             query = """
             query PRReviewThreads($owner: String!, $repo: String!, $pr_number: Int!) {
                 repository(owner: $owner, name: $repo) {
                     pullRequest(number: $pr_number) {
-                        reviewThreads(first: 50) {
+                        reviewThreads(first: 100) {
                             nodes {
                                 isResolved
                                 isOutdated
-                                comments(first: 10) {
+                                comments(first: 50) {
                                     nodes {
                                         body
                                         createdAt
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/integrations/github/api.py` around lines 534 - 556, The GraphQL query
stored in the variable `query` in src/integrations/github/api.py hardcodes
`reviewThreads(first: 50)` and `comments(first: 10)` which can truncate results
and make `UnresolvedCommentsCondition` miss threads; fix by implementing
cursor-based pagination for both `reviewThreads` and `comments` (add `after`
variables, return `pageInfo { hasNextPage endCursor }` and loop until exhausted)
or, as a documented interim measure, increase the limits (e.g., to `first: 100`)
and add a TODO referencing `UnresolvedCommentsCondition` to revisit with full
pagination.

variables = {"owner": owner, "repo": repo_name, "pr_number": pr_number}
response_model = await client.execute_query_typed(query, variables)

if response_model.errors:
logger.error("GraphQL query failed", errors=response_model.errors)
return []

repo_node = response_model.data.repository
if not repo_node or not repo_node.pull_request or not repo_node.pull_request.review_threads:
return []

threads = [thread.model_dump() for thread in repo_node.pull_request.review_threads.nodes]
logger.info(f"Retrieved {len(threads)} review threads for PR #{pr_number} in {repo}")
return threads
except Exception as e:
logger.error(f"Error getting review threads for PR #{pr_number} in {repo}: {e}")
return []

async def get_pull_request_files(self, repo: str, pr_number: int, installation_id: int) -> list[dict[str, Any]]:
"""Get files changed in a pull request."""
try:
Expand Down Expand Up @@ -1314,7 +1370,7 @@ async def fetch_pr_hygiene_stats(
# Check if it's a rate limit error - check both message and status code
is_rate_limit = "rate limit" in error_str or "403" in error_str
# Also check if it's an aiohttp ClientResponseError with status 403
if hasattr(e, "status") and e.status == 403:
if getattr(e, "status", None) == 403:
is_rate_limit = True
has_auth = user_token is not None or installation_id is not None

Expand Down
14 changes: 14 additions & 0 deletions src/integrations/github/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import httpx
import structlog
from pydantic import ValidationError

from src.integrations.github.models import GraphQLResponse

logger = structlog.get_logger()

Expand Down Expand Up @@ -45,3 +48,14 @@ async def execute_query(self, query: str, variables: dict[str, Any]) -> dict[str
except httpx.HTTPStatusError as e:
logger.error("graphql_request_failed", status=e.response.status_code)
raise

async def execute_query_typed(self, query: str, variables: dict[str, Any]) -> GraphQLResponse:
"""
Executes a GraphQL query and returns a strongly-typed Pydantic model.
"""
data = await self.execute_query(query, variables)
try:
return GraphQLResponse.model_validate(data)
except ValidationError as e:
logger.error("graphql_validation_failed", error=str(e), data=data)
raise
121 changes: 0 additions & 121 deletions src/integrations/github/graphql_client.py

This file was deleted.

21 changes: 21 additions & 0 deletions src/integrations/github/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ class CommentConnection(BaseModel):
total_count: int = Field(alias="totalCount")


class ThreadCommentNode(BaseModel):
author: Actor | None
body: str
createdAt: str


class ThreadCommentConnection(BaseModel):
nodes: list[ThreadCommentNode]


class ReviewThreadNode(BaseModel):
isResolved: bool
isOutdated: bool
comments: ThreadCommentConnection


class ReviewThreadConnection(BaseModel):
nodes: list[ReviewThreadNode]


class PullRequest(BaseModel):
"""
GitHub Pull Request Data Representation.
Expand All @@ -79,6 +99,7 @@ class PullRequest(BaseModel):
reviews: ReviewConnection = Field(alias="reviews")
commits: CommitConnection = Field(alias="commits")
files: FileConnection = Field(default_factory=lambda: FileConnection(edges=[]))
review_threads: ReviewThreadConnection | None = Field(None, alias="reviewThreads")


class Repository(BaseModel):
Expand Down
4 changes: 4 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from src.webhooks.handlers.deployment_status import DeploymentStatusEventHandler
from src.webhooks.handlers.issue_comment import IssueCommentEventHandler
from src.webhooks.handlers.pull_request import PullRequestEventHandler
from src.webhooks.handlers.pull_request_review import handle_pull_request_review
from src.webhooks.handlers.pull_request_review_thread import handle_pull_request_review_thread
from src.webhooks.handlers.push import PushEventHandler
from src.webhooks.router import router as webhook_router

Expand Down Expand Up @@ -74,6 +76,8 @@ async def lifespan(_app: FastAPI) -> Any:
deployment_protection_rule_handler = DeploymentProtectionRuleEventHandler()

dispatcher.register_handler(EventType.PULL_REQUEST, pull_request_handler.handle)
dispatcher.register_handler(EventType.PULL_REQUEST_REVIEW, handle_pull_request_review)
dispatcher.register_handler(EventType.PULL_REQUEST_REVIEW_THREAD, handle_pull_request_review_thread)
dispatcher.register_handler(EventType.PUSH, push_handler.handle)
dispatcher.register_handler(EventType.CHECK_RUN, check_run_handler.handle)
dispatcher.register_handler(EventType.ISSUE_COMMENT, issue_comment_handler.handle)
Expand Down
Loading
Loading