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