Skip to content

Commit 8a9393f

Browse files
authoredMar 21, 2025··
[ISV-5736] Approved label is used instead of PR reviews (#804)
* [ISV-5736] Approved label is used instead of PR reviews
1 parent 1de144b commit 8a9393f

File tree

3 files changed

+158
-52
lines changed

3 files changed

+158
-52
lines changed
 

‎ansible/roles/operator-pipeline/templates/openshift/tasks/merge-pr.yml

+8-23
Original file line numberDiff line numberDiff line change
@@ -63,31 +63,16 @@ spec:
6363
exit 0
6464
fi
6565
66-
# To avoid issuing too many GH API requests, fetch all PR info we need
67-
# in a single call upfront and process the result later
68-
gh pr view "$(params.git_pr_url)" --json isDraft,reviews,author >/tmp/pr.json
66+
# we are not using GH reviews for approval, so the only thing we need to check is the presence
67+
# of 'approved' label (added by reviewer through '/approve' comment or by the pipeline)
68+
gh pr view "$(params.git_pr_url)" --json labels >/tmp/pr.json
69+
IS_APPROVED=$(jq -r '[.labels[].name == "approved"] | any' /tmp/pr.json || echo "false")
6970
7071
71-
# Extract all reviews and return one line per reviewer containing three space separated fields:
72-
# $state $authorAssociation $author
73-
# where
74-
# $state is the outcome of the most recent review by the author and can be APPROVED, CHANGES_REQUESTED,
75-
# DISMISSED, COMMENTED or PENDING
76-
# $authorAssociation is the role of the author in the repository; for possible values see
77-
# https://docs.github.com/en/graphql/reference/enums#commentauthorassociation
78-
# $author is the GitHub handle of the reviewer
79-
# Example:
80-
# APPROVED MEMBER rh-operator-bundle-bot
81-
# PENDING NONE randomuser
82-
jq -r '[.reviews[]|{author:.author.login,authorAssociation,state,submittedAt}]|group_by(.author)|map(sort_by(.submittedAt)|.[-1]|"\(.state) \(.authorAssociation) \(.author)")[]' \
83-
/tmp/pr.json | tee /tmp/reviews.txt
84-
85-
86-
# Check if a PR author is a member matches a bot name
87-
if [[ "$(jq -r '.author.login' /tmp/pr.json)" == "rh-operator-bundle-bot" ]]; then
88-
echo "Author of the PR is known bot, merge is allowed"
89-
elif ! grep "^APPROVED MEMBER " /tmp/reviews.txt ; then
90-
# Do not merge if we do not have approval from the bot or any other repo member
72+
if [ "$IS_APPROVED" = "true" ]; then
73+
echo "PR is approved and can be merged"
74+
else
75+
# Do not merge if we do not have approval
9176
echo -n "Skipping merge: PR is not approved." | tee "$(workspaces.output.path)/merge_error.txt"
9277
echo -n "false" > "$(results.pr_merged.path)"
9378
exit 0

‎operator-pipeline-images/operatorcert/entrypoints/check_permissions.py

+66-13
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77
import urllib
88
from typing import Any
99

10-
from github import Auth, Github, UnknownObjectException
10+
from github import (
11+
Auth,
12+
Github,
13+
UnknownObjectException,
14+
GithubException,
15+
)
1116
from operatorcert import pyxis
17+
from operatorcert.github import add_labels_to_pull_request, parse_github_issue_url
1218
from operatorcert.logger import setup_logger
1319
from operatorcert.operator_repo import Operator
1420
from operatorcert.operator_repo import Repo as OperatorRepo
@@ -213,7 +219,11 @@ def check_permissions(self) -> bool:
213219
return True
214220
if self.is_partner():
215221
return self.check_permission_for_partner()
216-
return self.check_permission_for_community()
222+
if self.check_permission_for_community():
223+
return True
224+
# Enable PR approval on forked repository
225+
# and approves rh-operator-bundle-bot PRs
226+
return self.pr_owner_can_write()
217227

218228
def is_org_member(self) -> bool:
219229
"""
@@ -248,6 +258,35 @@ def is_org_member(self) -> bool:
248258
)
249259
return False
250260

261+
def pr_owner_can_write(self) -> bool:
262+
"""
263+
Check if the pull request owner has write permissions for the repository
264+
265+
Returns:
266+
bool: A boolean value indicating if the user
267+
has write permissions for the repository
268+
"""
269+
LOGGER.info(
270+
"Checking if the pull request owner has write permissions for the repository"
271+
)
272+
github_auth = Auth.Token(os.environ.get("GITHUB_TOKEN") or "")
273+
github = Github(auth=github_auth)
274+
repo = github.get_repo(self.github_repo_name)
275+
try:
276+
permission = repo.get_collaborator_permission(self.pr_owner)
277+
except GithubException:
278+
LOGGER.info(
279+
"Cannot get permissions for the pull request owner '%s'", self.pr_owner
280+
)
281+
return False
282+
if permission in ["admin", "write"]:
283+
LOGGER.info(
284+
"Pull request owner '%s' has write permissions for the repository",
285+
self.pr_owner,
286+
)
287+
return True
288+
return False
289+
251290
def check_permission_for_partner(self) -> bool:
252291
"""
253292
Check if the pull request owner has permissions to submit a PR for the for
@@ -328,22 +367,21 @@ def check_permission_for_community(self) -> bool:
328367

329368
def request_review_from_maintainers(self) -> None:
330369
"""
331-
Request review from repository global maintainers.
332-
The maintainers are tagged in the PR as reviewers.
370+
Request review from the operator maintainers by adding a comment to the PR.
333371
"""
334372
LOGGER.info(
335373
"Requesting a review from maintainers for the pull request: %s",
336374
self.pull_request_url,
337375
)
376+
maintainers_with_at = ", ".join(map(lambda x: f"@{x}", self.maintainers))
377+
comment_text = (
378+
"This PR requires a review from repository maintainers.\n"
379+
f"{maintainers_with_at}: please review the PR and approve it with an "
380+
"`approved` label if the pipeline is still running or merge the PR "
381+
"directly after review if the pipeline already passed successfully."
382+
)
338383
run_command(
339-
[
340-
"gh",
341-
"pr",
342-
"edit",
343-
self.pull_request_url,
344-
"--add-reviewer",
345-
",".join(self.maintainers),
346-
]
384+
["gh", "pr", "comment", self.pull_request_url, "--body", comment_text]
347385
)
348386

349387
def request_review_from_owners(self) -> None:
@@ -461,6 +499,21 @@ def check_permissions(
461499
return all(is_approved)
462500

463501

502+
def add_label_approved(pull_request_url: str) -> None:
503+
"""
504+
Add 'approved' label to the pull request
505+
506+
Args:
507+
pull_request_url (str): URL to the pull request
508+
"""
509+
repository_name, pr_id = parse_github_issue_url(pull_request_url)
510+
github_auth = Auth.Token(os.environ.get("GITHUB_TOKEN") or "")
511+
github = Github(auth=github_auth)
512+
repository = github.get_repo(repository_name)
513+
pull_request = repository.get_pull(pr_id)
514+
add_labels_to_pull_request(pull_request, ["approved"])
515+
516+
464517
def main() -> None:
465518
"""
466519
Main function of the script
@@ -481,7 +534,7 @@ def main() -> None:
481534
is_approved = check_permissions(base_repo, head_repo, args)
482535

483536
if is_approved:
484-
run_command(["gh", "pr", "review", args.pull_request_url, "--approve"])
537+
add_label_approved(args.pull_request_url)
485538

486539
# Save the results to a file
487540
output = {

‎operator-pipeline-images/tests/entrypoints/test_check_permissions.py

+84-16
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from pathlib import Path
2-
from typing import Any, Optional
2+
from typing import Any
33
from unittest import mock
44
from unittest.mock import MagicMock, call, patch
55

66
import operatorcert.entrypoints.check_permissions as check_permissions
77
import pytest
8-
from github import UnknownObjectException
8+
from github import GithubException, UnknownObjectException
99
from operatorcert.operator_repo import Repo as OperatorRepo
1010
from tests.operator_repo import bundle_files, create_files
1111

@@ -133,20 +133,41 @@ def _mock_label(name: str) -> MagicMock:
133133

134134

135135
@pytest.mark.parametrize(
136-
"is_org_member, is_partner, permission_partner, permission_community, permission_partner_called, permission_community_called, expected_result",
136+
"is_org_member, is_partner, owner_can_write, permission_partner, permission_community, permission_partner_called, permission_community_called, expected_result",
137137
[
138-
pytest.param(True, False, False, False, False, False, True, id="org member"),
139138
pytest.param(
140-
False, True, True, False, True, False, True, id="partner - approved"
139+
True, False, False, False, False, False, False, True, id="org member"
141140
),
142141
pytest.param(
143-
False, True, False, False, True, False, False, id="partner - denied"
142+
False, True, False, True, False, True, False, True, id="partner - approved"
144143
),
145144
pytest.param(
146-
False, False, False, True, False, True, True, id="community - approved"
145+
False, True, False, False, False, True, False, False, id="partner - denied"
147146
),
148147
pytest.param(
149-
False, False, False, False, False, True, False, id="community - denied"
148+
False, False, True, False, False, False, True, True, id="owner - approved"
149+
),
150+
pytest.param(
151+
False,
152+
False,
153+
False,
154+
False,
155+
True,
156+
False,
157+
True,
158+
True,
159+
id="community - approved",
160+
),
161+
pytest.param(
162+
False,
163+
False,
164+
False,
165+
False,
166+
False,
167+
False,
168+
True,
169+
False,
170+
id="community - denied",
150171
),
151172
],
152173
)
@@ -158,14 +179,17 @@ def _mock_label(name: str) -> MagicMock:
158179
)
159180
@patch("operatorcert.entrypoints.check_permissions.OperatorReview.is_partner")
160181
@patch("operatorcert.entrypoints.check_permissions.OperatorReview.is_org_member")
182+
@patch("operatorcert.entrypoints.check_permissions.OperatorReview.pr_owner_can_write")
161183
def test_OperatorReview_check_permissions(
184+
mock_pr_owner_can_write: MagicMock,
162185
mock_is_org_member: MagicMock,
163186
mock_is_partner: MagicMock,
164187
mock_check_permission_for_partner: MagicMock,
165188
mock_check_permission_for_community: MagicMock,
166189
review_community: check_permissions.OperatorReview,
167190
is_org_member: bool,
168191
is_partner: bool,
192+
owner_can_write: bool,
169193
permission_partner: bool,
170194
permission_community: bool,
171195
permission_partner_called: bool,
@@ -174,6 +198,7 @@ def test_OperatorReview_check_permissions(
174198
) -> None:
175199
mock_is_org_member.return_value = is_org_member
176200
mock_is_partner.return_value = is_partner
201+
mock_pr_owner_can_write.return_value = owner_can_write
177202
mock_check_permission_for_partner.return_value = permission_partner
178203
mock_check_permission_for_community.return_value = permission_community
179204
assert review_community.check_permissions() == expected_result
@@ -216,6 +241,29 @@ def test_OperatorReview_is_org_member(
216241
assert review_community.is_org_member() == False
217242

218243

244+
@patch("operatorcert.entrypoints.check_permissions.Github")
245+
@patch("operatorcert.entrypoints.check_permissions.Auth.Token")
246+
def test_OperatorReview_pr_owner_can_write(
247+
mock_token: MagicMock,
248+
mock_github: MagicMock,
249+
review_community: check_permissions.OperatorReview,
250+
) -> None:
251+
mock_repo = MagicMock()
252+
mock_github.return_value.get_repo.return_value = mock_repo
253+
254+
# User can write to the repository
255+
mock_repo.get_collaborator_permission.return_value = "write"
256+
assert review_community.pr_owner_can_write() == True
257+
258+
# User cannot write to the repository
259+
mock_repo.get_collaborator_permission.return_value = "read"
260+
assert review_community.pr_owner_can_write() == False
261+
262+
# Cannot get collaborator permission
263+
mock_repo.get_collaborator_permission.side_effect = GithubException(404, "", {})
264+
assert review_community.pr_owner_can_write() == False
265+
266+
219267
@pytest.mark.parametrize(
220268
["project", "valid"],
221269
[
@@ -331,10 +379,13 @@ def test_OperatorReview_request_review_from_maintainers(
331379
[
332380
"gh",
333381
"pr",
334-
"edit",
382+
"comment",
335383
review_community.pull_request_url,
336-
"--add-reviewer",
337-
"maintainer1,maintainer2",
384+
"--body",
385+
"This PR requires a review from repository maintainers.\n"
386+
f"@maintainer1, @maintainer2: please review the PR and approve it with an "
387+
"`approved` label if the pipeline is still running or merge the PR "
388+
"directly after review if the pipeline already passed successfully.",
338389
]
339390
)
340391

@@ -446,8 +497,27 @@ def test_check_permissions(
446497
)
447498

448499

500+
@patch("operatorcert.entrypoints.check_permissions.Github")
501+
@patch("operatorcert.entrypoints.check_permissions.Auth.Token")
502+
@patch("operatorcert.entrypoints.check_permissions.add_labels_to_pull_request")
503+
def test_add_label_approved(
504+
mock_add_labels: MagicMock,
505+
mock_token: MagicMock,
506+
mock_github: MagicMock,
507+
) -> None:
508+
mock_repo = MagicMock()
509+
mock_github.return_value.get_repo.return_value = mock_repo
510+
mock_pull = MagicMock()
511+
mock_repo.get_pull.return_value = mock_pull
512+
513+
check_permissions.add_label_approved("https://github.com/my-org/repo-123/pulls/1")
514+
mock_github.return_value.get_repo.assert_has_calls([call("my-org/repo-123")])
515+
mock_repo.get_pull.assert_called_once_with(1)
516+
mock_add_labels.assert_called_once_with(mock_pull, ["approved"])
517+
518+
449519
@patch("operatorcert.entrypoints.check_permissions.json.dump")
450-
@patch("operatorcert.entrypoints.check_permissions.run_command")
520+
@patch("operatorcert.entrypoints.check_permissions.add_label_approved")
451521
@patch("operatorcert.entrypoints.check_permissions.check_permissions")
452522
@patch("operatorcert.entrypoints.check_permissions.OperatorRepo")
453523
@patch("operatorcert.entrypoints.check_permissions.setup_logger")
@@ -457,7 +527,7 @@ def test_main(
457527
mock_setup_logger: MagicMock,
458528
mock_operator_repo: MagicMock,
459529
mock_check_permissions: MagicMock,
460-
mock_run_command: MagicMock,
530+
mock_add_label_approved: MagicMock,
461531
mock_json_dump: MagicMock,
462532
monkeypatch: Any,
463533
tmp_path: Path,
@@ -480,9 +550,7 @@ def test_main(
480550
check_permissions.main()
481551

482552
mock_check_permissions.assert_called_once_with(base_repo, head_repo, args)
483-
mock_run_command.assert_called_once_with(
484-
["gh", "pr", "review", args.pull_request_url, "--approve"]
485-
)
553+
mock_add_label_approved.assert_called_once_with(args.pull_request_url)
486554

487555
expected_output = {
488556
"approved": mock_check_permissions.return_value,

0 commit comments

Comments
 (0)
Please sign in to comment.