WKR build fix. Somehow tool.bugs.quips() doesn't work in WKR so work around that.
[WebKit-https.git] / Tools / Scripts / webkitpy / tool / bot / irc_command.py
1 # Copyright (c) 2010 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 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16 #
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 import itertools
30 import random
31 import re
32
33 from webkitpy.common.config import irc as config_irc
34 from webkitpy.common.config import urls
35 from webkitpy.common.config.committers import CommitterList
36 from webkitpy.common.net.web import Web
37 from webkitpy.common.system.executive import ScriptError
38 from webkitpy.tool.bot.queueengine import TerminateQueue
39 from webkitpy.tool.grammar import join_with_separators
40
41
42 def _post_error_and_check_for_bug_url(tool, nicks_string, exception):
43     tool.irc().post("%s" % exception)
44     bug_id = urls.parse_bug_id(exception.output)
45     if bug_id:
46         bug_url = tool.bugs.bug_url_for_bug_id(bug_id)
47         tool.irc().post("%s: Ugg...  Might have created %s" % (nicks_string, bug_url))
48
49
50 # FIXME: Merge with Command?
51 class IRCCommand(object):
52     usage_string = None
53     help_string = None
54
55     def execute(self, nick, args, tool, sheriff):
56         raise NotImplementedError("subclasses must implement")
57
58     @classmethod
59     def usage(cls, nick):
60         return "%s: Usage: %s" % (nick, cls.usage_string)
61
62     @classmethod
63     def help(cls, nick):
64         return "%s: %s" % (nick, cls.help_string)
65
66
67 class CreateBug(IRCCommand):
68     usage_string = "create-bug BUG_TITLE"
69     help_string = "Creates a Bugzilla bug with the given title."
70
71     def execute(self, nick, args, tool, sheriff):
72         if not args:
73             return self.usage(nick)
74
75         bug_title = " ".join(args)
76         bug_description = "%s\nRequested by %s on %s." % (bug_title, nick, config_irc.channel)
77
78         # There happens to be a committers list hung off of Bugzilla, so
79         # re-using that one makes things easiest for now.
80         requester = tool.bugs.committers.contributor_by_irc_nickname(nick)
81         requester_email = requester.bugzilla_email() if requester else None
82
83         try:
84             bug_id = tool.bugs.create_bug(bug_title, bug_description, cc=requester_email, assignee=requester_email)
85             bug_url = tool.bugs.bug_url_for_bug_id(bug_id)
86             return "%s: Created bug: %s" % (nick, bug_url)
87         except Exception, e:
88             return "%s: Failed to create bug:\n%s" % (nick, e)
89
90
91 class Help(IRCCommand):
92     usage_string = "help [COMMAND]"
93     help_string = "Provides help on my individual commands."
94
95     def execute(self, nick, args, tool, sheriff):
96         if args:
97             for command_name in args:
98                 if command_name in commands:
99                     self._post_command_help(nick, tool, commands[command_name])
100         else:
101             tool.irc().post("%s: Available commands: %s" % (nick, ", ".join(sorted(visible_commands.keys()))))
102             tool.irc().post('%s: Type "%s: help COMMAND" for help on my individual commands.' % (nick, sheriff.name()))
103
104     def _post_command_help(self, nick, tool, command):
105         tool.irc().post(command.usage(nick))
106         tool.irc().post(command.help(nick))
107         aliases = " ".join(sorted(filter(lambda alias: commands[alias] == command and alias not in visible_commands, commands)))
108         if aliases:
109             tool.irc().post("%s: Aliases: %s" % (nick, aliases))
110
111
112 class Hi(IRCCommand):
113     usage_string = "hi"
114     help_string = "Responds with hi."
115
116     def execute(self, nick, args, tool, sheriff):
117         if len(args) and re.match(sheriff.name() + r'_*\s*!\s*', ' '.join(args)):
118             return "%s: hi %s!" % (nick, nick)
119         if sheriff.name() == 'WKR':  # For some unknown reason, WKR can't use tool.bugs.quips().
120             return "You're doing it wrong"
121         quips = tool.bugs.quips()
122         quips.append('"Only you can prevent forest fires." -- Smokey the Bear')
123         return random.choice(quips)
124
125
126 class PingPong(IRCCommand):
127     usage_string = "ping"
128     help_string = "Responds with pong."
129
130     def execute(self, nick, args, tool, sheriff):
131         return nick + ": pong"
132
133
134 class YouThere(IRCCommand):
135     usage_string = "yt?"
136     help_string = "Responds with yes."
137
138     def execute(self, nick, args, tool, sheriff):
139         return "%s: yes" % nick
140
141
142 class Restart(IRCCommand):
143     usage_string = "restart"
144     help_string = "Restarts sherrifbot.  Will update its WebKit checkout, and re-join the channel momentarily."
145
146     def execute(self, nick, args, tool, sheriff):
147         tool.irc().post("Restarting...")
148         raise TerminateQueue()
149
150
151 class RollChromiumDEPS(IRCCommand):
152     usage_string = "roll-chromium-deps REVISION"
153     help_string = "Rolls WebKit's Chromium DEPS to the given revision???"
154
155     def execute(self, nick, args, tool, sheriff):
156         if not len(args):
157             return self.usage(nick)
158         tool.irc().post("%s: Will roll Chromium DEPS to %s" % (nick, ' '.join(args)))
159         tool.irc().post("%s: Rolling Chromium DEPS to %s" % (nick, ' '.join(args)))
160         tool.irc().post("%s: Rolled Chromium DEPS to %s" % (nick, ' '.join(args)))
161         tool.irc().post("%s: Thank You" % nick)
162
163
164 class Rollout(IRCCommand):
165     usage_string = "rollout SVN_REVISION [SVN_REVISIONS] REASON"
166     help_string = "Opens a rollout bug, CCing author + reviewer, and attaching the reverse-diff of the given revisions marked as commit-queue=?."
167
168     def _extract_revisions(self, arg):
169         revision_list = []
170         possible_revisions = arg.split(",")
171         for revision in possible_revisions:
172             revision = revision.strip()
173             if not revision:
174                 continue
175             revision = revision.lstrip("r")
176             # If one part of the arg isn't in the correct format,
177             # then none of the arg should be considered a revision.
178             if not revision.isdigit():
179                 return None
180             revision_list.append(int(revision))
181         return revision_list
182
183     def _parse_args(self, args):
184         if not args:
185             return (None, None)
186
187         svn_revision_list = []
188         remaining_args = args[:]
189         # First process all revisions.
190         while remaining_args:
191             new_revisions = self._extract_revisions(remaining_args[0])
192             if not new_revisions:
193                 break
194             svn_revision_list += new_revisions
195             remaining_args = remaining_args[1:]
196
197         # Was there a revision number?
198         if not len(svn_revision_list):
199             return (None, None)
200
201         # Everything left is the reason.
202         rollout_reason = " ".join(remaining_args)
203         return svn_revision_list, rollout_reason
204
205     def _responsible_nicknames_from_revisions(self, tool, sheriff, svn_revision_list):
206         commit_infos = map(tool.checkout().commit_info_for_revision, svn_revision_list)
207         nickname_lists = map(sheriff.responsible_nicknames_from_commit_info, commit_infos)
208         return sorted(set(itertools.chain(*nickname_lists)))
209
210     def _nicks_string(self, tool, sheriff, requester_nick, svn_revision_list):
211         # FIXME: _parse_args guarentees that our svn_revision_list is all numbers.
212         # However, it's possible our checkout will not include one of the revisions,
213         # so we may need to catch exceptions from commit_info_for_revision here.
214         target_nicks = [requester_nick] + self._responsible_nicknames_from_revisions(tool, sheriff, svn_revision_list)
215         return ", ".join(target_nicks)
216
217     def _update_working_copy(self, tool):
218         tool.scm().discard_local_changes()
219         tool.executive.run_and_throw_if_fail(tool.deprecated_port().update_webkit_command(), quiet=True, cwd=tool.scm().checkout_root)
220
221     def _check_diff_failure(self, error_log, tool):
222         if not error_log:
223             return None
224
225         revert_failure_message_start = error_log.find("Failed to apply reverse diff for revision")
226         if revert_failure_message_start == -1:
227             return None
228
229         lines = error_log[revert_failure_message_start:].split('\n')[1:]
230         files = itertools.takewhile(lambda line: tool.filesystem.exists(tool.scm().absolute_path(line)), lines)
231         if files:
232             return "Failed to apply reverse diff for file(s): %s" % ", ".join(files)
233         return None
234
235     def execute(self, nick, args, tool, sheriff):
236         svn_revision_list, rollout_reason = self._parse_args(args)
237
238         if (not svn_revision_list or not rollout_reason):
239             return self.usage(nick)
240
241         revision_urls_string = join_with_separators([urls.view_revision_url(revision) for revision in svn_revision_list])
242         tool.irc().post("%s: Preparing rollout for %s ..." % (nick, revision_urls_string))
243
244         self._update_working_copy(tool)
245
246         # FIXME: IRCCommand should bind to a tool and have a self._tool like Command objects do.
247         # Likewise we should probably have a self._sheriff.
248         nicks_string = self._nicks_string(tool, sheriff, nick, svn_revision_list)
249
250         try:
251             complete_reason = "%s (Requested by %s on %s)." % (
252                 rollout_reason, nick, config_irc.channel)
253             bug_id = sheriff.post_rollout_patch(svn_revision_list, complete_reason)
254             bug_url = tool.bugs.bug_url_for_bug_id(bug_id)
255             tool.irc().post("%s: Created rollout: %s" % (nicks_string, bug_url))
256         except ScriptError, e:
257             tool.irc().post("%s: Failed to create rollout patch:" % nicks_string)
258             diff_failure = self._check_diff_failure(e.output, tool)
259             if diff_failure:
260                 return "%s: %s" % (nicks_string, diff_failure)
261             _post_error_and_check_for_bug_url(tool, nicks_string, e)
262
263
264 class Whois(IRCCommand):
265     usage_string = "whois SEARCH_STRING"
266     help_string = "Searches known contributors and returns any matches with irc, email and full name. Wild card * permitted."
267
268     def _full_record_and_nick(self, contributor):
269         result = ''
270
271         if contributor.irc_nicknames:
272             result += ' (:%s)' % ', :'.join(contributor.irc_nicknames)
273
274         if contributor.can_review:
275             result += ' (r)'
276         elif contributor.can_commit:
277             result += ' (c)'
278
279         return unicode(contributor) + result
280
281     def execute(self, nick, args, tool, sheriff):
282         if not args:
283             return self.usage(nick)
284         search_string = unicode(" ".join(args))
285         # FIXME: We should get the ContributorList off the tool somewhere.
286         contributors = CommitterList().contributors_by_search_string(search_string)
287         if not contributors:
288             return unicode("%s: Sorry, I don't know any contributors matching '%s'.") % (nick, search_string)
289         if len(contributors) > 5:
290             return unicode("%s: More than 5 contributors match '%s', could you be more specific?") % (nick, search_string)
291         if len(contributors) == 1:
292             contributor = contributors[0]
293             if not contributor.irc_nicknames:
294                 return unicode("%s: %s hasn't told me their nick. Boo hoo :-(") % (nick, contributor)
295             return unicode("%s: %s is %s. Why do you ask?") % (nick, search_string, self._full_record_and_nick(contributor))
296         contributor_nicks = map(self._full_record_and_nick, contributors)
297         contributors_string = join_with_separators(contributor_nicks, only_two_separator=" or ", last_separator=', or ')
298         return unicode("%s: I'm not sure who you mean?  %s could be '%s'.") % (nick, contributors_string, search_string)
299
300
301 # FIXME: Lame.  We should have an auto-registering CommandCenter.
302 visible_commands = {
303     "create-bug": CreateBug,
304     "help": Help,
305     "hi": Hi,
306     "ping": PingPong,
307     "restart": Restart,
308     "roll-chromium-deps": RollChromiumDEPS,
309     "rollout": Rollout,
310     "whois": Whois,
311     "yt?": YouThere,
312 }
313
314 # Add revert as an "easter egg" command. Why?
315 # revert is the same as rollout and it would be confusing to list both when
316 # they do the same thing. However, this command is a very natural thing for
317 # people to use and it seems silly to have them hunt around for "rollout" instead.
318 commands = visible_commands.copy()
319 commands["revert"] = Rollout
320 # "hello" Alias for "hi" command for the purposes of testing aliases
321 commands["hello"] = Hi