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