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