diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..cfcaea0 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,41 @@ +- name: status:needs-triage + color: 'FBCA04' + description: Signal that the PR is ready for human triage + +- name: status:under-review + color: '1D76DB' + +- name: status:stale-review + color: '6F7A8A' + description: Applied if a PR is waiting on a reviewer for too long + +- name: status:blocked + color: 'D93F0B' + +- name: status:ready-to-merge + color: '0E8A16' + description: Signaling to the DevOps team that it can be safely merged + +- name: status:stale + color: '6F7A8A' + description: Automatically applied if a PR is blocked waiting for the author's response for 30 days + +- name: status:merged + color: '6F42C1' + description: Signify the completion of its lifecycle and provide clear visibility for reporting + +- name: area:payments + color: '0052CC' + +- name: devops + color: 'FFD1DC' + +- name: gov:needs-tc-review + color: 'D93F0B' + +- name: gov:needs-gc-review + color: 'FDE5BE' + +- name: gov:approved + color: 'C2E0C6' + description: Triggers the final code ownership checks diff --git a/.github/workflows/label-sync.yml b/.github/workflows/label-sync.yml new file mode 100644 index 0000000..e46ad95 --- /dev/null +++ b/.github/workflows/label-sync.yml @@ -0,0 +1,24 @@ +name: Sync Labels + +on: + push: + branches: + - main + paths: + - '.github/labels.yml' + workflow_dispatch: + +jobs: + labeler: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Sync labels + uses: EndBug/label-sync@v2 + with: + config-file: .github/labels.yml + delete-other-labels: false diff --git a/.github/workflows/pr-cron-stale-abandon.yml b/.github/workflows/pr-cron-stale-abandon.yml new file mode 100644 index 0000000..6a56ca6 --- /dev/null +++ b/.github/workflows/pr-cron-stale-abandon.yml @@ -0,0 +1,29 @@ +--- +name: PR Stale and Abandon Tracker + +on: + schedule: + - cron: '17 2 * * *' # Daily at 2:17 AM UTC to avoid peak scheduler hours + workflow_dispatch: # Allows manual trigger for testing + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + stale_tracker: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Trace and Mark Stale PRs + run: uv run .github/workflows/scripts/routing/pr-cron-stale-abandon.py + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/pr-triage-automation.yml b/.github/workflows/pr-triage-automation.yml new file mode 100644 index 0000000..d780021 --- /dev/null +++ b/.github/workflows/pr-triage-automation.yml @@ -0,0 +1,38 @@ +--- +name: PR Triage Automation + +on: + pull_request: + types: [opened, ready_for_review, synchronize, labeled] + issue_comment: + types: [created] + check_suite: + types: [completed] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + triage_realtime: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event.pull_request.draft == false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Validate Routing Configurations + run: uv run .github/workflows/scripts/routing/validate-routing.py + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check and Update PR Labels + run: uv run .github/workflows/scripts/routing/pr-triage-automation.py + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/scripts/routing/UCP_PR_REVIEW_ROUTING.yml b/.github/workflows/scripts/routing/UCP_PR_REVIEW_ROUTING.yml new file mode 100644 index 0000000..7deff1c --- /dev/null +++ b/.github/workflows/scripts/routing/UCP_PR_REVIEW_ROUTING.yml @@ -0,0 +1,66 @@ +# UCP PR Review Routing Configuration +# This file acts as the configuration map for pr-triage-automation script. +# It defines glob patterns for changed files and associates them with required reviewer sets and label states. + +routing_rules: + - name: "Governance Files" + patterns: + - "LICENSE" + - "GOVERNANCE.md" + - "CONTRIBUTING.md" + - ".github/CODEOWNERS" + review_requirements: + "@Universal-Commerce-Protocol/governance-council": + threshold: 1 + needs_review_label: "gov:needs-gc-review" + approved_label: "gov:gc-approved" + + - name: "Core Protocol & Spec" + patterns: + - "schemas/**/*.json" + - "spec/**/*.md" + review_requirements: + "@Universal-Commerce-Protocol/tech-council": + threshold: "majority" + needs_review_label: "gov:needs-tc-review" + approved_label: "gov:tc-approved" + "@Universal-Commerce-Protocol/maintainers": + threshold: 1 + needs_review_label: "status:review-needed-maintainers" + approved_label: "gov:maintainer-approved" + + - name: "Infrastructure & Tooling" + patterns: + - ".github/workflows/**" + - ".gitignore" + - ".pre-commit-config.yaml" + - "pyproject.toml" + - "uv.lock" + review_requirements: + "@Universal-Commerce-Protocol/devops-maintainers": + threshold: 1 + needs_review_label: "status:needs-triage" + approved_label: "status:under-review" + + - name: "SDK Code & Maintenance" + patterns: + - "src/**" + - "templates/**" + review_requirements: + "@Universal-Commerce-Protocol/maintainers": + threshold: 1 + needs_review_label: "status:needs-triage" + approved_label: "status:under-review" + + - name: "Payments Custom Review Rules" + patterns: + - "src/components/payments/**" + review_requirements: + "@Universal-Commerce-Protocol/maintainers": + threshold: 1 + needs_review_label: "status:review-needed-payments-maintainers" + approved_label: "status:payments-approved" + "@Universal-Commerce-Protocol/tech-council": + threshold: 1 + needs_review_label: "gov:needs-tc-review" + approved_label: "gov:tc-approved" diff --git a/.github/workflows/scripts/routing/__init__.py b/.github/workflows/scripts/routing/__init__.py new file mode 100644 index 0000000..143f486 --- /dev/null +++ b/.github/workflows/scripts/routing/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/.github/workflows/scripts/routing/pr-cron-stale-abandon.py b/.github/workflows/scripts/routing/pr-cron-stale-abandon.py new file mode 100755 index 0000000..b9057ee --- /dev/null +++ b/.github/workflows/scripts/routing/pr-cron-stale-abandon.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pygithub", +# "pyyaml", +# ] +# /// +import os +import sys +from triage.github_api import GitHubAPIClient +from triage.rules_engine import RulesEngine +from triage.rules import StalePRRule + +def main(): + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + print("[ERROR] GH_TOKEN or GITHUB_TOKEN is not set.") + sys.exit(1) + + # Extract active repository name dynamically from the git config of the local clone. + try: + import subprocess + script_dir = os.path.dirname(os.path.abspath(__file__)) + origin_url = subprocess.check_output(["git", "-C", script_dir, "config", "--get", "remote.origin.url"]).decode("utf-8").strip() + clean_url = origin_url.replace(".git", "").replace(":", "/") + parts = clean_url.split("/") + repo_name = f"{parts[-2]}/{parts[-1]}" + print(f"[INFO] Target Repository resolved: {repo_name}") + except Exception as e: + print(f"[ERROR] Failed to dynamically determine current Git repository name: {e}") + sys.exit(1) + + + + + # Configure thresholds (stale after 30 days, abandon candidate after 37 days) + STALE_THRESHOLD_DAYS = 30 + ABANDON_THRESHOLD_DAYS = 37 + + print(f"[START] Inactivity Scan: Scanning open PRs in '{repo_name}'...") + + try: + # Initialize client and engine + client = GitHubAPIClient(token, repo_name) + engine = RulesEngine(client) + + # Register only stale/abandon inactivity rules + engine.add_rule(StalePRRule( + stale_threshold_days=STALE_THRESHOLD_DAYS, + abandon_threshold_days=ABANDON_THRESHOLD_DAYS + )) + + # Fetch all currently open pull requests + pulls = client.repo.get_pulls(state="open") + total_scanned = 0 + + for pygithub_pr in pulls: + if pygithub_pr.draft: + continue + + total_scanned += 1 + print(f" - [SCANNING] PR #{pygithub_pr.number}: '{pygithub_pr.title}' (Updated at: {pygithub_pr.updated_at})") + + try: + # Wrap the PR inside our shared context model and run engine + context = client.get_pr_context(pygithub_pr.number, event_name="schedule") + engine.run(context) + except Exception as pe: + print(f" [ERROR] Failed to run stale evaluation on PR #{pygithub_pr.number}: {pe}", file=sys.stderr) + + print(f"[SUCCESS] Inactivity Scan complete. Scanned {total_scanned} open non-draft pull requests.") + except Exception as e: + print(f"[ERROR] Inactivity scan runner failed: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/routing/pr-triage-automation.py b/.github/workflows/scripts/routing/pr-triage-automation.py new file mode 100755 index 0000000..e737f67 --- /dev/null +++ b/.github/workflows/scripts/routing/pr-triage-automation.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pygithub", +# "pyyaml", +# ] +# /// +import json +import os +import sys +from triage.github_api import GitHubAPIClient +from triage.rules_engine import RulesEngine +from triage.rules import FileRoutingRule, ReviewerApprovalRule, LabelLifecycleRule + +def main(): + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + print("[ERROR] GH_TOKEN or GITHUB_TOKEN is not set.") + sys.exit(1) + + event_path = os.environ.get("GITHUB_EVENT_PATH") + event_name = os.environ.get("GITHUB_EVENT_NAME") + repo_name = os.environ.get("GITHUB_REPOSITORY") + + if not event_path or not os.path.exists(event_path): + print("[ERROR] GITHUB_EVENT_PATH is not set or invalid.") + sys.exit(1) + + with open(event_path, "r", encoding="utf-8") as f: + payload = json.load(f) + + # Determine pull request number from payload + pr_number = ( + payload.get("pull_request", {}).get("number") or + payload.get("issue", {}).get("number") or + (payload.get("check_suite", {}).get("pull_requests") or [{}])[0].get("number") + ) + + if not pr_number: + print("[SKIP] Event not associated with an open Pull Request.") + return + + print(f"[START] Real-time Triage webhook trigger: '{event_name}' for PR #{pr_number}") + + try: + # Initialize GitHub client and Rules Engine + client = GitHubAPIClient(token, repo_name) + engine = RulesEngine(client) + + # Load YML configuration + routing_config = engine.load_routing_config() + + # Register real-time rules + engine.add_rule(FileRoutingRule(routing_config)) + engine.add_rule(LabelLifecycleRule()) + engine.add_rule(ReviewerApprovalRule(routing_config)) + + # Fetch live PR details and run engine + context = client.get_pr_context(pr_number, event_name=event_name, event_payload=payload) + + # Skip evaluations for draft PRs + if context.is_draft: + print(f"[SKIP] Pull request #{pr_number} is a draft.") + return + + engine.run(context) + print("[SUCCESS] Real-time triage automated evaluations completed.") + except Exception as e: + print(f"[ERROR] Real-time triage webhook processing failed: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/routing/triage/__init__.py b/.github/workflows/scripts/routing/triage/__init__.py new file mode 100644 index 0000000..143f486 --- /dev/null +++ b/.github/workflows/scripts/routing/triage/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/.github/workflows/scripts/routing/triage/github_api.py b/.github/workflows/scripts/routing/triage/github_api.py new file mode 100644 index 0000000..5fd72fc --- /dev/null +++ b/.github/workflows/scripts/routing/triage/github_api.py @@ -0,0 +1,88 @@ +# github_api.py +import os +from typing import List, Set +from github import Github, Auth +from .models import PRContext, ReviewInfo + +class GitHubAPIClient: + def __init__(self, token: str, repo_name: str): + self.auth = Auth.Token(token) + self.g = Github(auth=self.auth) + self.repo = self.g.get_repo(repo_name) + self._team_members_cache = {} + + def get_pr_context(self, pr_number: int, event_name: str = "", event_payload: dict = None) -> PRContext: + """Fetches pull request details and wraps them inside PRContext.""" + pr = self.repo.get_pull(pr_number) + + labels = {label.name for label in pr.get_labels()} + modified_files = [f.filename for f in pr.get_files()] + + # Convert pygithub review models to ReviewInfo + reviews = [] + for review in pr.get_reviews(): + reviews.append( + ReviewInfo( + user=review.user.login, + state=review.state, + submitted_at=review.submitted_at + ) + ) + + return PRContext( + pr_number=pr_number, + repo_name=self.repo.full_name, + title=pr.title, + author=pr.user.login, + is_draft=pr.draft, + labels=labels, + modified_files=modified_files, + reviews=reviews, + created_at=pr.created_at, + updated_at=pr.updated_at, + event_name=event_name, + event_payload=event_payload or {} + ) + + def check_team_membership(self, org_name: str, team_slug: str, username: str) -> bool: + """Checks if a given user is a member of a specific organization team dynamically.""" + cache_key = f"{org_name}/{team_slug}" + + # Dynamic caching to avoid repeated API requests within a run + if cache_key not in self._team_members_cache: + try: + org = self.g.get_organization(org_name) + team = org.get_team_by_slug(team_slug) + members = {member.login for member in team.get_members()} + self._team_members_cache[cache_key] = members + except Exception as e: + print(f"[WARNING] Failed to fetch team details for {cache_key}: {e}") + self._team_members_cache[cache_key] = set() + + return username in self._team_members_cache[cache_key] + + def add_labels_to_pr(self, pr_number: int, labels: Set[str]): + """Applies a set of labels to a target PR.""" + if not labels: + return + pr = self.repo.get_pull(pr_number) + for label in labels: + print(f"[API] Adding label: {label}") + pr.add_to_labels(label) + + def remove_labels_from_pr(self, pr_number: int, labels: Set[str]): + """Removes a set of labels from a target PR.""" + if not labels: + return + pr = self.repo.get_pull(pr_number) + current_labels = {label.name for label in pr.get_labels()} + for label in labels: + if label in current_labels: + print(f"[API] Removing label: {label}") + pr.remove_from_labels(label) + + def create_comment(self, pr_number: int, body: str): + """Posts an issue comment on the pull request.""" + pr = self.repo.get_pull(pr_number) + print(f"[API] Creating issue comment on PR #{pr_number}") + pr.create_issue_comment(body) diff --git a/.github/workflows/scripts/routing/triage/models.py b/.github/workflows/scripts/routing/triage/models.py new file mode 100644 index 0000000..2c4fcd7 --- /dev/null +++ b/.github/workflows/scripts/routing/triage/models.py @@ -0,0 +1,61 @@ +# models.py +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Set, Dict, Any, Optional + +# ============================================================================== +# Centralized Label Constants +# ============================================================================== +class Label: + # Standard PR status labels + NEEDS_TRIAGE = "status:needs-triage" + UNDER_REVIEW = "status:under-review" + STALE_REVIEW = "status:stale-review" + BLOCKED = "blocked" + READY_TO_MERGE = "status:ready-to-merge" + STALE = "status:stale" + MERGED = "status:merged" + ABANDON_CANDIDATE = "status:abandon-candidate" + + # Governance status labels + NEEDS_TC_REVIEW = "gov:needs-tc-review" + TC_APPROVED = "gov:tc-approved" + NEEDS_GC_REVIEW = "gov:needs-gc-review" + GC_APPROVED = "gov:gc-approved" + APPROVED = "gov:approved" + + # Specialized trigger labels + TC_MAJORITY_APPROVED = "status:tc-majority-approved" + +# ============================================================================== +# PR Core Context Dataclasses +# ============================================================================== +@dataclass +class ReviewInfo: + user: str + state: str # APPROVED, CHANGES_REQUESTED, COMMENTED, etc. + submitted_at: Optional[datetime] = None + +@dataclass +class PRContext: + pr_number: int + repo_name: str + title: str + author: str + is_draft: bool + labels: Set[str] = field(default_factory=set) + modified_files: List[str] = field(default_factory=list) + reviews: List[ReviewInfo] = field(default_factory=list) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + event_name: str = "" + event_payload: Dict[str, Any] = field(default_factory=dict) + +@dataclass +class RuleResult: + rule_name: str + satisfied: bool + labels_to_add: Set[str] = field(default_factory=set) + labels_to_remove: Set[str] = field(default_factory=set) + comments_to_create: List[str] = field(default_factory=list) + action_taken: str = "No action required" diff --git a/.github/workflows/scripts/routing/triage/rules.py b/.github/workflows/scripts/routing/triage/rules.py new file mode 100644 index 0000000..22476f3 --- /dev/null +++ b/.github/workflows/scripts/routing/triage/rules.py @@ -0,0 +1,350 @@ +# rules.py +import fnmatch +from datetime import datetime, timedelta, timezone +from typing import Set, Tuple +from .models import PRContext, RuleResult, Label, ReviewInfo +from .github_api import GitHubAPIClient + +# ============================================================================== +# Base Rule Class +# ============================================================================== +class BaseRule: + def __init__(self, name: str): + self.name = name + + def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult: + raise NotImplementedError("Rules must implement evaluate()") + +# ============================================================================== +# Rule 1: Dynamic File Pattern Routing Rule +# ============================================================================== +class FileRoutingRule(BaseRule): + """Matches modified files against UCP_PR_REVIEW_ROUTING.yml configurations.""" + def __init__(self, rules_config: list): + super().__init__("File Pattern Routing Rule") + self.config = rules_config + + def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult: + labels_to_add = set() + labels_to_remove = set() + + # Skip for draft PRs + if context.is_draft: + return RuleResult(self.name, satisfied=True, action_taken="Skipped: Draft PR") + + # Determine which routing files matched + matched_any = False + for rule in self.config: + patterns = rule.get("patterns", []) + review_reqs = rule.get("review_requirements", {}) + + rule_matches = False + for filepath in context.modified_files: + for pattern in patterns: + if fnmatch.fnmatch(filepath, pattern) or filepath.startswith(pattern.replace("**/", "")): + rule_matches = True + break + if rule_matches: + break + + if rule_matches: + matched_any = True + # Inspect if each required group's reviews are met + for team_handle, req_details in review_reqs.items(): + threshold = req_details.get("threshold", 1) + needs_label = req_details.get("needs_review_label") + approved_label = req_details.get("approved_label") + + satisfied, _ = verify_team_approvals(context, team_handle, threshold, client) + + if not satisfied: + if needs_label: + labels_to_add.add(needs_label) + if approved_label: + labels_to_remove.add(approved_label) + else: + if needs_label: + labels_to_remove.add(needs_label) + if approved_label: + labels_to_add.add(approved_label) + + # Ingest: if no specific protocol/rules match, or standard triage applies, flag needs-triage + if not matched_any and Label.UNDER_REVIEW not in context.labels: + labels_to_add.add(Label.NEEDS_TRIAGE) + + return RuleResult( + self.name, + satisfied=True, + labels_to_add=labels_to_add, + labels_to_remove=labels_to_remove, + action_taken="Evaluated folder file mappings and applied review requirements." + ) + +# ============================================================================== +# Rule 2: Dynamic Reviewer approvals & Security Check Rule +# ============================================================================== +class ReviewerApprovalRule(BaseRule): + """Verifies custom team approval criteria and enforces permission guardrails.""" + def __init__(self, rules_config: list): + super().__init__("Reviewer Approval Rule") + self.config = rules_config + + def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult: + labels_to_add = set() + labels_to_remove = set() + comments = [] + + # 1. Superpower Override (e.g., Amit's approval satisfies all rules) + SUPERPOWER_USERS = {"amithanda"} + for review in context.reviews: + if review.user in SUPERPOWER_USERS and review.state == "APPROVED": + print(f"[SUPERPOWER] Override triggered by approval from {review.user}") + # Clear all pending review tags, apply approvals + for rule in self.config: + for team, details in rule.get("review_requirements", {}).items(): + if details.get("needs_review_label"): + labels_to_remove.add(details.get("needs_review_label")) + if details.get("approved_label"): + labels_to_add.add(details.get("approved_label")) + + labels_to_add.add(Label.APPROVED) + labels_to_add.add(Label.READY_TO_MERGE) + return RuleResult( + self.name, + satisfied=True, + labels_to_add=labels_to_add, + labels_to_remove=labels_to_remove, + action_taken="Superpower approval override triggered." + ) + + # 2. Review requirements matching core spec rules or relaxed SDK settings + is_sdk = any(sdk_repo in context.repo_name.lower() for sdk_repo in ["sdk", "meeting-minutes"]) + + all_rules_satisfied = True + rules_evaluated = 0 + + for rule in self.config: + patterns = rule.get("patterns", []) + review_reqs = rule.get("review_requirements", {}) + + # Check if this rule matches PR's files + matches = False + for filepath in context.modified_files: + for pattern in patterns: + if fnmatch.fnmatch(filepath, pattern) or filepath.startswith(pattern.replace("**/", "")): + matches = True + break + if matches: + break + + if not matches: + continue + + rules_evaluated += 1 + + # Verify approvals for each required team + for team_handle, req_details in review_reqs.items(): + threshold = req_details.get("threshold", 1) + needs_label = req_details.get("needs_review_label") + approved_label = req_details.get("approved_label") + + # SDK relaxed rules override: 1 team approval is always sufficient + if is_sdk: + threshold = 1 + + satisfied, current_approvals = verify_team_approvals(context, team_handle, threshold, client) + + # Guard against manual label addition by non-TC members + if needs_label == Label.NEEDS_TC_REVIEW and Label.TC_MAJORITY_APPROVED in context.labels: + # If majority label is present, it acts as meeting standard override + satisfied = True + + if not satisfied: + all_rules_satisfied = False + if needs_label: + labels_to_add.add(needs_label) + if approved_label: + labels_to_remove.add(approved_label) + else: + if needs_label: + labels_to_remove.add(needs_label) + if approved_label: + labels_to_add.add(approved_label) + + # Enforce guardrail logic for governance labels + if Label.TC_MAJORITY_APPROVED in context.labels: + # Ensure applier was actually TC or DevOps + event_user = context.event_payload.get("sender", {}).get("login") + if event_user and context.event_name == "pull_request" and context.event_payload.get("action") == "labeled": + label_added = context.event_payload.get("label", {}).get("name") + if label_added == Label.TC_MAJORITY_APPROVED: + is_tc = client.check_team_membership("Universal-Commerce-Protocol", "tech-council", event_user) + is_devops = client.check_team_membership("Universal-Commerce-Protocol", "devops-maintainers", event_user) + if not is_tc and not is_devops: + print(f"[GUARDRAIL] Unauthorized user {event_user} applied majority label. Revoking.") + labels_to_remove.add(Label.TC_MAJORITY_APPROVED) + comments.append( + f"Warning: @{event_user}, you do not have permission to apply " + f"`{Label.TC_MAJORITY_APPROVED}`. This action has been automatically reverted." + ) + + # If all rules passed, transition to ready-to-merge + if all_rules_satisfied and rules_evaluated > 0: + labels_to_add.add(Label.APPROVED) + labels_to_add.add(Label.READY_TO_MERGE) + # Cleanup pending needs labels + for rule in self.config: + for team, details in rule.get("review_requirements", {}).items(): + if details.get("needs_review_label"): + labels_to_remove.add(details.get("needs_review_label")) + else: + labels_to_remove.add(Label.APPROVED) + labels_to_remove.add(Label.READY_TO_MERGE) + + return RuleResult( + self.name, + satisfied=all_rules_satisfied, + labels_to_add=labels_to_add, + labels_to_remove=labels_to_remove, + comments_to_create=comments, + action_taken="Verified team approval thresholds." + ) + +# ============================================================================== +# Rule 3: Block/Resume Lifecycle state-machine Rule +# ============================================================================== +class LabelLifecycleRule(BaseRule): + """Automates blocked/resumed PR transitions based on labeling and comments.""" + def __init__(self): + super().__init__("Label Lifecycle Rule") + + def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult: + labels_to_add = set() + labels_to_remove = set() + action = "Evaluated lifecycle rules." + + event_action = context.event_payload.get("action") + + # 1. Manual block label added + if context.event_name == "pull_request" and event_action == "labeled": + label_added = context.event_payload.get("label", {}).get("name") + if label_added == Label.BLOCKED: + labels_to_remove.add(Label.UNDER_REVIEW) + action = "Blocked: suspended under-review state" + + # 2. Author commits/pushes a new synchronized update + if context.event_name == "pull_request" and event_action == "synchronize": + if Label.BLOCKED in context.labels: + labels_to_add.add(Label.UNDER_REVIEW) + labels_to_remove.add(Label.BLOCKED) + action = "Resumed: synch push removed blocker label" + + # 3. Author comments on a blocked PR + if context.event_name == "issue_comment" and event_action == "created": + comment_author = context.event_payload.get("comment", {}).get("user", {}).get("login") + if comment_author == context.author and Label.BLOCKED in context.labels: + labels_to_add.add(Label.UNDER_REVIEW) + labels_to_remove.add(Label.BLOCKED) + action = "Resumed: comment by author resolved blocker label" + + return RuleResult( + self.name, + satisfied=True, + labels_to_add=labels_to_add, + labels_to_remove=labels_to_remove, + action_taken=action + ) + +# ============================================================================== +# Rule 4 & 5: Integrated Cron Stale PR & Abandon Candidates Rules +# ============================================================================== +class StalePRRule(BaseRule): + """Detects and labels inactive PRs waiting on review or blocked for too long.""" + def __init__(self, stale_threshold_days: int = 30, abandon_threshold_days: int = 37): + super().__init__("Stale PR Inactivity Rule") + self.stale_days = stale_threshold_days + self.abandon_days = abandon_threshold_days + + def evaluate(self, context: PRContext, client: GitHubAPIClient) -> RuleResult: + labels_to_add = set() + comments = [] + + if context.is_draft: + return RuleResult(self.name, satisfied=True, action_taken="Skipped: Draft PR") + + now = datetime.now(timezone.utc) + updated_at = context.updated_at + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + + stale_limit = now - timedelta(days=self.stale_days) + abandon_limit = now - timedelta(days=self.abandon_days) + + is_inactive_stale = updated_at < stale_limit + is_inactive_abandon = updated_at < abandon_limit + + # Scenario A: abandoned (no reviews for long time, abandon candidate) + if is_inactive_abandon in context.labels: + if Label.ABANDON_CANDIDATE not in context.labels: + labels_to_add.add(Label.ABANDON_CANDIDATE) + labels_to_add.add(Label.NEEDS_TRIAGE) + labels_to_add.add(Label.STALE_REVIEW) + comments.append( + f"This pull request has been blocked and inactive for " + f"{self.abandon_days} days. " + f"It has been marked as an `{Label.ABANDON_CANDIDATE}`. " + f"Please resolve the blockers to resume review." + ) + + # Scenario B: PR waiting on reviews + elif is_inactive_stale and Label.UNDER_REVIEW in context.labels: + if Label.STALE_REVIEW not in context.labels: + labels_to_add.add(Label.STALE_REVIEW) + labels_to_add.add(Label.NEEDS_TRIAGE) + comments.append( + f"This pull request has been inactive for " + f"{self.stale_days} days. " + f"Could you please provide an update or follow up on reviews?" + ) + + return RuleResult( + self.name, + satisfied=True, + labels_to_add=labels_to_add, + comments_to_create=comments, + action_taken="Evaluated inactivity limits." + ) + +# ============================================================================== +# Private Logic Verification Helper +# ============================================================================== +def verify_team_approvals(context: PRContext, team_handle: str, threshold: any, client: GitHubAPIClient) -> Tuple[bool, int]: + """Parses dynamic handles and checks dynamic pygithub approvals count.""" + # Handle parsing + if not team_handle.startswith("@") or "/" not in team_handle: + return False, 0 + + clean_handle = team_handle.lstrip("@") + org_name, team_slug = clean_handle.split("/", 1) + + approvals = 0 + approved_users = set() + + # Select active approvals + for review in context.reviews: + if review.state == "APPROVED": + is_member = client.check_team_membership(org_name, team_slug, review.user) + if is_member: + approvals += 1 + approved_users.add(review.user) + + if threshold == "majority": + # Programmatic majority overrides: TC triggers majority via manual majority label or meetings + return Label.TC_MAJORITY_APPROVED in context.labels, approvals + + try: + required_count = int(threshold) + except ValueError: + required_count = 1 + + return approvals >= required_count, approvals diff --git a/.github/workflows/scripts/routing/triage/rules_engine.py b/.github/workflows/scripts/routing/triage/rules_engine.py new file mode 100644 index 0000000..ca1ab61 --- /dev/null +++ b/.github/workflows/scripts/routing/triage/rules_engine.py @@ -0,0 +1,81 @@ +# rules_engine.py +import os +import yaml +from typing import List +from .models import PRContext, RuleResult +from .github_api import GitHubAPIClient +from .rules import BaseRule + +class RulesEngine: + def __init__(self, client: GitHubAPIClient): + self.client = client + self.rules: List[BaseRule] = [] + + def add_rule(self, rule: BaseRule): + """Registers a triage rule.""" + self.rules.append(rule) + + def load_routing_config(self) -> list: + """Helper to load and parse the UCP_PR_REVIEW_ROUTING.yml configuration.""" + config_path = os.path.join(os.path.dirname(__file__), "UCP_PR_REVIEW_ROUTING.yml") + if not os.path.exists(config_path): + print(f"[WARNING] Config not found at standard module path. Attempting root paths.") + # Fallback to sibling folder path configuration + config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "UCP_PR_REVIEW_ROUTING.yml") + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + return config.get("routing_rules", []) + except Exception as e: + print(f"[ERROR] Failed to load YAML routing rules config: {e}") + return [] + + def run(self, context: PRContext) -> str: + """Sequentially evaluates all registered rules, aggregates operations, and batches label and comment updates.""" + print(f"[ENGINE] Starting Rules Engine run for PR #{context.pr_number} in '{context.repo_name}'") + + labels_to_add = set() + labels_to_remove = set() + comments_to_create = [] + actions_summaries = [] + + for rule in self.rules: + print(f" - [EVALUATING] Rule: {rule.name}") + try: + result: RuleResult = rule.evaluate(context, self.client) + + # Aggregate changes + labels_to_add.update(result.labels_to_add) + labels_to_remove.update(result.labels_to_remove) + comments_to_create.extend(result.comments_to_create) + + print(f" [RESULT] Rule '{rule.name}' evaluated. Action: '{result.action_taken}'") + actions_summaries.append(f"{rule.name}: {result.action_taken}") + except Exception as e: + print(f" [ERROR] Rule '{rule.name}' raised an exception during execution: {e}") + + # Resolve contradictions (adding and removing the same label) + contradictions = labels_to_add.intersection(labels_to_remove) + if contradictions: + print(f"[ENGINE] Warning: Contradicting labels operations resolved (favoring addition): {contradictions}") + # Favor addition: remove them from removal set + labels_to_remove = labels_to_remove - contradictions + + # Clean up current context labels from the sets + labels_to_add = labels_to_add - context.labels + labels_to_remove = labels_to_remove.intersection(context.labels) + + # Batch actual GitHub API modifications + if labels_to_add: + self.client.add_labels_to_pr(context.pr_number, labels_to_add) + if labels_to_remove: + self.client.remove_labels_from_pr(context.pr_number, labels_to_remove) + + # Create comments sequentially + for comment in comments_to_create: + self.client.create_comment(context.pr_number, comment) + + summary_action = "; ".join(actions_summaries) + print(f"[ENGINE] Rules Engine execution completed for PR #{context.pr_number}. Summary: {summary_action}") + return summary_action diff --git a/.github/workflows/scripts/routing/validate-routing.py b/.github/workflows/scripts/routing/validate-routing.py new file mode 100755 index 0000000..0d46ce2 --- /dev/null +++ b/.github/workflows/scripts/routing/validate-routing.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "pygithub", +# "pyyaml", +# ] +# /// +import os +import sys +import yaml +from github import Github, Auth + +def parse_team_handle(handle: str): + """Parses a dynamic team handle (e.g. @Universal-Commerce-Protocol/payments-maintainers) to get org name and slug.""" + if not handle.startswith("@"): + raise ValueError(f"Invalid team handle format: '{handle}'. Handles must start with '@'.") + + clean_handle = handle.lstrip("@") + if "/" not in clean_handle: + raise ValueError(f"Invalid team handle format: '{handle}'. Must be in format '@org/team-slug'.") + + org_name, team_slug = clean_handle.split("/", 1) + return org_name, team_slug + +def main(): + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + print("[ERROR] GH_TOKEN or GITHUB_TOKEN environment variable is required to validate team existence.") + sys.exit(1) + + # Extract active repository name dynamically from the git config of the local clone. + try: + import subprocess + # Use file directory to ensure it runs git in correct path context + script_dir = os.path.dirname(os.path.abspath(__file__)) + origin_url = subprocess.check_output(["git", "-C", script_dir, "config", "--get", "remote.origin.url"]).decode("utf-8").strip() + clean_url = origin_url.replace(".git", "").replace(":", "/") + parts = clean_url.split("/") + repo_name = f"{parts[-2]}/{parts[-1]}" + print(f"[INFO] Target Repository resolved: {repo_name}") + except Exception as e: + print(f"[ERROR] Failed to dynamically determine current Git repository name: {e}") + sys.exit(1) + + config_path = os.path.join(os.path.dirname(__file__), "UCP_PR_REVIEW_ROUTING.yml") + if not os.path.exists(config_path): + print(f"[ERROR] Configuration file not found at: {config_path}") + sys.exit(1) + + print(f"[START] Validating routing configuration file: {config_path}") + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + except Exception as e: + print(f"[FAIL] Failed to parse YAML configuration syntax: {e}") + sys.exit(1) + + routing_rules = config.get("routing_rules") + if not routing_rules or not isinstance(routing_rules, list): + print("[FAIL] Missing or invalid 'routing_rules' root list in configuration.") + sys.exit(1) + + auth = Auth.Token(token) + g = Github(auth=auth) + + has_errors = False + referenced_teams = set() + + # 1. Syntax and Key Structure Checks + for idx, rule in enumerate(routing_rules): + rule_name = rule.get("name", f"Rule #{idx}") + patterns = rule.get("patterns") + review_reqs = rule.get("review_requirements") + + if not patterns or not isinstance(patterns, list): + print(f"[FAIL] Rule '{rule_name}' must have a non-empty list of 'patterns'.") + has_errors = True + + if not review_reqs or not isinstance(review_reqs, dict): + print(f"[FAIL] Rule '{rule_name}' must have a 'review_requirements' dictionary mapping.") + has_errors = True + continue + + # Parse team handles from review_requirements keys + for handle, details in review_reqs.items(): + if not isinstance(details, dict) or "threshold" not in details: + print(f"[FAIL] Rule '{rule_name}' requirement '{handle}' must be a dictionary with a 'threshold' key.") + has_errors = True + continue + + # Check needs_review_label and approved_label exist under details + if "needs_review_label" not in details or "approved_label" not in details: + print(f"[FAIL] Rule '{rule_name}' requirement '{handle}' must configure 'needs_review_label' and 'approved_label' properties.") + has_errors = True + + try: + org_name, team_slug = parse_team_handle(handle) + referenced_teams.add((org_name, team_slug, handle)) + except ValueError as ve: + print(f"[FAIL] Rule '{rule_name}' invalid team handle: {ve}") + has_errors = True + + if has_errors: + print("[FAIL] YAML validation failed due to syntax/structure errors.") + sys.exit(1) + + print("[INFO] YAML syntax and structure verification: PASSED") + # Extract organization name dynamically from repo_name to check teams in the current workspace org context + org_name = repo_name.split("/")[0] + print(f"[INFO] Verifying organization '{org_name}' existence for {len(referenced_teams)} dynamic team references...") + + # 2. API Group Existence Checks + verified_teams = 0 + for org_from_handle, team_slug, handle in referenced_teams: + try: + # Try to fetch organization using handle's declared org first + org = g.get_organization(org_from_handle) + team = org.get_team_by_slug(team_slug) + print(f" - [OK] Verified active team: {handle} (Slug: '{team_slug}', ID: {team.id})") + verified_teams += 1 + except Exception as e: + # If running in a personal repository fork, org is a standard user (not an org) which throws 404. + # Let's verify if the target org_from_handle matches a personal fork instead of failure + if org_from_handle == "Universal-Commerce-Protocol" and org_name != "Universal-Commerce-Protocol": + print(f" - [WARN] Skipping live verification for: {handle} (Running on local fork '{org_name}')") + verified_teams += 1 + else: + print(f" - [FAIL] Team handle does not exist on GitHub organization '{org_from_handle}': {handle} (Error: {e})") + has_errors = True + + if has_errors: + print(f"[FAIL] Config contains invalid or non-existent GitHub organization teams.") + sys.exit(1) + + print(f"[SUCCESS] Validation complete. All {verified_teams} dynamic review groups exist successfully on GitHub.") + +if __name__ == "__main__": + main()