1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
|
# Copyright (C) 2023 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
"""Use a passed repo list, or query gerrit for a list of repos, then iterate the list and
# query for the webhooks.config file on refs/meta/config for each repo.
# If the file exists and has a section titled `remote "qt-cherry-pick-bot"`,
# Then update the url of the section to point to the correct url for staging or production.
# If the file does not exist, create it using the template below.
# If the file exists but does not have a section titled `remote "qt-cherry-pick-bot"`,
# Then append the template below to the end of the file.
# Push the changes to gerrit. Publish the change and submit it."""
import sys
import json
import logging
import re
import urllib.request
import urllib.parse
import urllib.error
import base64
import argparse
from os import environ as envs
from urllib.parse import unquote, quote
# Parse arguments
parser = argparse.ArgumentParser(description="Add or update webhooks.config for qt-cherry-pick-bot")
parser.add_argument("--simulate", action="store_true", help="Perform a dry run but make no changes")
parser.add_argument("repos", nargs="*", help="List of repos to update. Defaults to all repos with"
" existing webhooks.config", default=[])
parser.add_argument("--add", action="store_true", help="Add webhooks.config to repos that don't"
" have it. Ignored if --repos is not specified")
parser.add_argument("--production", action="store_true", help="Update webhooks.config for"
" production, defaults to staging if not set")
parser.add_argument("--update-rootconfig", action="store_true", help="Update webhooks.config for the"
" 'All-Projects' repository with specific jira-closer-bot and "
"gerrit-webhooks-forwarder settings. Other repo arguments are ignored.")
parser.add_argument("--updateConfig", action="store_true", help="Update the entire"
" qt-cherry-pick-bot section if it differs from the template")
args = parser.parse_args()
gerrit_url = envs.get("GERRIT_URL", "https://codereview.qt-project.org")
CHERRY_PICK_PROD_URL = "https://qt-cherry-pick-bot.herokuapp.com/gerrit-events"
CHERRY_PICK_STAGE_URL = "https://qt-cherry-pick-bot-staging.herokuapp.com/gerrit-events"
# Gerrit user must have the following permissions:
# - Create change
# - Push
# - Submit
# - Approver
gerrit_username = envs.get("GERRIT_USERNAME", "")
gerrit_password = envs.get("GERRIT_PASSWORD", "")
gerrit_repos_with_webhooks = []
gerrit_repos_with_webhooks_to_update = []
gerrit_repos_with_webhooks_to_update_failed = []
gerrit_repos_with_webhooks_to_update_succeeded = []
gerrit_repos_without_webhooks = []
gerrit_repos_with_webhooks_not_updated = []
# Configuration for "All-Projects" repository when --update-rootconfig is used
ROOT_CONFIG_ALL_PROJECTS_PROD = """[remote "jira-closer-bot"]
url = https://qt-cherry-pick-bot.herokuapp.com/jiracloser
maxTries = 10
event = change-merged
[remote "gerrit-webhooks-forwarder"]
url = http://10.212.0.77:8080
maxTries = 10"""
ROOT_CONFIG_ALL_PROJECTS_STAGING = """[remote "jira-closer-bot"]
url = https://qt-cherry-pick-bot-staging.herokuapp.com/jiracloser
maxTries = 10
event = change-merged
# [remote "gerrit-webhooks-forwarder"]
# url = http://10.212.0.77:8080
# maxTries = 10"""
CONFIG_SECTION_HEADER = '[remote "qt-cherry-pick-bot"]'
CONFIG_SECTION_HEADER_ESC = r'\[remote "qt-cherry-pick-bot"\]' # Escaped for regex
# Determine target URL and template based on args
TARGET_URL = CHERRY_PICK_PROD_URL if args.production else CHERRY_PICK_STAGE_URL
WEBHOOK_TEMPLATE = f"""{CONFIG_SECTION_HEADER}
url = {TARGET_URL}
maxTries = 10
event = patchset-created
event = change-merged
event = change-abandoned
event = change-staged
event = change-unstaged
event = comment-added
event = change-deferred
event = change-restored
event = change-integration-pass
event = change-integration-fail
"""
gerrit_authstring = base64.b64encode(f"{gerrit_username}:{gerrit_password}"
.encode("utf-8")).decode("utf-8")
gerrit_authstring = f"Basic {gerrit_authstring}"
# Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Functions
def get_gerrit_repos():
""" Get a list of all repos from gerrit."""
url = f"{gerrit_url}/a/projects/"
request = urllib.request.Request(url)
request.add_header("Authorization", gerrit_authstring)
try:
with urllib.request.urlopen(request) as response:
if response.status == 200:
content = json.loads(response.read().decode("utf-8").replace(")]}'", ""))
return [content[repo]["id"] for repo in content.keys()
if content[repo]["state"] == "ACTIVE"]
else:
logger.error("Failed to get list of repos from gerrit: %s %s",
response.status, response.reason)
sys.exit(1)
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
def get_gerrit_repo_webhooks(repo):
""" Get the webhooks.config file from the refs/meta/config branch
It is returned as a base64 encoded string. Decode before returning."""
url = f"{gerrit_url}/a/projects/{repo}"
print("trying", url)
request = urllib.request.Request(url)
request.add_header("Authorization", gerrit_authstring)
try:
with urllib.request.urlopen(request) as response:
if response.status == 200:
logger.info("Repo %s exists", urllib.parse.unquote(repo))
except urllib.error.URLError as err:
if err.status == 404:
logger.error("Repo %s does not exist", urllib.parse.unquote(repo))
sys.exit(1)
logger.error("Failed to connect to gerrit: %s %s", err.status, err.reason)
sys.exit(1)
url = f"{gerrit_url}/a/projects/{repo}/branches/refs%2Fmeta%2Fconfig" \
"/files/webhooks.config/content"
request = urllib.request.Request(url)
request.add_header("Authorization", gerrit_authstring)
try:
with urllib.request.urlopen(request) as response:
if response.status == 200:
logger.info("Got webhooks.config file from repo %s", urllib.parse.unquote(repo))
return base64.b64decode(response.read()).decode("utf-8")
else:
logger.info("No webhooks.config file in repo %s: %s %s",
urllib.parse.unquote(repo), response.status, response.reason)
gerrit_repos_without_webhooks.append(repo)
return None
except urllib.error.URLError as err:
if err.status == 404:
logger.info("No webhooks.config file in repo %s", urllib.parse.unquote(repo))
gerrit_repos_without_webhooks.append(repo)
return None
logger.error("Failed to connect to gerrit: %s %s", err.status, err.reason)
sys.exit(1)
def update_gerrit_repo_webhooks(repo, webhooks_config):
""" Create a new change request with the webhooks.config file as the only file to be changed.
The change request will be created with the following commit message:
"Update webhook url for qt-cherry-pick-bot to [staging|production]"
The new/modified webhooks.config file will be pushed to the change request.
The change edit will be published.
The change request will be self-reviewed and submitted.
"""
# Create a new change request
change_input = {"project": unquote(repo),
"branch": "refs/meta/config",
"subject": "Update webhook url for qt-cherry-pick-bot to "
f"{'production' if args.production else 'staging'}",
"is_work_in_progress": False
}
if args.simulate:
logger.info("Simulating change request creation for repo %s", unquote(repo))
logger.info("Change request input: %s", change_input)
change_request_id = "abcd1234"
else:
url = f"{gerrit_url}/a/changes/"
data = json.dumps(change_input).encode("utf-8")
request = urllib.request.Request(url, data=data, method="POST")
request.add_header("Authorization", gerrit_authstring)
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request, timeout=60) as response:
if response.status == 201:
change_request = json.loads(response.read().decode("utf-8").replace(")]}'", ""))
change_request_id = change_request["id"]
logger.info("Created change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
else:
logger.error("Failed to create change request for repo %s",
urllib.parse.unquote(repo))
logger.error("%s: %s", response.status, response.read().decode("utf-8"))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
return None
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
# Upload the webhooks.config file to the change request
if args.simulate:
logger.info("Simulating upload of webhooks.config to change request %s for repo %s",
change_request_id, unquote(repo))
logger.info("Webhooks.config: %s", webhooks_config)
else:
url = f"{gerrit_url}/a/changes/{change_request_id}/edit/webhooks.config"
data = webhooks_config.encode("utf-8")
request = urllib.request.Request(url, data=data, method="PUT")
request.add_header("Authorization", gerrit_authstring)
request.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(request) as response:
if response.status == 204:
logger.info("Uploaded webhooks.config to change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
elif response.status == 409:
logger.info("Webhooks.config is already up-to-date for %s",
urllib.parse.unquote(repo))
gerrit_repos_with_webhooks_not_updated.append(repo)
return None
else:
logger.error("Failed to upload webhooks.config to change request"
" %s for repo %s", change_request_id, urllib.parse.unquote(repo))
logger.error("%s: %s", response.status, response.read().decode("utf-8"))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
return None
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
# Publish the change request
if args.simulate:
logger.info("Simulating publishing change request " + change_request_id
+ " for repo " + unquote(repo))
else:
url = f"{gerrit_url}/a/changes/{change_request_id}/edit:publish"
request = urllib.request.Request(url, method="POST")
request.add_header("Authorization", gerrit_authstring)
try:
with urllib.request.urlopen(request) as response:
if response.status == 204:
logger.info("Published change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
else:
logger.error("Failed to publish change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
logger.error("%s: %s", response.status, response.read().decode("utf-8"))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
return None
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
# Self-review the change request
if args.simulate:
logger.info("Simulating self-reviewing change request " + change_request_id
+ " for repo " + unquote(repo))
else:
url = f"{gerrit_url}/a/changes/{change_request_id}/revisions/current/review"
data = json.dumps({"labels": {"Code-Review": 2}}).encode("utf-8")
request = urllib.request.Request(url, data=data, method="POST")
request.add_header("Authorization", gerrit_authstring)
request.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(request) as response:
if response.status == 200:
logger.info("Self-reviewed change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
else:
logger.error("Failed to self-review change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
logger.error("%s: %s", response.status, response.read().decode("utf-8"))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
return None
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
# Submit the change request
if args.simulate:
logger.info("Simulating submitting change request " + change_request_id
+ " for repo " + unquote(repo))
gerrit_repos_with_webhooks_to_update_succeeded.append(repo)
else:
url = f"{gerrit_url}/a/changes/{change_request_id}/submit"
data = None
request = urllib.request.Request(url, data=data, method="POST")
request.add_header("Authorization", gerrit_authstring)
try:
with urllib.request.urlopen(request) as response:
if response.status == 200:
logger.info("Submitted change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
gerrit_repos_with_webhooks_to_update_succeeded.append(repo)
else:
logger.error("Failed to submit change request %s for repo %s",
change_request_id, urllib.parse.unquote(repo))
logger.error("%s: %s", response.status, response.read().decode("utf-8"))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
return None
except urllib.error.URLError as err:
logger.error("Failed to connect to gerrit: %s", err.reason)
sys.exit(1)
return True
def main():
if args.update_rootconfig:
repo_name = "All-Projects"
encoded_repo_name = quote(repo_name, safe='')
config_content_root: str
env_type: str
if args.production:
config_content_root = ROOT_CONFIG_ALL_PROJECTS_PROD
env_type = "production"
else:
config_content_root = ROOT_CONFIG_ALL_PROJECTS_STAGING
env_type = "staging"
logger.info(f"Processing --update-rootconfig for '{repo_name}' with {env_type} settings.")
update_gerrit_repo_webhooks(encoded_repo_name, config_content_root)
else:
if len(args.repos) > 0:
gerrit_repos = [quote(repo, safe='') for repo in args.repos]
else:
gerrit_repos = get_gerrit_repos()
# Regex to find the section header and capture everything until the next section or EOF
# DOTALL allows . to match newline, non-greedy .*? ensures we stop at the first \n[ or EOF
section_re = re.compile(rf"({CONFIG_SECTION_HEADER_ESC}(?:.|\n)*?(?=\n\[|$))", re.DOTALL)
# Regex to find the URL line within the section
url_re = re.compile(r"^\s*url\s*=\s*(.*)", re.MULTILINE)
for repo in gerrit_repos:
needs_update = False
new_webhooks_config = None
webhooks_config = get_gerrit_repo_webhooks(repo)
if webhooks_config:
section_match = section_re.search(webhooks_config)
if section_match:
existing_section = section_match.group(1).strip()
if args.updateConfig:
# Compare entire section with template
if existing_section != WEBHOOK_TEMPLATE.strip():
logger.info("Config section differs from template for repo %s", unquote(repo))
new_webhooks_config = section_re.sub(WEBHOOK_TEMPLATE, webhooks_config, count=1)
needs_update = True
else:
logger.info("Config section already matches template for repo %s", unquote(repo))
gerrit_repos_with_webhooks_not_updated.append(repo)
else:
# Only update the URL line within the existing section if --updateConfig is not set
url_match = url_re.search(existing_section)
if url_match:
existing_url = url_match.group(1).strip()
if existing_url != TARGET_URL:
logger.info("Webhook URL needs update for repo %s", unquote(repo))
# Replace only the URL line within the original config
new_url_line = f" url = {TARGET_URL}"
# We need to be careful to replace the URL only within the matched section
# One way is to replace within existing_section and then replace existing_section in webhooks_config
updated_section = url_re.sub(new_url_line, existing_section, count=1)
new_webhooks_config = webhooks_config.replace(existing_section, updated_section, 1)
needs_update = True
else:
logger.info("Webhook URL already up-to-date for repo %s", unquote(repo))
gerrit_repos_with_webhooks_not_updated.append(repo)
else:
# This case implies the section exists but has no URL, which is unlikely for this template
# but if it happens, and --updateConfig is false, we might want to add the URL or log an error.
# For now, let's assume if --updateConfig is false, we only modify existing URLs.
# If --updateConfig was true, the whole section would be replaced.
logger.error("Could not find URL line in existing section for repo %s, and --updateConfig is false.", unquote(repo))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
elif args.add:
# Section header not found, but file exists and --add is specified
logger.info("Adding webhook section to existing config for repo %s", unquote(repo))
# Append template, ensuring a newline separates it from existing content if webhooks_config is not empty
separator = "\n" if webhooks_config.strip() else ""
new_webhooks_config = webhooks_config + separator + WEBHOOK_TEMPLATE
needs_update = True
else:
# Section header not found, file exists, --add not specified
logger.info("No %s section in config for %s and --add not specified.",
CONFIG_SECTION_HEADER, unquote(repo))
gerrit_repos_with_webhooks_not_updated.append(repo) # Treat as not updated
elif args.add:
# webhooks.config file does not exist and --add is specified
logger.info("Creating webhooks.config for repo %s", unquote(repo))
new_webhooks_config = WEBHOOK_TEMPLATE
needs_update = True
else:
# webhooks.config file does not exist, --add not specified
logger.info("No webhooks.config for %s and --add not specified.", unquote(repo))
gerrit_repos_without_webhooks.append(repo) # Keep track of repos without webhooks
if needs_update and new_webhooks_config is not None:
gerrit_repos_with_webhooks.append(repo) # Mark as attempted
if update_gerrit_repo_webhooks(repo, new_webhooks_config):
logger.info("Successfully updated/added webhook config for repo %s", unquote(repo))
else:
# update_gerrit_repo_webhooks logs errors and adds to failed list
logger.error("Failed to update/add webhook config for repo %s", unquote(repo))
elif needs_update and new_webhooks_config is None:
# Should not happen if logic is correct, but good to catch
logger.error("Internal error: Update flagged but no new config generated for %s", unquote(repo))
gerrit_repos_with_webhooks_to_update_failed.append(repo)
logger.info("Summary:")
logger.info("Succeeded: %s", str([unquote(repo) for repo in gerrit_repos_with_webhooks_to_update_succeeded]))
logger.info("Repos with webhooks that failed to update: %s",
str([unquote(repo) for repo in gerrit_repos_with_webhooks_to_update_failed]))
logger.info("Repos with webhooks that were not updated: %s",
str([unquote(repo) for repo in gerrit_repos_with_webhooks_not_updated]))
logger.info("Repos without webhooks: %s",
str([unquote(repo) for repo in gerrit_repos_without_webhooks]))
logger.info("Repos with webhooks that were not updated: %s",
str([unquote(repo) for repo in gerrit_repos_with_webhooks_not_updated]))
if __name__ == "__main__":
main()
|