diff --git a/org-tools/governance/scripts/pr_validator.py b/org-tools/governance/scripts/pr_validator.py index 297b887..fb7648d 100755 --- a/org-tools/governance/scripts/pr_validator.py +++ b/org-tools/governance/scripts/pr_validator.py @@ -174,12 +174,18 @@ def validate(self, pr: PullRequest) -> ValidationResult: # 7. Approvals & Assignments Evaluation (Global) requirement_statuses = self._evaluate_requirements( - merged_requirements, approver_usernames, assigned_usernames + merged_requirements, + approver_usernames, + assigned_usernames, + author=pr.author, ) # 8. File-by-File Evaluation file_statuses = self._evaluate_file_statuses( - requirements_by_file, approver_usernames, assigned_usernames + requirements_by_file, + approver_usernames, + assigned_usernames, + author=pr.author, ) # 9. Changes Requested Check @@ -223,6 +229,7 @@ def _evaluate_requirement( req: RuleRequirement, approver_usernames: set[str], requested_users_set: set[str], + author: str | None = None, ) -> RequirementStatus: """Calculate and return status for a requirement under the Venn Diagram model.""" approver_users = [User.create(u, self.memberships) for u in approver_usernames] @@ -231,11 +238,41 @@ def _evaluate_requirement( approvers = [u.username for u in approver_users if req.is_satisfied_by(u)] assigned_count = sum(1 for u in assigned_users if req.is_satisfied_by(u)) approved_count = len(approvers) + + # Dynamic Governance Council requirement evaluation based on PR author + is_gc_req = (req.team and req.team.name == "governance-council") or ( + req.min_team and req.min_team.name == "governance-council" + ) + + effective_req = req + require_total_approvals = None + + if is_gc_req and author: + author_user = User.create(author, self.memberships) + is_author_gc = "governance-council" in author_user.teams + + if is_author_gc: + min_approvals = 1 + if min_approvals != req.min_approvals: + effective_req = RuleRequirement( + min_approvals=min_approvals, + team=req.team, + min_team=req.min_team, + ) + require_total_approvals = req.min_approvals + + is_satisfied = approved_count >= effective_req.min_approvals + if ( + require_total_approvals is not None + and len(approver_usernames) < require_total_approvals + ): + is_satisfied = False + return RequirementStatus( - requirement=req, + requirement=effective_req, approved_count=approved_count, assigned_count=assigned_count, - is_satisfied=approved_count >= req.min_approvals, + is_satisfied=is_satisfied, approvers=sorted(approvers), ) @@ -244,12 +281,13 @@ def _evaluate_requirements( requirements: list[RuleRequirement], approver_usernames: set[str], assigned_usernames: set[str], + author: str | None = None, ) -> list[RequirementStatus]: """Evaluate each requirement's approvals and assignments count under the Venn Diagram model.""" requirement_statuses = [] for req in requirements: status = self._evaluate_requirement( - req, approver_usernames, assigned_usernames + req, approver_usernames, assigned_usernames, author=author ) requirement_statuses.append(status) @@ -305,12 +343,13 @@ def _evaluate_file_statuses( requirements_by_file: dict[str, list[RuleRequirement]], approver_usernames: set[str], assigned_usernames: set[str], + author: str | None = None, ) -> list[FileValidationStatus]: """Evaluates and generates validation statuses for each changed file in the PR.""" file_statuses = [] for file, file_requirements in requirements_by_file.items(): file_req_statuses = self._evaluate_requirements( - file_requirements, approver_usernames, assigned_usernames + file_requirements, approver_usernames, assigned_usernames, author=author ) file_satisfied = all(status.is_satisfied for status in file_req_statuses) diff --git a/org-tools/governance/scripts/validation_logger.py b/org-tools/governance/scripts/validation_logger.py index f6d9e14..919bfab 100644 --- a/org-tools/governance/scripts/validation_logger.py +++ b/org-tools/governance/scripts/validation_logger.py @@ -147,15 +147,21 @@ def _format_requirement_status( return f"{req_line}\n{met_line}" missing = req.min_approvals - status.approved_count - if status.assigned_count >= missing: - hint = f"Waiting for approval from {team_desc}." + if missing <= 0: + pending_line = ( + f"{indent} * **Pending:** Governance Council requirement met ({status.approved_count}/{req.min_approvals}), " + f"waiting for additional review from any member to satisfy total required approvals." + ) else: - needed = missing - status.assigned_count - hint = f"Waiting for {needed} more reviewer(s) to be assigned from {team_desc}." - pending_line = ( - f"{indent} * **Pending:** Needs {missing} approval{'s' if missing > 1 else ''} " - f"from {team_desc} ({status.assigned_count} eligible " - f"reviewer{'s' if status.assigned_count != 1 else ''} assigned). {hint}" - ) + if status.assigned_count >= missing: + hint = f"Waiting for approval from {team_desc}." + else: + needed = missing - status.assigned_count + hint = f"Waiting for {needed} more reviewer(s) to be assigned from {team_desc}." + pending_line = ( + f"{indent} * **Pending:** Needs {missing} approval{'s' if missing > 1 else ''} " + f"from {team_desc} ({status.assigned_count} eligible " + f"reviewer{'s' if status.assigned_count != 1 else ''} assigned). {hint}" + ) return f"{req_line}\n{met_line}\n{pending_line}" diff --git a/org-tools/governance/tests/test_pr_validator.py b/org-tools/governance/tests/test_pr_validator.py index 9fc1217..728817e 100644 --- a/org-tools/governance/tests/test_pr_validator.py +++ b/org-tools/governance/tests/test_pr_validator.py @@ -80,7 +80,7 @@ def setUp(self): patterns=["LICENSE", ".github/CODEOWNERS"], requires_all=[ RuleRequirement( - min_approvals=1, team=self.hierarchy["governance-council"] + min_approvals=2, team=self.hierarchy["governance-council"] ) ], ), @@ -120,6 +120,7 @@ def setUp(self): "governance-council": { "gov-member1", "gov-member2", + "gov-member3", "proxy1", }, }, @@ -259,7 +260,7 @@ def test_specific_team_requirement(self): self.assertFalse(res.is_mergeable) self.assertEqual(res.error, ValidationErrorReason.INSUFFICIENT_APPROVALS) - # gov-member1 is in governance-council, should pass + # gov-member1 and gov-member2 (2 GC members) for non-GC author1 should pass pr_ok = PullRequest( number=1, author="author1", @@ -267,11 +268,131 @@ def test_specific_team_requirement(self): changed_files=["LICENSE"], reviews=[ Review(user="gov-member1", state=ReviewState.APPROVED), + Review(user="gov-member2", state=ReviewState.APPROVED), ], ) res_ok = self.validator.validate(pr_ok) self.assertTrue(res_ok.is_mergeable) + def test_gc_author_non_gc_author_requires_two_approvals(self): + """Non-GC author requires 2 GC approvers.""" + pr_1_app = PullRequest( + number=1, + author="author1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member1", state=ReviewState.APPROVED), + ], + ) + res_1 = self.validator.validate(pr_1_app) + self.assertFalse(res_1.is_mergeable) + self.assertEqual(res_1.error, ValidationErrorReason.INSUFFICIENT_APPROVALS) + + pr_2_app = PullRequest( + number=1, + author="author1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member1", state=ReviewState.APPROVED), + Review(user="gov-member2", state=ReviewState.APPROVED), + ], + ) + res_2 = self.validator.validate(pr_2_app) + self.assertTrue(res_2.is_mergeable) + + def test_gc_author_requires_one_approval(self): + """GC author requires 1 GC approver and 2 total approvers when rule requires 2.""" + # gov-member1 (GC) author, 0 approvals -> fails + pr_0_app = PullRequest( + number=1, + author="gov-member1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[], + ) + res_0 = self.validator.validate(pr_0_app) + self.assertFalse(res_0.is_mergeable) + self.assertEqual(res_0.error, ValidationErrorReason.INSUFFICIENT_APPROVALS) + + # gov-member1 (GC) author, 1 GC approval alone -> fails (needs 2 total approvals) + pr_1_app = PullRequest( + number=1, + author="gov-member1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member2", state=ReviewState.APPROVED), + ], + ) + res_1 = self.validator.validate(pr_1_app) + self.assertFalse(res_1.is_mergeable) + self.assertEqual(res_1.error, ValidationErrorReason.INSUFFICIENT_APPROVALS) + + # gov-member1 (GC) author, 1 GC approval + 1 peer/maintainer approval -> passes + pr_1_gc_1_peer = PullRequest( + number=1, + author="gov-member1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member2", state=ReviewState.APPROVED), + Review(user="maint1", state=ReviewState.APPROVED), + ], + ) + res_gc_peer = self.validator.validate(pr_1_gc_1_peer) + self.assertTrue(res_gc_peer.is_mergeable) + self.assertEqual(res_gc_peer.mergeable_reason, MergeableReason.RULES_SATISFIED) + + # gov-member1 (GC) author, 2 GC approvals -> passes + pr_2_gc = PullRequest( + number=1, + author="gov-member1", + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member2", state=ReviewState.APPROVED), + Review(user="gov-member3", state=ReviewState.APPROVED), + ], + ) + res_2_gc = self.validator.validate(pr_2_gc) + self.assertTrue(res_2_gc.is_mergeable) + self.assertEqual(res_2_gc.mergeable_reason, MergeableReason.RULES_SATISFIED) + + def test_gc_requirement_non_gc_author_respects_yaml_1(self): + """Test that if YAML requires 1 approval, non-GC author only needs 1 (no override to 2).""" + rules = [ + GovernanceRule( + name="Gov 1", + patterns=["LICENSE"], + requires_all=[ + RuleRequirement( + min_approvals=1, team=self.hierarchy["governance-council"] + ) + ], + ) + ] + config = GovernanceConfig( + teams=self.hierarchy, + rules=rules, + fallback=self.fallback, + proxy_reviewers=self.proxy_reviewers, + ) + validator = PullRequestValidator(config, self.memberships) + + pr = PullRequest( + number=1, + author="author1", # Not GC + is_draft=False, + changed_files=["LICENSE"], + reviews=[ + Review(user="gov-member1", state=ReviewState.APPROVED), + ], + ) + res = validator.validate(pr) + self.assertTrue(res.is_mergeable) + def test_changes_requested_blocks(self): """Test that changes requested block validation.""" pr = PullRequest(