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