First pass at tool to list gerrits included in a karaf distribution
[integration/test.git] / tools / distchanges / gerritquery.py
1 """
2 This module contains functions to manipulate gerrit queries.
3 """
4 import datetime
5 import json
6 import os
7 import re
8 import shlex
9 import subprocess
10 import traceback
11 import sys
12
13
14 # TODO: Haven't tested python 3
15 if sys.version < '3':
16     import urllib
17     import urlparse
18     urlencode = urllib.urlencode
19     urljoin = urlparse.urljoin
20     urlparse = urlparse.urlparse
21     do_input = raw_input
22 else:
23     import urllib.parse
24     import urllib.request
25     urlencode = urllib.parse.urlencode
26     urljoin = urllib.parse.urljoin
27     urlparse = urllib.parse.urlparse
28     do_input = input
29
30
31 class GitReviewException(Exception):
32     EXIT_CODE = 127
33
34
35 class CommandFailed(GitReviewException):
36
37     def __init__(self, *args):
38         Exception.__init__(self, *args)
39         (self.rc, self.output, self.argv, self.envp) = args
40         self.quickmsg = dict([
41             ("argv", " ".join(self.argv)),
42             ("rc", self.rc),
43             ("output", self.output)])
44
45     def __str__(self):
46         return self.__doc__ + """
47 The following command failed with exit code %(rc)d
48     "%(argv)s"
49 -----------------------
50 %(output)s
51 -----------------------""" % self.quickmsg
52
53
54 class GerritQuery:
55     REMOTE_URL = 'ssh://git.opendaylight.org:29418'
56     BRANCH = 'master'
57     QUERY_LIMIT = 50
58
59     remote_url = REMOTE_URL
60     branch = BRANCH
61     query_limit = QUERY_LIMIT
62     verbose = 0
63
64     def __init__(self, remote_url, branch, query_limit, verbose):
65         self.remote_url = remote_url
66         self.branch = branch
67         self.query_limit = query_limit
68         self.verbose = verbose
69
70     def set_verbose(self, verbose):
71         self.verbose = verbose
72
73     @staticmethod
74     def print_safe_encoding(string):
75         if sys.stdout.encoding is None:
76             print(string)
77         else:
78             print(string.encode(sys.stdout.encoding, 'replace'))
79
80     def run_command_status(self, *argv, **kwargs):
81
82         if self.verbose >= 2:
83             print(datetime.datetime.now(), "Running:", " ".join(argv))
84         if len(argv) == 1:
85             # for python2 compatibility with shlex
86             if sys.version_info < (3,) and isinstance(argv[0], unicode):
87                 argv = shlex.split(argv[0].encode('utf-8'))
88             else:
89                 argv = shlex.split(str(argv[0]))
90         stdin = kwargs.pop('stdin', None)
91         newenv = os.environ.copy()
92         newenv['LANG'] = 'C'
93         newenv['LANGUAGE'] = 'C'
94         newenv.update(kwargs)
95         p = subprocess.Popen(argv,
96                              stdin=subprocess.PIPE if stdin else None,
97                              stdout=subprocess.PIPE,
98                              stderr=subprocess.STDOUT,
99                              env=newenv)
100         (out, nothing) = p.communicate(stdin)
101         out = out.decode('utf-8', 'replace')
102         return (p.returncode, out.strip())
103
104     def run_command(self, *argv, **kwargs):
105         (rc, output) = self.run_command_status(*argv, **kwargs)
106         return output
107
108     def run_command_exc(self, klazz, *argv, **env):
109         """
110         Run command *argv, on failure raise klazz
111
112         klazz should be derived from CommandFailed
113         """
114         (rc, output) = self.run_command_status(*argv, **env)
115         if rc != 0:
116             raise klazz(rc, output, argv, env)
117         return output
118
119     def parse_gerrit_ssh_params_from_git_url(self):
120         """
121         Parse a given Git "URL" into Gerrit parameters. Git "URLs" are either
122         real URLs or SCP-style addresses.
123         """
124
125         # The exact code for this in Git itself is a bit obtuse, so just do
126         # something sensible and pythonic here instead of copying the exact
127         # minutiae from Git.
128
129         # Handle real(ish) URLs
130         if "://" in self.remote_url:
131             parsed_url = urlparse(self.remote_url)
132             path = parsed_url.path
133
134             hostname = parsed_url.netloc
135             username = None
136             port = parsed_url.port
137
138             # Workaround bug in urlparse on OSX
139             if parsed_url.scheme == "ssh" and parsed_url.path[:2] == "//":
140                 hostname = parsed_url.path[2:].split("/")[0]
141
142             if "@" in hostname:
143                 (username, hostname) = hostname.split("@")
144             if ":" in hostname:
145                 (hostname, port) = hostname.split(":")
146
147             if port is not None:
148                 port = str(port)
149
150         # Handle SCP-style addresses
151         else:
152             username = None
153             port = None
154             (hostname, path) = self.remote_url_url.split(":", 1)
155             if "@" in hostname:
156                 (username, hostname) = hostname.split("@", 1)
157
158         # Strip leading slash and trailing .git from the path to form the project
159         # name.
160         project_name = re.sub(r"^/|(\.git$)", "", path)
161
162         return (hostname, username, port, project_name)
163
164     def gerrit_request(self, request):
165         """
166         Send a gerrit request and receive a response.
167
168         :param str request: A gerrit query
169         :return unicode: The JSON response
170         """
171         (hostname, username, port, project_name) = \
172             self.parse_gerrit_ssh_params_from_git_url()
173
174         port_data = "p%s" % port if port is not None else ""
175         if username is None:
176             userhost = hostname
177         else:
178             userhost = "%s@%s" % (username, hostname)
179
180         if self.verbose >= 2:
181             print("gerrit request %s %s" % (self.remote_url, request))
182         output = self.run_command_exc(
183             CommandFailed,
184             "ssh", "-x" + port_data, userhost,
185             request)
186         if self.verbose >= 3:
187             self.print_safe_encoding(output)
188         return output
189
190     def make_gerrit_query(self, project, changeid=None, limit=1, msg=None):
191         """
192         Make a gerrit query by combining the given options.
193
194         :param str project: The project to search
195         :param str changeid: A Change-Id to search
196         :param int limit: The number of items to return
197         :param str msg: A commit-msg to search
198         :return str: A gerrit query
199         """
200         query = "gerrit query --format=json limit:%d status:merged --all-approvals " \
201                 "project:%s branch:%s" \
202                 % (limit, project, self.branch)
203         if changeid:
204             query += " change:%s" % changeid
205         if msg:
206             query += " message:%s" % msg
207         return query
208
209     def parse_gerrit(self, line, parse_exc=Exception):
210         """
211         Parse a single gerrit line and copy certain fields to a dictionary.
212
213         The merge time is found by looking for the Patch Set->Approval with
214         a SUBM type. Then use the grantedOn value.
215
216         :param str line: A single line from a previous gerrit query
217         :param parse_exc: The exception to except
218         :return dict: Pairs of gerrit items and their values
219         """
220         parsed = {}
221         try:
222             if line and line[0] == "{":
223                 try:
224                     data = json.loads(line)
225                     parsed['id'] = data['id']
226                     parsed['number'] = data['number']
227                     parsed['subject'] = data['subject']
228                     parsed['url'] = data['url']
229                     parsed['lastUpdated'] = data['lastUpdated']
230                     if "patchSets" in data:
231                         patch_sets = data['patchSets']
232                         for patch_set in reversed(patch_sets):
233                             if "approvals" in patch_set:
234                                 approvals = patch_set['approvals']
235                                 for approval in approvals:
236                                     if 'type' in approval and approval['type'] == 'SUBM':
237                                         parsed['grantedOn'] = approval['grantedOn']
238                                         break
239                                 if parsed['grantedOn']:
240                                     break
241                 except Exception:
242                     if self.verbose:
243                         print("Failed to decode JSON: %s" % traceback.format_exc())
244                         self.print_safe_encoding(line)
245         except Exception as err:
246             print("Exception: %s" % traceback.format_exc())
247             raise parse_exc(err)
248         return parsed
249
250     def extract_lines_from_json(self, changes):
251         """
252         Extract a list of lines from the JSON gerrit query response.
253
254         Drop the stats line.
255
256         :param unicode changes: The full JSON gerrit query response
257         :return list: Lines of the JSON
258         """
259         lines = []
260         for line in changes.split("\n"):
261             if line.find('stats') == -1:
262                 lines.append(line)
263         if self.verbose >= 2:
264             print("get_gerrit_lines: found %d lines" % len(lines))
265         return lines
266
267     def get_gerrits(self, project, changeid=None, limit=1, msg=None):
268         """
269         Get a list of gerrits from gerrit query request.
270
271         Gerrit returns queries in order of lastUpdated so resort based on merge time.
272         Also because gerrit returns them in lastUpdated order, it means all gerrits
273         merged after the one we are using will be returned, so the query limit needs to be
274         high enough to capture those extra merges plus the limit requested.
275         TODO: possibly add the before query to set a start time for the query around the change
276
277         :param str project: The project to search
278         :param str or None changeid: A Change-Id to search
279         :param int limit: The number of items to return
280         :param str msg: A commit-msg to search
281         :return str: A gerrit query
282         """
283         query = self.make_gerrit_query(project, changeid, limit, msg)
284         changes = self.gerrit_request(query)
285         lines = self.extract_lines_from_json(changes)
286         gerrits = []
287         for line in lines:
288             gerrits.append(self.parse_gerrit(line))
289
290         from operator import itemgetter
291         sorted_gerrits = sorted(gerrits, key=itemgetter('grantedOn'), reverse=True)
292         return sorted_gerrits