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