~svn/ubuntu/raring/subversion/ppa

« back to all changes in this revision

Viewing changes to tools/hook-scripts/mailer/mailer.py

  • Committer: Bazaar Package Importer
  • Author(s): Adam Conrad
  • Date: 2005-12-05 01:26:14 UTC
  • mfrom: (1.1.2 upstream)
  • Revision ID: james.westby@ubuntu.com-20051205012614-qom4xfypgtsqc2xq
Tags: 1.2.3dfsg1-3ubuntu1
Merge with the final Debian release of 1.2.3dfsg1-3, bringing in
fixes to the clean target, better documentation of the libdb4.3
upgrade and build fixes to work with swig1.3_1.3.27.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#!/usr/bin/env python
 
2
#
 
3
# mailer.py: send email describing a commit
 
4
#
 
5
# $HeadURL: http://svn.collab.net/repos/svn/branches/1.2.x/tools/hook-scripts/mailer/mailer.py $
 
6
# $LastChangedDate: 2005-04-21 14:12:41 -0400 (Thu, 21 Apr 2005) $
 
7
# $LastChangedBy: breser $
 
8
# $LastChangedRevision: 14358 $
 
9
#
 
10
# USAGE: mailer.py commit      REPOS REVISION [CONFIG-FILE]
 
11
#        mailer.py propchange  REPOS REVISION AUTHOR PROPNAME [CONFIG-FILE]
 
12
#        mailer.py propchange2 REPOS REVISION AUTHOR PROPNAME ACTION \
 
13
#                              [CONFIG-FILE]
 
14
#        mailer.py lock        REPOS AUTHOR [CONFIG-FILE]
 
15
#        mailer.py unlock      REPOS AUTHOR [CONFIG-FILE]
 
16
#
 
17
#   Using CONFIG-FILE, deliver an email describing the changes between
 
18
#   REV and REV-1 for the repository REPOS.
 
19
#
 
20
#   ACTION was added as a fifth argument to the post-revprop-change hook
 
21
#   in Subversion 1.2.0.  Its value is one of 'A', 'M' or 'D' to indicate
 
22
#   if the property was added, modified or deleted, respectively.
 
23
#
 
24
#   This version of mailer.py requires the python bindings from
 
25
#   subversion 1.2.0 or later.
 
26
#
 
27
 
 
28
import os
 
29
import sys
 
30
import string
 
31
import ConfigParser
 
32
import time
 
33
import popen2
 
34
import cStringIO
 
35
import smtplib
 
36
import re
 
37
import tempfile
 
38
import types
 
39
 
 
40
import svn.fs
 
41
import svn.delta
 
42
import svn.repos
 
43
import svn.core
 
44
 
 
45
SEPARATOR = '=' * 78
 
46
 
 
47
 
 
48
def main(pool, cmd, config_fname, repos_dir, cmd_args):
 
49
  ### TODO:  Sanity check the incoming args
 
50
  
 
51
  if cmd == 'commit':
 
52
    revision = int(cmd_args[0])
 
53
    repos = Repository(repos_dir, revision, pool)
 
54
    cfg = Config(config_fname, repos, { 'author' : repos.author })
 
55
    messenger = Commit(pool, cfg, repos)
 
56
  elif cmd == 'propchange' or cmd == 'propchange2':
 
57
    revision = int(cmd_args[0])
 
58
    author = cmd_args[1]
 
59
    propname = cmd_args[2]
 
60
    action = (cmd == 'propchange2' and cmd_args[3] or 'A')
 
61
    repos = Repository(repos_dir, revision, pool)
 
62
    # Override the repos revision author with the author of the propchange
 
63
    repos.author = author
 
64
    cfg = Config(config_fname, repos, { 'author' : author })
 
65
    messenger = PropChange(pool, cfg, repos, author, propname, action)
 
66
  elif cmd == 'lock' or cmd == 'unlock':
 
67
    author = cmd_args[0]
 
68
    repos = Repository(repos_dir, 0, pool) ### any old revision will do
 
69
    cfg = Config(config_fname, repos, { 'author' : author })
 
70
    messenger = Lock(pool, cfg, repos, author, cmd == 'lock')
 
71
  else:
 
72
    raise UnknownSubcommand(cmd)
 
73
 
 
74
  messenger.generate()
 
75
 
 
76
 
 
77
# Minimal, incomplete, versions of popen2.Popen[34] for those platforms
 
78
# for which popen2 does not provide them.
 
79
try:
 
80
  Popen3 = popen2.Popen3
 
81
  Popen4 = popen2.Popen4
 
82
except AttributeError:
 
83
  class Popen3:
 
84
    def __init__(self, cmd, capturestderr = False):
 
85
      if type(cmd) != types.StringType:
 
86
        cmd = svn.core.argv_to_command_string(cmd)
 
87
      if capturestderr:
 
88
        self.fromchild, self.tochild, self.childerr \
 
89
            = popen2.popen3(cmd, mode='b')
 
90
      else:
 
91
        self.fromchild, self.tochild = popen2.popen2(cmd, mode='b')
 
92
        self.childerr = None
 
93
 
 
94
    def wait(self):
 
95
      rv = self.fromchild.close()
 
96
      rv = self.tochild.close() or rv
 
97
      if self.childerr is not None:
 
98
        rv = self.childerr.close() or rv
 
99
      return rv
 
100
 
 
101
  class Popen4:
 
102
    def __init__(self, cmd):
 
103
      if type(cmd) != types.StringType:
 
104
        cmd = svn.core.argv_to_command_string(cmd)
 
105
      self.fromchild, self.tochild = popen2.popen4(cmd, mode='b')
 
106
 
 
107
    def wait(self):
 
108
      rv = self.fromchild.close()
 
109
      rv = self.tochild.close() or rv
 
110
      return rv
 
111
 
 
112
 
 
113
class OutputBase:
 
114
  "Abstract base class to formalize the inteface of output methods"
 
115
 
 
116
  def __init__(self, cfg, repos, prefix_param):
 
117
    self.cfg = cfg
 
118
    self.repos = repos
 
119
    self.prefix_param = prefix_param
 
120
    self._CHUNKSIZE = 128 * 1024
 
121
 
 
122
    # This is a public member variable. This must be assigned a suitable
 
123
    # piece of descriptive text before make_subject() is called.
 
124
    self.subject = ""
 
125
 
 
126
  def make_subject(self, group, params):
 
127
    prefix = self.cfg.get(self.prefix_param, group, params)
 
128
    if prefix:
 
129
      subject = prefix + ' ' + self.subject
 
130
    else:
 
131
      subject = self.subject
 
132
 
 
133
    try:
 
134
      truncate_subject = int(
 
135
          self.cfg.get('truncate-subject', group, params))
 
136
    except ValueError:
 
137
      truncate_subject = 0
 
138
 
 
139
    if truncate_subject and len(subject) > truncate_subject:
 
140
      subject = subject[:(truncate_subject - 3)] + "..."
 
141
    return subject
 
142
 
 
143
  def start(self, group, params):
 
144
    """Override this method.
 
145
    Begin writing an output representation. GROUP is the name of the
 
146
    configuration file group which is causing this output to be produced.
 
147
    PARAMS is a dictionary of any named subexpressions of regular expressions
 
148
    defined in the configuration file, plus the key 'author' contains the
 
149
    author of the action being reported."""
 
150
    raise NotImplementedError
 
151
 
 
152
  def finish(self):
 
153
    """Override this method.
 
154
    Flush any cached information and finish writing the output
 
155
    representation."""
 
156
    raise NotImplementedError
 
157
 
 
158
  def write(self, output):
 
159
    """Override this method.
 
160
    Append the literal text string OUTPUT to the output representation."""
 
161
    raise NotImplementedError
 
162
 
 
163
  def run(self, cmd):
 
164
    """Override this method, if the default implementation is not sufficient.
 
165
    Execute CMD, writing the stdout produced to the output representation."""
 
166
    # By default we choose to incorporate child stderr into the output
 
167
    pipe_ob = Popen4(cmd)
 
168
 
 
169
    buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
 
170
    while buf:
 
171
      self.write(buf)
 
172
      buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
 
173
 
 
174
    # wait on the child so we don't end up with a billion zombies
 
175
    pipe_ob.wait()
 
176
 
 
177
 
 
178
class MailedOutput(OutputBase):
 
179
  def __init__(self, cfg, repos, prefix_param):
 
180
    OutputBase.__init__(self, cfg, repos, prefix_param)
 
181
 
 
182
  def start(self, group, params):
 
183
    # whitespace-separated list of addresses; split into a clean list:
 
184
    self.to_addrs = \
 
185
        filter(None, string.split(self.cfg.get('to_addr', group, params)))
 
186
    self.from_addr = self.cfg.get('from_addr', group, params) \
 
187
                     or self.repos.author or 'no_author'
 
188
    self.reply_to = self.cfg.get('reply_to', group, params)
 
189
 
 
190
  def mail_headers(self, group, params):
 
191
    subject = self.make_subject(group, params)
 
192
    hdrs = 'From: %s\n'    \
 
193
           'To: %s\n'      \
 
194
           'Subject: %s\n' \
 
195
           'MIME-Version: 1.0\n' \
 
196
           'Content-Type: text/plain; charset=UTF-8\n' \
 
197
           % (self.from_addr, string.join(self.to_addrs, ', '), subject)
 
198
    if self.reply_to:
 
199
      hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to)
 
200
    return hdrs + '\n'
 
201
 
 
202
 
 
203
class SMTPOutput(MailedOutput):
 
204
  "Deliver a mail message to an MTA using SMTP."
 
205
 
 
206
  def start(self, group, params):
 
207
    MailedOutput.start(self, group, params)
 
208
 
 
209
    self.buffer = cStringIO.StringIO()
 
210
    self.write = self.buffer.write
 
211
 
 
212
    self.write(self.mail_headers(group, params))
 
213
 
 
214
  def finish(self):
 
215
    server = smtplib.SMTP(self.cfg.general.smtp_hostname)
 
216
    if self.cfg.is_set('general.smtp_username'):
 
217
      server.login(self.cfg.general.smtp_username,
 
218
                   self.cfg.general.smtp_password)
 
219
    server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue())
 
220
    server.quit()
 
221
 
 
222
 
 
223
class StandardOutput(OutputBase):
 
224
  "Print the commit message to stdout."
 
225
 
 
226
  def __init__(self, cfg, repos, prefix_param):
 
227
    OutputBase.__init__(self, cfg, repos, prefix_param)
 
228
    self.write = sys.stdout.write
 
229
 
 
230
  def start(self, group, params):
 
231
    self.write("Group: " + (group or "defaults") + "\n")
 
232
    self.write("Subject: " + self.make_subject(group, params) + "\n\n")
 
233
 
 
234
  def finish(self):
 
235
    pass
 
236
 
 
237
 
 
238
class PipeOutput(MailedOutput):
 
239
  "Deliver a mail message to an MDA via a pipe."
 
240
 
 
241
  def __init__(self, cfg, repos, prefix_param):
 
242
    MailedOutput.__init__(self, cfg, repos, prefix_param)
 
243
 
 
244
    # figure out the command for delivery
 
245
    self.cmd = string.split(cfg.general.mail_command)
 
246
 
 
247
  def start(self, group, params):
 
248
    MailedOutput.start(self, group, params)
 
249
 
 
250
    ### gotta fix this. this is pretty specific to sendmail and qmail's
 
251
    ### mailwrapper program. should be able to use option param substitution
 
252
    cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs
 
253
 
 
254
    # construct the pipe for talking to the mailer
 
255
    self.pipe = Popen3(cmd)
 
256
    self.write = self.pipe.tochild.write
 
257
 
 
258
    # we don't need the read-from-mailer descriptor, so close it
 
259
    self.pipe.fromchild.close()
 
260
 
 
261
    # start writing out the mail message
 
262
    self.write(self.mail_headers(group, params))
 
263
 
 
264
  def finish(self):
 
265
    # signal that we're done sending content
 
266
    self.pipe.tochild.close()
 
267
 
 
268
    # wait to avoid zombies
 
269
    self.pipe.wait()
 
270
 
 
271
 
 
272
class Messenger:
 
273
  def __init__(self, pool, cfg, repos, prefix_param):
 
274
    self.pool = pool
 
275
    self.cfg = cfg
 
276
    self.repos = repos
 
277
 
 
278
    if cfg.is_set('general.mail_command'):
 
279
      cls = PipeOutput
 
280
    elif cfg.is_set('general.smtp_hostname'):
 
281
      cls = SMTPOutput
 
282
    else:
 
283
      cls = StandardOutput
 
284
 
 
285
    self.output = cls(cfg, repos, prefix_param)
 
286
 
 
287
 
 
288
class Commit(Messenger):
 
289
  def __init__(self, pool, cfg, repos):
 
290
    Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix')
 
291
 
 
292
    # get all the changes and sort by path
 
293
    editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, self.pool)
 
294
    e_ptr, e_baton = svn.delta.make_editor(editor, self.pool)
 
295
    svn.repos.replay(repos.root_this, e_ptr, e_baton, self.pool)
 
296
 
 
297
    self.changelist = editor.get_changes().items()
 
298
    self.changelist.sort()
 
299
 
 
300
    # collect the set of groups and the unique sets of params for the options
 
301
    self.groups = { }
 
302
    for path, change in self.changelist:
 
303
      for (group, params) in self.cfg.which_groups(path):
 
304
        # turn the params into a hashable object and stash it away
 
305
        param_list = params.items()
 
306
        param_list.sort()
 
307
        # collect the set of paths belonging to this group
 
308
        if self.groups.has_key( (group, tuple(param_list)) ):
 
309
          old_param, paths = self.groups[group, tuple(param_list)]
 
310
        else:
 
311
          paths = { }
 
312
        paths[path] = None
 
313
        self.groups[group, tuple(param_list)] = (params, paths)
 
314
 
 
315
    # figure out the changed directories
 
316
    dirs = { }
 
317
    for path, change in self.changelist:
 
318
      if change.item_kind == svn.core.svn_node_dir:
 
319
        dirs[path] = None
 
320
      else:
 
321
        idx = string.rfind(path, '/')
 
322
        if idx == -1:
 
323
          dirs[''] = None
 
324
        else:
 
325
          dirs[path[:idx]] = None
 
326
 
 
327
    dirlist = dirs.keys()
 
328
 
 
329
    commondir, dirlist = get_commondir(dirlist)
 
330
 
 
331
    # compose the basic subject line. later, we can prefix it.
 
332
    dirlist.sort()
 
333
    dirlist = string.join(dirlist)
 
334
    if commondir:
 
335
      self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist)
 
336
    else:
 
337
      self.output.subject = 'r%d - %s' % (repos.rev, dirlist)
 
338
 
 
339
  def generate(self):
 
340
    "Generate email for the various groups and option-params."
 
341
 
 
342
    ### the groups need to be further compressed. if the headers and
 
343
    ### body are the same across groups, then we can have multiple To:
 
344
    ### addresses. SMTPOutput holds the entire message body in memory,
 
345
    ### so if the body doesn't change, then it can be sent N times
 
346
    ### rather than rebuilding it each time.
 
347
 
 
348
    subpool = svn.core.svn_pool_create(self.pool)
 
349
 
 
350
    # build a renderer, tied to our output stream
 
351
    renderer = TextCommitRenderer(self.output)
 
352
 
 
353
    for (group, param_tuple), (params, paths) in self.groups.items():
 
354
      self.output.start(group, params)
 
355
 
 
356
      # generate the content for this group and set of params
 
357
      generate_content(renderer, self.cfg, self.repos, self.changelist,
 
358
                       group, params, paths, subpool)
 
359
 
 
360
      self.output.finish()
 
361
      svn.core.svn_pool_clear(subpool)
 
362
 
 
363
    svn.core.svn_pool_destroy(subpool)
 
364
 
 
365
 
 
366
try:
 
367
  from tempfile import NamedTemporaryFile
 
368
except ImportError:
 
369
  # NamedTemporaryFile was added in Python 2.3, so we need to emulate it
 
370
  # for older Pythons.
 
371
  class NamedTemporaryFile:
 
372
    def __init__(self):
 
373
      self.name = tempfile.mktemp()
 
374
      self.file = open(self.name, 'w+b')
 
375
    def __del__(self):
 
376
      os.remove(self.name)
 
377
    def write(self, data):
 
378
      self.file.write(data)
 
379
    def flush(self):
 
380
      self.file.flush()
 
381
 
 
382
 
 
383
class PropChange(Messenger):
 
384
  def __init__(self, pool, cfg, repos, author, propname, action):
 
385
    Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix')
 
386
    self.author = author
 
387
    self.propname = propname
 
388
    self.action = action
 
389
 
 
390
    # collect the set of groups and the unique sets of params for the options
 
391
    self.groups = { }
 
392
    for (group, params) in self.cfg.which_groups(''):
 
393
      # turn the params into a hashable object and stash it away
 
394
      param_list = params.items()
 
395
      param_list.sort()
 
396
      self.groups[group, tuple(param_list)] = params
 
397
 
 
398
    self.output.subject = 'r%d - %s' % (repos.rev, propname)
 
399
 
 
400
  def generate(self):
 
401
    actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' }
 
402
    for (group, param_tuple), params in self.groups.items():
 
403
      self.output.start(group, params)
 
404
      self.output.write('Author: %s\n'
 
405
                        'Revision: %s\n'
 
406
                        'Property Name: %s\n'
 
407
                        'Action: %s\n'
 
408
                        '\n'
 
409
                        % (self.author, self.repos.rev, self.propname,
 
410
                           actions.get(self.action, 'Unknown (\'%s\')' \
 
411
                                       % self.action)))
 
412
      if self.action == 'A' or not actions.has_key(self.action):
 
413
        self.output.write('Property value:\n')
 
414
        propvalue = self.repos.get_rev_prop(self.propname)
 
415
        self.output.write(propvalue)
 
416
      elif self.action == 'M':
 
417
        self.output.write('Property diff:\n')
 
418
        tempfile1 = NamedTemporaryFile()
 
419
        tempfile1.write(sys.stdin.read())
 
420
        tempfile1.flush()
 
421
        tempfile2 = NamedTemporaryFile()
 
422
        tempfile2.write(self.repos.get_rev_prop(self.propname))
 
423
        tempfile2.flush()
 
424
        self.output.run(self.cfg.get_diff_cmd(group, {
 
425
          'label_from' : 'old property value',
 
426
          'label_to' : 'new property value',
 
427
          'from' : tempfile1.name,
 
428
          'to' : tempfile2.name,
 
429
          }))
 
430
      self.output.finish()
 
431
 
 
432
 
 
433
def get_commondir(dirlist):
 
434
  """Figure out the common portion/parent (commondir) of all the paths
 
435
  in DIRLIST and return a tuple consisting of commondir, dirlist.  If
 
436
  a commondir is found, the dirlist returned is rooted in that
 
437
  commondir.  If no commondir is found, dirlist is returned unchanged,
 
438
  and commondir is the empty string."""
 
439
  if len(dirlist) == 1 or '/' in dirlist:
 
440
    commondir = ''
 
441
    newdirs = dirlist
 
442
  else:
 
443
    common = string.split(dirlist.pop(), '/')
 
444
    for d in dirlist:
 
445
      parts = string.split(d, '/')
 
446
      for i in range(len(common)):
 
447
        if i == len(parts) or common[i] != parts[i]:
 
448
          del common[i:]
 
449
          break
 
450
    commondir = string.join(common, '/')
 
451
    if commondir:
 
452
      # strip the common portion from each directory
 
453
      l = len(commondir) + 1
 
454
      newdirs = [ ]
 
455
      for d in dirlist:
 
456
        if d == commondir:
 
457
          newdirs.append('.')
 
458
        else:
 
459
          newdirs.append(d[l:])
 
460
    else:
 
461
      # nothing in common, so reset the list of directories
 
462
      newdirs = dirlist
 
463
 
 
464
  return commondir, newdirs
 
465
 
 
466
 
 
467
class Lock(Messenger):
 
468
  def __init__(self, pool, cfg, repos, author, do_lock):
 
469
    self.author = author
 
470
    self.do_lock = do_lock
 
471
 
 
472
    Messenger.__init__(self, pool, cfg, repos,
 
473
                       (do_lock and 'lock_subject_prefix'
 
474
                        or 'unlock_subject_prefix'))
 
475
 
 
476
    # read all the locked paths from STDIN and strip off the trailing newlines
 
477
    self.dirlist = map(lambda x: x.rstrip(), sys.stdin.readlines())
 
478
 
 
479
    # collect the set of groups and the unique sets of params for the options
 
480
    self.groups = { }
 
481
    for path in self.dirlist:
 
482
      for (group, params) in self.cfg.which_groups(path):
 
483
        # turn the params into a hashable object and stash it away
 
484
        param_list = params.items()
 
485
        param_list.sort()
 
486
        # collect the set of paths belonging to this group
 
487
        if self.groups.has_key( (group, tuple(param_list)) ):
 
488
          old_param, paths = self.groups[group, tuple(param_list)]
 
489
        else:
 
490
          paths = { }
 
491
        paths[path] = None
 
492
        self.groups[group, tuple(param_list)] = (params, paths)
 
493
 
 
494
    commondir, dirlist = get_commondir(self.dirlist)
 
495
 
 
496
    # compose the basic subject line. later, we can prefix it.
 
497
    dirlist.sort()
 
498
    dirlist = string.join(dirlist)
 
499
    if commondir:
 
500
      self.output.subject = '%s: %s' % (commondir, dirlist)
 
501
    else:
 
502
      self.output.subject = '%s' % (dirlist)
 
503
 
 
504
    # The lock comment is the same for all paths, so we can just pull
 
505
    # the comment for the first path in the dirlist and cache it.
 
506
    self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr,
 
507
                                       self.dirlist[0], self.pool)
 
508
 
 
509
  def generate(self):
 
510
    for (group, param_tuple), (params, paths) in self.groups.items():
 
511
      self.output.start(group, params)
 
512
 
 
513
      self.output.write('Author: %s\n'
 
514
                        '%s paths:\n' %
 
515
                        (self.author, self.do_lock and 'Locked' or 'Unlocked'))
 
516
 
 
517
      self.dirlist.sort()
 
518
      for dir in self.dirlist:
 
519
        self.output.write('   %s\n\n' % dir)
 
520
 
 
521
      if self.do_lock:
 
522
        self.output.write('Comment:\n%s\n' % (self.lock.comment or ''))
 
523
 
 
524
      self.output.finish()
 
525
 
 
526
 
 
527
class DiffSelections:
 
528
  def __init__(self, cfg, group, params):
 
529
    self.add = False
 
530
    self.copy = False
 
531
    self.delete = False
 
532
    self.modify = False
 
533
 
 
534
    gen_diffs = cfg.get('generate_diffs', group, params)
 
535
 
 
536
    ### Do a little dance for deprecated options.  Note that even if you
 
537
    ### don't have an option anywhere in your configuration file, it
 
538
    ### still gets returned as non-None.
 
539
    if len(gen_diffs):
 
540
      list = string.split(gen_diffs, " ")
 
541
      for item in list:
 
542
        if item == 'add':
 
543
          self.add = True
 
544
        if item == 'copy':
 
545
          self.copy = True
 
546
        if item == 'delete':
 
547
          self.delete = True
 
548
        if item == 'modify':
 
549
          self.modify = True
 
550
    else:
 
551
      self.add = True
 
552
      self.copy = True
 
553
      self.delete = True
 
554
      self.modify = True
 
555
      ### These options are deprecated
 
556
      suppress = cfg.get('suppress_deletes', group, params)
 
557
      if suppress == 'yes':
 
558
        self.delete = False
 
559
      suppress = cfg.get('suppress_adds', group, params)
 
560
      if suppress == 'yes':
 
561
        self.add = False
 
562
 
 
563
 
 
564
def generate_content(renderer, cfg, repos, changelist, group, params, paths,
 
565
                     pool):
 
566
 
 
567
  svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE)
 
568
  ### pick a different date format?
 
569
  date = time.ctime(svn.core.secs_from_timestr(svndate, pool))
 
570
 
 
571
  diffsels = DiffSelections(cfg, group, params)
 
572
 
 
573
  show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \
 
574
      or 'yes'
 
575
 
 
576
  # figure out the lists of changes outside the selected path-space
 
577
  other_added_data = other_removed_data = other_modified_data = [ ]
 
578
  if len(paths) != len(changelist) and show_nonmatching_paths != 'no':
 
579
    other_added_data = generate_list('A', changelist, paths, False)
 
580
    other_removed_data = generate_list('R', changelist, paths, False)
 
581
    other_modified_data = generate_list('M', changelist, paths, False)
 
582
 
 
583
  if len(paths) != len(changelist) and show_nonmatching_paths == 'yes':
 
584
    other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date,
 
585
                                group, params, diffsels, pool),
 
586
  else:
 
587
    other_diffs = [ ]
 
588
 
 
589
  data = _data(
 
590
    author=repos.author,
 
591
    date=date,
 
592
    rev=repos.rev,
 
593
    log=repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '',
 
594
    added_data=generate_list('A', changelist, paths, True),
 
595
    removed_data=generate_list('R', changelist, paths, True),
 
596
    modified_data=generate_list('M', changelist, paths, True),
 
597
    other_added_data=other_added_data,
 
598
    other_removed_data=other_removed_data,
 
599
    other_modified_data=other_modified_data,
 
600
    diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group,
 
601
                        params, diffsels, pool),
 
602
    other_diffs=other_diffs,
 
603
    )
 
604
  renderer.render(data)
 
605
 
 
606
 
 
607
def generate_list(changekind, changelist, paths, in_paths):
 
608
  if changekind == 'A':
 
609
    selection = lambda change: change.added
 
610
  elif changekind == 'R':
 
611
    selection = lambda change: change.path is None
 
612
  elif changekind == 'M':
 
613
    selection = lambda change: not change.added and change.path is not None
 
614
 
 
615
  items = [ ]
 
616
  for path, change in changelist:
 
617
    if selection(change) and paths.has_key(path) == in_paths:
 
618
      item = _data(
 
619
        path=path,
 
620
        is_dir=change.item_kind == svn.core.svn_node_dir,
 
621
        props_changed=change.prop_changes,
 
622
        text_changed=change.text_changed,
 
623
        copied=change.added and change.base_path,
 
624
        base_path=change.base_path,
 
625
        base_rev=change.base_rev,
 
626
        )
 
627
      items.append(item)
 
628
 
 
629
  return items
 
630
 
 
631
 
 
632
class DiffGenerator:
 
633
  "This is a generator-like object returning DiffContent objects."
 
634
 
 
635
  def __init__(self, changelist, paths, in_paths, cfg, repos, date, group,
 
636
               params, diffsels, pool):
 
637
    self.changelist = changelist
 
638
    self.paths = paths
 
639
    self.in_paths = in_paths
 
640
    self.cfg = cfg
 
641
    self.repos = repos
 
642
    self.date = date
 
643
    self.group = group
 
644
    self.params = params
 
645
    self.diffsels = diffsels
 
646
    self.pool = pool
 
647
 
 
648
    self.idx = 0
 
649
 
 
650
  def __nonzero__(self):
 
651
    # we always have some items
 
652
    return True
 
653
 
 
654
  def __getitem__(self, idx):
 
655
    while 1:
 
656
      if self.idx == len(self.changelist):
 
657
        raise IndexError
 
658
 
 
659
      path, change = self.changelist[self.idx]
 
660
      self.idx = self.idx + 1
 
661
 
 
662
      # just skip directories. they have no diffs.
 
663
      if change.item_kind == svn.core.svn_node_dir:
 
664
        continue
 
665
 
 
666
      # is this change in (or out of) the set of matched paths?
 
667
      if self.paths.has_key(path) != self.in_paths:
 
668
        continue
 
669
 
 
670
      # figure out if/how to generate a diff
 
671
 
 
672
      if not change.path:
 
673
        # it was deleted. should we show deletion diffs?
 
674
        if not self.diffsels.delete:
 
675
          continue
 
676
 
 
677
        kind = 'R'
 
678
        diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
 
679
                               change.base_path, None, None, self.pool)
 
680
 
 
681
        label1 = '%s\t%s' % (change.base_path, self.date)
 
682
        label2 = '(empty file)'
 
683
        singular = True
 
684
      elif change.added:
 
685
        if change.base_path and (change.base_rev != -1):
 
686
          # this file was copied. any diff to show? should we?
 
687
          if not change.text_changed or not self.diffsels.copy:
 
688
            continue
 
689
 
 
690
          kind = 'C'
 
691
          diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
 
692
                                 change.base_path,
 
693
                                 self.repos.root_this, change.path,
 
694
                                 self.pool)
 
695
          label1 = change.base_path + '\t(original)'
 
696
          label2 = '%s\t%s' % (change.path, self.date)
 
697
          singular = False
 
698
        else:
 
699
          # the file was added. should we show it?
 
700
          if not self.diffsels.add:
 
701
            continue
 
702
 
 
703
          kind = 'A'
 
704
          diff = svn.fs.FileDiff(None, None, self.repos.root_this,
 
705
                                 change.path, self.pool)
 
706
          label1 = '(empty file)'
 
707
          label2 = '%s\t%s' % (change.path, self.date)
 
708
          singular = True
 
709
 
 
710
      elif not change.text_changed:
 
711
        # the text didn't change, so nothing to show.
 
712
        continue
 
713
      else:
 
714
        # a simple modification. show the diff?
 
715
        if not self.diffsels.modify:
 
716
          continue
 
717
 
 
718
        kind = 'M'
 
719
        diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
 
720
                               change.base_path,
 
721
                               self.repos.root_this, change.path,
 
722
                               self.pool)
 
723
        label1 = change.base_path + '\t(original)'
 
724
        label2 = '%s\t%s' % (change.path, self.date)
 
725
        singular = False
 
726
 
 
727
      binary = diff.either_binary()
 
728
      if binary:
 
729
        content = src_fname = dst_fname = None
 
730
      else:
 
731
        src_fname, dst_fname = diff.get_files()
 
732
        content = DiffContent(self.cfg.get_diff_cmd(self.group, {
 
733
          'label_from' : label1,
 
734
          'label_to' : label2,
 
735
          'from' : src_fname,
 
736
          'to' : dst_fname,
 
737
          }))
 
738
 
 
739
      # return a data item for this diff
 
740
      return _data(
 
741
        kind=kind,
 
742
        path=change.path,
 
743
        base_path=change.base_path,
 
744
        base_rev=change.base_rev,
 
745
        label_from=label1,
 
746
        label_to=label2,
 
747
        from_fname=src_fname,
 
748
        to_fname=dst_fname,
 
749
        binary=binary,
 
750
        singular=singular,
 
751
        content=content,
 
752
        diff=diff,
 
753
        )
 
754
 
 
755
 
 
756
class DiffContent:
 
757
  "This is a generator-like object returning annotated lines of a diff."
 
758
 
 
759
  def __init__(self, cmd):
 
760
    self.seen_change = False
 
761
 
 
762
    # By default we choose to incorporate child stderr into the output
 
763
    self.pipe = Popen4(cmd)
 
764
 
 
765
  def __nonzero__(self):
 
766
    # we always have some items
 
767
    return True
 
768
 
 
769
  def __getitem__(self, idx):
 
770
    if self.pipe is None:
 
771
      raise IndexError
 
772
 
 
773
    line = self.pipe.fromchild.readline()
 
774
    if not line:
 
775
      # wait on the child so we don't end up with a billion zombies
 
776
      self.pipe.wait()
 
777
      self.pipe = None
 
778
      raise IndexError
 
779
 
 
780
    # classify the type of line.
 
781
    first = line[:1]
 
782
    if first == '@':
 
783
      self.seen_change = True
 
784
      ltype = 'H'
 
785
    elif first == '-':
 
786
      if self.seen_change:
 
787
        ltype = 'D'
 
788
      else:
 
789
        ltype = 'F'
 
790
    elif first == '+':
 
791
      if self.seen_change:
 
792
        ltype = 'A'
 
793
      else:
 
794
        ltype = 'T'
 
795
    elif first == ' ':
 
796
      ltype = 'C'
 
797
    else:
 
798
      ltype = 'U'
 
799
 
 
800
    return _data(
 
801
      raw=line,
 
802
      text=line[1:-1],  # remove indicator and newline
 
803
      type=ltype,
 
804
      )
 
805
 
 
806
 
 
807
class TextCommitRenderer:
 
808
  "This class will render the commit mail in plain text."
 
809
 
 
810
  def __init__(self, output):
 
811
    self.output = output
 
812
 
 
813
  def render(self, data):
 
814
    "Render the commit defined by 'data'."
 
815
 
 
816
    w = self.output.write
 
817
 
 
818
    w('Author: %s\nDate: %s\nNew Revision: %s\n\n'
 
819
      % (data.author, data.date, data.rev))
 
820
 
 
821
    # print summary sections
 
822
    self._render_list('Added', data.added_data)
 
823
    self._render_list('Removed', data.removed_data)
 
824
    self._render_list('Modified', data.modified_data)
 
825
 
 
826
    if data.other_added_data or data.other_removed_data \
 
827
           or data.other_modified_data:
 
828
      if data.show_nonmatching_paths:
 
829
        w('\nChanges in other areas also in this revision:\n')
 
830
        self._render_list('Added', data.other_added_data)
 
831
        self._render_list('Removed', data.other_removed_data)
 
832
        self._render_list('Modified', data.other_modified_data)
 
833
      else:
 
834
        w('and changes in other areas\n')
 
835
 
 
836
    w('\nLog:\n%s\n' % data.log)
 
837
 
 
838
    self._render_diffs(data.diffs)
 
839
    if data.other_diffs:
 
840
      w('\nDiffs of changes in other areas also in this revision:\n')
 
841
      self._render_diffs(data.other_diffs)
 
842
 
 
843
  def _render_list(self, header, data_list):
 
844
    if not data_list:
 
845
      return
 
846
 
 
847
    w = self.output.write
 
848
    w(header + ':\n')
 
849
    for d in data_list:
 
850
      if d.is_dir:
 
851
        is_dir = '/'
 
852
      else:
 
853
        is_dir = ''
 
854
      if d.props_changed:
 
855
        if d.text_changed:
 
856
          props = '   (contents, props changed)'
 
857
        else:
 
858
          props = '   (props changed)'
 
859
      else:
 
860
        props = ''
 
861
      w('   %s%s%s\n' % (d.path, is_dir, props))
 
862
      if d.copied:
 
863
        if is_dir:
 
864
          text = ''
 
865
        elif d.text_changed:
 
866
          text = ', changed'
 
867
        else:
 
868
          text = ' unchanged'
 
869
        w('      - copied%s from r%d, %s%s\n'
 
870
          % (text, d.base_rev, d.base_path, is_dir))
 
871
 
 
872
  def _render_diffs(self, diffs):
 
873
    w = self.output.write
 
874
 
 
875
    for diff in diffs:
 
876
      if diff.kind == 'D':
 
877
        w('\nDeleted: %s\n' % diff.base_path)
 
878
      elif diff.kind == 'C':
 
879
        w('\nCopied: %s (from r%d, %s)\n'
 
880
          % (diff.path, diff.base_rev, diff.base_path))
 
881
      elif diff.kind == 'A':
 
882
        w('\nAdded: %s\n' % diff.path)
 
883
      else:
 
884
        # kind == 'M'
 
885
        w('\nModified: %s\n' % diff.path)
 
886
 
 
887
      w(SEPARATOR + '\n')
 
888
 
 
889
      if diff.binary:
 
890
        if diff.singular:
 
891
          w('Binary file. No diff available.\n')
 
892
        else:
 
893
          w('Binary files. No diff available.\n')
 
894
        continue
 
895
 
 
896
      for line in diff.content:
 
897
        w(line.raw)
 
898
 
 
899
 
 
900
class Repository:
 
901
  "Hold roots and other information about the repository."
 
902
 
 
903
  def __init__(self, repos_dir, rev, pool):
 
904
    self.repos_dir = repos_dir
 
905
    self.rev = rev
 
906
    self.pool = pool
 
907
 
 
908
    self.repos_ptr = svn.repos.open(repos_dir, pool)
 
909
    self.fs_ptr = svn.repos.fs(self.repos_ptr)
 
910
 
 
911
    self.roots = { }
 
912
 
 
913
    self.root_this = self.get_root(rev)
 
914
 
 
915
    self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR)
 
916
 
 
917
  def get_rev_prop(self, propname):
 
918
    return svn.fs.revision_prop(self.fs_ptr, self.rev, propname, self.pool)
 
919
 
 
920
  def get_root(self, rev):
 
921
    try:
 
922
      return self.roots[rev]
 
923
    except KeyError:
 
924
      pass
 
925
    root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool)
 
926
    return root
 
927
 
 
928
 
 
929
class Config:
 
930
 
 
931
  # The predefined configuration sections. These are omitted from the
 
932
  # set of groups.
 
933
  _predefined = ('general', 'defaults', 'maps')
 
934
 
 
935
  def __init__(self, fname, repos, global_params):
 
936
    cp = ConfigParser.ConfigParser()
 
937
    cp.read(fname)
 
938
 
 
939
    # record the (non-default) groups that we find
 
940
    self._groups = [ ]
 
941
 
 
942
    for section in cp.sections():
 
943
      if not hasattr(self, section):
 
944
        section_ob = _sub_section()
 
945
        setattr(self, section, section_ob)
 
946
        if section not in self._predefined:
 
947
          self._groups.append(section)
 
948
      else:
 
949
        section_ob = getattr(self, section)
 
950
      for option in cp.options(section):
 
951
        # get the raw value -- we use the same format for *our* interpolation
 
952
        value = cp.get(section, option, raw=1)
 
953
        setattr(section_ob, option, value)
 
954
 
 
955
    # be compatible with old format config files
 
956
    if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'):
 
957
      self.defaults.diff = self.general.diff
 
958
    if not hasattr(self, 'maps'):
 
959
      self.maps = _sub_section()
 
960
 
 
961
    # these params are always available, although they may be overridden
 
962
    self._global_params = global_params.copy()
 
963
 
 
964
    # prepare maps. this may remove sections from consideration as a group.
 
965
    self._prep_maps()
 
966
 
 
967
    # process all the group sections.
 
968
    self._prep_groups(repos)
 
969
 
 
970
  def is_set(self, option):
 
971
    """Return None if the option is not set; otherwise, its value is returned.
 
972
 
 
973
    The option is specified as a dotted symbol, such as 'general.mail_command'
 
974
    """
 
975
    ob = self
 
976
    for part in string.split(option, '.'):
 
977
      if not hasattr(ob, part):
 
978
        return None
 
979
      ob = getattr(ob, part)
 
980
    return ob
 
981
 
 
982
  def get(self, option, group, params):
 
983
    "Get a config value with appropriate substitutions and value mapping."
 
984
 
 
985
    # find the right value
 
986
    value = None
 
987
    if group:
 
988
      sub = getattr(self, group)
 
989
      value = getattr(sub, option, None)
 
990
    if value is None:
 
991
      value = getattr(self.defaults, option, '')
 
992
    
 
993
    # parameterize it
 
994
    if params is not None:
 
995
      value = value % params
 
996
 
 
997
    # apply any mapper
 
998
    mapper = getattr(self.maps, option, None)
 
999
    if mapper is not None:
 
1000
      value = mapper(value)
 
1001
 
 
1002
    return value
 
1003
 
 
1004
  def get_diff_cmd(self, group, args):
 
1005
    "Get a diff command as a list of argv elements."
 
1006
    ### do some better splitting to enable quoting of spaces
 
1007
    diff_cmd = string.split(self.get('diff', group, None))
 
1008
 
 
1009
    cmd = [ ]
 
1010
    for part in diff_cmd:
 
1011
      cmd.append(part % args)
 
1012
    return cmd
 
1013
 
 
1014
  def _prep_maps(self):
 
1015
    "Rewrite the [maps] options into callables that look up values."
 
1016
 
 
1017
    for optname, mapvalue in vars(self.maps).items():
 
1018
      if mapvalue[:1] == '[':
 
1019
        # a section is acting as a mapping
 
1020
        sectname = mapvalue[1:-1]
 
1021
        if not hasattr(self, sectname):
 
1022
          raise UnknownMappingSection(sectname)
 
1023
        # construct a lambda to look up the given value as an option name,
 
1024
        # and return the option's value. if the option is not present,
 
1025
        # then just return the value unchanged.
 
1026
        setattr(self.maps, optname,
 
1027
                lambda value,
 
1028
                       sect=getattr(self, sectname): getattr(sect, value,
 
1029
                                                             value))
 
1030
        # remove the mapping section from consideration as a group
 
1031
        self._groups.remove(sectname)
 
1032
 
 
1033
      # elif test for other mapper types. possible examples:
 
1034
      #   dbm:filename.db
 
1035
      #   file:two-column-file.txt
 
1036
      #   ldap:some-query-spec
 
1037
      # just craft a mapper function and insert it appropriately
 
1038
 
 
1039
      else:
 
1040
        raise UnknownMappingSpec(mapvalue)
 
1041
 
 
1042
  def _prep_groups(self, repos):
 
1043
    self._group_re = [ ]
 
1044
 
 
1045
    repos_dir = os.path.abspath(repos.repos_dir)
 
1046
 
 
1047
    # compute the default repository-based parameters. start with some
 
1048
    # basic parameters, then bring in the regex-based params.
 
1049
    default_params = self._global_params.copy()
 
1050
 
 
1051
    try:
 
1052
      match = re.match(self.defaults.for_repos, repos_dir)
 
1053
      if match:
 
1054
        default_params.update(match.groupdict())
 
1055
    except AttributeError:
 
1056
      # there is no self.defaults.for_repos
 
1057
      pass
 
1058
 
 
1059
    # select the groups that apply to this repository
 
1060
    for group in self._groups:
 
1061
      sub = getattr(self, group)
 
1062
      params = default_params
 
1063
      if hasattr(sub, 'for_repos'):
 
1064
        match = re.match(sub.for_repos, repos_dir)
 
1065
        if not match:
 
1066
          continue
 
1067
        params = self._global_params.copy()
 
1068
        params.update(match.groupdict())
 
1069
 
 
1070
      # if a matching rule hasn't been given, then use the empty string
 
1071
      # as it will match all paths
 
1072
      for_paths = getattr(sub, 'for_paths', '')
 
1073
      exclude_paths = getattr(sub, 'exclude_paths', None)
 
1074
      if exclude_paths:
 
1075
        exclude_paths_re = re.compile(exclude_paths)
 
1076
      else:
 
1077
        exclude_paths_re = None
 
1078
 
 
1079
      self._group_re.append((group, re.compile(for_paths),
 
1080
                             exclude_paths_re, params))
 
1081
 
 
1082
    # after all the groups are done, add in the default group
 
1083
    try:
 
1084
      self._group_re.append((None,
 
1085
                             re.compile(self.defaults.for_paths),
 
1086
                             None,
 
1087
                             default_params))
 
1088
    except AttributeError:
 
1089
      # there is no self.defaults.for_paths
 
1090
      pass
 
1091
 
 
1092
  def which_groups(self, path):
 
1093
    "Return the path's associated groups."
 
1094
    groups = []
 
1095
    for group, pattern, exclude_pattern, repos_params in self._group_re:
 
1096
      match = pattern.match(path)
 
1097
      if match:
 
1098
        if exclude_pattern and exclude_pattern.match(path):
 
1099
          continue
 
1100
        params = repos_params.copy()
 
1101
        params.update(match.groupdict())
 
1102
        groups.append((group, params))
 
1103
    if not groups:
 
1104
      groups.append((None, self._global_params))
 
1105
    return groups
 
1106
 
 
1107
 
 
1108
class _sub_section:
 
1109
  pass
 
1110
 
 
1111
class _data:
 
1112
  "Helper class to define an attribute-based hunk o' data."
 
1113
  def __init__(self, **kw):
 
1114
    vars(self).update(kw)
 
1115
 
 
1116
class MissingConfig(Exception):
 
1117
  pass
 
1118
class UnknownMappingSection(Exception):
 
1119
  pass
 
1120
class UnknownMappingSpec(Exception):
 
1121
  pass
 
1122
class UnknownSubcommand(Exception):
 
1123
  pass
 
1124
 
 
1125
 
 
1126
# enable True/False in older vsns of Python
 
1127
try:
 
1128
  _unused = True
 
1129
except NameError:
 
1130
  True = 1
 
1131
  False = 0
 
1132
 
 
1133
 
 
1134
if __name__ == '__main__':
 
1135
  def usage():
 
1136
    sys.stderr.write(
 
1137
"""USAGE: %s commit      REPOS REVISION [CONFIG-FILE]
 
1138
       %s propchange  REPOS REVISION AUTHOR PROPNAME [CONFIG-FILE]
 
1139
       %s propchange2 REPOS REVISION AUTHOR PROPNAME ACTION
 
1140
       %s             [CONFIG-FILE]
 
1141
       %s lock        REPOS AUTHOR [CONFIG-FILE]
 
1142
       %s unlock      REPOS AUTHOR [CONFIG-FILE]
 
1143
 
 
1144
If no CONFIG-FILE is provided, the script will first search for a mailer.conf
 
1145
file in REPOS/conf/.  Failing that, it will search the directory in which
 
1146
the script itself resides.
 
1147
 
 
1148
ACTION was added as a fifth argument to the post-revprop-change hook
 
1149
in Subversion 1.2.0.  Its value is one of 'A', 'M' or 'D' to indicate
 
1150
if the property was added, modified or deleted, respectively.
 
1151
 
 
1152
""" % (sys.argv[0], sys.argv[0], sys.argv[0], ' ' * len(sys.argv[0]),
 
1153
       sys.argv[0], sys.argv[0]))
 
1154
    sys.exit(1)
 
1155
 
 
1156
  # Command list:  subcommand -> number of arguments expected (not including
 
1157
  #                              the repository directory and config-file)
 
1158
  cmd_list = {'commit'     : 1,
 
1159
              'propchange' : 3,
 
1160
              'propchange2': 4,
 
1161
              'lock'       : 1,
 
1162
              'unlock'     : 1,
 
1163
              }
 
1164
 
 
1165
  config_fname = None
 
1166
  argc = len(sys.argv)
 
1167
  if argc < 3:
 
1168
    usage()
 
1169
 
 
1170
  cmd = sys.argv[1]
 
1171
  repos_dir = sys.argv[2]
 
1172
  try:
 
1173
    expected_args = cmd_list[cmd]
 
1174
  except KeyError:
 
1175
    usage()
 
1176
 
 
1177
  if argc < (expected_args + 3):
 
1178
    usage()
 
1179
  elif argc > expected_args + 4:
 
1180
    usage()
 
1181
  elif argc == (expected_args + 4):
 
1182
    config_fname = sys.argv[expected_args + 3]
 
1183
 
 
1184
  # Settle on a config file location, and open it.
 
1185
  if config_fname is None:
 
1186
    # Default to REPOS-DIR/conf/mailer.conf.
 
1187
    config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf')
 
1188
    if not os.path.exists(config_fname):
 
1189
      # Okay.  Look for 'mailer.conf' as a sibling of this script.
 
1190
      config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf')
 
1191
  if not os.path.exists(config_fname):
 
1192
    raise MissingConfig(config_fname)
 
1193
  config_fp = open(config_fname)
 
1194
 
 
1195
  svn.core.run_app(main, cmd, config_fname, repos_dir,
 
1196
                   sys.argv[3:3+expected_args])
 
1197
 
 
1198
# ------------------------------------------------------------------------
 
1199
# TODO
 
1200
#
 
1201
# * add configuration options
 
1202
#   - each group defines delivery info:
 
1203
#     o whether to set Reply-To and/or Mail-Followup-To
 
1204
#       (btw: it is legal do set Reply-To since this is the originator of the
 
1205
#        mail; i.e. different from MLMs that munge it)
 
1206
#   - each group defines content construction:
 
1207
#     o max size of diff before trimming
 
1208
#     o max size of entire commit message before truncation
 
1209
#   - per-repository configuration
 
1210
#     o extra config living in repos
 
1211
#     o how to construct a ViewCVS URL for the diff  [DONE (as patch)]
 
1212
#     o optional, non-mail log file
 
1213
#     o look up authors (username -> email; for the From: header) in a
 
1214
#       file(s) or DBM
 
1215
#   - if the subject line gets too long, then trim it. configurable?
 
1216
# * get rid of global functions that should properly be class methods