Skip to content

Commit 04f128a

Browse files
committed
add initial auto-merge capability
1 parent abfcc62 commit 04f128a

File tree

4 files changed

+104
-10
lines changed

4 files changed

+104
-10
lines changed

job-runner

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ from lib.aio.job import Job, run_job
2626
from lib.aio.jobcontext import JobContext
2727
from lib.aio.jsonutil import JsonError
2828
from lib.aio.util import JsonObjectAction, KeyValueAction
29+
from lib.bots_automerge import auto_merge_bots_pr
2930

3031
logger = logging.getLogger(__name__)
3132

lib/bots_automerge.py

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