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