| OLD | NEW |
| (Empty) |
| 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 | |
| 26 import logging | |
| 27 import re | |
| 28 import threading | |
| 29 import time | |
| 30 | |
| 31 from webkitpy.common.system.executive import ScriptError | |
| 32 from webkitpy.thirdparty.irc.ircbot import SingleServerIRCBot | |
| 33 | |
| 34 _log = logging.getLogger(__name__) | |
| 35 | |
| 36 SERVER = 'irc.freenode.net' | |
| 37 PORT = 6667 | |
| 38 CHANNEL = '#blink' | |
| 39 NICKNAME = 'commit-bot' | |
| 40 | |
| 41 PULL_TIMEOUT_SECONDS = 60 * 5 | |
| 42 UPDATE_WAIT_SECONDS = 10 | |
| 43 RETRY_ATTEMPTS = 8 | |
| 44 | |
| 45 | |
| 46 class CommitAnnouncer(SingleServerIRCBot): | |
| 47 _commit_detail_format = '%H\n%ae\n%s\n%b' # commit-sha1, author email, subj
ect, body | |
| 48 | |
| 49 def __init__(self, tool, announce_path, irc_password): | |
| 50 SingleServerIRCBot.__init__(self, [(SERVER, PORT, irc_password)], NICKNA
ME, NICKNAME) | |
| 51 self.announce_path = announce_path | |
| 52 self.git = tool.git(path=tool.git().checkout_root) | |
| 53 self.commands = { | |
| 54 'help': self.help, | |
| 55 'ping': self.ping, | |
| 56 'quit': self.stop, | |
| 57 } | |
| 58 self.last_commit = None | |
| 59 | |
| 60 def start(self): | |
| 61 if not self._update(): | |
| 62 return | |
| 63 self.last_commit = self.git.latest_git_commit() | |
| 64 SingleServerIRCBot.start(self) | |
| 65 | |
| 66 def post_new_commits(self): | |
| 67 if not self.connection.is_connected(): | |
| 68 return | |
| 69 if not self._update(force_clean=True): | |
| 70 self.stop('Failed to update repository!') | |
| 71 return | |
| 72 new_commits = self.git.git_commits_since(self.last_commit) | |
| 73 if not new_commits: | |
| 74 return | |
| 75 self.last_commit = new_commits[-1] | |
| 76 for commit in new_commits: | |
| 77 if not self._should_announce_commit(commit): | |
| 78 continue | |
| 79 commit_detail = self._commit_detail(commit) | |
| 80 if commit_detail: | |
| 81 _log.info('%s Posting commit %s', self._time(), commit) | |
| 82 _log.info('%s Posted message: %s', self._time(), repr(commit_det
ail)) | |
| 83 self._post(commit_detail) | |
| 84 else: | |
| 85 _log.error('Malformed commit log for %s', commit) | |
| 86 | |
| 87 # Bot commands. | |
| 88 | |
| 89 def help(self): | |
| 90 self._post('Commands available: %s' % ' '.join(self.commands.keys())) | |
| 91 | |
| 92 def ping(self): | |
| 93 self._post('Pong.') | |
| 94 | |
| 95 def stop(self, message=''): | |
| 96 self.connection.execute_delayed(0, lambda: self.die(message)) | |
| 97 | |
| 98 # IRC event handlers. Methods' arguments are determined by superclass | |
| 99 # and some arguments maybe unused - pylint: disable=unused-argument | |
| 100 | |
| 101 def on_nicknameinuse(self, connection, event): | |
| 102 connection.nick('%s_' % connection.get_nickname()) | |
| 103 | |
| 104 def on_welcome(self, connection, event): | |
| 105 connection.join(CHANNEL) | |
| 106 | |
| 107 def on_pubmsg(self, connection, event): | |
| 108 message = event.arguments()[0] | |
| 109 command = self._message_command(message) | |
| 110 if command: | |
| 111 command() | |
| 112 | |
| 113 def _update(self, force_clean=False): | |
| 114 if not self.git.is_cleanly_tracking_remote_master(): | |
| 115 if not force_clean: | |
| 116 confirm = raw_input('This repository has local changes, continue
? (uncommitted changes will be lost) y/n: ') | |
| 117 if not confirm.lower() == 'y': | |
| 118 return False | |
| 119 try: | |
| 120 self.git.ensure_cleanly_tracking_remote_master() | |
| 121 except ScriptError as error: | |
| 122 _log.error('Failed to clean repository: %s', error) | |
| 123 return False | |
| 124 | |
| 125 attempts = 1 | |
| 126 while attempts <= RETRY_ATTEMPTS: | |
| 127 if attempts > 1: | |
| 128 # User may have sent a keyboard interrupt during the wait. | |
| 129 if not self.connection.is_connected(): | |
| 130 return False | |
| 131 wait = int(UPDATE_WAIT_SECONDS) << (attempts - 1) | |
| 132 if wait < 120: | |
| 133 _log.info('Waiting %s seconds', wait) | |
| 134 else: | |
| 135 _log.info('Waiting %s minutes', wait / 60) | |
| 136 time.sleep(wait) | |
| 137 _log.info('Pull attempt %s out of %s', attempts, RETRY_ATTEMPTS) | |
| 138 try: | |
| 139 self.git.pull(timeout_seconds=PULL_TIMEOUT_SECONDS) | |
| 140 return True | |
| 141 except ScriptError as error: | |
| 142 _log.error('Error pulling from server: %s', error) | |
| 143 _log.error('Output: %s', error.output) | |
| 144 attempts += 1 | |
| 145 _log.error('Exceeded pull attempts') | |
| 146 _log.error('Aborting at time: %s', self._time()) | |
| 147 return False | |
| 148 | |
| 149 def _time(self): | |
| 150 return time.strftime('[%x %X %Z]', time.localtime()) | |
| 151 | |
| 152 def _message_command(self, message): | |
| 153 prefix = '%s:' % self.connection.get_nickname() | |
| 154 if message.startswith(prefix): | |
| 155 command_name = message[len(prefix):].strip() | |
| 156 if command_name in self.commands: | |
| 157 return self.commands[command_name] | |
| 158 return None | |
| 159 | |
| 160 def _should_announce_commit(self, commit): | |
| 161 return any(path.startswith(self.announce_path) for path in self.git.affe
cted_files(commit)) | |
| 162 | |
| 163 def _commit_detail(self, commit): | |
| 164 return self._format_commit_detail(self.git.git_commit_detail(commit, sel
f._commit_detail_format)) | |
| 165 | |
| 166 def _format_commit_detail(self, commit_detail): | |
| 167 if commit_detail.count('\n') < self._commit_detail_format.count('\n'): | |
| 168 return '' | |
| 169 | |
| 170 commit, email, subject, body = commit_detail.split('\n', 3) | |
| 171 commit_position_re = r'^Cr-Commit-Position: refs/heads/master@\{#(?P<com
mit_position>\d+)\}' | |
| 172 commit_position = None | |
| 173 red_flag_strings = ['NOTRY=true', 'TBR='] | |
| 174 red_flags = [] | |
| 175 | |
| 176 for line in body.split('\n'): | |
| 177 match = re.search(commit_position_re, line) | |
| 178 if match: | |
| 179 commit_position = match.group('commit_position') | |
| 180 | |
| 181 for red_flag_string in red_flag_strings: | |
| 182 if line.lower().startswith(red_flag_string.lower()): | |
| 183 red_flags.append(line.strip()) | |
| 184 | |
| 185 url = 'https://crrev.com/%s' % (commit_position if commit_position else
commit[:8]) | |
| 186 red_flag_message = '\x037%s\x03' % (' '.join(red_flags)) if red_flags el
se '' | |
| 187 | |
| 188 return ('%s %s committed "%s" %s' % (url, email, subject, red_flag_messa
ge)).strip() | |
| 189 | |
| 190 def _post(self, message): | |
| 191 self.connection.execute_delayed(0, lambda: self.connection.privmsg(CHANN
EL, self._sanitize_string(message))) | |
| 192 | |
| 193 def _sanitize_string(self, message): | |
| 194 return message.encode('ascii', 'backslashreplace') | |
| 195 | |
| 196 | |
| 197 class CommitAnnouncerThread(threading.Thread): | |
| 198 | |
| 199 def __init__(self, tool, announce_path, irc_password): | |
| 200 threading.Thread.__init__(self) | |
| 201 self.bot = CommitAnnouncer(tool, announce_path, irc_password) | |
| 202 | |
| 203 def run(self): | |
| 204 self.bot.start() | |
| 205 | |
| 206 def stop(self): | |
| 207 self.bot.stop() | |
| 208 self.join() | |
| OLD | NEW |