7f604548a8a99292bb3b7ae6043f222b10fb0c67
[integration/test.git] / tools / distchanges / changes.py
1 #!/usr/bin/env python
2 import argparse
3 import gerritquery
4 import os
5 import re
6 import sys
7 import time
8 import urllib3
9 import zipfile
10
11 """
12 TODO:
13 1. What about the time between when a merge is submitted and
14 the patch is in the distribution? Should we look at the other
15 events and see when the merge job finished?
16 2. Use the git query option to request records in multiple queries
17 rather than grabbing all 50 in one shot. Keep going until the requested
18 number is found. Verify if this is better than just doing all 50 in one
19 shot since multiple requests are ssh round trips per request.
20 """
21
22 # This file started as an exact copy of git-review so including it"s copyright
23
24 COPYRIGHT = """\
25 Copyright (C) 2011-2017 OpenStack LLC.
26
27 Licensed under the Apache License, Version 2.0 (the "License");
28 you may not use this file except in compliance with the License.
29 You may obtain a copy of the License at
30
31    http://www.apache.org/licenses/LICENSE-2.0
32
33 Unless required by applicable law or agreed to in writing, software
34 distributed under the License is distributed on an "AS IS" BASIS,
35 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
36 implied.
37
38 See the License for the specific language governing permissions and
39 limitations under the License."""
40
41
42 class Changes(object):
43     # NETVIRT_PROJECTS, as taken from autorelease dependency info [0]
44     # TODO: it would be nice to fetch the dependency info on the fly in case it changes down the road
45     # [0] https://logs.opendaylight.org/releng/jenkins092/autorelease-release-carbon/127/archives/dependencies.log.gz
46     NETVIRT_PROJECTS = ["netvirt", "controller", "dlux", "dluxapps", "genius", "infrautils", "mdsal", "netconf",
47                         "neutron", "odlparent", "openflowplugin", "ovsdb", "sfc", "yangtools"]
48     PROJECT_NAMES = NETVIRT_PROJECTS
49     VERBOSE = 0
50     DISTRO_PATH = "/tmp/distribution-karaf"
51     DISTRO_URL = None
52     REMOTE_URL = gerritquery.GerritQuery.REMOTE_URL
53     BRANCH = "master"
54     LIMIT = 10
55     QUERY_LIMIT = 50
56
57     gerritquery = None
58     distro_path = DISTRO_PATH
59     distro_url = DISTRO_URL
60     project_names = PROJECT_NAMES
61     branch = BRANCH
62     limit = LIMIT
63     qlimit = QUERY_LIMIT
64     remote_url = REMOTE_URL
65     verbose = VERBOSE
66     projects = {}
67
68     def __init__(self, branch=BRANCH, distro_path=DISTRO_PATH,
69                  limit=LIMIT, qlimit=QUERY_LIMIT,
70                  project_names=PROJECT_NAMES, remote_url=REMOTE_URL,
71                  verbose=VERBOSE):
72         self.branch = branch
73         self.distro_path = distro_path
74         self.limit = limit
75         self.qlimit = qlimit
76         self.project_names = project_names
77         self.remote_url = remote_url
78         self.verbose = verbose
79         self.projects = {}
80
81     def epoch_to_utc(self, epoch):
82         utc = time.gmtime(epoch)
83
84         return time.strftime("%Y-%m-%d %H:%M:%S", utc)
85
86     def pretty_print_gerrits(self, project, gerrits):
87         print("")
88         if project:
89             print("%s" % project)
90         print("i  grantedOn           lastUpdatd          chang subject")
91         print("-- ------------------- ------------------- ----- -----------------------------------------")
92         if gerrits is None:
93             print("gerrit is under review")
94             return
95         for i, gerrit in enumerate(gerrits):
96             if isinstance(gerrit, dict):
97                 print("%02d %19s %19s %5s %s"
98                       % (i,
99                          self.epoch_to_utc(gerrit["grantedOn"]) if "grantedOn" in gerrit else 0,
100                          self.epoch_to_utc(gerrit["lastUpdated"]) if "lastUpdated" in gerrit else 0,
101                          gerrit["number"] if "number" in gerrit else "00000",
102                          gerrit["subject"] if "subject" in gerrit else "none"))
103
104     def pretty_print_projects(self, projects):
105         if isinstance(projects, dict):
106             for project_name, values in projects.items():
107                 if "includes" in values:
108                     self.pretty_print_gerrits(project_name, values["includes"])
109
110     def set_projects(self, project_names=PROJECT_NAMES):
111         for project in project_names:
112             self.projects[project] = {"commit": [], "includes": []}
113
114     def download_distro(self):
115         """
116         Download the distribution from self.distro_url and extract it to self.distro_path
117         """
118         if self.verbose >= 2:
119             print("attempting to download distribution from %s and extract to %s " %
120                   (self.distro_url, self.distro_path))
121
122         tmp_distro_zip = '/tmp/distro.zip'
123         tmp_unzipped_location = '/tmp/distro_unzipped'
124         downloader = urllib3.PoolManager(cert_reqs='CERT_NONE')
125
126         # disabling warnings to prevent scaring the user with InsecureRequestWarning
127         urllib3.disable_warnings()
128
129         downloaded_distro = downloader.request('GET', self.distro_url)
130         with open(tmp_distro_zip, 'wb') as f:
131             f.write(downloaded_distro.data)
132
133         downloaded_distro.release_conn()
134
135         # after the .zip is extracted we want to rename it to be the distro_path which may have
136         # been given by the user
137         distro_zip = zipfile.ZipFile(tmp_distro_zip, 'r')
138         distro_zip.extractall(tmp_unzipped_location)
139         unzipped_distro_folder = os.listdir(tmp_unzipped_location)
140
141         # if the distro_path already exists, we wont overwrite it and just continue hoping what's
142         # there is relevant (and maybe already put there by this tool earlier)
143         try:
144             os.rename(tmp_unzipped_location + "/" + unzipped_distro_folder[0], self.distro_path)
145         except OSError as e:
146             print(e)
147             print("Unable to move extracted files from %s to %s. Using whatever bits are already there" %
148                   (tmp_unzipped_location, self.distro_path))
149
150     def get_includes(self, project, changeid=None, msg=None):
151         """
152         Get the gerrits that would be included before the change merge time.
153
154         :param str project: The project to search
155         :param str changeid: The Change-Id of the gerrit to use for the merge time
156         :param str msg: The commit message of the gerrit to use for the merge time
157         :return list: includes[0] is the gerrit requested, [1 to limit] are the gerrits found.
158         """
159         includes = self.gerritquery.get_gerrits(project, changeid, 1, msg)
160         if not includes:
161             print("Review %s in %s:%s was not found" % (changeid, project, self.gerritquery.branch))
162             return None
163
164         gerrits = self.gerritquery.get_gerrits(project, changeid=None, limit=self.qlimit, msg=msg)
165         for gerrit in gerrits:
166             # don"t include the same change in the list
167             if gerrit["id"] == changeid:
168                 continue
169
170             # TODO: should the check be < or <=?
171             if gerrit["grantedOn"] <= includes[0]["grantedOn"]:
172                 includes.append(gerrit)
173
174             # break out if we have the number requested
175             if len(includes) == self.limit + 1:
176                 break
177
178         if len(includes) != self.limit + 1:
179             print("%s query limit was not large enough to capture %d gerrits" % (project, self.limit))
180
181         return includes
182
183     @staticmethod
184     def extract_gitproperties_file(fullpath):
185         """
186         Extract a git.properties from a jar archive.
187
188         :param str fullpath: Path to the jar
189         :return str: Containing git.properties or None if not found
190         """
191         if zipfile.is_zipfile(fullpath):
192             zf = zipfile.ZipFile(fullpath, "r")
193             try:
194                 pfile = zf.open("META-INF/git.properties")
195                 return str(pfile.read())
196             except KeyError:
197                 pass
198         return None
199
200     def get_changeid_from_properties(self, project, pfile):
201         """
202         Parse the git.properties file to find a Change-Id.
203
204         There are a few different forms that we know of so far:
205         - I0123456789012345678901234567890123456789
206         - I01234567
207         - no Change-Id at all. There is a commit message and commit hash.
208         In this example the commit hash cannot be found because it was a merge
209         so you must use the message. Note spaces need to be replaced with +"s
210
211         :param str project: The project to search
212         :param str pfile: String containing the content of the git.properties file
213         :return str: The Change-Id or None if not found
214         """
215         # match a 40 or 8 char Change-Id hash. both start with I
216         regex = re.compile(r'\bI([a-f0-9]{40})\b|\bI([a-f0-9]{8})\b')
217         changeid = regex.search(pfile)
218         if changeid:
219             return changeid.group()
220
221         # Didn't find a Change-Id so try to get a commit message
222         # match on "blah" but only keep the blah
223         if self.verbose >= 2:
224             print("did not find Change-Id in %s, trying with commit-msg" % (project))
225         regex_msg = re.compile(r'"([^"]*)"|^git.commit.message.short=(.*)$')
226         msg = regex_msg.search(pfile)
227         if msg:
228             if self.verbose >= 2:
229                 print("did not find Change-Id in %s, trying with commit-msg: %s" % (project, msg.group()))
230
231                 # TODO: add new query using this msg
232             gerrits = self.gerritquery.get_gerrits(project, None, 1, msg.group())
233             if gerrits:
234                 return gerrits[0]["id"]
235
236         # Maybe one of the monster 'merge the world' gerrits
237         regex_msg = re.compile(r'git.commit.message.full=(.*)')
238         msg = regex_msg.search(pfile)
239         first_msg = None
240         if msg:
241             lines = str(msg.group()).split("\\n")
242             cli = next((i for i, line in enumerate(lines[:-1]) if '* changes\\:' in line), None)
243             first_msg = lines[cli+1] if cli else None
244         if first_msg:
245             gerrits = self.gerritquery.get_gerrits(project, None, 1, first_msg)
246             if gerrits:
247                 return gerrits[0]["id"]
248
249         print("did not find Change-Id for %s" % project)
250
251         return None
252
253     def find_distro_changeid(self, project):
254         """
255         Find a distribution Change-Id by finding a project jar in
256         the distribution and parsing it's git.properties.
257
258         :param str project: The project to search
259         :return str: The Change-Id or None if not found
260         """
261         project_dir = os.path.join(self.distro_path, "system", "org", "opendaylight", project)
262         pfile = None
263         for root, dirs, files in os.walk(project_dir):
264             for file_ in files:
265                 if file_.endswith(".jar"):
266                     fullpath = os.path.join(root, file_)
267                     pfile = self.extract_gitproperties_file(fullpath)
268                     if pfile:
269                         changeid = self.get_changeid_from_properties(project, pfile)
270                         if changeid:
271                             return changeid
272                         else:
273                             print("Could not find %s Change-Id in git.properties" % project)
274                             break  # all jars will have the same git.properties
275             if pfile is not None:
276                 break  # all jars will have the same git.properties
277         return None
278
279     def init(self):
280         self.gerritquery = gerritquery.GerritQuery(self.remote_url, self.branch, self.qlimit, self.verbose)
281         self.set_projects(self.project_names)
282
283     def print_options(self):
284         print("Using these options: branch: %s, limit: %d, qlimit: %d"
285               % (self.branch, self.limit, self.qlimit))
286         print("remote_url: %s" % self.remote_url)
287         print("distro_path: %s" % self.distro_path)
288         print("projects: %s" % (", ".join(map(str, self.projects))))
289         print("gerrit 00 is the most recent patch from which the project was built followed by the next most"
290               " recently merged patches up to %s." % self.limit)
291
292     def run_cmd(self):
293         """
294         Internal wrapper between main, options parser and internal code.
295
296         Get the gerrit for the given Change-Id and parse it.
297         Loop over all projects:
298             get qlimit gerrits and parse them
299             copy up to limit gerrits with a SUBM time (grantedOn) <= to the given change-id
300         """
301         # TODO: need method to validate the branch matches the distribution
302
303         self.init()
304         self.print_options()
305
306         if self.distro_url is not None:
307             self.download_distro()
308
309         for project in self.projects:
310             changeid = self.find_distro_changeid(project)
311             if changeid:
312                 self.projects[project]['commit'] = changeid
313                 self.projects[project]["includes"] = self.get_includes(project, changeid)
314         return self.projects
315
316     def main(self):
317         parser = argparse.ArgumentParser(description=COPYRIGHT)
318
319         parser.add_argument("-b", "--branch", default=self.BRANCH,
320                             help="git branch for patch under test")
321         parser.add_argument("-d", "--distro-path", dest="distro_path", default=self.DISTRO_PATH,
322                             help="path to the expanded distribution, i.e. " + self.DISTRO_PATH)
323         parser.add_argument("-u", "--distro-url", dest="distro_url", default=self.DISTRO_URL,
324                             help="optional url to download a distribution " + str(self.DISTRO_URL))
325         parser.add_argument("-l", "--limit", dest="limit", type=int, default=self.LIMIT,
326                             help="number of gerrits to return")
327         parser.add_argument("-p", "--projects", dest="projects", default=self.PROJECT_NAMES,
328                             help="list of projects to include in output")
329         parser.add_argument("-q", "--query-limit", dest="qlimit", type=int, default=self.QUERY_LIMIT,
330                             help="number of gerrits to search")
331         parser.add_argument("-r", "--remote", dest="remote_url", default=self.REMOTE_URL,
332                             help="git remote url to use for gerrit")
333         parser.add_argument("-v", "--verbose", dest="verbose", action="count", default=self.VERBOSE,
334                             help="Output more information about what's going on")
335         parser.add_argument("--license", dest="license", action="store_true",
336                             help="Print the license and exit")
337         parser.add_argument("-V", "--version", action="version",
338                             version="%s version %s" %
339                                     (os.path.split(sys.argv[0])[-1], 0.1))
340
341         options = parser.parse_args()
342
343         if options.license:
344             print(COPYRIGHT)
345             sys.exit(0)
346
347         self.branch = options.branch
348         self.distro_path = options.distro_path
349         self.distro_url = options.distro_url
350         self.limit = options.limit
351         self.qlimit = options.qlimit
352         self.remote_url = options.remote_url
353         self.verbose = options.verbose
354         if options.projects != self.PROJECT_NAMES:
355             self.project_names = options.projects.split(',')
356
357         # TODO: add check to verify that the remote can be reached,
358         # though the first gerrit query will fail anyways
359
360         projects = self.run_cmd()
361         self.pretty_print_projects(projects)
362         sys.exit(0)
363
364
365 def main():
366     changez = Changes()
367     try:
368         changez.main()
369     except Exception as e:
370         # If one does unguarded print(e) here, in certain locales the implicit
371         # str(e) blows up with familiar "UnicodeEncodeError ... ordinal not in
372         # range(128)". See rhbz#1058167.
373         try:
374             u = unicode(e)
375         except NameError:
376             # Python 3, we"re home free.
377             print(e)
378         else:
379             print(u.encode("utf-8"))
380             raise
381         sys.exit(getattr(e, "EXIT_CODE", -1))
382
383
384 if __name__ == "__main__":
385     main()