Skip to content

Commit 3410c87

Browse files
committed
add initial auto-merge capability
1 parent abfcc62 commit 3410c87

File tree

3 files changed

+104
-10
lines changed

3 files changed

+104
-10
lines changed

lib/bots_automerge.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# This file is part of Cockpit.
2+
#
3+
# Copyright (C) 2025 Red Hat, Inc.
4+
#
5+
# Cockpit is free software; you can redistribute it and/or modify it
6+
# under the terms of the GNU Lesser General Public License as published by
7+
# the Free Software Foundation; either version 2.1 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# Cockpit is distributed in the hope that it will be useful, but
11+
# WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
# Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public License
16+
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
17+
18+
import logging
19+
20+
from lib.aio.jsonutil import get_int, get_str
21+
from task import github
22+
23+
logger = logging.getLogger(__name__)
24+
25+
# TODO: verify if this is always the same
26+
GITHUB_CI = {
27+
'login': 'github-actions[bot]',
28+
'id': 41898282,
29+
}
30+
31+
COCKPITUOUS = {
32+
'login': 'cockpituous',
33+
'id': 14330603,
34+
}
35+
36+
37+
def is_ci_bot(api: github.GitHub, pr: int) -> bool:
38+
author = api.get_author(pr)
39+
login = get_str(author, 'login')
40+
login_id = get_int(author, 'id')
41+
42+
return ((login == GITHUB_CI['login'] and login_id == GITHUB_CI['id']) or
43+
(login == COCKPITUOUS['login'] and login_id == COCKPITUOUS['id']))
44+
45+
46+
def all_checks_pass(api: github.GitHub, commit_hash: str) -> bool:
47+
statuses = api.statuses(commit_hash)
48+
49+
logger.info("Checking statuses:")
50+
if len(statuses) == 0:
51+
logger.info("No statuses found for commit %s", commit_hash)
52+
return False
53+
54+
for context in statuses:
55+
status = statuses[context]
56+
status_state = get_str(status, 'state')
57+
logger.info("Status for context '%s': %s", context, status_state)
58+
if status_state != 'success':
59+
return False
60+
61+
return True
62+
63+
64+
def auto_merge_bots_pr(repo: str, pr: int, sha: str) -> None:
65+
api = github.GitHub(repo=repo)
66+
67+
# Make sure that the PR was made by cockpituous or github actions
68+
# if not is_ci_bot(api, pr_num):
69+
# logger.info("PR not made by CI bot, skipping automerge")
70+
# return
71+
72+
# check that all checks are green
73+
if not all_checks_pass(api, sha):
74+
logger.info("Not every check has passed, skipping automerge")
75+
return
76+
77+
logger.info("All checks green, can automerge")
78+
# merge the PR
79+
api.approve_pr(pr, sha)

run-queue

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ from collections.abc import Sequence
3131

3232
import pika
3333

34+
from lib.aio.jsonutil import JsonObject, get_int, get_str
35+
from lib.bots_automerge import auto_merge_bots_pr
3436
from lib.directories import get_images_data_dir
3537
from lib.network import redhat_network
3638
from lib.stores import LOG_STORE
@@ -43,7 +45,9 @@ statistics_queue = os.environ.get("RUN_STATISTICS_QUEUE")
4345
# as per pika docs
4446
DeliveryTag = int
4547

46-
ConsumeResult = tuple[Sequence[str] | str | None, DeliveryTag | None]
48+
JobSubject = JsonObject
49+
50+
ConsumeResult = tuple[Sequence[str] | str | None, DeliveryTag | None, JobSubject | None]
4751

4852

4953
# Returns a command argv to execute and the delivery tag needed to ack the message
@@ -52,7 +56,7 @@ def consume_webhook_queue(dq: distributed_queue.DistributedQueue) -> ConsumeResu
5256
# call tests-scan or issue-scan appropriately
5357
method_frame, _header_frame, message = dq.channel.basic_get(queue='webhook')
5458
if not method_frame or not message:
55-
return None, None
59+
return None, None, None
5660

5761
body = json.loads(message)
5862
event = body['event']
@@ -97,9 +101,9 @@ def consume_webhook_queue(dq: distributed_queue.DistributedQueue) -> ConsumeResu
97101
cmd = ['./issue-scan', '--issues-data', json.dumps(request), '--amqp', dq.address]
98102
else:
99103
logging.error('Unkown event type in the webhook queue')
100-
return None, None
104+
return None, None, None
101105

102-
return cmd, method_frame.delivery_tag
106+
return cmd, method_frame.delivery_tag, None
103107

104108

105109
# Returns a command to execute and the delivery tag needed to ack the message
@@ -119,18 +123,20 @@ def consume_task_queue(dq: distributed_queue.DistributedQueue) -> ConsumeResult:
119123
queue = ['public', 'rhel'][random.randrange(2)]
120124
else:
121125
# nothing to do
122-
return None, None
126+
return None, None, None
123127

124128
method_frame, _header_frame, message = dq.channel.basic_get(queue=queue)
125129
if not method_frame or not message:
126-
return None, None
130+
return None, None, None
127131

128132
body = json.loads(message)
129133
if job := body.get('job'):
130134
command = ['./job-runner', 'json', json.dumps(job)]
135+
job_subject = job.get('subject')
131136
else:
132137
command = body['command']
133-
return command, method_frame.delivery_tag
138+
job_subject = None
139+
return command, method_frame.delivery_tag, job_subject
134140

135141

136142
def mail_notification(body: str) -> None:
@@ -159,14 +165,14 @@ def main() -> int:
159165
opts = parser.parse_args()
160166

161167
with distributed_queue.DistributedQueue(opts.amqp, ['webhook', 'rhel', 'public', 'statistics']) as dq:
162-
cmd, delivery_tag = consume_webhook_queue(dq)
168+
cmd, delivery_tag, job_subj = consume_webhook_queue(dq)
163169
if not cmd and delivery_tag:
164170
logging.info("Webhook message interpretation generated no command")
165171
dq.channel.basic_ack(delivery_tag)
166172
return 0
167173

168174
if not cmd:
169-
cmd, delivery_tag = consume_task_queue(dq)
175+
cmd, delivery_tag, job_subj = consume_task_queue(dq)
170176
if not cmd:
171177
logging.info("All queues are empty")
172178
return 1
@@ -191,6 +197,16 @@ failed with exit code %i. Please check the container logs for details.""" % (cmd
191197
if delivery_tag is not None:
192198
dq.channel.basic_ack(delivery_tag)
193199

200+
if job_subj is not None:
201+
repo = get_str(job_subj, 'repo')
202+
pull = get_int(job_subj, 'pull')
203+
sha = get_str(job_subj, 'sha')
204+
# skip automerge if jobs don't run against a PR
205+
if repo is not None and pull is not None and sha is not None:
206+
auto_merge_bots_pr(repo, pull, sha)
207+
else:
208+
logging.info("Skipping automerge for job: %s", job_subj)
209+
194210
return 0
195211

196212

task/github.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,6 @@ def get_author(self, pr: int) -> JsonObject:
426426

427427
def approve_pr(self, pr: int, sha: str) -> None:
428428
# https://docs.github.com/en/rest/pulls/reviews?apiVersion=2022-11-28#create-a-review-for-a-pull-request
429-
430429
data = {
431430
'commit_id': sha,
432431
'event': 'APPROVE',

0 commit comments

Comments
 (0)