Skip to content

Commit 0a1bf35

Browse files
chmouelpipelines-as-code[bot]
authored andcommitted
feat: Add direct PR approval check for LGTM
This commit introduces a new feature that enables the PRHandler to also consider direct PR approvals when determining if the LGTM threshold is met. The code now fetches PR reviews from the GitHub API and counts 'approved' reviews towards the LGTM count, in addition to comment-based /lgtm votes. Self-approvals are skipped. Fixes #42 Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent a4f617c commit 0a1bf35

File tree

4 files changed

+57
-44
lines changed

4 files changed

+57
-44
lines changed

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[MESSAGES CONTROL]
2-
disable=redefined-outer-name,line-too-long,missing-function-docstring,missing-module-docstring,too-many-locals,invalid-name,protected-access,consider-using-with,fixme
2+
disable=redefined-outer-name,line-too-long,missing-function-docstring,missing-module-docstring,too-many-locals,invalid-name,protected-access,consider-using-with,fixme,too-many-branches
33

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ UVCMD = uv run
55
PIPELINE_PROW = pipeline-boussole.yaml
66
GH_REPO_OWNER = openshift-pipelines
77
GH_REPO_NAME = pac-boussole
8-
GH_PR_NUM = 16
9-
GH_PR_SENDER = chmouel
10-
GH_COMMENT_SENDER = anotheruser
8+
GH_PR_NUM = 44
9+
GH_PR_SENDER = anotheruser
10+
GH_COMMENT_SENDER = chmouel
1111
PASS_TOKEN = github/chmouel-token
1212
PRURL = https://github.com/$(GH_REPO_OWNER)/$(GH_REPO_NAME)/pull/$(GH_PR_NUM)
1313
CONTAINER_IMAGE = ghcr.io/openshift-pipelines/pac-boussole:nightly
@@ -29,7 +29,7 @@ directtest: ## Run a specific command directly (e.g., make directtest CMD=/lgtm)
2929
@env GH_PR_NUM=$(GH_PR_NUM) GH_REPO_NAME=$(GH_REPO_NAME) GH_REPO_OWNER=$(GH_REPO_OWNER) \
3030
GH_PR_SENDER=$(GH_PR_SENDER) GH_COMMENT_SENDER=$(GH_COMMENT_SENDER) \
3131
PAC_TRIGGER_COMMENT="$(CMD)" GITHUB_TOKEN=`pass show $(PASS_TOKEN)` \
32-
python3 ./boussole/boussole.py
32+
./pac-boussole
3333

3434
open_pr: ## Open the PR in the browser
3535
@if type -p xdg-open > /dev/null; then xdg-open $(PRURL); \

boussole/boussole.py

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,32 @@ def _post_comment(self, message: str) -> RequestResponse:
7676
endpoint = f"issues/{self.pr_num}/comments"
7777
return self.api.post(endpoint, {"body": message})
7878

79-
def _fetch_and_validate_lgtm_votes(self):
79+
def _fetch_and_validate_lgtm_votes(self) -> Tuple[int, Dict[str, Optional[str]]]:
8080
"""
8181
Fetches LGTM votes and validates them.
8282
8383
Returns the number of valid votes and a dictionary of users with their
8484
permissions.
8585
"""
86+
reviews_endpoint = f"pulls/{self.pr_num}/reviews"
87+
reviews_response = self.api.get(reviews_endpoint)
88+
if reviews_response.status_code != 200:
89+
error_message = COMMENTS_FETCH_ERROR.format(
90+
status_code=reviews_response.status_code,
91+
response_text=reviews_response.text,
92+
pr_num=self.pr_num,
93+
)
94+
print(error_message, file=sys.stderr)
95+
sys.exit(1)
96+
97+
lgtm_users: Dict[str, Optional[str]] = {}
98+
reviews = reviews_response.json()
99+
for review in reviews:
100+
if review["state"].lower() == "approved":
101+
user = review["user"]["login"]
102+
if user != self.pr_sender: # Skip self-approvals
103+
lgtm_users[user] = None
104+
86105
endpoint = f"issues/{self.pr_num}/comments"
87106
response = self.api.get(endpoint)
88107
if response.status_code != 200:
@@ -93,9 +112,7 @@ def _fetch_and_validate_lgtm_votes(self):
93112
)
94113
print(error_message, file=sys.stderr)
95114
sys.exit(1)
96-
97115
comments = response.json()
98-
lgtm_users: Dict[str, Optional[str]] = {}
99116
for comment in comments:
100117
body = comment.get("body", "")
101118
if re.search(r"^/lgtm\b", body, re.IGNORECASE):
@@ -331,40 +348,11 @@ def rebase(self) -> RequestResponse:
331348
def lgtm(self, send_comment: bool = True) -> int:
332349
"""
333350
Processes LGTM votes and approves the PR if the threshold is met.
334-
"""
335-
endpoint = f"issues/{self.pr_num}/comments"
336-
response = self.api.get(endpoint)
337-
if response.status_code != 200:
338-
error_message = COMMENTS_FETCH_ERROR.format(
339-
status_code=response.status_code,
340-
response_text=response.text,
341-
pr_num=self.pr_num,
342-
)
343-
print(error_message, file=sys.stderr)
344-
sys.exit(1)
345-
346-
comments = response.json()
347-
lgtm_users: Dict[str, Optional[str]] = {}
348-
for comment in comments:
349-
body = comment.get("body", "")
350-
if re.search(r"^/lgtm\b", body, re.IGNORECASE):
351-
user = comment["user"]["login"]
352-
if user == self.pr_sender:
353-
msg = SELF_APPROVAL_ERROR.format(
354-
user=user, comment_url=comment["html_url"]
355-
)
356-
self._post_comment(msg)
357-
print(msg, file=sys.stderr)
358-
sys.exit(1)
359-
lgtm_users[user] = None
360-
361-
valid_votes = 0
362-
for user in lgtm_users:
363-
permission, is_valid = self._check_membership(user)
364-
lgtm_users[user] = permission
365-
if is_valid:
366-
valid_votes += 1
367351
352+
Includes both comment-based LGTM and direct PR approvals.
353+
"""
354+
# First check direct PR approvals
355+
valid_votes, lgtm_users = self._fetch_and_validate_lgtm_votes()
368356
if valid_votes >= self.lgtm_threshold:
369357
users_table = ""
370358
for user, permission in lgtm_users.items():
@@ -431,7 +419,6 @@ def merge_pr(self) -> bool:
431419

432420
# Fetch LGTM votes and check if the threshold is met
433421
valid_votes, lgtm_users = self._fetch_and_validate_lgtm_votes()
434-
435422
if valid_votes >= self.lgtm_threshold:
436423
endpoint = f"pulls/{self.pr_num}/merge"
437424
data = {

boussole/test_boussole.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def mock_json_permissions():
109109

110110
# Assign the side_effect to return different results on multiple calls
111111
mock_api.get.return_value.json.side_effect = [
112+
[],
112113
mock_json_comments(), # First call to json() -> PR comments
113114
mock_json_permissions(), # Second call to json() -> reviewer1 permission
114115
mock_json_permissions(), # Third call to json() -> reviewer2 permission
@@ -118,12 +119,36 @@ def mock_json_permissions():
118119
mock_api.get.return_value.status_code = 200
119120

120121

122+
def test_lgtm_by_review_request(pr_handler, mock_api, capsys):
123+
mock_api.get.return_value.status_code = 200
124+
125+
# Mock response for PR comments with self-approval
126+
mock_api.get.return_value.json.side_effect = [
127+
[
128+
{"state": "APPROVED", "user": {"login": "reviewer1"}},
129+
{"state": "APPROVED", "user": {"login": "reviewer2"}},
130+
],
131+
[],
132+
{"permission": "write"},
133+
{"permission": "write"},
134+
]
135+
pr_handler.lgtm()
136+
assert "PR approved with LGTM votes" in capsys.readouterr().out
137+
138+
121139
def test_lgtm_self_approval(pr_handler, mock_api):
122140
mock_api.get.return_value.status_code = 200
123141

124142
# Mock response for PR comments with self-approval
125-
mock_api.get.return_value.json.return_value = [
126-
{"body": "/lgtm", "user": {"login": "test_user"}, "html_url": "http://test.url"}
143+
mock_api.get.return_value.json.side_effect = [
144+
[],
145+
[
146+
{
147+
"body": "/lgtm",
148+
"user": {"login": "test_user"},
149+
"html_url": "http://test.url",
150+
},
151+
],
127152
]
128153

129154
with pytest.raises(SystemExit) as exc_info:
@@ -209,6 +234,7 @@ def test_merge_pr_success(pr_handler, mock_api):
209234
MyFakeResponse(200, {"permission": "admin"}),
210235
MyFakeResponse(200, {"head": {"sha": "abc123"}}),
211236
all_checks,
237+
MyFakeResponse(200, {}),
212238
all_comments,
213239
MyFakeResponse(200, {"permission": "write"}), # reviewer1
214240
MyFakeResponse(200, {"permission": "write"}), # reviewer2

0 commit comments

Comments
 (0)