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