3 # Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19 Provide and email interface to the distributed bugtracker Bugs
20 Everywhere. Recieves incoming email via procmail. Provides an
21 interface similar to the Debian Bug Tracker. There are currently
22 three distinct email types: submits, comments, and controls. The
23 email types are differentiated by tags in the email subject. See
24 SUBJECT_TAG* for the current values.
26 Submit emails create a bug (and optionally add some intitial
27 comments). The post-tag subject is used as the bug summary, and the
28 email body is parsed for a pseudo-header. Any text after the
29 psuedo-header but before a possible line starting with BREAK is added
30 as the initial bug comment.
32 Comment emails add comments to a bug. The first non-multipart portion
33 of the email is used as the comment body. If that portion has a
34 "text/plain" type, any text after and including a possible line
35 starting with BREAK is stripped to avoid lots of taglines cluttering
38 Control emails preform any allowed BE commands. The first
39 non-multipart portion of the email is used as the comment body. If
40 that portion has a "text/plain" type, any text after and including a
41 possible line starting with BREAK is stripped. Each pre-BREAK line of
42 the portion should be a valid BE command, with the initial "be"
43 omitted, e.g. "be status XYZ fixed" --> "status XYZ fixed".
45 Any changes made to the repository are commited after the email is
46 executed, with the email's post-tag subject as the commit message.
50 import StringIO as StringIO
52 from email.mime.multipart import MIMEMultipart
70 import libbe.command.subscribe as subscribe
72 import libbe.ui.command_line
73 import libbe.util.encoding
74 import libbe.util.utility
77 THIS_SERVER = u'thor.physics.drexel.edu'
78 THIS_ADDRESS = u'BE Bugs <wking@thor.physics.drexel.edu>'
80 _THIS_DIR = os.path.abspath(os.path.dirname(__file__))
81 LOGPATH = os.path.join(_THIS_DIR, u'be-handle-mail.log')
84 # Tag strings generated by generate_global_tags()
85 SUBJECT_TAG_BASE = u'be-bug'
86 SUBJECT_TAG_RESPONSE = None
87 SUBJECT_TAG_START = None
88 SUBJECT_TAG_NEW = None
89 SUBJECT_TAG_COMMENT = None
90 SUBJECT_TAG_CONTROL = None
93 NEW_REQUIRED_PSEUDOHEADERS = [u'Version']
94 NEW_OPTIONAL_PSEUDOHEADERS = [u'Reporter', u'Assign', u'Depend', u'Severity',
95 u'Status', u'Tag', u'Target',
96 u'Confirm', u'Subscribe']
97 CONTROL_COMMENT = u'#'
98 ALLOWED_COMMANDS = [u'assign', u'comment', u'commit', u'depend', u'diff',
99 u'due', u'help', u'list', u'merge', u'new', u'severity',
100 u'show', u'status', u'subscribe', u'tag', u'target']
105 libbe.util.encoding.ENCODING = ENCODING # force default encoding
107 class InvalidEmail (ValueError):
108 def __init__(self, msg, message):
109 ValueError.__init__(self, message)
112 header = self.msg.response_header
113 body = [u'Error processing email:\n',
114 self.response_body(), u'']
115 response_generator = \
116 send_pgp_mime.PGPMimeMessageFactory(u'\n'.join(body))
117 response = MIMEMultipart()
118 response.attach(response_generator.plain())
119 response.attach(self.msg.msg)
120 ret = send_pgp_mime.attach_root(header, response)
122 def response_body(self):
123 err_text = [unicode(self)]
124 return u'\n'.join(err_text)
126 class InvalidSubject (InvalidEmail):
127 def __init__(self, msg, message=None):
129 message = u'Invalid subject'
130 InvalidEmail.__init__(self, msg, message)
131 def response_body(self):
132 err_text = u'\n'.join([unicode(self), u'',
133 u'full subject was:',
137 class InvalidPseudoHeader (InvalidEmail):
138 def response_body(self):
139 err_text = [u'Invalid pseudo-header:\n',
141 return u'\n'.join(err_text)
143 class InvalidCommand (InvalidEmail):
144 def __init__(self, msg, command, message=None):
145 bigmessage = u'Invalid execution command "%s"' % command
147 bigmessage += u'\n%s' % message
148 InvalidEmail.__init__(self, msg, bigmessage)
149 self.command = command
151 class InvalidOption (InvalidCommand):
152 def __init__(self, msg, option, message=None):
153 bigmessage = u'Invalid option "%s"' % (option)
155 bigmessage += u'\n%s' % message
156 InvalidCommand.__init__(self, msg, info, command, bigmessage)
159 class NotificationFailed (Exception):
160 def __init__(self, msg):
161 bigmessage = 'Notification failed: %s' % msg
162 Exception.__init__(self, bigmessage)
167 Sometimes you want to reference the output of a command that
168 hasn't been executed yet. ID is there for situations like
169 > a = Command(msg, "new", ["create a bug"])
170 > b = Command(msg, "comment", [ID(a), "and comment on it"])
172 def __init__(self, command):
173 self.command = command
174 def extract_id(self):
175 if hasattr(self, 'cached_id'):
176 return self._cached_id
177 assert self.command.ret == 0, self.command.ret
178 if self.command.command.name == u'new':
179 regexp = re.compile(u'Created bug with ID (.*)')
181 raise NotImplementedError, self.command.command
182 match = regexp.match(self.command.stdout)
183 assert len(match.groups()) == 1, str(match.groups())
184 self._cached_id = match.group(1)
185 return self._cached_id
187 if self.command.ret != 0:
188 return '<id for %s>' % repr(self.command)
189 return '<id %s>' % self.extract_id()
191 class Command (object):
193 A libbe.command.Command handler.
196 Command(msg, command, args=None, stdin=None)
198 msg: the Message instance prompting this command
199 command: name of becommand to execute, e.g. "new"
200 args: list of arguments to pass to the command
201 stdin: if non-null, a string to pipe into the command's stdin
203 def __init__(self, msg, command, args=None, stdin=None):
209 self.command = libbe.command.get_command_class(command_name=command)()
210 self.command._setup_io = lambda i_enc,o_enc : None
215 return '<command: %s %s>' % (self.command, ' '.join([str(s) for s in self.args]))
216 def normalize_args(self):
218 Expand any ID placeholders in self.args.
220 for i,arg in enumerate(self.args):
221 if isinstance(arg, ID):
222 self.args[i] = arg.extract_id()
225 Attempt to execute the command whose info is given in the dictionary
226 info. Returns the exit code, stdout, and stderr produced by the
229 if self.command.name in [None, u'']: # don't accept blank commands
230 raise InvalidCommand(self.msg, self, 'Blank')
231 elif self.command.name not in ALLOWED_COMMANDS:
232 raise InvalidCommand(self.msg, self, 'Not allowed')
233 assert self.ret == None, u'running %s twice!' % unicode(self)
234 self.normalize_args()
235 UI.io.set_stdin(self.stdin)
236 self.ret = libbe.ui.command_line.dispatch(UI, self.command, self.args)
237 self.stdout = UI.io.get_stdout()
238 return (self.ret, self.stdout)
239 def response_msg(self):
240 if self.ret == None: self.ret = -1
241 response_body = [u'Results of running: (exit code %d)' % self.ret,
242 u' %s %s' % (self.command.name,u' '.join(self.args))]
243 if self.stdout != None and len(self.stdout) > 0:
244 response_body.extend([u'', u'output:', u'', self.stdout])
245 response_body.append(u'') # trailing endline
246 response_generator = \
247 send_pgp_mime.PGPMimeMessageFactory(u'\n'.join(response_body))
248 return response_generator.plain()
250 class DiffTree (libbe.diff.DiffTree):
252 In order to avoid tons of tiny MIMEText attachments, bug-level
253 nodes set .add_child_text=True (in .join()), which is propogated
254 on to their descendents. Instead of creating their own
255 attachement, each of these descendents appends his data_part to
256 the end of the bug-level MIMEText attachment.
258 For the example tree in the libbe.diff.Diff unittests:
263 bugdir/bugs/new/c <- sets .add_child_text
265 bugdir/bugs/rem/b <- sets .add_child_text
267 bugdir/bugs/mod/a <- sets .add_child_text
268 bugdir/bugs/mod/a/settings
269 bugdir/bugs/mod/a/comments
270 bugdir/bugs/mod/a/comments/new
271 bugdir/bugs/mod/a/comments/new/acom
272 bugdir/bugs/mod/a/comments/rem
273 bugdir/bugs/mod/a/comments/mod
275 def report_or_none(self):
276 report = self.report()
279 payload = report.get_payload()
280 if payload == None or len(payload) == 0:
283 def report_string(self):
284 report = self.report_or_none()
288 return send_pgp_mime.flatten(report, to_unicode=True)
290 return MIMEMultipart()
291 def join(self, root, parent, data_part):
292 if hasattr(parent, 'attach_child_text'):
293 self.attach_child_text = True
294 if data_part != None:
295 send_pgp_mime.append_text(parent.data_mime_part, u'\n\n%s' % (data_part))
296 self.data_mime_part = parent.data_mime_part
298 self.data_mime_part = None
299 if data_part != None:
300 self.data_mime_part = send_pgp_mime.encodedMIMEText(data_part)
301 if parent != None and parent.name in [u'new', u'rem', u'mod']:
302 self.attach_child_text = True
303 if data_part == None: # make blank data_mime_part for children's appends
304 self.data_mime_part = send_pgp_mime.encodedMIMEText(u'')
305 if self.data_mime_part != None:
306 self.data_mime_part[u'Content-Description'] = self.name
307 root.attach(self.data_mime_part)
308 def data_part(self, depth, indent=False):
309 return libbe.diff.DiffTree.data_part(self, depth, indent=indent)
311 class Diff (libbe.diff.Diff):
312 def bug_add_string(self, bug):
313 return bug.string(show_comments=True)
314 def _comment_summary_string(self, comment):
315 return comment.string()
316 def comment_add_string(self, comment):
317 return self._comment_summary_string(comment)
318 def comment_rem_string(self, comment):
319 return self._comment_summary_string(comment)
321 class Message (object):
322 def __init__(self, email_text=None, disable_parsing=False):
323 if disable_parsing == False:
324 self.text = email_text
325 p=email.Parser.Parser()
326 self.msg=p.parsestr(self.text)
328 LOGFILE.write(u'handling %s\n' % self.author_addr())
329 LOGFILE.write(u'\n%s\n\n' % self.text)
330 self.confirm = True # enable/disable confirmation email
331 def _yes_no(self, boolean):
335 def author_tuple(self):
337 Extract and normalize the sender's email address. Returns a
340 if not hasattr(self, 'author_tuple_cache'):
341 self._author_tuple_cache = \
342 send_pgp_mime.source_email(self.msg, return_realname=True)
343 return self._author_tuple_cache
344 def author_addr(self):
345 return email.utils.formataddr(self.author_tuple())
346 def author_name(self):
347 return self.author_tuple()[0]
348 def author_email(self):
349 return self.author_tuple()[1]
350 def default_msg_attribute_access(self, attr_name, default=None):
351 if attr_name in self.msg:
352 return self.msg[attr_name]
354 def message_id(self, default=None):
355 return self.default_msg_attribute_access('message-id', default=default)
357 if 'subject' not in self.msg:
358 raise InvalidSubject(self, u'Email must contain a subject')
359 return self.msg['subject']
360 def _split_subject(self):
362 Returns (tag, subject), with missing values replaced by None.
364 if hasattr(self, '_split_subject_cache'):
365 return self._split_subject_cache
366 args = self.subject().split(u']',1)
368 self._split_subject_cache = (None, None)
370 self._split_subject_cache = (args[0]+u']', None)
372 self._split_subject_cache = (args[0]+u']', args[1].strip())
373 return self._split_subject_cache
374 def _subject_tag_type(self):
376 Parse subject tag, return (type, value), where type is one of
377 None, "new", "comment", "control", or "xml"; and value is None
378 except in the case of "comment", in which case it's the bug
381 tag,subject = self._split_subject()
384 if tag == SUBJECT_TAG_NEW:
386 elif tag == SUBJECT_TAG_CONTROL:
389 match = SUBJECT_TAG_COMMENT.match(tag)
390 if len(match.groups()) == 1:
392 value = match.group(1)
394 def validate_subject(self):
396 Validate the subject line.
398 tag,subject = self._split_subject()
399 if not tag.startswith(SUBJECT_TAG_START):
400 raise InvalidSubject(
401 self, u'Subject must start with "%s"' % SUBJECT_TAG_START)
402 tag_type,value = self._subject_tag_type()
404 raise InvalidSubject(self, u'Invalid tag "%s"' % tag)
405 elif tag_type == u'new' and len(subject) == 0:
406 raise InvalidSubject(self, u'Cannot create a bug with blank title')
407 elif tag_type == u'comment' and len(value) == 0:
408 raise InvalidSubject(self, u'Must specify a bug ID to comment')
409 def _get_bodies_and_mime_types(self):
411 Traverse the email message returning (body, mime_type) for
412 each non-mulitpart portion of the message.
414 msg_charset = self.msg.get_content_charset(ENCODING).lower()
415 for part in self.msg.walk():
416 if part.is_multipart():
418 body,mime_type=(part.get_payload(decode=True),part.get_content_type())
419 charset = part.get_content_charset(msg_charset).lower()
420 if mime_type.startswith('text/'):
421 body = unicode(body, charset) # convert text types to unicode
422 yield (body, mime_type)
423 def _parse_body_pseudoheaders(self, body, required, optional,
426 Grab any pseudo-headers from the beginning of body. Raise
427 InvalidPseudoHeader on errors. Returns the body text after
428 the pseudo-header and a dictionary of set options. If you
429 like, you can initialize the dictionary with some defaults
430 and pass your initialized dict in as dictionary.
432 if dictionary == None:
434 body_lines = body.splitlines()
435 all = required+optional
436 for i,line in enumerate(body_lines):
441 raise InvalidPseudoheader(self, line)
442 key,value = line.split(':', 1)
443 value = value.strip()
445 raise InvalidPseudoHeader(self, key)
448 self, u'Blank value for: %s' % key)
449 dictionary[key] = value
452 if key not in dictionary:
455 raise InvalidPseudoHeader(self,
456 u'Missing required pseudo-headers:\n%s'
457 % u', '.join(missing))
458 remaining_body = u'\n'.join(body_lines[i:]).strip()
459 return (remaining_body, dictionary)
460 def _strip_footer(self, body):
461 body_lines = body.splitlines()
462 for i,line in enumerate(body_lines):
463 if line.startswith(BREAK):
465 i += 1 # increment past the current valid line.
466 return u'\n'.join(body_lines[:i]).strip()
469 Parse the commands given in the email. Raises assorted
470 subclasses of InvalidEmail in the case of invalid messages,
471 otherwise returns a list of suggested commands to run.
473 self.validate_subject()
474 tag_type,value = self._subject_tag_type()
475 if tag_type == u'new':
476 commands = self.parse_new()
477 elif tag_type == u'comment':
478 commands = self.parse_comment(value)
479 elif tag_type == u'control':
480 commands = self.parse_control()
482 raise Exception, u'Unrecognized tag type "%s"' % tag_type
486 tag,subject = self._split_subject()
488 options = {u'Reporter': self.author_addr(),
489 u'Confirm': self._yes_no(self.confirm),
492 body,mime_type = list(self._get_bodies_and_mime_types())[0]
493 comment_body,options = \
494 self._parse_body_pseudoheaders(body,
495 NEW_REQUIRED_PSEUDOHEADERS,
496 NEW_OPTIONAL_PSEUDOHEADERS,
498 if options[u'Confirm'].lower() == 'no':
500 if options[u'Subscribe'].lower() == 'yes' and self.confirm == True:
501 # respond with the subscription format rather than the
502 # normal command-output format, because the subscription
503 # format is more user-friendly.
505 args = [u'--reporter', options[u'Reporter']]
507 commands = [Command(self, command, args)]
509 comment_body = self._strip_footer(comment_body)
510 if len(comment_body) > 0:
512 comment = u'Version: %s\n\n'%options[u'Version'] + comment_body
513 args = [u'--author', self.author_addr(),
514 u'--alt-id', self.message_id(),
515 u'--content-type', mime_type]
518 commands.append(Command(self, u'comment', args, stdin=comment))
519 for key,value in options.items():
520 if key in [u'Version', u'Reporter', u'Confirm']:
521 continue # we've already handled these options
522 command = key.lower()
523 if key in [u'Depend', u'Tag', u'Target', u'Subscribe']:
527 if key == u'Subscribe':
528 if value.lower() != 'yes':
530 args = ['--subscriber', self.author_addr(), id]
531 commands.append(Command(self, command, args))
533 def parse_comment(self, bug_uuid):
536 author = self.author_addr()
537 alt_id = self.message_id()
538 body,mime_type = list(self._get_bodies_and_mime_types())[0]
539 if mime_type == 'text/plain':
540 body = self._strip_footer(body)
541 content_type = mime_type
542 args = [u'--author', author]
544 args.extend([u'--alt-id', alt_id])
545 args.extend([u'--content-type', content_type, bug_id, u'-'])
546 commands = [Command(self, command, args, stdin=body)]
548 def parse_control(self):
549 body,mime_type = list(self._get_bodies_and_mime_types())[0]
551 for line in body.splitlines():
553 if line.startswith(CONTROL_COMMENT) or len(line) == 0:
555 if line.startswith(BREAK):
557 if type(line) == types.UnicodeType:
558 # work around http://bugs.python.org/issue1170
559 line = line.encode('unicode escape')
560 fields = shlex.split(line)
561 if type(line) == types.UnicodeType:
562 # work around http://bugs.python.org/issue1170
564 field = unicode(field, 'unicode escape')
565 command,args = (fields[0], fields[1:])
566 commands.append(Command(self, command, args))
567 if len(commands) == 0:
568 raise InvalidEmail(self, u'No commands in control email.')
570 def run(self, repo='.'):
571 self._begin_response()
572 commands = self.parse()
574 for i,command in enumerate(commands):
576 self._add_response(command.response_msg())
578 if AUTOCOMMIT == True:
579 tag,subject = self._split_subject()
580 self.commit_command = Command(self, 'commit', [subject])
581 self.commit_command.run()
583 LOGFILE.write(u'Autocommit:\n%s\n\n' %
584 send_pgp_mime.flatten(self.commit_command.response_msg(),
586 def _begin_response(self):
587 tag,subject = self._split_subject()
588 response_header = [u'From: %s' % THIS_ADDRESS,
589 u'To: %s' % self.author_addr(),
590 u'Date: %s' % libbe.util.utility.time_to_str(time.time()),
591 u'Subject: %s Re: %s'%(SUBJECT_TAG_RESPONSE,subject)
593 if self.message_id() != None:
594 response_header.append(u'In-reply-to: %s' % self.message_id())
595 self.response_header = \
596 send_pgp_mime.header_from_text(text=u'\n'.join(response_header))
597 self._response_messages = []
598 def _add_response(self, response_message):
599 self._response_messages.append(response_message)
600 def response_email(self):
601 assert len(self._response_messages) > 0
602 if len(self._response_messages) == 1:
603 response_body = self._response_messages[0]
605 response_body = MIMEMultipart()
606 for message in self._response_messages:
607 response_body.attach(message)
608 return send_pgp_mime.attach_root(self.response_header, response_body)
609 def subscriber_emails(self, previous_revision=None):
610 if previous_revision == None:
611 if AUTOCOMMIT != True: # no way to tell what's changed
612 raise NotificationFailed('Autocommit dissabled')
613 if len(self._response_messages) == 0:
614 raise NotificationFailed('Initial email failed.')
615 if self.commit_command.ret != 0:
616 # commit failed. Error already logged.
617 raise NotificationFailed('Commit failed')
619 bd = UI.storage_callbacks.get_bugdir()
620 writeable = bd.storage.writeable
621 bd.storage.writeable = False
622 if bd.vcs.versioned == False: # no way to tell what's changed
623 bd.storage.writeable = writeable
624 raise NotificationFailed('Not versioned')
627 subscribers = subscribe.get_bugdir_subscribers(bd, THIS_SERVER)
628 if len(subscribers) == 0:
629 bd.storage.writeable = writeable
631 for subscriber,subscriptions in subscribers.items():
632 subscribers[subscriber] = []
633 for id,types in subscriptions.items():
635 subscribers[subscriber].append(
636 libbe.diff.Subscription(id,type))
638 before_bd, after_bd = self._get_before_and_after_bugdirs(bd, previous_revision)
639 diff = Diff(before_bd, after_bd)
640 diff.full_report(diff_tree=DiffTree)
641 header = self._subscriber_header(bd, previous_revision)
644 for subscriber,subscriptions in subscribers.items():
645 header.replace_header('to', subscriber)
646 report = diff.report_tree(subscriptions, diff_tree=DiffTree)
647 root = report.report_or_none()
649 emails.append(send_pgp_mime.attach_root(header, root))
651 LOGFILE.write(u'Preparing to notify %s of changes\n' % subscriber)
652 bd.storage.writeable = writeable
654 def _get_before_and_after_bugdirs(self, bd, previous_revision=None):
655 if previous_revision == None:
656 commit_msg = self.commit_command.stdout
657 assert commit_msg.startswith('Committed '), commit_msg
658 after_revision = commit_msg[len('Committed '):]
659 before_revision = bd.vcs.revision_id(-2)
661 before_revision = previous_revision
662 if before_revision == None:
663 # this commit was the initial commit
664 before_bd = libbe.bugdir.BugDir(from_disk=False,
665 manipulate_encodings=False)
667 before_bd = bd.duplicate_bugdir(before_revision)
668 #after_bd = bd.duplicate_bugdir(after_revision)
669 after_bd = bd # assume no changes since commit a few cycles ago
670 return (before_bd, after_bd)
671 def _subscriber_header(self, bd, previous_revision=None):
672 root_dir = os.path.basename(bd.root)
673 if previous_revision == None:
674 subject = 'Changes to %s on %s by %s' \
675 % (root_dir, THIS_SERVER, self.author_addr())
677 subject = 'Changes to %s on %s since revision %s' \
678 % (root_dir, THIS_SERVER, previous_revision)
679 header = [u'From: %s' % THIS_ADDRESS,
680 u'To: %s' % u'DUMMY-AUTHOR',
681 u'Date: %s' % libbe.util.utility.time_to_str(time.time()),
682 u'Subject: %s Re: %s' % (SUBJECT_TAG_RESPONSE, subject)
684 return send_pgp_mime.header_from_text(text=u'\n'.join(header))
686 def generate_global_tags(tag_base=u'be-bug'):
688 Generate a series of tags from a base tag string.
690 global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
691 SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
692 SUBJECT_TAG_BASE = tag_base
693 SUBJECT_TAG_START = u'[%s' % tag_base
694 SUBJECT_TAG_RESPONSE = u'[%s]' % tag_base
695 SUBJECT_TAG_NEW = u'[%s:submit]' % tag_base
696 SUBJECT_TAG_COMMENT = re.compile(u'\[%s:([\-0-9a-z]*)]' % tag_base)
697 SUBJECT_TAG_CONTROL = SUBJECT_TAG_RESPONSE
699 def open_logfile(logpath=None):
701 If logpath=None, default to global LOGPATH.
702 Special logpath strings:
703 "-" set LOGFILE to sys.stderr
704 "none" disable logging
705 Relative logpaths are expanded relative to _THIS_DIR
707 global LOGPATH, LOGFILE
712 elif logpath == u'none':
715 elif os.path.isabs(logpath):
718 LOGPATH = os.path.join(_THIS_DIR, logpath)
719 if LOGFILE == None and LOGPATH != u'none':
720 LOGFILE = codecs.open(LOGPATH, u'a+',
721 libbe.utuil.encoding.get_filesystem_encoding())
724 if LOGFILE != None and LOGPATH not in [u'stderr', u'none']:
727 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
728 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
731 result = unittest.TextTestRunner(verbosity=2).run(suite)
732 num_errors = len(result.errors)
733 num_failures = len(result.failures)
734 num_bad = num_errors + num_failures
738 from optparse import OptionParser
739 global AUTOCOMMIT, UI
741 usage='be-handle-mail [options]\n\n%s' % (__doc__)
742 parser = OptionParser(usage=usage)
743 parser.add_option('-r', '--repo', dest='repo', default=_THIS_DIR,
745 help='Select the BE repository to serve (%default).')
746 parser.add_option('-t', '--tag-base', dest='tag_base',
747 default=SUBJECT_TAG_BASE, metavar='TAG',
748 help='Set the subject tag base (%default).')
749 parser.add_option('-o', '--output', dest='output', action='store_true',
750 help="Don't mail the generated message, print it to stdout instead. Useful for testing be-handle-mail functionality without the whole mail transfer agent and procmail setup.")
751 parser.add_option('-l', '--logfile', dest='logfile', metavar='LOGFILE',
752 help='Set the logfile to LOGFILE. Relative paths are relative to the location of this be-handle-mail file (%s). The special value of "-" directs the log output to stderr, and "none" disables logging.' % _THIS_DIR)
753 parser.add_option('-a', '--disable-autocommit', dest='autocommit',
754 default=True, action='store_false',
755 help='Disable the autocommit after parsing the email.')
756 parser.add_option('-s', '--disable-subscribers', dest='subscribers',
757 default=True, action='store_false',
758 help='Disable subscriber notification emails.')
759 parser.add_option('--notify-since', dest='notify_since', metavar='REVISION',
760 help='Notify subscribers of all changes since REVISION. When this option is set, no input email parsing is done.')
761 parser.add_option('--test', dest='test', action='store_true',
762 help='Run internal unit-tests and exit.')
765 options,args = parser.parse_args(args[1:])
767 if options.test == True:
773 AUTOCOMMIT = options.autocommit
775 if options.notify_since == None:
776 msg_text = sys.stdin.read()
778 open_logfile(options.logfile)
779 generate_global_tags(options.tag_base)
781 io = libbe.command.StringInputOutput()
782 UI = libbe.command.UserInterface(io, location=options.repo)
784 if options.notify_since != None:
785 if options.subscribers == True:
787 LOGFILE.write(u'Checking for subscribers to notify since revision %s\n'
788 % options.notify_since)
790 m = Message(disable_parsing=True)
791 emails = m.subscriber_emails(options.notify_since)
792 except NotificationFailed, e:
794 LOGFILE.write(unicode(e) + u'\n')
797 if options.output == True:
798 print send_pgp_mime.flatten(msg, to_unicode=True)
800 send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
801 self.commit_command.cleanup()
806 if len(msg_text.strip()) == 0: # blank email!?
808 LOGFILE.write(u'Blank email!\n')
813 m = Message(msg_text)
815 except InvalidEmail, e:
816 response = e.response()
819 LOGFILE.write(u'Uncaught exception:\n%s\n' % (e,))
820 traceback.print_tb(sys.exc_traceback, file=LOGFILE)
825 response = m.response_email()
826 if options.output == True:
827 print send_pgp_mime.flatten(response, to_unicode=True)
828 elif m.confirm == True:
830 LOGFILE.write(u'Sending response to %s\n' % m.author_addr())
831 LOGFILE.write(u'\n%s\n\n' % send_pgp_mime.flatten(response,
833 send_pgp_mime.mail(response, send_pgp_mime.sendmail)
836 LOGFILE.write(u'Response declined by %s\n' % m.author_addr())
837 if options.subscribers == True:
839 LOGFILE.write(u'Checking for subscribers\n')
841 emails = m.subscriber_emails()
842 except NotificationFailed, e:
844 LOGFILE.write(unicode(e) + u'\n')
847 if options.output == True:
848 print send_pgp_mime.flatten(msg, to_unicode=True)
850 send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
855 class GenerateGlobalTagsTestCase (unittest.TestCase):
857 super(GenerateGlobalTagsTestCase, self).setUp()
858 self.save_global_tags()
860 self.restore_global_tags()
861 super(GenerateGlobalTagsTestCase, self).tearDown()
862 def save_global_tags(self):
863 self.saved_globals = [SUBJECT_TAG_BASE, SUBJECT_TAG_START,
864 SUBJECT_TAG_RESPONSE, SUBJECT_TAG_NEW,
865 SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL]
866 def restore_global_tags(self):
867 global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
868 SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL
869 SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \
870 SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL = \
872 def test_restore_global_tags(self):
873 "Test global tag restoration by teardown function."
874 global SUBJECT_TAG_BASE
875 self.failUnlessEqual(SUBJECT_TAG_BASE, u'be-bug')
876 SUBJECT_TAG_BASE = 'projectX-bug'
877 self.failUnlessEqual(SUBJECT_TAG_BASE, u'projectX-bug')
878 self.restore_global_tags()
879 self.failUnlessEqual(SUBJECT_TAG_BASE, u'be-bug')
880 def test_subject_tag_base(self):
881 "Should set SUBJECT_TAG_BASE global correctly"
882 generate_global_tags(u'projectX-bug')
883 self.failUnlessEqual(SUBJECT_TAG_BASE, u'projectX-bug')
884 def test_subject_tag_start(self):
885 "Should set SUBJECT_TAG_START global correctly"
886 generate_global_tags(u'projectX-bug')
887 self.failUnlessEqual(SUBJECT_TAG_START, u'[projectX-bug')
888 def test_subject_tag_response(self):
889 "Should set SUBJECT_TAG_RESPONSE global correctly"
890 generate_global_tags(u'projectX-bug')
891 self.failUnlessEqual(SUBJECT_TAG_RESPONSE, u'[projectX-bug]')
892 def test_subject_tag_new(self):
893 "Should set SUBJECT_TAG_NEW global correctly"
894 generate_global_tags(u'projectX-bug')
895 self.failUnlessEqual(SUBJECT_TAG_NEW, u'[projectX-bug:submit]')
896 def test_subject_tag_control(self):
897 "Should set SUBJECT_TAG_CONTROL global correctly"
898 generate_global_tags(u'projectX-bug')
899 self.failUnlessEqual(SUBJECT_TAG_CONTROL, u'[projectX-bug]')
900 def test_subject_tag_comment(self):
901 "Should set SUBJECT_TAG_COMMENT global correctly"
902 generate_global_tags(u'projectX-bug')
903 m = SUBJECT_TAG_COMMENT.match('[projectX-bug:xyz-123]')
904 self.failUnlessEqual(len(m.groups()), 1)
905 self.failUnlessEqual(m.group(1), u'xyz-123')
907 if __name__ == "__main__":