OpenVPN
gerrit-send-mail.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2
3# Copyright (C) 2023-2026 OpenVPN Inc <sales@openvpn.net>
4# Copyright (C) 2023-2026 Frank Lichtenheld <frank.lichtenheld@openvpn.net>
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License version 2
8# as published by the Free Software Foundation.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License along
16# with this program; if not, see <https://www.gnu.org/licenses/>.
17
18# Extract a patch from Gerrit and transform it in a file suitable as input
19# for git send-email.
20
21import argparse
22import base64
23from datetime import timezone
24import json
25import sys
26from urllib.parse import urlparse
27
28import dateutil.parser
29import requests
30
31
32def get_details(args):
33 params = {"o": ["CURRENT_REVISION", "LABELS", "DETAILED_ACCOUNTS"]}
34 r = requests.get(f"{args.url}/changes/{args.changeid}", params=params)
35 print(r.url)
36 json_txt = r.text.removeprefix(")]}'\n")
37 json_data = json.loads(json_txt)
38 assert len(json_data["revisions"]) == 1 # CURRENT_REVISION works as expected
39 revision = json_data["revisions"].popitem()[1]["_number"]
40 assert "Code-Review" in json_data["labels"]
41 acked_by = []
42 for reviewer in json_data["labels"]["Code-Review"]["all"]:
43 if "value" in reviewer:
44 assert reviewer["value"] >= 0 # no NACK
45 if reviewer["value"] == 2:
46 # fall back to user name if optional fields are not set
47 reviewer_name = reviewer.get("display_name", reviewer["name"])
48 reviewer_mail = reviewer.get("email", reviewer["name"])
49 ack = f"{reviewer_name} <{reviewer_mail}>"
50 print(f"Acked-by: {ack}")
51 acked_by.append(ack)
52 # construct Signed-off-by in case it is missing
53 owner = json_data["owner"]
54 owner_name = owner.get("display_name", owner["name"])
55 owner_mail = owner.get("email", owner["name"])
56 sign_off = f"{owner_name} <{owner_mail}>"
57 print(f"Signed-off-by: {sign_off}")
58 change_id = json_data["change_id"]
59 # assumes that the created date in Gerrit is in UTC
60 utc_stamp = (
61 dateutil.parser.parse(json_data["created"])
62 .replace(tzinfo=timezone.utc)
63 .timestamp()
64 )
65 # convert to milliseconds as used in message id
66 created_stamp = int(utc_stamp * 1000)
67 hostname = urlparse(args.url).hostname
68 msg_id = f"gerrit.{created_stamp}.{change_id}@{hostname}"
69 return {
70 "revision": revision,
71 "project": json_data["project"],
72 "target": json_data["branch"],
73 "msg_id": msg_id,
74 "acked_by": acked_by,
75 "sign_off": sign_off,
76 }
77
78
79def get_patch(details, args):
80 r = requests.get(
81 f"{args.url}/changes/{args.changeid}/revisions/{details['revision']}/patch?download"
82 )
83 print(r.url)
84 patch_text = base64.b64decode(r.text).decode()
85 return patch_text
86
87
88def apply_patch_mods(patch_text, details, args):
89 comment_start = patch_text.index("\n---\n") + len("\n---\n")
90 signed_off_text = ""
91 signed_off_comment = ""
92 try:
93 signed_off_start = patch_text.rindex("\nSigned-off-by: ")
94 signed_off_end = patch_text.index("\n", signed_off_start + 1) + 1
95 except ValueError: # Signed-off missing
96 signed_off_text = f"Signed-off-by: {details['sign_off']}\n"
97 signed_off_comment = "\nSigned-off-by line for the author was added as per our policy.\n"
98 signed_off_end = patch_text.index("\n---\n") + 1
99 assert comment_start > signed_off_end
100 acked_by_text = ""
101 acked_by_names = ""
102 gerrit_url = f"{args.url}/c/{details['project']}/+/{args.changeid}"
103 for ack in details["acked_by"]:
104 acked_by_text += f"Acked-by: {ack}\n"
105 acked_by_names += f"{ack}\n"
106 patch_text_mod = (
107 patch_text[:signed_off_end]
108 + signed_off_text
109 + acked_by_text
110 + f"Gerrit URL: {gerrit_url}\n"
111 + patch_text[signed_off_end:comment_start]
112 + f"""
113This change was reviewed on Gerrit and approved by at least one
114developer. I request to merge it to {details["target"]}.
115
116Gerrit URL: {gerrit_url}
117This mail reflects revision {details["revision"]} of this Change.
118{signed_off_comment}
119Acked-by according to Gerrit (reflected above):
120{acked_by_names}
121 """
122 + patch_text[comment_start:]
123 )
124 filename = f"gerrit-{args.changeid}-{details['revision']}.patch"
125 patch_text_final = patch_text_mod.replace("Subject: [PATCH]", f"Subject: [PATCH v{details['revision']}]")
126 with open(filename, "w", encoding="utf-8", newline="\n") as patch_file:
127 patch_file.write(patch_text_final)
128 print("send with:")
129 print(f"git send-email --in-reply-to {details['msg_id']} {filename}")
130
131
132def main():
133 parser = argparse.ArgumentParser(
134 prog="gerrit-send-mail",
135 description="Send patchset from Gerrit to mailing list",
136 )
137 parser.add_argument("changeid")
138 parser.add_argument("-u", "--url", default="https://gerrit.openvpn.net")
139 args = parser.parse_args()
140
141 details = get_details(args)
142 patch = get_patch(details, args)
143 apply_patch_mods(patch, details, args)
144
145
146if __name__ == "__main__":
147 sys.exit(main())
apply_patch_mods(patch_text, details, args)
get_patch(details, args)