Updated copyright information
[be.git] / interfaces / email / interactive / be-handle-mail
1 #!/usr/bin/env python
2 #
3 # Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu>
4 #
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.
9 #
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.
14 #
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.
18 """
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.
25
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.
31
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
36 up the repository.
37
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".
44
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.
47 """
48
49 import codecs
50 import StringIO as StringIO
51 import email
52 from email.mime.multipart import MIMEMultipart
53 import email.utils
54 import os
55 import os.path
56 import re
57 import shlex
58 import sys
59 import time
60 import traceback
61 import types
62 import doctest
63 import unittest
64
65 import libbe.bugdir
66 import libbe.bug
67 import libbe.comment
68 import libbe.diff
69 import libbe.command
70 import libbe.command.subscribe as subscribe
71 import libbe.storage
72 import libbe.ui.command_line
73 import libbe.util.encoding
74 import libbe.util.utility
75 import send_pgp_mime
76
77 THIS_SERVER = u'thor.physics.drexel.edu'
78 THIS_ADDRESS = u'BE Bugs <wking@thor.physics.drexel.edu>'
79 UI = None
80 _THIS_DIR = os.path.abspath(os.path.dirname(__file__))
81 LOGPATH = os.path.join(_THIS_DIR, u'be-handle-mail.log')
82 LOGFILE = None
83
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
91
92 BREAK = u'--'
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']
101
102 AUTOCOMMIT = True
103
104 ENCODING = u'utf-8'
105 libbe.util.encoding.ENCODING = ENCODING # force default encoding
106
107 class InvalidEmail (ValueError):
108     def __init__(self, msg, message):
109         ValueError.__init__(self, message)
110         self.msg = msg
111     def response(self):
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)
121         return ret
122     def response_body(self):
123         err_text = [unicode(self)]
124         return u'\n'.join(err_text)
125
126 class InvalidSubject (InvalidEmail):
127     def __init__(self, msg, message=None):
128         if 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:',
134                                self.msg.subject()])
135         return err_text
136
137 class InvalidPseudoHeader (InvalidEmail):
138     def response_body(self):
139         err_text = [u'Invalid pseudo-header:\n',
140                     unicode(self)]
141         return u'\n'.join(err_text)
142
143 class InvalidCommand (InvalidEmail):
144     def __init__(self, msg, command, message=None):
145         bigmessage = u'Invalid execution command "%s"' % command
146         if message != None:
147             bigmessage += u'\n%s' % message
148         InvalidEmail.__init__(self, msg, bigmessage)
149         self.command = command
150
151 class InvalidOption (InvalidCommand):
152     def __init__(self, msg, option, message=None):
153         bigmessage = u'Invalid option "%s"' % (option)
154         if message != None:
155             bigmessage += u'\n%s' % message
156         InvalidCommand.__init__(self, msg, info, command, bigmessage)
157         self.option = option
158
159 class NotificationFailed (Exception):
160     def __init__(self, msg):
161         bigmessage = 'Notification failed: %s' % msg
162         Exception.__init__(self, bigmessage)
163         self.short_msg = msg
164
165 class ID (object):
166     """
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"])
171     """
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 (.*)')
180         else:
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
186     def __str__(self):
187         if self.command.ret != 0:
188             return '<id for %s>' % repr(self.command)
189         return '<id %s>' % self.extract_id()
190
191 class Command (object):
192     """
193     A libbe.command.Command handler.
194
195     Initialize with
196       Command(msg, command, args=None, stdin=None)
197     where
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
202     """
203     def __init__(self, msg, command, args=None, stdin=None):
204         self.msg = msg
205         if args == None:
206             self.args = []
207         else:
208             self.args = args
209         self.command = libbe.command.get_command_class(command_name=command)()
210         self.command._setup_io = lambda i_enc,o_enc : None
211         self.ret = None
212         self.stdin = stdin
213         self.stdout = None
214     def __str__(self):
215         return '<command: %s %s>' % (self.command, ' '.join([str(s) for s in self.args]))
216     def normalize_args(self):
217         """
218         Expand any ID placeholders in self.args.
219         """
220         for i,arg in enumerate(self.args):
221             if isinstance(arg, ID):
222                 self.args[i] = arg.extract_id()
223     def run(self):
224         """
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
227         command.
228         """
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()
249
250 class DiffTree (libbe.diff.DiffTree):
251     """
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.
257
258     For the example tree in the libbe.diff.Diff unittests:
259       bugdir
260       bugdir/settings
261       bugdir/bugs
262       bugdir/bugs/new
263       bugdir/bugs/new/c          <- sets .add_child_text
264       bugdir/bugs/rem
265       bugdir/bugs/rem/b          <- sets .add_child_text
266       bugdir/bugs/mod
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
274     """
275     def report_or_none(self):
276         report = self.report()
277         if report == None:
278             return None
279         payload = report.get_payload()
280         if payload == None or len(payload) == 0:
281             return None
282         return report
283     def report_string(self):
284         report = self.report_or_none()
285         if report == None:
286             return 'No changes'
287         else:
288             return send_pgp_mime.flatten(report, to_unicode=True)
289     def make_root(self):
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
297         else:
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)
310
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)
320
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)
327             if LOGFILE != None:
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):
332         if boolean == True:
333             return 'yes'
334         return 'no'
335     def author_tuple(self):
336         """
337         Extract and normalize the sender's email address.  Returns a
338         (name, email) tuple.
339         """
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]
353         return default
354     def message_id(self, default=None):
355         return self.default_msg_attribute_access('message-id', default=default)
356     def subject(self):
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):
361         """
362         Returns (tag, subject), with missing values replaced by None.
363         """
364         if hasattr(self, '_split_subject_cache'):
365             return self._split_subject_cache
366         args = self.subject().split(u']',1)
367         if len(args) < 1:
368             self._split_subject_cache = (None, None)
369         elif len(args) < 2:
370             self._split_subject_cache = (args[0]+u']', None)
371         else:
372             self._split_subject_cache = (args[0]+u']', args[1].strip())
373         return self._split_subject_cache
374     def _subject_tag_type(self):
375         """
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
379         ID/shortname.
380         """
381         tag,subject = self._split_subject()
382         type = None
383         value = None
384         if tag == SUBJECT_TAG_NEW:
385             type = u'new'
386         elif tag == SUBJECT_TAG_CONTROL:
387             type = u'control'
388         else:
389             match = SUBJECT_TAG_COMMENT.match(tag)
390             if len(match.groups()) == 1:
391                 type = u'comment'
392                 value = match.group(1)
393         return (type, value)
394     def validate_subject(self):
395         """
396         Validate the subject line.
397         """
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()
403         if tag_type == None:
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):
410         """
411         Traverse the email message returning (body, mime_type) for
412         each non-mulitpart portion of the message.
413         """
414         msg_charset = self.msg.get_content_charset(ENCODING).lower()
415         for part in self.msg.walk():
416             if part.is_multipart():
417                 continue
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,
424                                   dictionary=None):
425         """
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.
431         """
432         if dictionary == None:
433             dictionary = {}
434         body_lines = body.splitlines()
435         all = required+optional
436         for i,line in enumerate(body_lines):
437             line = line.strip()
438             if len(line) == 0:
439                 break
440             if ':' not in line:
441                 raise InvalidPseudoheader(self, line)
442             key,value = line.split(':', 1)
443             value = value.strip()
444             if key not in all:
445                 raise InvalidPseudoHeader(self, key)
446             if len(value) == 0:
447                 raise InvalidEmail(
448                     self, u'Blank value for: %s' % key)
449             dictionary[key] = value
450         missing = []
451         for key in required:
452             if key not in dictionary:
453                 missing.append(key)
454         if len(missing) > 0:
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):
464                 break
465             i += 1 # increment past the current valid line.
466         return u'\n'.join(body_lines[:i]).strip()
467     def parse(self):
468         """
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.
472         """
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()
481         else:
482             raise Exception, u'Unrecognized tag type "%s"' % tag_type
483         return commands
484     def parse_new(self):
485         command = u'new'
486         tag,subject = self._split_subject()
487         summary = subject
488         options = {u'Reporter': self.author_addr(),
489                    u'Confirm': self._yes_no(self.confirm),
490                    u'Subscribe': 'no',
491                    }
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,
497                                            options)
498         if options[u'Confirm'].lower() == 'no':
499             self.confirm = False
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.
504             self.confirm = False
505         args = [u'--reporter', options[u'Reporter']]
506         args.append(summary)
507         commands = [Command(self, command, args)]
508         id = ID(commands[0])
509         comment_body = self._strip_footer(comment_body)
510         if len(comment_body) > 0:
511             command = u'comment'
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]
516             args.append(id)
517             args.append(u'-')
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']:
524                 args = [id, value]
525             else:
526                 args = [value, id]
527             if key == u'Subscribe':
528                 if value.lower() != 'yes':
529                     continue
530                 args = ['--subscriber', self.author_addr(), id]
531             commands.append(Command(self, command, args))
532         return commands
533     def parse_comment(self, bug_uuid):
534         command = u'comment'
535         bug_id = 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]
543         if alt_id != None:
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)]
547         return commands
548     def parse_control(self):
549         body,mime_type = list(self._get_bodies_and_mime_types())[0]
550         commands = []
551         for line in body.splitlines():
552             line = line.strip()
553             if line.startswith(CONTROL_COMMENT) or len(line) == 0:
554                 continue
555             if line.startswith(BREAK):
556                 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
563                 for field in fields:
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.')
569         return commands
570     def run(self, repo='.'):
571         self._begin_response()
572         commands = self.parse()
573         try:
574             for i,command in enumerate(commands):
575                 command.run()
576                 self._add_response(command.response_msg())
577         finally:
578             if AUTOCOMMIT == True:
579                 tag,subject = self._split_subject()
580                 self.commit_command = Command(self, 'commit', [subject])
581                 self.commit_command.run()
582                 if LOGFILE != None:
583                     LOGFILE.write(u'Autocommit:\n%s\n\n' %
584                       send_pgp_mime.flatten(self.commit_command.response_msg(),
585                                             to_unicode=True))
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)
592                            ]
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]
604         else:
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')
618
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')
625
626         bd.load_all_bugs()
627         subscribers = subscribe.get_bugdir_subscribers(bd, THIS_SERVER)
628         if len(subscribers) == 0:
629             bd.storage.writeable = writeable
630             return []
631         for subscriber,subscriptions in subscribers.items():
632             subscribers[subscriber] = []
633             for id,types in subscriptions.items():
634                 for type in types:
635                     subscribers[subscriber].append(
636                         libbe.diff.Subscription(id,type))
637
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)
642
643         emails = []
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()
648             if root != None:
649                 emails.append(send_pgp_mime.attach_root(header, root))
650                 if LOGFILE != None:
651                     LOGFILE.write(u'Preparing to notify %s of changes\n' % subscriber)
652         bd.storage.writeable = writeable
653         return emails
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)
660         else:
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)
666         else:
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())
676         else:
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)
683                   ]
684         return send_pgp_mime.header_from_text(text=u'\n'.join(header))
685
686 def generate_global_tags(tag_base=u'be-bug'):
687     """
688     Generate a series of tags from a base tag string.
689     """
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
698
699 def open_logfile(logpath=None):
700     """
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
706     """
707     global LOGPATH, LOGFILE
708     if logpath != None:
709         if logpath == u'-':
710             LOGPATH = u'stderr'
711             LOGFILE = sys.stderr
712         elif logpath == u'none':
713             LOGPATH = u'none'
714             LOGFILE = None
715         elif os.path.isabs(logpath):
716             LOGPATH = logpath
717         else:
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())
722
723 def close_logfile():
724     if LOGFILE != None and LOGPATH not in [u'stderr', u'none']:
725         LOGFILE.close()
726
727 unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
728 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
729
730 def test():
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
735     return num_bad
736
737 def main(args):
738     from optparse import OptionParser
739     global AUTOCOMMIT, UI
740
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,
744                       metavar='REPO',
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.')
763
764     pargs = args
765     options,args = parser.parse_args(args[1:])
766
767     if options.test == True:
768         num_bad = test()
769         if num_bad > 126:
770             num_bad = 1
771         sys.exit(num_bad)
772     
773     AUTOCOMMIT = options.autocommit
774
775     if options.notify_since == None:
776         msg_text = sys.stdin.read()
777
778     open_logfile(options.logfile)
779     generate_global_tags(options.tag_base)
780
781     io = libbe.command.StringInputOutput()
782     UI = libbe.command.UserInterface(io, location=options.repo)
783
784     if options.notify_since != None:
785         if options.subscribers == True:
786             if LOGFILE != None:
787                 LOGFILE.write(u'Checking for subscribers to notify since revision %s\n'
788                               % options.notify_since)
789             try:
790                 m = Message(disable_parsing=True)
791                 emails = m.subscriber_emails(options.notify_since)
792             except NotificationFailed, e:
793                 if LOGFILE != None:
794                     LOGFILE.write(unicode(e) + u'\n')
795             else:
796                 for msg in emails:
797                     if options.output == True:
798                         print send_pgp_mime.flatten(msg, to_unicode=True)
799                     else:
800                         send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
801             self.commit_command.cleanup()
802             close_logfile()
803         UI.cleanup()
804         sys.exit(0)
805
806     if len(msg_text.strip()) == 0: # blank email!?
807         if LOGFILE != None:
808             LOGFILE.write(u'Blank email!\n')
809             close_logfile()
810         UI.cleanup()
811         sys.exit(1)
812     try:
813         m = Message(msg_text)
814         m.run()
815     except InvalidEmail, e:
816         response = e.response()
817     except Exception, e:
818         if LOGFILE != None:
819             LOGFILE.write(u'Uncaught exception:\n%s\n' % (e,))
820             traceback.print_tb(sys.exc_traceback, file=LOGFILE)
821             close_logfile()
822         UI.cleanup()
823         sys.exit(1)
824     else:
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:
829         if LOGFILE != None:
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,
832                                                               to_unicode=True))
833         send_pgp_mime.mail(response, send_pgp_mime.sendmail)
834     else:
835         if LOGFILE != None:
836             LOGFILE.write(u'Response declined by %s\n' % m.author_addr())
837     if options.subscribers == True:
838         if LOGFILE != None:
839             LOGFILE.write(u'Checking for subscribers\n')
840         try:
841             emails = m.subscriber_emails()
842         except NotificationFailed, e:
843             if LOGFILE != None:
844                 LOGFILE.write(unicode(e) + u'\n')
845         else:
846             for msg in emails:
847                 if options.output == True:
848                     print send_pgp_mime.flatten(msg, to_unicode=True)
849                 else:
850                     send_pgp_mime.mail(msg, send_pgp_mime.sendmail)
851
852     close_logfile()
853     UI.cleanup()
854
855 class GenerateGlobalTagsTestCase (unittest.TestCase):
856     def setUp(self):
857         super(GenerateGlobalTagsTestCase, self).setUp()
858         self.save_global_tags()
859     def tearDown(self):
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 = \
871             self.saved_globals
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')
906
907 if __name__ == "__main__":
908     main(sys.argv)