Process unmerged gerrits in distchanges
[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 # TODO: Haven't tested python 3
14 if sys.version < '3':
15     import urllib
16     import urlparse
17
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
26     urlencode = urllib.parse.urlencode
27     urljoin = urllib.parse.urljoin
28     urlparse = urllib.parse.urlparse
29     do_input = input
30
31
32 class GitReviewException(Exception):
33     EXIT_CODE = 127
34
35
36 class CommandFailed(GitReviewException):
37     """Command Failure Analysis"""
38
39     def __init__(self, *args):
40         Exception.__init__(self, *args)
41         (self.rc, self.output, self.argv, self.envp) = args
42         self.quickmsg = dict([
43             ("argv", " ".join(self.argv)),
44             ("rc", self.rc),
45             ("output", self.output)])
46
47     def __str__(self):
48         return self.__doc__ + """
49 The following command failed with exit code %(rc)d
50     "%(argv)s"
51 -----------------------
52 %(output)s
53 -----------------------""" % self.quickmsg
54
55
56 class GerritQuery:
57     REMOTE_URL = 'ssh://git.opendaylight.org:29418'
58     BRANCH = 'master'
59     QUERY_LIMIT = 50
60
61     remote_url = REMOTE_URL
62     branch = BRANCH
63     query_limit = QUERY_LIMIT
64     verbose = 0
65
66     def __init__(self, remote_url, branch, query_limit, verbose):
67         self.remote_url = remote_url
68         self.branch = branch
69         self.query_limit = query_limit
70         self.verbose = verbose
71
72     def set_verbose(self, verbose):
73         self.verbose = verbose
74
75     @staticmethod
76     def print_safe_encoding(string):
77         if sys.stdout.encoding is None:
78             # just print(string) could still throw a UnicodeEncodeError sometimes so casting string to unicode
79             print(unicode(string))
80         else:
81             print(string.encode(sys.stdout.encoding, 'replace'))
82
83     def run_command_status(self, *argv, **kwargs):
84
85         if self.verbose >= 2:
86             print(datetime.datetime.now(), "Running:", " ".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         if self.verbose >= 2:
184             print("gerrit request %s %s" % (self.remote_url, request))
185         output = self.run_command_exc(
186             CommandFailed,
187             "ssh", "-x" + port_data, userhost,
188             request)
189         if self.verbose >= 3:
190             self.print_safe_encoding(output)
191         return output
192
193     def make_gerrit_query(self, project, changeid=None, limit=1, msg=None, status=None, comments=False):
194         """
195         Make a gerrit query by combining the given options.
196
197         :param str project: The project to search
198         :param str changeid: A Change-Id to search
199         :param int limit: The number of items to return
200         :param str msg or None: A commit-msg to search
201         :param str status or None: The gerrit status, i.e. merged
202         :param bool comments: If true include comments
203         :return str: A gerrit query
204         """
205         query = "gerrit query --format=json limit:%d " \
206                 "project:%s branch:%s" \
207                 % (limit, project, self.branch)
208         if changeid:
209             query += " change:%s" % changeid
210         if msg:
211             query += " message:%s" % msg
212         if status:
213             query += " status:%s --all-approvals" % status
214         if comments:
215             query += " --comments"
216         return query
217
218     def parse_gerrit(self, line, parse_exc=Exception):
219         """
220         Parse a single gerrit line and copy certain fields to a dictionary.
221
222         The merge time is found by looking for the Patch Set->Approval with
223         a SUBM type. Then use the grantedOn value.
224
225         :param str line: A single line from a previous gerrit query
226         :param parse_exc: The exception to except
227         :return dict: Pairs of gerrit items and their values
228         """
229         parsed = {}
230         try:
231             if line and line[0] == "{":
232                 try:
233                     data = json.loads(line)
234                     parsed['id'] = data['id']
235                     parsed['number'] = data['number']
236                     parsed['subject'] = data['subject']
237                     parsed['url'] = data['url']
238                     parsed['lastUpdated'] = data['lastUpdated']
239                     parsed['grantedOn'] = 0
240                     if "patchSets" in data:
241                         patch_sets = data['patchSets']
242                         for patch_set in reversed(patch_sets):
243                             if "approvals" in patch_set:
244                                 approvals = patch_set['approvals']
245                                 for approval in approvals:
246                                     if 'type' in approval and approval['type'] == 'SUBM':
247                                         parsed['grantedOn'] = approval['grantedOn']
248                                         break
249                                 if parsed['grantedOn'] != 0:
250                                     break
251                     if "comments" in data:
252                         comments = data['comments']
253                         for comment in reversed(comments):
254                             if "message" in comment and "timestamp" in comment:
255                                 message = comment['message']
256                                 timestamp = comment['timestamp']
257                                 if "Build Started" in message and "patch-test" in message:
258                                     parsed['grantedOn'] = timestamp
259                                     break
260                 except Exception:
261                     if self.verbose:
262                         print("Failed to decode JSON: %s" % traceback.format_exc())
263                         self.print_safe_encoding(line)
264         except Exception as err:
265             print("Exception: %s" % traceback.format_exc())
266             raise parse_exc(err)
267         return parsed
268
269     def extract_lines_from_json(self, changes):
270         """
271         Extract a list of lines from the JSON gerrit query response.
272
273         Drop the stats line.
274
275         :param unicode changes: The full JSON gerrit query response
276         :return list: Lines of the JSON
277         """
278         lines = []
279         for line in changes.split("\n"):
280             if line.find('"type":"error","message"') != -1:
281                 print("there was a query error")
282                 continue
283             if line.find('stats') == -1:
284                 lines.append(line)
285         if self.verbose >= 2:
286             print("get_gerrit_lines: found %d lines" % len(lines))
287         return lines
288
289     def get_gerrits(self, project, changeid=None, limit=1, msg=None, status=None, comments=False):
290         """
291         Get a list of gerrits from gerrit query request.
292
293         Gerrit returns queries in order of lastUpdated so resort based on merge time.
294         Also because gerrit returns them in lastUpdated order, it means all gerrits
295         merged after the one we are using will be returned, so the query limit needs to be
296         high enough to capture those extra merges plus the limit requested.
297         TODO: possibly add the before query to set a start time for the query around the change
298
299         :param str project: The project to search
300         :param str or None changeid: A Change-Id to search
301         :param int limit: The number of items to return
302         :param str or None msg: A commit-msg to search
303         :param str or None status: The gerrit status, i.e. merged
304         :param bool comments: If true include comments
305         :return str: List of gerrits sorted by merge time
306         """
307         query = self.make_gerrit_query(project, changeid, limit, msg, status, comments)
308         changes = self.gerrit_request(query)
309         lines = self.extract_lines_from_json(changes)
310         gerrits = []
311         for line in lines:
312             gerrits.append(self.parse_gerrit(line))
313
314         from operator import itemgetter
315         sorted_gerrits = sorted(gerrits, key=itemgetter('grantedOn'), reverse=True)
316         return sorted_gerrits