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