├── .gitignore ├── README.md ├── __init__.py ├── config.py ├── plugin.py ├── requirements.txt ├── test-data ├── empty.ini ├── multi-channel.ini └── one.ini └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Supybot Git Plugin 2 | ================== 3 | 4 | This is a plugin for the IRC bot Supybot that introduces the ability to 5 | monitor Git repositories. Features: 6 | 7 | * Notifies IRC channel of new commits. 8 | * Display a log of recent commits on command. 9 | * Monitor as many repository/branch combinations as you like. 10 | * Privacy: repositories are associated with a channel and cannot be seen from 11 | other channels. 12 | * Highly configurable. 13 | 14 | NEWS 15 | ---- 16 | 17 | ### November 17, 2012 18 | 19 | Interface changes: 20 | 21 | * Several commands have been renamed. Sorry for the inconvenience, but it was 22 | time to make some common sense usabliity improvements. 23 | * Repository definitions now take a `channels` option instead of a single 24 | `channel` (although `channel` is supported for backwards compatibility). 25 | 26 | Dependencies 27 | ------------ 28 | 29 | This plugin depends on the Python packages: 30 | 31 | * GitPython (supports 0.1.x and 0.3.x) 32 | * Mock (if you want to run the tests) 33 | 34 | Dependencies are also listed in `requirements.txt`. You can install them with 35 | the command `pip install -r requirements.txt`. 36 | 37 | Configuration 38 | ------------- 39 | 40 | The Git plugin has a few standard configuration settings, but the primary 41 | configuration - where the repositories are defined - lives in an INI file. 42 | By default, it will look for the file 'git.ini' in the directory where you run 43 | Supybot. You can override this with "config plugins.Git.configFile 44 | /path/to/file". 45 | 46 | Here is an example of a repository definition: 47 | 48 | [Prototype] 49 | short name = prototype 50 | url = https://github.com/sstephenson/prototype.git 51 | commit link = https://github.com/sstephenson/prototype/commit/%c 52 | channels = #prototype 53 | 54 | Most of this will be self-explanatory. This defines a repository for the 55 | Prototype JavaScript library, so the Git plugin will be able to fetch a copy 56 | of it and display commits as they happen. 57 | 58 | Let's break down the possible settings: 59 | 60 | * `short name`: *Required.* This is the nickname you use in all commands that 61 | interact with the repository. 62 | 63 | * `url`: *Required.* The URL to the git repository, which may be a path on 64 | disk, or a URL to a remote repository. 65 | 66 | * `channels`: *Required.* A space-separated list of channels where 67 | notifications of new commits will appear. If you provide more than one 68 | channel, all channels will receive commit messages. This is also a weak 69 | privacy measure; people on other channels will not be able to request 70 | information about the repository. All interaction with the repository is 71 | limited to these channels. 72 | 73 | * `branch`: *Optional.* The branch to follow for this repository. If you want 74 | to follow multiple branches, you need to define multiple repository sections 75 | with different nicknames. Default: master. 76 | 77 | * `commit link`: *Optional.* A format string describing how to link to a 78 | particular commit. These links may appear in commit notifications from the 79 | plugin. Two format specifiers are supported: %c (7-digit SHA) and %C (full 80 | 40-digit SHA). Default: nothing. 81 | 82 | * `commit message`: *Optional.* A format string describing how to display 83 | commits in the channel. See Commit Messages below for detail. Default: 84 | `[%s|%b|%a] %m` 85 | 86 | * `commit reply`: *Optional* A format string for displaying commits as replies 87 | to commands or SHA snarfing (user-triggered events, instead of 88 | polling-triggered events). If empty, your `commit message` format will be 89 | used. Default: (empty string) 90 | 91 | Commit Messages 92 | --------------- 93 | 94 | Commit messages are produced from a general format string that you define. 95 | It uses the following substitution parameters: 96 | 97 | %a Author name 98 | %b Branch being watched 99 | %c Commit SHA (first 7 digits) 100 | %C Commit SHA (entire 40 digits) 101 | %e Author email 102 | %l Link to view commit on the web 103 | %m Commit message (first line only) 104 | %n Name of repository (config section heading) 105 | %s Short name of repository 106 | %u Git URL for repository 107 | %(fg) IRC color code (foreground only) 108 | %(fg,bg) IRC color code (foreground and background) 109 | %! Toggle bold 110 | %r Reset text color and attributes 111 | %% A literal percent sign. 112 | 113 | The format string can span multiple lines, in which case, the plugin will 114 | output multiple messages per commit. Here is a format string that I am 115 | partial to: 116 | 117 | commit message = %![%!%(14)%s%(15)%!|%!%(14)%b%(15)%!|%!%(14)%a%(15)%!]%! %m 118 | View%!:%! %(4)%l 119 | 120 | As noted above, the default is a simpler version of this: 121 | 122 | commit message = [%s|%b|%a] %m 123 | 124 | Leading spaces in any line of the message are discarded, so you can format it 125 | nicely in the file. 126 | 127 | Configurable Values 128 | ------------------- 129 | 130 | As mentioned above, there are a few things that can be configured within the 131 | Supybot configuration framework. For relative paths, they are relative to 132 | where Supybot is invoked. If you're unsure what that might be, just set them 133 | to absolute paths. The settings are found within `supybot.plugins.Git`: 134 | 135 | * `configFile`: Path to the INI file. Default: git.ini 136 | 137 | * `repoDir`: Path where local clones of repositories will be kept. This is a 138 | directory that will contain a copy of all repository being tracked. 139 | Default: git\_repositories 140 | 141 | * `pollPeriod`: How often (in seconds) that repositories will be polled for 142 | changes. Zero disables periodic polling. If you change the value from zero 143 | to a positive value, call `rehash` to restart polling. Default: 120 144 | 145 | * `maxCommitsAtOnce`: Limit how many commits can be displayed in one update. 146 | This will affect output from the periodic polling as well as the log 147 | command. Default: 5 148 | 149 | * `shaSnarfing`: Enables or disables SHA sharfing, a feature which watches the 150 | channel for mentions of a SHA and replies with the description of the 151 | matching commit, if found. Default: True 152 | 153 | How Notification Works 154 | ---------------------- 155 | 156 | The first time a repository is loaded from the INI file, a clone will be 157 | performed and saved in the repoDir defined above. 158 | 159 | **Warning #1:** If the repository is big and/or the network is slow, the 160 | first load may take a very long time! 161 | 162 | **Warning #2:** If the repositories you track are big, this plugin will use a 163 | lot of disk space for its local clones. 164 | 165 | After this, the poll operation involves a fetch (generally pretty quick), and 166 | then a check for any commits that arrived since the last check. 167 | 168 | Repository clones are never deleted. If you decide to stop tracking one, you 169 | may want to go manually delete it to free up disk space. 170 | 171 | Command List 172 | ------------ 173 | 174 | * `log`: Takes a repository nickname (aka "short name") and an optional 175 | count parameter (default 1). Shows the last n commits on the branch tracked 176 | for that repository. Only works if the repository is configured for the 177 | current channel. 178 | 179 | * `repositories`: List any known repositories configured for the current 180 | channel. 181 | 182 | * `rehash`: Reload the INI file, cloning any newly present repositories. 183 | Restarts any polling if applicable. 184 | 185 | As usual with Supybot plugins, you can call these commands by themselves or 186 | with the plugin name prefix, e.g. `@git log`. The latter form is only 187 | necessary if another plugin has a command called `log` as well, causing a 188 | conflict. 189 | 190 | Known Bugs 191 | ---------- 192 | 193 | In Supybot 0.83.4.1, the Owner plugin has a `log` command that might interfere 194 | with this plugin's `log` command. Not only that, but Owner's `log` is broken, 195 | and raises this exception: 196 | 197 | TypeError: 'NoneType' object is not callable 198 | 199 | If you see this, the simplest workaround is to set Git as the primary plugin 200 | to handle the `log` command: 201 | 202 | @defaultplugin log Git 203 | 204 | Alternatively, specify `@git log` instead of just `@log` when calling. 205 | This was reported as issue #9. 206 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | ### 2 | # Copyright (c) 2009, Mike Mueller 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are met: 7 | # 8 | # * Redistributions of source code must retain the above copyright notice, 9 | # this list of conditions, and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions, and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # * Neither the name of the author of this software nor the name of 14 | # contributors to this software may be used to endorse or promote products 15 | # derived from this software without specific prior written consent. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 20 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 21 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 22 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 23 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 24 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 25 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 26 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | # POSSIBILITY OF SUCH DAMAGE. 28 | 29 | ### 30 | 31 | """ 32 | Provides Git integration capabilities. 33 | """ 34 | 35 | import supybot 36 | import supybot.world as world 37 | 38 | # Use this for the version of this plugin. You may wish to put a CVS keyword 39 | # in here if you're keeping the plugin in CVS or some similar system. 40 | __version__ = "" 41 | 42 | # Replace this with an appropriate author or supybot.Author instance. 43 | __author__ = supybot.Author('Mike Mueller', 'mmueller', 'mike.mueller@panopticdev.com') 44 | 45 | # This is a dictionary mapping supybot.Author instances to lists of 46 | # contributions. 47 | __contributors__ = {} 48 | 49 | # This is a url where the most recent plugin package can be downloaded. 50 | __url__ = 'http://github.com/mmueller/supybot-git' 51 | 52 | import config 53 | import plugin 54 | reload(plugin) # In case we're being reloaded. 55 | # Add more reloads here if you add third-party modules and want them to be 56 | # reloaded when this plugin is reloaded. Don't forget to import them as well! 57 | 58 | if world.testing: 59 | import test 60 | 61 | Class = plugin.Class 62 | configure = config.configure 63 | 64 | 65 | # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: 66 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | ### 2 | # Copyright (c) 2009, Mike Mueller 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are met: 7 | # 8 | # * Do whatever you want 9 | # 10 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 11 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 12 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 13 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 14 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 15 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 16 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 17 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 18 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 19 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 20 | # POSSIBILITY OF SUCH DAMAGE. 21 | ### 22 | 23 | import supybot.conf as conf 24 | import supybot.registry as registry 25 | 26 | def configure(advanced): 27 | # This will be called by supybot to configure this module. advanced is 28 | # a bool that specifies whether the user identified himself as an advanced 29 | # user or not. You should effect your configuration by manipulating the 30 | # registry as appropriate. 31 | from supybot.questions import expect, anything, something, yn 32 | conf.registerPlugin('Git', True) 33 | 34 | Git = conf.registerPlugin('Git') 35 | 36 | conf.registerGlobalValue(Git, 'configFile', 37 | registry.String('git.ini', """The path to the repository configuration 38 | file.""")) 39 | 40 | conf.registerGlobalValue(Git, 'repoDir', 41 | registry.String('git_repositories', """The path where local copies of 42 | repositories will be kept.""")) 43 | 44 | conf.registerGlobalValue(Git, 'pollPeriod', 45 | registry.NonNegativeInteger(120, """The frequency (in seconds) repositories 46 | will be polled for changes. Set to zero to disable polling.""")) 47 | 48 | conf.registerGlobalValue(Git, 'maxCommitsAtOnce', 49 | registry.NonNegativeInteger(5, """How many commits are displayed at 50 | once from each repository.""")) 51 | 52 | conf.registerGlobalValue(Git, 'shaSnarfing', 53 | registry.Boolean(True, """Look for SHAs in user messages written to the 54 | channel, and reply with the commit description if one is found.""")) 55 | 56 | # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: 57 | -------------------------------------------------------------------------------- /plugin.py: -------------------------------------------------------------------------------- 1 | ### 2 | # Copyright (c) 2011-2012, Mike Mueller 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are met: 7 | # 8 | # * Do whatever you want 9 | # 10 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 11 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 12 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 13 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 14 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 15 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 16 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 17 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 18 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 19 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 20 | # POSSIBILITY OF SUCH DAMAGE. 21 | ### 22 | 23 | """ 24 | A Supybot plugin that monitors and interacts with git repositories. 25 | """ 26 | 27 | import supybot.utils as utils 28 | from supybot.commands import * 29 | import supybot.plugins as plugins 30 | import supybot.ircmsgs as ircmsgs 31 | import supybot.ircutils as ircutils 32 | import supybot.callbacks as callbacks 33 | import supybot.schedule as schedule 34 | import supybot.log as log 35 | import supybot.world as world 36 | 37 | import ConfigParser 38 | from functools import wraps 39 | import os 40 | import threading 41 | import time 42 | import traceback 43 | 44 | # 'import git' is performed during plugin initialization. 45 | # 46 | # The GitPython library has different APIs depending on the version installed. 47 | # (0.1.x, 0.3.x supported) 48 | GIT_API_VERSION = -1 49 | 50 | def log_info(message): 51 | log.info("Git: " + message) 52 | 53 | def log_warning(message): 54 | log.warning("Git: " + message) 55 | 56 | def log_error(message): 57 | log.error("Git: " + message) 58 | 59 | def plural(count, singular, plural=None): 60 | if count == 1: 61 | return singular 62 | if plural: 63 | return plural 64 | if singular[-1] == 's': 65 | return singular + 'es' 66 | if singular[-1] == 'y': 67 | return singular[:-1] + 'ies' 68 | return singular + 's' 69 | 70 | def synchronized(tlockname): 71 | """ 72 | Decorates a class method (with self as the first parameter) to acquire the 73 | member variable lock with the given name (e.g. 'lock' ==> self.lock) for 74 | the duration of the function (blocking). 75 | """ 76 | def _synched(func): 77 | @wraps(func) 78 | def _synchronizer(self, *args, **kwargs): 79 | tlock = self.__getattribute__(tlockname) 80 | tlock.acquire() 81 | try: 82 | return func(self, *args, **kwargs) 83 | finally: 84 | tlock.release() 85 | return _synchronizer 86 | return _synched 87 | 88 | class Repository(object): 89 | "Represents a git repository being monitored." 90 | 91 | def __init__(self, repo_dir, long_name, options): 92 | """ 93 | Initialize with a repository with the given name and dict of options 94 | from the config section. 95 | """ 96 | if GIT_API_VERSION == -1: 97 | raise Exception("Git-python API version uninitialized.") 98 | 99 | # Validate configuration ("channel" allowed for backward compatibility) 100 | required_values = ['short name', 'url'] 101 | optional_values = ['branch', 'channel', 'channels', 'commit link', 102 | 'commit message', 'commit reply'] 103 | for name in required_values: 104 | if name not in options: 105 | raise Exception('Section %s missing required value: %s' % 106 | (long_name, name)) 107 | for name, value in options.items(): 108 | if name not in required_values and name not in optional_values: 109 | raise Exception('Section %s contains unrecognized value: %s' % 110 | (long_name, name)) 111 | 112 | # Initialize 113 | self.branch = 'origin/' + options.get('branch', 'master') 114 | self.channels = options.get('channels', options.get('channel')).split() 115 | self.commit_link = options.get('commit link', '') 116 | self.commit_message = options.get('commit message', '[%s|%b|%a] %m') 117 | self.commit_reply = options.get('commit reply', '') 118 | self.errors = [] 119 | self.last_commit = None 120 | self.lock = threading.RLock() 121 | self.long_name = long_name 122 | self.short_name = options['short name'] 123 | self.repo = None 124 | self.url = options['url'] 125 | 126 | if not os.path.exists(repo_dir): 127 | os.makedirs(repo_dir) 128 | self.path = os.path.join(repo_dir, self.short_name) 129 | 130 | # TODO: Move this to GitWatcher (separate thread) 131 | self.clone() 132 | 133 | @synchronized('lock') 134 | def clone(self): 135 | "If the repository doesn't exist on disk, clone it." 136 | if not os.path.exists(self.path): 137 | git.Git('.').clone(self.url, self.path, no_checkout=True) 138 | self.repo = git.Repo(self.path) 139 | self.last_commit = self.repo.commit(self.branch) 140 | 141 | @synchronized('lock') 142 | def fetch(self): 143 | "Contact git repository and update last_commit appropriately." 144 | self.repo.git.fetch() 145 | 146 | @synchronized('lock') 147 | def get_commit(self, sha): 148 | "Fetch the commit with the given SHA. Returns None if not found." 149 | try: 150 | return self.repo.commit(sha) 151 | except ValueError: # 0.1.x 152 | return None 153 | except git.GitCommandError: # 0.3.x 154 | return None 155 | except git.BadObject: # 0.3.2 156 | return None 157 | 158 | @synchronized('lock') 159 | def get_commit_id(self, commit): 160 | if GIT_API_VERSION == 1: 161 | return commit.id 162 | elif GIT_API_VERSION == 3: 163 | return commit.hexsha 164 | else: 165 | raise Exception("Unsupported API version: %d" % GIT_API_VERSION) 166 | 167 | @synchronized('lock') 168 | def get_new_commits(self): 169 | if GIT_API_VERSION == 1: 170 | result = self.repo.commits_between(self.last_commit, self.branch) 171 | elif GIT_API_VERSION == 3: 172 | rev = "%s..%s" % (self.last_commit, self.branch) 173 | # Workaround for GitPython bug: 174 | # https://github.com/gitpython-developers/GitPython/issues/61 175 | self.repo.odb.update_cache() 176 | result = self.repo.iter_commits(rev) 177 | else: 178 | raise Exception("Unsupported API version: %d" % GIT_API_VERSION) 179 | self.last_commit = self.repo.commit(self.branch) 180 | return list(result) 181 | 182 | @synchronized('lock') 183 | def get_recent_commits(self, count): 184 | if GIT_API_VERSION == 1: 185 | return self.repo.commits(start=self.branch, max_count=count) 186 | elif GIT_API_VERSION == 3: 187 | return list(self.repo.iter_commits(self.branch))[:count] 188 | else: 189 | raise Exception("Unsupported API version: %d" % GIT_API_VERSION) 190 | 191 | @synchronized('lock') 192 | def format_link(self, commit): 193 | "Return a link to view a given commit, based on config setting." 194 | result = '' 195 | escaped = False 196 | for c in self.commit_link: 197 | if escaped: 198 | if c == 'c': 199 | result += self.get_commit_id(commit)[0:7] 200 | elif c == 'C': 201 | result += self.get_commit_id(commit) 202 | else: 203 | result += c 204 | escaped = False 205 | elif c == '%': 206 | escaped = True 207 | else: 208 | result += c 209 | return result 210 | 211 | @synchronized('lock') 212 | def format_message(self, commit, format_str=None): 213 | """ 214 | Generate an formatted message for IRC from the given commit, using 215 | the format specified in the config. Returns a list of strings. 216 | """ 217 | MODE_NORMAL = 0 218 | MODE_SUBST = 1 219 | MODE_COLOR = 2 220 | subst = { 221 | 'a': commit.author.name, 222 | 'b': self.branch[self.branch.rfind('/')+1:], 223 | 'c': self.get_commit_id(commit)[0:7], 224 | 'C': self.get_commit_id(commit), 225 | 'e': commit.author.email, 226 | 'l': self.format_link(commit), 227 | 'm': commit.message.split('\n')[0], 228 | 'n': self.long_name, 229 | 's': self.short_name, 230 | 'u': self.url, 231 | 'r': '\x0f', 232 | '!': '\x02', 233 | '%': '%', 234 | } 235 | result = [] 236 | if not format_str: 237 | format_str = self.commit_message 238 | lines = format_str.split('\n') 239 | for line in lines: 240 | mode = MODE_NORMAL 241 | outline = '' 242 | for c in line: 243 | if mode == MODE_SUBST: 244 | if c in subst.keys(): 245 | outline += subst[c] 246 | mode = MODE_NORMAL 247 | elif c == '(': 248 | color = '' 249 | mode = MODE_COLOR 250 | else: 251 | outline += c 252 | mode = MODE_NORMAL 253 | elif mode == MODE_COLOR: 254 | if c == ')': 255 | outline += '\x03' + color 256 | mode = MODE_NORMAL 257 | else: 258 | color += c 259 | elif c == '%': 260 | mode = MODE_SUBST 261 | else: 262 | outline += c 263 | result.append(outline.encode('utf-8')) 264 | return result 265 | 266 | @synchronized('lock') 267 | def record_error(self, e): 268 | "Save the exception 'e' for future error reporting." 269 | self.errors.append(e) 270 | 271 | @synchronized('lock') 272 | def get_errors(self): 273 | "Return a list of exceptions that have occurred since last get_errors." 274 | result = self.errors 275 | self.errors = [] 276 | return result 277 | 278 | class Git(callbacks.PluginRegexp): 279 | "Please see the README file to configure and use this plugin." 280 | 281 | threaded = True 282 | unaddressedRegexps = [ '_snarf' ] 283 | 284 | def __init__(self, irc): 285 | self.init_git_python() 286 | self.__parent = super(Git, self) 287 | self.__parent.__init__(irc) 288 | # Workaround the fact that self.log already exists in plugins 289 | self.log = LogWrapper(self.log, Git._log.__get__(self)) 290 | self.fetcher = None 291 | self._stop_polling() 292 | try: 293 | self._read_config() 294 | except Exception, e: 295 | if 'reply' in dir(irc): 296 | irc.reply('Warning: %s' % str(e)) 297 | else: 298 | # During bot startup, there is no one to reply to. 299 | log_warning(str(e)) 300 | self._schedule_next_event() 301 | 302 | def init_git_python(self): 303 | global GIT_API_VERSION, git 304 | try: 305 | import git 306 | except ImportError: 307 | raise Exception("GitPython is not installed.") 308 | if not git.__version__.startswith('0.'): 309 | raise Exception("Unsupported GitPython version.") 310 | GIT_API_VERSION = int(git.__version__[2]) 311 | if not GIT_API_VERSION in [1, 3]: 312 | log_error('GitPython version %s unrecognized, using 0.3.x API.' 313 | % git.__version__) 314 | GIT_API_VERSION = 3 315 | 316 | def die(self): 317 | self._stop_polling() 318 | self.__parent.die() 319 | 320 | def _log(self, irc, msg, args, channel, name, count): 321 | """ [count] 322 | 323 | Display the last commits on the named repository. [count] defaults to 324 | 1 if unspecified. 325 | """ 326 | matches = filter(lambda r: r.short_name == name, self.repository_list) 327 | if not matches: 328 | irc.reply('No configured repository named %s.' % name) 329 | return 330 | # Enforce a modest privacy measure... don't let people probe the 331 | # repository outside the designated channel. 332 | repository = matches[0] 333 | if channel not in repository.channels: 334 | irc.reply('Sorry, not allowed in this channel.') 335 | return 336 | commits = repository.get_recent_commits(count)[::-1] 337 | self._reply_commits(irc, channel, repository, commits) 338 | _log = wrap(_log, ['channel', 'somethingWithoutSpaces', 339 | optional('positiveInt', 1)]) 340 | 341 | def rehash(self, irc, msg, args): 342 | """(takes no arguments) 343 | 344 | Reload the Git ini file and restart any period polling. 345 | """ 346 | self._stop_polling() 347 | try: 348 | self._read_config() 349 | self._schedule_next_event() 350 | n = len(self.repository_list) 351 | irc.reply('Git reinitialized with %d %s.' % 352 | (n, plural(n, 'repository'))) 353 | except Exception, e: 354 | irc.reply('Warning: %s' % str(e)) 355 | 356 | def repositories(self, irc, msg, args, channel): 357 | """(takes no arguments) 358 | 359 | Display the names of known repositories configured for this channel. 360 | """ 361 | repositories = filter(lambda r: channel in r.channels, 362 | self.repository_list) 363 | if not repositories: 364 | irc.reply('No repositories configured for this channel.') 365 | return 366 | for r in repositories: 367 | fmt = '\x02%(short_name)s\x02 (%(name)s, branch: %(branch)s)' 368 | irc.reply(fmt % { 369 | 'branch': r.branch.split('/')[-1], 370 | 'name': r.long_name, 371 | 'short_name': r.short_name, 372 | 'url': r.url, 373 | }) 374 | repositories = wrap(repositories, ['channel']) 375 | 376 | def gitrehash(self, irc, msg, args): 377 | "Obsolete command, remove this function eventually." 378 | irc.reply('"gitrehash" is obsolete, please use "rehash".') 379 | 380 | def repolist(self, irc, msg, args): 381 | "Obsolete command, remove this function eventually." 382 | irc.reply('"repolist" is obsolete, please use "repositories".') 383 | 384 | def shortlog(self, irc, msg, args): 385 | "Obsolete command, remove this function eventually." 386 | irc.reply('"shortlog" is obsolete, please use "log".') 387 | 388 | # Overridden to hide the obsolete commands 389 | def listCommands(self, pluginCommands=[]): 390 | return ['log', 'rehash', 'repositories'] 391 | 392 | def _display_commits(self, irc, channel, repository, commits): 393 | "Display a nicely-formatted list of commits in a channel." 394 | commits = list(commits) 395 | commits_at_once = self.registryValue('maxCommitsAtOnce') 396 | if len(commits) > commits_at_once: 397 | irc.queueMsg(ircmsgs.privmsg(channel, 398 | "Showing latest %d of %d commits to %s..." % 399 | (commits_at_once, len(commits), repository.long_name))) 400 | for commit in commits[-commits_at_once:]: 401 | lines = repository.format_message(commit) 402 | for line in lines: 403 | msg = ircmsgs.privmsg(channel, line) 404 | irc.queueMsg(msg) 405 | 406 | # Post commits to channel as a reply 407 | def _reply_commits(self, irc, channel, repository, commits): 408 | commits = list(commits) 409 | commits_at_once = self.registryValue('maxCommitsAtOnce') 410 | if len(commits) > commits_at_once: 411 | irc.reply("Showing latest %d of %d commits to %s..." % 412 | (commits_at_once, len(commits), repository.long_name)) 413 | format_str = repository.commit_reply or repository.commit_message 414 | for commit in commits[-commits_at_once:]: 415 | lines = repository.format_message(commit, format_str) 416 | map(irc.reply, lines) 417 | 418 | def _poll(self): 419 | # Note that polling happens in two steps: 420 | # 421 | # 1. The GitFetcher class, running its own poll loop, fetches 422 | # repositories to keep the local copies up to date. 423 | # 2. This _poll occurs, and looks for new commits in those local 424 | # copies. (Therefore this function should be quick. If it is 425 | # slow, it may block the entire bot.) 426 | try: 427 | for repository in self.repository_list: 428 | # Find the IRC/channel pairs to notify 429 | targets = [] 430 | for irc in world.ircs: 431 | for channel in repository.channels: 432 | if channel in irc.state.channels: 433 | targets.append((irc, channel)) 434 | if not targets: 435 | log_info("Skipping %s: not in configured channel(s)." % 436 | repository.long_name) 437 | continue 438 | 439 | # Manual non-blocking lock calls here to avoid potentially long 440 | # waits (if it fails, hope for better luck in the next _poll). 441 | if repository.lock.acquire(blocking=False): 442 | try: 443 | errors = repository.get_errors() 444 | for e in errors: 445 | log_error('Unable to fetch %s: %s' % 446 | (repository.long_name, str(e))) 447 | commits = repository.get_new_commits()[::-1] 448 | for irc, channel in targets: 449 | self._display_commits(irc, channel, repository, 450 | commits) 451 | except Exception, e: 452 | log_error('Exception in _poll repository %s: %s' % 453 | (repository.short_name, str(e))) 454 | finally: 455 | repository.lock.release() 456 | else: 457 | log.info('Postponing repository read: %s: Locked.' % 458 | repository.long_name) 459 | self._schedule_next_event() 460 | except Exception, e: 461 | log_error('Exception in _poll(): %s' % str(e)) 462 | traceback.print_exc(e) 463 | 464 | def _read_config(self): 465 | self.repository_list = [] 466 | repo_dir = self.registryValue('repoDir') 467 | config = self.registryValue('configFile') 468 | if not os.access(config, os.R_OK): 469 | raise Exception('Cannot access configuration file: %s' % config) 470 | parser = ConfigParser.RawConfigParser() 471 | parser.read(config) 472 | for section in parser.sections(): 473 | options = dict(parser.items(section)) 474 | self.repository_list.append(Repository(repo_dir, section, options)) 475 | 476 | def _schedule_next_event(self): 477 | period = self.registryValue('pollPeriod') 478 | if period > 0: 479 | if not self.fetcher or not self.fetcher.isAlive(): 480 | self.fetcher = GitFetcher(self.repository_list, period) 481 | self.fetcher.start() 482 | schedule.addEvent(self._poll, time.time() + period, 483 | name=self.name()) 484 | else: 485 | self._stop_polling() 486 | 487 | def _snarf(self, irc, msg, match): 488 | r"""\b(?P[0-9a-f]{6,40})\b""" 489 | if self.registryValue('shaSnarfing'): 490 | sha = match.group('sha') 491 | channel = msg.args[0] 492 | repositories = filter(lambda r: channel in r.channels, 493 | self.repository_list) 494 | for repository in repositories: 495 | commit = repository.get_commit(sha) 496 | if commit: 497 | self._reply_commits(irc, channel, repository, [commit]) 498 | break 499 | 500 | def _stop_polling(self): 501 | # Never allow an exception to propagate since this is called in die() 502 | if self.fetcher: 503 | try: 504 | self.fetcher.stop() 505 | self.fetcher.join() # This might take time, but it's safest. 506 | except Exception, e: 507 | log_error('Stopping fetcher: %s' % str(e)) 508 | self.fetcher = None 509 | try: 510 | schedule.removeEvent(self.name()) 511 | except KeyError: 512 | pass 513 | except Exception, e: 514 | log_error('Stopping scheduled task: %s' % str(e)) 515 | 516 | class GitFetcher(threading.Thread): 517 | "A thread object to perform long-running Git operations." 518 | 519 | # I don't know of any way to shut down a thread except to have it 520 | # check a variable very frequently. 521 | SHUTDOWN_CHECK_PERIOD = 0.1 # Seconds 522 | 523 | # TODO: Wrap git fetch command and enforce a timeout. Git will probably 524 | # timeout on its own in most cases, but I have actually seen it hang 525 | # forever on "fetch" before. 526 | 527 | def __init__(self, repositories, period, *args, **kwargs): 528 | """ 529 | Takes a list of repositories and a period (in seconds) to poll them. 530 | As long as it is running, the repositories will be kept up to date 531 | every period seconds (with a git fetch). 532 | """ 533 | super(GitFetcher, self).__init__(*args, **kwargs) 534 | self.repository_list = repositories 535 | self.period = period * 1.1 # Hacky attempt to avoid resonance 536 | self.shutdown = False 537 | 538 | def stop(self): 539 | """ 540 | Shut down the thread as soon as possible. May take some time if 541 | inside a long-running fetch operation. 542 | """ 543 | self.shutdown = True 544 | 545 | def run(self): 546 | "The main thread method." 547 | # Initially wait for half the period to stagger this thread and 548 | # the main thread and avoid lock contention. 549 | end_time = time.time() + self.period/2 550 | while not self.shutdown: 551 | try: 552 | for repository in self.repository_list: 553 | if self.shutdown: break 554 | if repository.lock.acquire(blocking=False): 555 | try: 556 | repository.fetch() 557 | except Exception, e: 558 | repository.record_error(e) 559 | finally: 560 | repository.lock.release() 561 | else: 562 | log_info('Postponing repository fetch: %s: Locked.' % 563 | repository.long_name) 564 | except Exception, e: 565 | log_error('Exception checking repository %s: %s' % 566 | (repository.short_name, str(e))) 567 | # Wait for the next periodic check 568 | while not self.shutdown and time.time() < end_time: 569 | time.sleep(GitFetcher.SHUTDOWN_CHECK_PERIOD) 570 | end_time = time.time() + self.period 571 | 572 | class LogWrapper(object): 573 | """ 574 | Horrific workaround for the fact that PluginMixin has a member variable 575 | called 'log' -- wiping out my 'log' command. Delegates all requests to 576 | the log, and when called as a function, performs the log command. 577 | """ 578 | 579 | LOGGER_METHODS = [ 580 | 'debug', 581 | 'info', 582 | 'warning', 583 | 'error', 584 | 'critical', 585 | 'exception', 586 | ] 587 | 588 | def __init__(self, log_object, log_command): 589 | "Construct the wrapper with the objects being wrapped." 590 | self.log_object = log_object 591 | self.log_command = log_command 592 | self.__doc__ = log_command.__doc__ 593 | 594 | def __call__(self, *args, **kwargs): 595 | return self.log_command(*args, **kwargs) 596 | 597 | def __getattr__(self, name): 598 | if name in LogWrapper.LOGGER_METHODS: 599 | return getattr(self.log_object, name) 600 | else: 601 | return getattr(self.log_command, name) 602 | 603 | # Because isCommandMethod() relies on inspection (whyyyy), I do this (gross) 604 | import inspect 605 | if 'git_orig_ismethod' not in dir(inspect): 606 | inspect.git_orig_ismethod = inspect.ismethod 607 | inspect.ismethod = \ 608 | lambda x: type(x) == LogWrapper or inspect.git_orig_ismethod(x) 609 | 610 | Class = Git 611 | 612 | # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: 613 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | GitPython 2 | mock 3 | -------------------------------------------------------------------------------- /test-data/empty.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmueller/supybot-git/a99d8f60170d4f5763f8e74ef78da99b15971690/test-data/empty.ini -------------------------------------------------------------------------------- /test-data/multi-channel.ini: -------------------------------------------------------------------------------- 1 | [Test Repository 1] 2 | short name = test1 3 | url = /somewhere/to/nowhere 4 | channels = #test 5 | 6 | [Test Repository 2] 7 | short name = test2 8 | branch = feature 9 | url = /somewhere/to/nowhere 10 | channels = #somewhere #test #somewhereelse 11 | 12 | [Test Repository 3] 13 | short name = test3 14 | url = /somewhere/to/nowhere 15 | channels = #somewhere 16 | -------------------------------------------------------------------------------- /test-data/one.ini: -------------------------------------------------------------------------------- 1 | [Test Repository] 2 | short name = test 3 | url = /somewhere/to/nowhere 4 | channels = #test 5 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011-2012, Mike Mueller 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # * Do whatever you want. 8 | # 9 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 10 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 11 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 12 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 13 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 14 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 15 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 16 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 17 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 18 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 19 | # POSSIBILITY OF SUCH DAMAGE. 20 | 21 | from supybot.test import * 22 | from supybot import conf 23 | 24 | from mock import Mock, patch 25 | import git 26 | import os 27 | import time 28 | 29 | SRC_DIR = os.path.dirname(os.path.abspath(__file__)) 30 | DATA_DIR = os.path.join(SRC_DIR, 'test-data') 31 | 32 | # This timeout value works for me and keeps the tests snappy. If test queries 33 | # are not getting responses, you may need to bump this higher. 34 | LOOP_TIMEOUT = 0.1 35 | 36 | # Global mocks 37 | git.Git.clone = Mock() 38 | git.Repo = Mock() 39 | 40 | # A pile of commits for use wherever (most recent first) 41 | COMMITS = [Mock(), Mock(), Mock(), Mock(), Mock()] 42 | COMMITS[0].author.name = 'nstark' 43 | COMMITS[0].hexsha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' 44 | COMMITS[0].message = 'Fix bugs.' 45 | COMMITS[1].author.name = 'tlannister' 46 | COMMITS[1].hexsha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' 47 | COMMITS[1].message = 'I am more long-winded\nand may even use newlines.' 48 | COMMITS[2].author.name = 'tlannister' 49 | COMMITS[2].hexsha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' 50 | COMMITS[2].message = 'Snarks and grumpkins' 51 | COMMITS[3].author.name = 'jsnow' 52 | COMMITS[3].hexsha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' 53 | COMMITS[3].message = "Finished brooding, think I'll go brood." 54 | COMMITS[4].author.name = 'tlannister' 55 | COMMITS[4].hexsha = 'deadbeefcdefabcdefabcdefabcdefabcdefabcd' 56 | COMMITS[4].message = "I'm the only one getting things done." 57 | 58 | # Workaround Supybot 0.83.4.1 bug with Owner treating 'log' as a command 59 | conf.registerGlobalValue(conf.supybot.commands.defaultPlugins, 60 | 'log', registry.String('Git', '')) 61 | conf.supybot.commands.defaultPlugins.get('log').set('Git') 62 | 63 | # Pre-test checks 64 | GIT_API_VERSION = int(git.__version__[2]) 65 | assert GIT_API_VERSION == 3, 'Tests only run against GitPython 0.3.x+ API.' 66 | 67 | class PluginTestCaseUtilMixin(object): 68 | "Some additional utilities used in this plugin's tests." 69 | 70 | def _feedMsgLoop(self, query, timeout=None, **kwargs): 71 | "Send a message and wait for a list of responses instead of just one." 72 | if timeout is None: 73 | timeout = LOOP_TIMEOUT 74 | responses = [] 75 | start = time.time() 76 | r = self._feedMsg(query, timeout=timeout, **kwargs) 77 | # Sleep off remaining time, then start sending empty queries until 78 | # the replies stop coming. 79 | remainder = timeout - (time.time() - start) 80 | time.sleep(remainder if remainder > 0 else 0) 81 | query = conf.supybot.reply.whenAddressedBy.chars()[0] 82 | while r: 83 | responses.append(r) 84 | r = self._feedMsg(query, timeout=0, **kwargs) 85 | return responses 86 | 87 | def assertResponses(self, query, expectedResponses, **kwargs): 88 | "Run a command and assert that it returns the given list of replies." 89 | responses = self._feedMsgLoop(query, **kwargs) 90 | responses = map(lambda m: m.args[1], responses) 91 | self.assertEqual(responses, expectedResponses, 92 | '\nActual:\n%s\n\nExpected:\n%s' % 93 | ('\n'.join(responses), '\n'.join(expectedResponses))) 94 | return responses 95 | 96 | class GitRehashTest(PluginTestCase): 97 | plugins = ('Git',) 98 | 99 | def setUp(self): 100 | super(GitRehashTest, self).setUp() 101 | conf.supybot.plugins.Git.pollPeriod.setValue(0) 102 | 103 | def testRehashEmpty(self): 104 | conf.supybot.plugins.Git.configFile.setValue(DATA_DIR + '/empty.ini') 105 | self.assertResponse('rehash', 'Git reinitialized with 0 repositories.') 106 | 107 | def testRehashOne(self): 108 | conf.supybot.plugins.Git.configFile.setValue(DATA_DIR + '/one.ini') 109 | self.assertResponse('rehash', 'Git reinitialized with 1 repository.') 110 | 111 | class GitRepositoryListTest(ChannelPluginTestCase, PluginTestCaseUtilMixin): 112 | channel = '#test' 113 | plugins = ('Git',) 114 | 115 | def setUp(self): 116 | super(GitRepositoryListTest, self).setUp() 117 | ini = os.path.join(DATA_DIR, 'multi-channel.ini') 118 | conf.supybot.plugins.Git.pollPeriod.setValue(0) 119 | conf.supybot.plugins.Git.configFile.setValue(ini) 120 | self.assertResponse('rehash', 'Git reinitialized with 3 repositories.') 121 | 122 | def testRepositoryList(self): 123 | expected = [ 124 | '\x02test1\x02 (Test Repository 1, branch: master)', 125 | '\x02test2\x02 (Test Repository 2, branch: feature)', 126 | ] 127 | self.assertResponses('repositories', expected) 128 | 129 | class GitNoAccessTest(ChannelPluginTestCase, PluginTestCaseUtilMixin): 130 | channel = '#unused' 131 | plugins = ('Git',) 132 | 133 | def setUp(self): 134 | super(GitNoAccessTest, self).setUp() 135 | ini = os.path.join(DATA_DIR, 'multi-channel.ini') 136 | conf.supybot.plugins.Git.configFile.setValue(ini) 137 | self.assertResponse('rehash', 'Git reinitialized with 3 repositories.') 138 | 139 | def testRepositoryListNoAccess(self): 140 | expected = ['No repositories configured for this channel.'] 141 | self.assertResponses('repositories', expected) 142 | 143 | def testLogNoAccess(self): 144 | expected = ['Sorry, not allowed in this channel.'] 145 | self.assertResponses('log test1', expected) 146 | 147 | class GitLogTest(ChannelPluginTestCase, PluginTestCaseUtilMixin): 148 | channel = '#somewhere' 149 | plugins = ('Git',) 150 | 151 | def setUp(self): 152 | super(GitLogTest, self).setUp() 153 | self._metamock = patch('git.Repo') 154 | self.Repo = self._metamock.__enter__() 155 | self.Repo.return_value = self.Repo 156 | self.Repo.iter_commits.return_value = COMMITS 157 | ini = os.path.join(DATA_DIR, 'multi-channel.ini') 158 | conf.supybot.plugins.Git.pollPeriod.setValue(0) 159 | conf.supybot.plugins.Git.maxCommitsAtOnce.setValue(3) 160 | conf.supybot.plugins.Git.configFile.setValue(ini) 161 | self.assertResponse('rehash', 'Git reinitialized with 3 repositories.') 162 | 163 | def tearDown(self): 164 | del self.Repo 165 | self._metamock.__exit__() 166 | 167 | def testLogNonexistent(self): 168 | expected = ['No configured repository named nothing.'] 169 | self.assertResponses('log nothing', expected) 170 | 171 | def testLogNotAllowed(self): 172 | expected = ['Sorry, not allowed in this channel.'] 173 | self.assertResponses('log test1', expected) 174 | 175 | def testLogZero(self): 176 | expected = ['(\x02log [count]\x02) -- Display the last ' + 177 | 'commits on the named repository. [count] defaults to 1 ' + 178 | 'if unspecified.'] 179 | self.assertResponses('log test2 0', expected) 180 | 181 | def testLogNegative(self): 182 | expected = ['(\x02log [count]\x02) -- Display the last ' + 183 | 'commits on the named repository. [count] defaults to 1 ' + 184 | 'if unspecified.'] 185 | self.assertResponses('log test2 -1', expected) 186 | 187 | def testLogOne(self): 188 | expected = ['[test2|feature|nstark] Fix bugs.'] 189 | self.assertResponses('log test2', expected) 190 | 191 | def testLogTwo(self): 192 | expected = [ 193 | '[test2|feature|tlannister] I am more long-winded', 194 | '[test2|feature|nstark] Fix bugs.', 195 | ] 196 | self.assertResponses('log test2 2', expected) 197 | 198 | def testLogFive(self): 199 | expected = [ 200 | 'Showing latest 3 of 5 commits to Test Repository 2...', 201 | '[test2|feature|tlannister] Snarks and grumpkins', 202 | '[test2|feature|tlannister] I am more long-winded', 203 | '[test2|feature|nstark] Fix bugs.', 204 | ] 205 | self.assertResponses('log test2 5', expected) 206 | 207 | def testSnarf(self): 208 | self.Repo.commit.return_value = COMMITS[4] 209 | expected = [ 210 | "[test2|feature|tlannister] I'm the only one getting things done.", 211 | ] 212 | self.assertResponses('who wants some deadbeef?', expected, 213 | usePrefixChar=False) 214 | 215 | # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: 216 | --------------------------------------------------------------------------------