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