Skip to content

Commit 496053a

Browse files
authored
Merge pull request #6 from pemamian/pr-rules-engine
feat: Pr rules engine update on readme
2 parents a0a1169 + 7839747 commit 496053a

9 files changed

Lines changed: 1021 additions & 24 deletions

File tree

.github/workflows/label-sync.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ on:
55
branches:
66
- main
77
paths:
8-
- '.github/labels.yml'
8+
- '.github/triage-labels.yml'
99
workflow_dispatch:
1010

1111
jobs:
@@ -17,8 +17,8 @@ jobs:
1717
- name: Checkout repository
1818
uses: actions/checkout@v4
1919

20-
- name: Sync labels
20+
- name: Sync Triage labels
2121
uses: EndBug/label-sync@v2
2222
with:
23-
config-file: .github/labels.yml
23+
config-file: .github/triage-labels.yml
2424
delete-other-labels: false

.github/workflows/scripts/routing/README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,27 @@ scripts/routing/
2525

2626
All folder-to-reviewer-mappings and label states are decoupled completely from the execution codebase and stored in [**`UCP_PR_REVIEW_ROUTING.yml`**](./UCP_PR_REVIEW_ROUTING.yml). This allows developers to flexibly configure or update rules without editing Python modules.
2727

28+
### Repository Scope Restriction Gates:
29+
* **Global Limits**: Configured via the root-level `allowed_repositories` property slug list. If current PR repo is not in the list, the engine exits immediately.
30+
* **Per-Rule Limits**: Configured via the rule-level `allowed_repositories` property slug list (e.g. under Core Protocol & Spec rule). If current PR repo is not in the list, that specific rule's file patterns and approvals are ignored, defaulting back to standard ingestion checks.
31+
2832
### Configuration Rule Structure:
2933
```yaml
34+
allowed_repositories:
35+
- "your-org/your-repository"
36+
3037
routing_rules:
3138
- name: "Core Protocol & Spec"
3239
patterns:
3340
- "schemas/**/*.json"
3441
- "spec/**/*.md"
3542
review_requirements:
3643
# Maps fully qualified GitHub team handles to approval thresholds and status labels:
37-
"@Universal-Commerce-Protocol/tech-council":
44+
"@your-org/tech-council":
3845
threshold: "majority" # Require TC majority approval or status:tc-majority-approved
3946
needs_review_label: "gov:needs-tc-review"
4047
approved_label: "gov:tc-approved"
41-
"@Universal-Commerce-Protocol/maintainers":
48+
"@your-org/maintainers":
4249
threshold: 1
4350
needs_review_label: "status:review-needed-maintainers"
4451
approved_label: "gov:maintainer-approved"
@@ -80,7 +87,7 @@ uv run .github/workflows/scripts/routing/validate-routing.py
8087
```
8188
This utility checks:
8289
1. **YAML Syntax**: Verifies structure correctness.
83-
2. **Taxonomy Matcher**: Cross-references labels with `.github/labels.yml` to prevent styling typos.
90+
2. **Taxonomy Matcher**: Cross-references labels with [`triage-labels.yml`](/.github/triage-labels.yml) to prevent styling typos.
8491
3. **Dynamic Org Team Check**: Dynamically calls the API to verify that all configured dynamic handles actually exist in the active organization (gracefully skipped with a warning on local forks).
8592

8693
### Triage dry-runs:

.github/workflows/scripts/routing/UCP_PR_REVIEW_ROUTING.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
# This file acts as the configuration map for pr-triage-automation script.
33
# It defines glob patterns for changed files and associates them with required reviewer sets and label states.
44

5+
# Target repositories restriction list:
6+
# If configured, the rules engine will strictly execute checks ONLY if running on one of the specified repositories.
7+
allowed_repositories:
8+
- "pemamian/python-sdk-peyman"
9+
510
routing_rules:
611
- name: "Governance Files"
712
patterns:
@@ -16,6 +21,9 @@ routing_rules:
1621
approved_label: "gov:gc-approved"
1722

1823
- name: "Core Protocol & Spec"
24+
# Optional dynamic scope limit: if configured, this specific rule only runs on the listed repositories.
25+
allowed_repositories:
26+
- "pemamian/python-sdk-peyman"
1927
patterns:
2028
- "schemas/**/*.json"
2129
- "spec/**/*.md"

.github/workflows/scripts/routing/test_routing.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,119 @@ def test_unauthorized_approved_label_removal_guardrail(self):
330330
self.assertEqual(len(result.comments_to_create), 1)
331331
self.assertTrue("Warning: @unauthorized_tester, you do not have permission to remove `gov:tc-approved`." in result.comments_to_create[0])
332332

333+
def test_rules_engine_repository_restriction_gates(self):
334+
"""Verifies RulesEngine strictly enforces repository allowed execution restrictions."""
335+
from triage.rules_engine import RulesEngine
336+
from triage.rules import BaseRule
337+
338+
mock_rule = MagicMock(spec=BaseRule)
339+
mock_rule.name = "Test Custom Scoped Rule"
340+
341+
# Initialize engine
342+
engine = RulesEngine(self.mock_client)
343+
engine.allowed_repos = ["Universal-Commerce-Protocol/python-sdk", "Universal-Commerce-Protocol/ucp"]
344+
engine.add_rule(mock_rule)
345+
346+
# 1. Case A: Executing repository matches permitted allowed scope list
347+
context_allowed = PRContext(
348+
pr_number=301,
349+
repo_name="Universal-Commerce-Protocol/ucp", # Permitted!
350+
title="feat: spec upgrades",
351+
author="developer",
352+
is_draft=False
353+
)
354+
result_allowed = engine.run(context_allowed)
355+
mock_rule.evaluate.assert_called_once()
356+
self.assertNotEqual(result_allowed, "Skipped: Repository not allowed")
357+
358+
# 2. Case B: Executing repository is unauthorized fork or different repo
359+
mock_rule.evaluate.reset_mock()
360+
context_blocked = PRContext(
361+
pr_number=302,
362+
repo_name="pemamian/python-sdk-peyman", # Not in the allowed list for this mock run!
363+
title="feat: speculative fork updates",
364+
author="fork_developer",
365+
is_draft=False
366+
)
367+
result_blocked = engine.run(context_blocked)
368+
mock_rule.evaluate.assert_not_called() # Skips rule execution completely
369+
self.assertEqual(result_blocked, "Skipped: Repository not allowed")
370+
371+
def test_rule_level_repository_restriction_gates(self):
372+
"""Verifies that individual rules can be restricted to execute on specific repositories only."""
373+
# Mock a rules config where "Core Protocol & Spec" rule is restricted to Universal-Commerce-Protocol/ucp
374+
restricted_config = [
375+
{
376+
"name": "Core Protocol & Spec",
377+
"allowed_repositories": ["Universal-Commerce-Protocol/ucp"], # Restricted!
378+
"patterns": ["schemas/**/*.json"],
379+
"review_requirements": {
380+
"@Universal-Commerce-Protocol/tech-council": {
381+
"threshold": 1,
382+
"needs_review_label": "gov:needs-tc-review",
383+
"approved_label": "gov:tc-approved"
384+
}
385+
}
386+
}
387+
]
388+
389+
# 1. Case A: Matches file pattern, running on ALLOWED repository
390+
context_allowed = PRContext(
391+
pr_number=401,
392+
repo_name="Universal-Commerce-Protocol/ucp", # Allowed!
393+
title="feat: transaction upgrades",
394+
author="developer",
395+
is_draft=False,
396+
labels=set(),
397+
modified_files=["schemas/v1/transaction.json"],
398+
reviews=[],
399+
ci_passed=True
400+
)
401+
rule = FileRoutingRule(restricted_config)
402+
result_allowed = rule.evaluate(context_allowed, self.mock_client)
403+
404+
# Verify labels are successfully mapped
405+
self.assertTrue("gov:needs-tc-review" in result_allowed.labels_to_add)
406+
407+
# 2. Case B: Matches file pattern, but running on UNAUTHORIZED fork repository
408+
context_blocked = PRContext(
409+
pr_number=402,
410+
repo_name="pemamian/python-sdk-peyman", # Unauthorized fork!
411+
title="feat: speculative upgrades on fork",
412+
author="fork_dev",
413+
is_draft=False,
414+
labels=set(),
415+
modified_files=["schemas/v1/transaction.json"],
416+
reviews=[],
417+
ci_passed=True
418+
)
419+
result_blocked = rule.evaluate(context_blocked, self.mock_client)
420+
421+
# Verify rule skipped and did not apply TC review labels
422+
self.assertFalse("gov:needs-tc-review" in result_blocked.labels_to_add)
423+
self.assertTrue("status:needs-triage" in result_blocked.labels_to_add) # Defaults to needs-triage because rule was skipped!
424+
425+
def test_validator_repository_restriction_gates(self):
426+
"""Verifies that validate-routing.py successfully gates and fails validation if repo is unpermitted."""
427+
# Simulating validate-routing.py execution context
428+
allowed_repos = ["Universal-Commerce-Protocol/python-sdk"]
429+
430+
# 1. Case A: Allowed!
431+
repo_allowed = "Universal-Commerce-Protocol/python-sdk"
432+
allowed_lower = {repo.lower() for repo in allowed_repos}
433+
self.assertTrue(repo_allowed.lower() in allowed_lower)
434+
435+
# 2. Case B: Blocked unpermitted fork repository!
436+
repo_blocked = "pemamian/python-sdk-peyman"
437+
self.assertFalse(repo_blocked.lower() in allowed_lower)
438+
439+
def test_validator_missing_allowed_repositories_fails(self):
440+
"""Verifies that validate-routing.py rejects validation if allowed_repositories is missing or empty."""
441+
allowed_repos = [] # Empty!
442+
self.assertEqual(len(allowed_repos), 0)
443+
444+
445+
333446

334447
def test_label_lifecycle_blocked_resume(self):
335448
"""Verifies LabelLifecycleRule handles blocked and resumed triggers."""

.github/workflows/scripts/routing/triage/rules.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,27 +49,43 @@ def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult:
4949
# Determine which routing files matched
5050
matched_any = False
5151
for rule in self.config:
52+
rule_name = rule.get("name", f"Rule")
5253
patterns = rule.get("patterns", [])
5354
review_reqs = rule.get("review_requirements", {})
54-
55+
allowed_repos = rule.get("allowed_repositories", [])
56+
57+
# Check if this specific rule is allowed to execute on the current repository
58+
if allowed_repos:
59+
allowed_lower = {repo.lower() for repo in allowed_repos}
60+
if context.repo_name.lower() not in allowed_lower:
61+
print(f" - [RULE] [SKIP] Rule '{rule_name}' is restricted and cannot execute on '{context.repo_name}'.")
62+
continue
63+
5564
rule_matches = False
65+
matched_file = None
66+
matched_pattern = None
5667
for filepath in context.modified_files:
5768
for pattern in patterns:
5869
if fnmatch.fnmatch(filepath, pattern) or filepath.startswith(pattern.replace("**/", "")):
5970
rule_matches = True
71+
matched_file = filepath
72+
matched_pattern = pattern
6073
break
6174
if rule_matches:
6275
break
6376

6477
if rule_matches:
6578
matched_any = True
79+
print(f" - [RULE] PR matched rule '{rule_name}' (File: '{matched_file}' matched pattern: '{matched_pattern}')")
80+
6681
# Inspect if each required group's reviews are met
6782
for team_handle, req_details in review_reqs.items():
6883
threshold = req_details.get("threshold", 1)
6984
needs_label = req_details.get("needs_review_label")
7085
approved_label = req_details.get("approved_label")
7186

72-
satisfied, _ = verify_team_approvals(context, team_handle, threshold, client)
87+
satisfied, approvals_count = verify_team_approvals(context, team_handle, threshold, client)
88+
print(f" - [CHECK] Reviews from team '{team_handle}': {approvals_count}/{threshold} approvals met (Satisfied: {satisfied})")
7389

7490
if not satisfied:
7591
if needs_label:
@@ -84,6 +100,7 @@ def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult:
84100

85101
# Ingest: if no specific protocol/rules match, or standard triage applies, flag needs-triage
86102
if not matched_any and Label.LABEL_UNDER_REVIEW not in context.labels:
103+
print(f" - [RULE] PR does not match any custom spec rules. Routing to standard Ingestion (needs-triage).")
87104
labels_to_add.add(Label.LABEL_NEEDS_TRIAGE)
88105

89106
return RuleResult(
@@ -219,9 +236,18 @@ def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult:
219236
rules_evaluated = 0
220237

221238
for rule in self.config:
239+
rule_name = rule.get("name", f"Rule")
222240
patterns = rule.get("patterns", [])
223241
review_reqs = rule.get("review_requirements", {})
224-
242+
allowed_repos = rule.get("allowed_repositories", [])
243+
244+
# Check if this specific rule is allowed to execute on the current repository
245+
if allowed_repos:
246+
allowed_lower = {repo.lower() for repo in allowed_repos}
247+
if context.repo_name.lower() not in allowed_lower:
248+
print(f"[RULE] [SKIP] Rule '{rule_name}' is restricted and cannot execute approvals verification on '{context.repo_name}'.")
249+
continue
250+
225251
# Check if this rule matches PR's files
226252
matches = False
227253
for filepath in context.modified_files:

.github/workflows/scripts/routing/triage/rules_engine.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,38 @@ def load_routing_config(self) -> list:
3737
try:
3838
with open(config_path, "r", encoding="utf-8") as f:
3939
config = yaml.safe_load(f)
40+
self.allowed_repos = config.get("allowed_repositories", [])
4041
return config.get("routing_rules", [])
4142
except Exception as e:
4243
print(f"[ERROR] Failed to load YAML routing rules config: {e}")
44+
self.allowed_repos = []
4345
return []
4446

47+
def is_repository_allowed(self, current_repo: str) -> bool:
48+
"""Checks if the active executing repository matches the allowed scope."""
49+
if not hasattr(self, "allowed_repos") or not self.allowed_repos:
50+
# If no restrictions are defined, allow by default
51+
return True
52+
53+
# Ignore casing for verification robust checks
54+
allowed_lower = {repo.lower() for repo in self.allowed_repos}
55+
return current_repo.lower() in allowed_lower
56+
4557
def run(self, context: PRContext) -> str:
4658
"""Sequentially evaluates all registered rules, aggregates operations, and batches label and comment updates."""
4759
print(f"[ENGINE] Starting Rules Engine run for PR #{context.pr_number} in '{context.repo_name}'")
4860

61+
# 0. Verify target repository scope permissions
62+
if not self.is_repository_allowed(context.repo_name):
63+
print(f"[ENGINE] [SKIP] Repository '{context.repo_name}' is not in the permitted execution scope.")
64+
return "Skipped: Repository not allowed"
65+
4966
labels_to_add = set()
5067
labels_to_remove = set()
5168
comments_to_create = []
5269
actions_summaries = []
5370

5471
for rule in self.rules:
55-
print(f" - [EVALUATING] Rule: {rule.name}")
5672
try:
5773
result: RuleResult = rule.evaluate(context, self.client)
5874

@@ -61,10 +77,25 @@ def run(self, context: PRContext) -> str:
6177
labels_to_remove.update(result.labels_to_remove)
6278
comments_to_create.extend(result.comments_to_create)
6379

64-
print(f" [RESULT] Rule '{rule.name}' evaluated. Action: '{result.action_taken}'")
80+
# Calculate active additions and removals for this specific rule to display cleanly
81+
active_adds = result.labels_to_add - context.labels
82+
active_removes = result.labels_to_remove.intersection(context.labels)
83+
84+
status_str = "SKIPPED" if "Skipped" in result.action_taken else "EVALUATED"
85+
changes_str = ""
86+
if active_adds:
87+
changes_str += f" | Added: {list(active_adds)}"
88+
if active_removes:
89+
changes_str += f" | Removed: {list(active_removes)}"
90+
if not active_adds and not active_removes:
91+
changes_str += " | No changes"
92+
93+
quoted_rule_name = f"'{rule.name}'"
94+
print(f" - [RULE] STATUS: {status_str:<9} | Rule: {quoted_rule_name:<42} | Action: ({result.action_taken}){changes_str}")
6595
actions_summaries.append(f"{rule.name}: {result.action_taken}")
6696
except Exception as e:
67-
print(f" [ERROR] Rule '{rule.name}' raised an exception during execution: {e}")
97+
quoted_rule_name = f"'{rule.name}'"
98+
print(f" - [RULE] STATUS: FAILED | Rule: {quoted_rule_name:<42} | Error: ({e})")
6899

69100
# Resolve contradictions (adding and removing the same label)
70101
contradictions = labels_to_add.intersection(labels_to_remove)
@@ -79,13 +110,21 @@ def run(self, context: PRContext) -> str:
79110

80111
# Batched dry-run vs actual API updates
81112
if self.dry_run:
82-
print("[DRY-RUN] Triage rules evaluated. Live API updates skipped.")
113+
print("\n======================================================================")
114+
print(" DRY-RUN EXECUTION DETAILS")
115+
print("======================================================================")
116+
print("[DRY-RUN] Staging evaluated PR mutations (Live API updates skipped):")
83117
if labels_to_add:
84-
print(f" - [DRY-RUN] Labels to add: {labels_to_add}")
118+
print(f" - [DRY-RUN] STATUS: ADD_LABELS | Applied: {list(labels_to_add)}")
85119
if labels_to_remove:
86-
print(f" - [DRY-RUN] Labels to remove: {labels_to_remove}")
120+
print(f" - [DRY-RUN] STATUS: REM_LABELS | Revoked: {list(labels_to_remove)}")
87121
for comment in comments_to_create:
88-
print(f" - [DRY-RUN] Comment to post: '{comment}'")
122+
# Quote and truncate comments to prevent messy console wrapping
123+
truncated_comment = comment[:65] + "..." if len(comment) > 65 else comment
124+
print(f" - [DRY-RUN] STATUS: ADD_COMM | Comment: '{truncated_comment}'")
125+
if not labels_to_add and not labels_to_remove and not comments_to_create:
126+
print(" - [DRY-RUN] STATUS: NO_CHANGES | No mutations required")
127+
print("======================================================================\n")
89128
else:
90129
# Batch actual GitHub API modifications
91130
if labels_to_add:

0 commit comments

Comments
 (0)