Migrate Get Requests invocations(libraries)
[integration/test.git] / tools / distchanges / changes.py
1 #!/usr/bin/env python
2 import argparse
3 import gerritquery
4 import logging
5 import os
6 import re
7 import sys
8 import time
9 import urllib3
10 import zipfile
11
12 """
13 TODO:
14 1. What about the time between when a merge is submitted and
15 the patch is in the distribution? Should we look at the other
16 events and see when the merge job finished?
17 2. Use the git query option to request records in multiple queries
18 rather than grabbing all 50 in one shot. Keep going until the requested
19 number is found. Verify if this is better than just doing all 50 in one
20 shot since multiple requests are ssh round trips per request.
21 """
22
23 # This file started as an exact copy of git-review so including it"s copyright
24
25 COPYRIGHT = """\
26 Copyright (C) 2011-2017 OpenStack LLC.
27
28 Licensed under the Apache License, Version 2.0 (the "License");
29 you may not use this file except in compliance with the License.
30 You may obtain a copy of the License at
31
32    http://www.apache.org/licenses/LICENSE-2.0
33
34 Unless required by applicable law or agreed to in writing, software
35 distributed under the License is distributed on an "AS IS" BASIS,
36 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
37 implied.
38
39 See the License for the specific language governing permissions and
40 limitations under the License."""
41
42
43 logger = logging.getLogger("changes")
44 logger.setLevel(logging.DEBUG)
45 formatter = logging.Formatter(
46     "%(asctime)s - %(levelname).4s - %(name)s - %(lineno)04d - %(message)s"
47 )
48 ch = logging.StreamHandler()
49 ch.setLevel(logging.INFO)
50 ch.setFormatter(formatter)
51 logger.addHandler(ch)
52 fh = logging.FileHandler("/tmp/changes.txt", "w")
53 fh.setLevel(logging.DEBUG)
54 fh.setFormatter(formatter)
55 logger.addHandler(fh)
56
57
58 class ChangeId(object):
59     def __init__(self, changeid, merged):
60         self.changeid = changeid
61         self.merged = merged
62
63
64 class Changes(object):
65     PROJECT_NAMES = [
66         "controller",
67         "dlux",
68         "dluxapps",
69         "infrautils",
70         "mdsal",
71         "netconf",
72         "neutron",
73         "odlparent",
74         "openflowplugin",
75         "ovsdb",
76         "sfc",
77         "yangtools",
78     ]
79     VERBOSE = logging.INFO
80     DISTRO_PATH = "/tmp/distribution-karaf"
81     DISTRO_URL = None
82     REMOTE_URL = gerritquery.GerritQuery.REMOTE_URL
83     BRANCH = "master"
84     LIMIT = 10
85     QUERY_LIMIT = 50
86
87     gerritquery = None
88     distro_path = DISTRO_PATH
89     distro_url = DISTRO_URL
90     project_names = PROJECT_NAMES
91     branch = BRANCH
92     limit = LIMIT
93     qlimit = QUERY_LIMIT
94     remote_url = REMOTE_URL
95     verbose = VERBOSE
96     projects = {}
97     regex_changeid = None
98     regex_shortmsg = None
99     regex_longmsg = None
100
101     def __init__(
102         self,
103         branch=BRANCH,
104         distro_path=DISTRO_PATH,
105         limit=LIMIT,
106         qlimit=QUERY_LIMIT,
107         project_names=PROJECT_NAMES,
108         remote_url=REMOTE_URL,
109         verbose=VERBOSE,
110     ):
111         self.branch = branch
112         self.distro_path = distro_path
113         self.limit = limit
114         self.qlimit = qlimit
115         self.project_names = project_names
116         self.remote_url = remote_url
117         self.verbose = verbose
118         self.projects = {}
119         self.set_log_level(verbose)
120         self.regex_changeid = re.compile(
121             r"(Change-Id.*: (\bI[a-f0-9]{40})\b|\bI([a-f0-9]{8})\b)"
122         )
123         # self.regex_shortmsg = re.compile(r'"([^"]*)"|(git.commit.message.short=(.*))')
124         self.regex_shortmsg1 = re.compile(r'(git.commit.message.short=.*"([^"]*)")')
125         self.regex_shortmsg2 = re.compile(r"(git.commit.message.short=(.*))")
126         self.regex_longmsg = re.compile(r"git.commit.message.full=(.*)")
127         self.regex_commitid = re.compile(r"(git.commit.id=(.*))")
128
129     @staticmethod
130     def set_log_level(level):
131         ch.setLevel(level)
132
133     def epoch_to_utc(self, epoch):
134         utc = time.gmtime(epoch)
135
136         return time.strftime("%Y-%m-%d %H:%M:%S", utc)
137
138     def pretty_print_gerrits(self, project, gerrits):
139         if project:
140             print("%s" % project)
141         print("i  grantedOn           lastUpdatd          chang subject")
142         print(
143             "-- ------------------- ------------------- ----- -----------------------------------------"
144         )
145         if gerrits is None:
146             print("gerrit is under review")
147             return
148         for i, gerrit in enumerate(gerrits):
149             if isinstance(gerrit, dict):
150                 print(
151                     "%02d %19s %19s %5s %s"
152                     % (
153                         i,
154                         self.epoch_to_utc(gerrit["grantedOn"])
155                         if "grantedOn" in gerrit
156                         else 0,
157                         self.epoch_to_utc(gerrit["lastUpdated"])
158                         if "lastUpdated" in gerrit
159                         else 0,
160                         gerrit["number"] if "number" in gerrit else "00000",
161                         gerrit["subject"].encode("ascii", "replace")
162                         if "subject" in gerrit
163                         else "none",
164                     )
165                 )
166
167     def pretty_print_projects(self, projects):
168         print("========================================")
169         print("distchanges")
170         print("========================================")
171         if isinstance(projects, dict):
172             for project_name, values in sorted(projects.items()):
173                 if "includes" in values:
174                     self.pretty_print_gerrits(project_name, values["includes"])
175
176     def set_projects(self, project_names=PROJECT_NAMES):
177         for project in project_names:
178             self.projects[project] = {"commit": [], "includes": []}
179
180     def download_distro(self):
181         """
182         Download the distribution from self.distro_url and extract it to self.distro_path
183         """
184         logger.info(
185             "attempting to download distribution from %s and extract to %s",
186             self.distro_url,
187             self.distro_path,
188         )
189
190         tmp_distro_zip = "/tmp/distro.zip"
191         tmp_unzipped_location = "/tmp/distro_unzipped"
192         downloader = urllib3.PoolManager(cert_reqs="CERT_NONE")
193
194         # disabling warnings to prevent scaring the user with InsecureRequestWarning
195         urllib3.disable_warnings()
196
197         downloaded_distro = downloader.request("GET", self.distro_url)
198         with open(tmp_distro_zip, "wb") as f:
199             f.write(downloaded_distro.data)
200
201         downloaded_distro.release_conn()
202
203         # after the .zip is extracted we want to rename it to be the distro_path which may have
204         # been given by the user
205         distro_zip = zipfile.ZipFile(tmp_distro_zip, "r")
206         distro_zip.extractall(tmp_unzipped_location)
207         unzipped_distro_folder = os.listdir(tmp_unzipped_location)
208
209         # if the distro_path already exists, we wont overwrite it and just continue hoping what's
210         # there is relevant (and maybe already put there by this tool earlier)
211         try:
212             os.rename(
213                 tmp_unzipped_location + "/" + unzipped_distro_folder[0],
214                 self.distro_path,
215             )
216         except OSError as e:
217             logger.warn(e)
218             logger.warn(
219                 "Unable to move extracted files from %s to %s. Using whatever bits are already there",
220                 tmp_unzipped_location,
221                 self.distro_path,
222             )
223
224     def get_includes(self, project, changeid=None, msg=None, merged=True):
225         """
226         Get the gerrits that would be included before the change merge time.
227
228         :param str project: The project to search
229         :param str or None changeid: The Change-Id of the gerrit to use for the merge time
230         :param str or None msg: The commit message of the gerrit to use for the merge time
231         :param bool merged: The requested gerrit was merged
232         :return list: includes[0] is the gerrit requested, [1 to limit] are the gerrits found.
233         """
234         if merged:
235             includes = self.gerritquery.get_gerrits(
236                 project, changeid, 1, msg, status="merged"
237             )
238         else:
239             includes = self.gerritquery.get_gerrits(
240                 project, changeid, 1, None, None, True
241             )
242         if not includes:
243             logger.info(
244                 "Review %s in %s:%s was not found",
245                 changeid,
246                 project,
247                 self.gerritquery.branch,
248             )
249             return None
250
251         gerrits = self.gerritquery.get_gerrits(
252             project, changeid=None, limit=self.qlimit, msg=msg, status="merged"
253         )
254         for gerrit in gerrits:
255             # don"t include the same change in the list
256             if gerrit["id"] == changeid:
257                 continue
258
259             # TODO: should the check be < or <=?
260             if gerrit["grantedOn"] <= includes[0]["grantedOn"]:
261                 includes.append(gerrit)
262
263             # break out if we have the number requested
264             if len(includes) == self.limit + 1:
265                 break
266
267         if len(includes) != self.limit + 1:
268             logger.info(
269                 "%s query limit was not large enough to capture %d gerrits",
270                 project,
271                 self.limit,
272             )
273
274         return includes
275
276     @staticmethod
277     def extract_gitproperties_file(fullpath):
278         """
279         Extract a git.properties from a jar archive.
280
281         :param str fullpath: Path to the jar
282         :return str: Containing git.properties or None if not found
283         """
284         if zipfile.is_zipfile(fullpath):
285             zf = zipfile.ZipFile(fullpath, "r")
286             try:
287                 pfile = zf.open("META-INF/git.properties")
288                 return str(pfile.read())
289             except KeyError:
290                 pass
291         return None
292
293     def get_changeid_from_properties(self, project, pfile):
294         """
295         Parse the git.properties file to find a Change-Id.
296
297         There are a few different forms that we know of so far:
298         - I0123456789012345678901234567890123456789
299         - I01234567
300         - no Change-Id at all. There is a commit message and commit hash.
301         In this example the commit hash cannot be found because it was a merge
302         so you must use the message. Note spaces need to be replaced with 's.
303         - a patch that has not been merged. For these we look at the gerrit comment
304         for when the patch-test job starts.
305
306         :param str project: The project to search
307         :param str pfile: String containing the content of the git.properties file
308         :return ChangeId: The Change-Id with a valid Change-Id or None if not found
309         """
310         logger.info("trying Change-Id from git.properties in %s", project)
311         # match a 40 or 8 char Change-Id hash. both start with I
312         changeid = self.regex_changeid.search(pfile)
313         if changeid and changeid.group(2):
314             logger.info(
315                 "trying Change-Id from git.properties as merged in %s: %s",
316                 project,
317                 changeid.group(2),
318             )
319
320             gerrits = self.gerritquery.get_gerrits(
321                 project, changeid.group(2), 1, None, status="merged"
322             )
323             if gerrits:
324                 logger.info(
325                     "found Change-Id from git.properties as merged in %s", project
326                 )
327                 return ChangeId(changeid.group(2), True)
328
329             # Maybe this is a patch that has not merged yet
330             logger.info(
331                 "did not find Change-Id from git.properties as merged in %s, trying as unmerged: %s",
332                 project,
333                 changeid.group(2),
334             )
335
336             gerrits = self.gerritquery.get_gerrits(
337                 project, changeid.group(2), 1, None, status=None, comments=True
338             )
339             if gerrits:
340                 logger.info(
341                     "found Change-Id from git.properties as unmerged in %s", project
342                 )
343                 return ChangeId(gerrits[0]["id"], False)
344
345         logger.info(
346             "did not find Change-Id from git.properties in %s, trying commitid", project
347         )
348
349         # match a git commit id
350         commitid = self.regex_commitid.search(pfile)
351         if commitid and commitid.group(2):
352             logger.info(
353                 "trying commitid from git.properties in %s: %s",
354                 project,
355                 commitid.group(2),
356             )
357
358             gerrits = self.gerritquery.get_gerrits(project, commitid=commitid.group(2))
359             if gerrits:
360                 logger.info(
361                     "found Change-Id from git.properties as unmerged in %s", project
362                 )
363                 return ChangeId(gerrits[0]["id"], True)
364
365         logger.info(
366             "did not find Change-Id from commitid from git.properties in %s, trying short commit message1",
367             project,
368         )
369
370         # Didn't find a Change-Id so try to get a commit message
371         # match on "blah" but only keep the blah
372         msg = self.regex_shortmsg1.search(pfile)
373         if msg and msg.group(2):
374             # logger.info("msg.groups 0: %s, 1: %s, 2: %s", msg.group(), msg.group(1), msg.group(2))
375             logger.info(
376                 "trying with short commit-msg 1 from git.properties in %s: %s",
377                 project,
378                 msg.group(2),
379             )
380
381             gerrits = self.gerritquery.get_gerrits(project, msg=msg.group(2))
382             if gerrits:
383                 logger.info(
384                     "found Change-Id from git.properties short commit-msg 1 in %s",
385                     project,
386                 )
387                 return ChangeId(gerrits[0]["id"], True)
388
389             msg_no_spaces = msg.group(2).replace(" ", "+")
390             logger.info(
391                 "did not find Change-Id in %s, trying with commit-msg 1 (no spaces): %s",
392                 project,
393                 msg_no_spaces,
394             )
395
396             gerrits = self.gerritquery.get_gerrits(project, msg=msg_no_spaces)
397             if gerrits:
398                 logger.info(
399                     "found Change-Id from git.properties short commit-msg 1 (no spaces) in %s",
400                     project,
401                 )
402                 return ChangeId(gerrits[0]["id"], True)
403
404         logger.info(
405             "did not find Change-Id from short commit message1 from git.properties in %s",
406             project,
407         )
408
409         # Didn't find a Change-Id so try to get a commit message
410         # match on "blah" but only keep the blah
411         msg = self.regex_shortmsg2.search(pfile)
412         if msg and msg.group(2):
413             logger.info(
414                 "trying with short commit-msg 2 from git.properties in %s: %s",
415                 project,
416                 msg.group(2),
417             )
418
419             gerrits = self.gerritquery.get_gerrits(project, msg=msg.group(2))
420             if gerrits:
421                 logger.info(
422                     "found Change-Id from git.properties short commit-msg 2 in %s",
423                     project,
424                 )
425                 return ChangeId(gerrits[0]["id"], True)
426
427             msg_no_spaces = msg.group(2).replace(" ", "+")
428             logger.info(
429                 "did not find Change-Id in %s, trying with commit-msg 2 (no spaces): %s",
430                 project,
431                 msg_no_spaces,
432             )
433
434             gerrits = self.gerritquery.get_gerrits(project, msg=msg_no_spaces)
435             if gerrits:
436                 logger.info(
437                     "found Change-Id from git.properties short commit-msg 2 (no spaces) in %s",
438                     project,
439                 )
440                 return ChangeId(gerrits[0]["id"], True)
441
442         logger.info(
443             "did not find Change-Id from short commit message2 from git.properties in %s",
444             project,
445         )
446
447         # Maybe one of the monster 'merge the world' gerrits
448         msg = self.regex_longmsg.search(pfile)
449         first_msg = None
450         if msg:
451             lines = str(msg.group()).split("\\n")
452             cli = next(
453                 (i for i, line in enumerate(lines[:-1]) if "* changes\\:" in line), None
454             )
455             first_msg = lines[cli + 1] if cli else None
456         if first_msg:
457             logger.info(
458                 "did not find Change-Id or short commit-msg in %s, trying with merge commit-msg: %s",
459                 project,
460                 first_msg,
461             )
462             gerrits = self.gerritquery.get_gerrits(project, None, 1, first_msg)
463             if gerrits:
464                 logger.info(
465                     "found Change-Id from git.properties merge commit-msg in %s",
466                     project,
467                 )
468                 return ChangeId(gerrits[0]["id"], True)
469
470         logger.warn("did not find Change-Id for %s" % project)
471
472         return ChangeId(None, False)
473
474     def find_distro_changeid(self, project):
475         """
476         Find a distribution Change-Id by finding a project jar in
477         the distribution and parsing it's git.properties.
478
479         :param str project: The project to search
480         :return ChangeId: The Change-Id with a valid Change-Id or None if not found
481         """
482         project_dir = os.path.join(
483             self.distro_path, "system", "org", "opendaylight", project
484         )
485         pfile = None
486         for root, dirs, files in os.walk(project_dir):
487             for file_ in files:
488                 if file_.endswith(".jar"):
489                     fullpath = os.path.join(root, file_)
490                     pfile = self.extract_gitproperties_file(fullpath)
491                     if pfile:
492                         changeid = self.get_changeid_from_properties(project, pfile)
493                         if changeid.changeid:
494                             return changeid
495                         else:
496                             logger.warn(
497                                 "Could not find %s Change-Id in git.properties", project
498                             )
499                             break  # all jars will have the same git.properties
500             if pfile is not None:
501                 break  # all jars will have the same git.properties
502         if pfile is None:
503             logger.warn("Could not find a git.properties file for %s", project)
504         return ChangeId(None, False)
505
506     def get_taglist(self):
507         """
508         Read a taglist.log file into memory
509
510         :return taglist: The taglist.log file read into memory
511         """
512         tagfile = os.path.join(self.distro_path, "taglist.log")
513         taglist = None
514         # Ensure the file exists and then read it
515         if os.path.isfile(tagfile):
516             with open(tagfile, "r") as fp:
517                 taglist = fp.read()
518         return taglist
519
520     def find_project_commit_changeid(self, taglist, project):
521         """
522         Find a commit id for the given project
523
524         :param str taglist: the taglist.log file read into memory
525         :param str project: The project to search
526         :return ChangeId: The Change-Id with a valid Change-Id or None if not found
527         """
528         # break the regex up since {} is a valid regex element but we need it for the format project
529         re1 = r"({0} ".format(project)
530         re1 = re1 + r"(\b[a-f0-9]{40})\b|\b([a-f0-9]{8})\b" + r")"
531         commitid = re.search(re1, taglist)
532         if commitid and commitid.group(2):
533             logger.info(
534                 "trying commitid from taglist.log in %s: %s", project, commitid.group(2)
535             )
536
537             gerrits = self.gerritquery.get_gerrits(project, commitid=commitid.group(2))
538             if gerrits:
539                 logger.info("found Change-Id from taglist.log as merged in %s", project)
540                 return ChangeId(gerrits[0]["id"], True)
541
542         logger.warn(
543             "did not find Change-Id from commitid from taglist.log in %s", project
544         )
545         return ChangeId(None, False)
546
547     def init(self):
548         self.gerritquery = gerritquery.GerritQuery(
549             self.remote_url, self.branch, self.qlimit, self.verbose
550         )
551         self.set_projects(self.project_names)
552
553     def print_options(self):
554         print(
555             "Using these options: branch: %s, limit: %d, qlimit: %d"
556             % (self.branch, self.limit, self.qlimit)
557         )
558         print("remote_url: %s" % self.remote_url)
559         print("distro_path: %s" % self.distro_path)
560         print("projects: %s" % (", ".join(map(str, self.projects))))
561         print(
562             "gerrit 00 is the most recent patch from which the project was built followed by the next most"
563             " recently merged patches up to %s." % self.limit
564         )
565
566     def run_cmd(self):
567         """
568         Internal wrapper between main, options parser and internal code.
569
570         Get the gerrit for the given Change-Id and parse it.
571         Loop over all projects:
572             get qlimit gerrits and parse them
573             copy up to limit gerrits with a SUBM time (grantedOn) <= to the given change-id
574         """
575         # TODO: need method to validate the branch matches the distribution
576
577         self.init()
578         self.print_options()
579
580         if self.distro_url is not None:
581             self.download_distro()
582
583         logger.info(
584             "Checking if this is an autorelease build by looking for taglist.log"
585         )
586         taglist = self.get_taglist()
587         if taglist is not None:
588             for project in sorted(self.projects):
589                 logger.info("Processing %s using taglist.log", project)
590                 changeid = self.find_project_commit_changeid(taglist, project)
591                 if changeid.changeid:
592                     self.projects[project]["commit"] = changeid.changeid
593                     self.projects[project]["includes"] = self.get_includes(
594                         project, changeid.changeid, msg=None, merged=changeid.merged
595                     )
596             return self.projects
597
598         logger.info(
599             "This is not an autorelease build, continuing as integration distribution"
600         )
601         for project in sorted(self.projects):
602             logger.info("Processing %s", project)
603             changeid = self.find_distro_changeid(project)
604             if changeid.changeid:
605                 self.projects[project]["commit"] = changeid.changeid
606                 self.projects[project]["includes"] = self.get_includes(
607                     project, changeid.changeid, msg=None, merged=changeid.merged
608                 )
609         return self.projects
610
611     def main(self):
612         parser = argparse.ArgumentParser(description=COPYRIGHT)
613
614         parser.add_argument(
615             "-b",
616             "--branch",
617             default=self.BRANCH,
618             help="git branch for patch under test",
619         )
620         parser.add_argument(
621             "-d",
622             "--distro-path",
623             dest="distro_path",
624             default=self.DISTRO_PATH,
625             help="path to the expanded distribution, i.e. " + self.DISTRO_PATH,
626         )
627         parser.add_argument(
628             "-u",
629             "--distro-url",
630             dest="distro_url",
631             default=self.DISTRO_URL,
632             help="optional url to download a distribution " + str(self.DISTRO_URL),
633         )
634         parser.add_argument(
635             "-l",
636             "--limit",
637             dest="limit",
638             type=int,
639             default=self.LIMIT,
640             help="number of gerrits to return",
641         )
642         parser.add_argument(
643             "-p",
644             "--projects",
645             dest="projects",
646             default=self.PROJECT_NAMES,
647             help="list of projects to include in output",
648         )
649         parser.add_argument(
650             "-q",
651             "--query-limit",
652             dest="qlimit",
653             type=int,
654             default=self.QUERY_LIMIT,
655             help="number of gerrits to search",
656         )
657         parser.add_argument(
658             "-r",
659             "--remote",
660             dest="remote_url",
661             default=self.REMOTE_URL,
662             help="git remote url to use for gerrit",
663         )
664         parser.add_argument(
665             "-v",
666             "--verbose",
667             dest="verbose",
668             action="count",
669             default=self.VERBOSE,
670             help="Output more information about what's going on",
671         )
672         parser.add_argument(
673             "--license",
674             dest="license",
675             action="store_true",
676             help="Print the license and exit",
677         )
678         parser.add_argument(
679             "-V",
680             "--version",
681             action="version",
682             version="%s version %s" % (os.path.split(sys.argv[0])[-1], 0.1),
683         )
684
685         options = parser.parse_args()
686
687         if options.license:
688             print(COPYRIGHT)
689             sys.exit(0)
690
691         self.branch = options.branch
692         self.distro_path = options.distro_path
693         self.distro_url = options.distro_url
694         self.limit = options.limit
695         self.qlimit = options.qlimit
696         self.remote_url = options.remote_url
697         self.verbose = options.verbose
698         if options.projects != self.PROJECT_NAMES:
699             self.project_names = options.projects.split(",")
700
701         # TODO: add check to verify that the remote can be reached,
702         # though the first gerrit query will fail anyways
703
704         projects = self.run_cmd()
705         self.pretty_print_projects(projects)
706         sys.exit(0)
707
708
709 def main():
710     changez = Changes()
711     try:
712         changez.main()
713     except Exception as e:
714         # If one does unguarded print(e) here, in certain locales the implicit
715         # str(e) blows up with familiar "UnicodeEncodeError ... ordinal not in
716         # range(128)". See rhbz#1058167.
717         try:
718             u = unicode(e)
719         except NameError:
720             # Python 3, we"re home free.
721             logger.warn(e)
722         else:
723             logger.warn(u.encode("utf-8"))
724             raise
725         sys.exit(getattr(e, "EXIT_CODE", -1))
726
727
728 if __name__ == "__main__":
729     main()