Teach webkitbot and WKR how to respond to yt?
[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         quips = tool.bugs.quips()
120         quips.append('"Only you can prevent forest fires." -- Smokey the Bear')
121         return random.choice(quips)
122
123
124 class PingPong(IRCCommand):
125     usage_string = "ping"
126     help_string = "Responds with pong."
127
128     def execute(self, nick, args, tool, sheriff):
129         return nick + ": pong"
130
131
132 class YouThere(IRCCommand):
133     usage_string = "yt?"
134     help_string = "Responds with yes."
135
136     def execute(self, nick, args, tool, sheriff):
137         return "%s: yes" % nick
138
139
140 class Restart(IRCCommand):
141     usage_string = "restart"
142     help_string = "Restarts sherrifbot.  Will update its WebKit checkout, and re-join the channel momentarily."
143
144     def execute(self, nick, args, tool, sheriff):
145         tool.irc().post("Restarting...")
146         raise TerminateQueue()
147
148
149 class RollChromiumDEPS(IRCCommand):
150     usage_string = "roll-chromium-deps REVISION"
151     help_string = "Rolls WebKit's Chromium DEPS to the given revision???"
152
153     def execute(self, nick, args, tool, sheriff):
154         if not len(args):
155             return self.usage(nick)
156         tool.irc().post("%s: Will roll Chromium DEPS to %s" % (nick, args[0]))
157         tool.irc().post("%s: Rolling Chromium DEPS to %s" % (nick, args[0]))
158         tool.irc().post("%s: Rolled Chromium DEPS to %s" % (nick, args[0]))
159         tool.irc().post("%s: Thank You" % nick)
160
161
162 class Rollout(IRCCommand):
163     usage_string = "rollout SVN_REVISION [SVN_REVISIONS] REASON"
164     help_string = "Opens a rollout bug, CCing author + reviewer, and attaching the reverse-diff of the given revisions marked as commit-queue=?."
165
166     def _extract_revisions(self, arg):
167         revision_list = []
168         possible_revisions = arg.split(",")
169         for revision in possible_revisions:
170             revision = revision.strip()
171             if not revision:
172                 continue
173             revision = revision.lstrip("r")
174             # If one part of the arg isn't in the correct format,
175             # then none of the arg should be considered a revision.
176             if not revision.isdigit():
177                 return None
178             revision_list.append(int(revision))
179         return revision_list
180
181     def _parse_args(self, args):
182         if not args:
183             return (None, None)
184
185         svn_revision_list = []
186         remaining_args = args[:]
187         # First process all revisions.
188         while remaining_args:
189             new_revisions = self._extract_revisions(remaining_args[0])
190             if not new_revisions:
191                 break
192             svn_revision_list += new_revisions
193             remaining_args = remaining_args[1:]
194
195         # Was there a revision number?
196         if not len(svn_revision_list):
197             return (None, None)
198
199         # Everything left is the reason.
200         rollout_reason = " ".join(remaining_args)
201         return svn_revision_list, rollout_reason
202
203     def _responsible_nicknames_from_revisions(self, tool, sheriff, svn_revision_list):
204         commit_infos = map(tool.checkout().commit_info_for_revision, svn_revision_list)
205         nickname_lists = map(sheriff.responsible_nicknames_from_commit_info, commit_infos)
206         return sorted(set(itertools.chain(*nickname_lists)))
207
208     def _nicks_string(self, tool, sheriff, requester_nick, svn_revision_list):
209         # FIXME: _parse_args guarentees that our svn_revision_list is all numbers.
210         # However, it's possible our checkout will not include one of the revisions,
211         # so we may need to catch exceptions from commit_info_for_revision here.
212         target_nicks = [requester_nick] + self._responsible_nicknames_from_revisions(tool, sheriff, svn_revision_list)
213         return ", ".join(target_nicks)
214
215     def _update_working_copy(self, tool):
216         tool.scm().discard_local_changes()
217         tool.executive.run_and_throw_if_fail(tool.deprecated_port().update_webkit_command(), quiet=True, cwd=tool.scm().checkout_root)
218
219     def _check_diff_failure(self, error_log, tool):
220         if not error_log:
221             return None
222
223         revert_failure_message_start = error_log.find("Failed to apply reverse diff for revision")
224         if revert_failure_message_start == -1:
225             return None
226
227         lines = error_log[revert_failure_message_start:].split('\n')[1:]
228         files = itertools.takewhile(lambda line: tool.filesystem.exists(tool.scm().absolute_path(line)), lines)
229         if files:
230             return "Failed to apply reverse diff for file(s): %s" % ", ".join(files)
231         return None
232
233     def execute(self, nick, args, tool, sheriff):
234         svn_revision_list, rollout_reason = self._parse_args(args)
235
236         if (not svn_revision_list or not rollout_reason):
237             return self.usage(nick)
238
239         revision_urls_string = join_with_separators([urls.view_revision_url(revision) for revision in svn_revision_list])
240         tool.irc().post("%s: Preparing rollout for %s ..." % (nick, revision_urls_string))
241
242         self._update_working_copy(tool)
243
244         # FIXME: IRCCommand should bind to a tool and have a self._tool like Command objects do.
245         # Likewise we should probably have a self._sheriff.
246         nicks_string = self._nicks_string(tool, sheriff, nick, svn_revision_list)
247
248         try:
249             complete_reason = "%s (Requested by %s on %s)." % (
250                 rollout_reason, nick, config_irc.channel)
251             bug_id = sheriff.post_rollout_patch(svn_revision_list, complete_reason)
252             bug_url = tool.bugs.bug_url_for_bug_id(bug_id)
253             tool.irc().post("%s: Created rollout: %s" % (nicks_string, bug_url))
254         except ScriptError, e:
255             tool.irc().post("%s: Failed to create rollout patch:" % nicks_string)
256             diff_failure = self._check_diff_failure(e.output, tool)
257             if diff_failure:
258                 return "%s: %s" % (nicks_string, diff_failure)
259             _post_error_and_check_for_bug_url(tool, nicks_string, e)
260
261
262 class Whois(IRCCommand):
263     usage_string = "whois SEARCH_STRING"
264     help_string = "Searches known contributors and returns any matches with irc, email and full name. Wild card * permitted."
265
266     def _full_record_and_nick(self, contributor):
267         result = ''
268
269         if contributor.irc_nicknames:
270             result += ' (:%s)' % ', :'.join(contributor.irc_nicknames)
271
272         if contributor.can_review:
273             result += ' (r)'
274         elif contributor.can_commit:
275             result += ' (c)'
276
277         return unicode(contributor) + result
278
279     def execute(self, nick, args, tool, sheriff):
280         if not args:
281             return self.usage(nick)
282         search_string = unicode(" ".join(args))
283         # FIXME: We should get the ContributorList off the tool somewhere.
284         contributors = CommitterList().contributors_by_search_string(search_string)
285         if not contributors:
286             return unicode("%s: Sorry, I don't know any contributors matching '%s'.") % (nick, search_string)
287         if len(contributors) > 5:
288             return unicode("%s: More than 5 contributors match '%s', could you be more specific?") % (nick, search_string)
289         if len(contributors) == 1:
290             contributor = contributors[0]
291             if not contributor.irc_nicknames:
292                 return unicode("%s: %s hasn't told me their nick. Boo hoo :-(") % (nick, contributor)
293             return unicode("%s: %s is %s. Why do you ask?") % (nick, search_string, self._full_record_and_nick(contributor))
294         contributor_nicks = map(self._full_record_and_nick, contributors)
295         contributors_string = join_with_separators(contributor_nicks, only_two_separator=" or ", last_separator=', or ')
296         return unicode("%s: I'm not sure who you mean?  %s could be '%s'.") % (nick, contributors_string, search_string)
297
298
299 # FIXME: Lame.  We should have an auto-registering CommandCenter.
300 visible_commands = {
301     "create-bug": CreateBug,
302     "help": Help,
303     "hi": Hi,
304     "ping": PingPong,
305     "restart": Restart,
306     "roll-chromium-deps": RollChromiumDEPS,
307     "rollout": Rollout,
308     "whois": Whois,
309     "yt?": YouThere,
310 }
311
312 # Add revert as an "easter egg" command. Why?
313 # revert is the same as rollout and it would be confusing to list both when
314 # they do the same thing. However, this command is a very natural thing for
315 # people to use and it seems silly to have them hunt around for "rollout" instead.
316 commands = visible_commands.copy()
317 commands["revert"] = Rollout
318 # "hello" Alias for "hi" command for the purposes of testing aliases
319 commands["hello"] = Hi