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