1# Copyright (C) 2013 Google Inc. All rights reserved.
2#
3# Redistribution and use in source and binary forms, with or without
4# modification, are permitted provided that the following conditions are
5# met:
6#
7#    * Redistributions of source code must retain the above copyright
8# notice, this list of conditions and the following disclaimer.
9#    * Redistributions in binary form must reproduce the above
10# copyright notice, this list of conditions and the following disclaimer
11# in the documentation and/or other materials provided with the
12# distribution.
13#
14# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
20# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
26import logging
27import re
28import threading
29import time
30
31from webkitpy.common.checkout.scm.git import Git
32from webkitpy.common.config.irc import server, port, channel, nickname
33from webkitpy.common.config.irc import update_wait_seconds, retry_attempts
34from webkitpy.common.system.executive import ScriptError
35from webkitpy.thirdparty.irc.ircbot import SingleServerIRCBot
36
37_log = logging.getLogger(__name__)
38
39
40class CommitAnnouncer(SingleServerIRCBot):
41    _commit_detail_format = "%H\n%cn\n%s\n%b"  # commit-sha1, author, subject, body
42
43    def __init__(self, tool, irc_password):
44        SingleServerIRCBot.__init__(self, [(server, port, irc_password)], nickname, nickname)
45        self.git = Git(cwd=tool.scm().checkout_root, filesystem=tool.filesystem, executive=tool.executive)
46        self.commands = {
47            'help': self.help,
48            'quit': self.stop,
49        }
50
51    def start(self):
52        if not self._update():
53            return
54        self.last_commit = self.git.latest_git_commit()
55        SingleServerIRCBot.start(self)
56
57    def post_new_commits(self):
58        if not self.connection.is_connected():
59            return
60        if not self._update(force_clean=True):
61            self.stop("Failed to update repository!")
62            return
63        new_commits = self.git.git_commits_since(self.last_commit)
64        if new_commits:
65            self.last_commit = new_commits[-1]
66            for commit in new_commits:
67                commit_detail = self._commit_detail(commit)
68                if commit_detail:
69                    _log.info('%s Posting commit %s' % (self._time(), commit))
70                    _log.info('%s Posted message: %s' % (self._time(), repr(commit_detail)))
71                    self._post(commit_detail)
72                else:
73                    _log.error('Malformed commit log for %s' % commit)
74
75    # Bot commands.
76
77    def help(self):
78        self._post('Commands available: %s' % ' '.join(self.commands.keys()))
79
80    def stop(self, message=""):
81        self.connection.execute_delayed(0, lambda: self.die(message))
82
83    # IRC event handlers.
84
85    def on_nicknameinuse(self, connection, event):
86        connection.nick('%s_' % connection.get_nickname())
87
88    def on_welcome(self, connection, event):
89        connection.join(channel)
90
91    def on_pubmsg(self, connection, event):
92        message = event.arguments()[0]
93        command = self._message_command(message)
94        if command:
95            command()
96
97    def _update(self, force_clean=False):
98        if not self.git.is_cleanly_tracking_remote_master():
99            if not force_clean:
100                confirm = raw_input('This repository has local changes, continue? (uncommitted changes will be lost) y/n: ')
101                if not confirm.lower() == 'y':
102                    return False
103            try:
104                self.git.ensure_cleanly_tracking_remote_master()
105            except ScriptError, e:
106                _log.error('Failed to clean repository: %s' % e)
107                return False
108
109        attempts = 1
110        while attempts <= retry_attempts:
111            if attempts > 1:
112                # User may have sent a keyboard interrupt during the wait.
113                if not self.connection.is_connected():
114                    return False
115                wait = int(update_wait_seconds) << (attempts - 1)
116                if wait < 120:
117                    _log.info('Waiting %s seconds' % wait)
118                else:
119                    _log.info('Waiting %s minutes' % (wait / 60))
120                time.sleep(wait)
121                _log.info('Pull attempt %s out of %s' % (attempts, retry_attempts))
122            try:
123                self.git.pull()
124                return True
125            except ScriptError, e:
126                _log.error('Error pulling from server: %s' % e)
127                _log.error('Output: %s' % e.output)
128            attempts += 1
129        _log.error('Exceeded pull attempts')
130        _log.error('Aborting at time: %s' % self._time())
131        return False
132
133    def _time(self):
134        return time.strftime('[%x %X %Z]', time.localtime())
135
136    def _message_command(self, message):
137        prefix = '%s:' % self.connection.get_nickname()
138        if message.startswith(prefix):
139            command_name = message[len(prefix):].strip()
140            if command_name in self.commands:
141                return self.commands[command_name]
142        return None
143
144    def _commit_detail(self, commit):
145        return self._format_commit_detail(self.git.git_commit_detail(commit, self._commit_detail_format))
146
147    def _format_commit_detail(self, commit_detail):
148        if commit_detail.count('\n') < self._commit_detail_format.count('\n'):
149            return ''
150
151        commit, email, subject, body = commit_detail.split('\n', 3)
152        review_string = 'Review URL: '
153        svn_string = 'git-svn-id: svn://svn.chromium.org/blink/trunk@'
154        red_flag_strings = ['NOTRY=true', 'TBR=']
155        review_url = ''
156        svn_revision = ''
157        red_flags = []
158
159        for line in body.split('\n'):
160            if line.startswith(review_string):
161                review_url = line[len(review_string):]
162            if line.startswith(svn_string):
163                tokens = line[len(svn_string):].split()
164                if not tokens:
165                    continue
166                revision = tokens[0]
167                if not revision.isdigit():
168                    continue
169                svn_revision = 'r%s' % revision
170            for red_flag_string in red_flag_strings:
171                if line.lower().startswith(red_flag_string.lower()):
172                    red_flags.append(line.strip())
173
174        if review_url:
175            match = re.search(r'(?P<review_id>\d+)', review_url)
176            if match:
177                review_url = 'http://crrev.com/%s' % match.group('review_id')
178        first_url = review_url if review_url else 'https://chromium.googlesource.com/chromium/blink/+/%s' % commit[:8]
179
180        red_flag_message = '\x037%s\x03' % (' '.join(red_flags)) if red_flags else ''
181
182        return ('%s %s %s committed "%s" %s' % (svn_revision, first_url, email, subject, red_flag_message)).strip()
183
184    def _post(self, message):
185        self.connection.execute_delayed(0, lambda: self.connection.privmsg(channel, self._sanitize_string(message)))
186
187    def _sanitize_string(self, message):
188        return message.encode('ascii', 'backslashreplace')
189
190
191class CommitAnnouncerThread(threading.Thread):
192    def __init__(self, tool, irc_password):
193        threading.Thread.__init__(self)
194        self.bot = CommitAnnouncer(tool, irc_password)
195
196    def run(self):
197        self.bot.start()
198
199    def stop(self):
200        self.bot.stop()
201        self.join()
202