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