/[adm]/puppet/deployment/mgagit/templates/git_multimail.py
ViewVC logotype

Contents of /puppet/deployment/mgagit/templates/git_multimail.py

Parent Directory Parent Directory | Revision Log Revision Log


Revision 3463 - (show annotations) (download) (as text)
Sun Apr 27 13:58:23 2014 UTC (9 years, 11 months ago) by colin
File MIME type: text/x-python
File size: 87218 byte(s)
mgagit: Add bug number (forgot to save before commit)
1 #! /usr/bin/env python2
2
3 # Copyright (c) 2012-2014 Michael Haggerty and others
4 # Derived from contrib/hooks/post-receive-email, which is
5 # Copyright (c) 2007 Andy Parkins
6 # and also includes contributions by other authors.
7 #
8 # This file is part of git-multimail.
9 #
10 # git-multimail is free software: you can redistribute it and/or
11 # modify it under the terms of the GNU General Public License version
12 # 2 as published by the Free Software Foundation.
13 #
14 # This program is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17 # General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with this program. If not, see
21 # <http://www.gnu.org/licenses/>.
22
23 """Generate notification emails for pushes to a git repository.
24
25 This hook sends emails describing changes introduced by pushes to a
26 git repository. For each reference that was changed, it emits one
27 ReferenceChange email summarizing how the reference was changed,
28 followed by one Revision email for each new commit that was introduced
29 by the reference change.
30
31 Each commit is announced in exactly one Revision email. If the same
32 commit is merged into another branch in the same or a later push, then
33 the ReferenceChange email will list the commit's SHA1 and its one-line
34 summary, but no new Revision email will be generated.
35
36 This script is designed to be used as a "post-receive" hook in a git
37 repository (see githooks(5)). It can also be used as an "update"
38 script, but this usage is not completely reliable and is deprecated.
39
40 To help with debugging, this script accepts a --stdout option, which
41 causes the emails to be written to standard output rather than sent
42 using sendmail.
43
44 See the accompanying README file for the complete documentation.
45
46 """
47
48 import sys
49 import os
50 import re
51 import bisect
52 import socket
53 import subprocess
54 import shlex
55 import optparse
56 import smtplib
57 import time
58
59 try:
60 from email.utils import make_msgid
61 from email.utils import getaddresses
62 from email.utils import formataddr
63 from email.utils import formatdate
64 from email.header import Header
65 except ImportError:
66 # Prior to Python 2.5, the email module used different names:
67 from email.Utils import make_msgid
68 from email.Utils import getaddresses
69 from email.Utils import formataddr
70 from email.Utils import formatdate
71 from email.Header import Header
72
73
74 DEBUG = False
75
76 ZEROS = '0' * 40
77 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
78 LOGEND = '-----------------------------------------------------------------------\n'
79
80 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
81
82 # It is assumed in many places that the encoding is uniformly UTF-8,
83 # so changing these constants is unsupported. But define them here
84 # anyway, to make it easier to find (at least most of) the places
85 # where the encoding is important.
86 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
87
88
89 REF_CREATED_SUBJECT_TEMPLATE = (
90 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
91 ' (now %(newrev_short)s)'
92 )
93 REF_UPDATED_SUBJECT_TEMPLATE = (
94 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
95 ' (%(oldrev_short)s -> %(newrev_short)s)'
96 )
97 REF_DELETED_SUBJECT_TEMPLATE = (
98 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
99 ' (was %(oldrev_short)s)'
100 )
101
102 REFCHANGE_HEADER_TEMPLATE = """\
103 Date: %(send_date)s
104 To: %(recipients)s
105 Subject: %(subject)s
106 MIME-Version: 1.0
107 Content-Type: text/plain; charset=%(charset)s
108 Content-Transfer-Encoding: 8bit
109 Message-ID: %(msgid)s
110 From: %(fromaddr)s
111 Reply-To: %(reply_to)s
112 X-Git-Host: %(fqdn)s
113 X-Git-Repo: %(repo_shortname)s
114 X-Git-Refname: %(refname)s
115 X-Git-Reftype: %(refname_type)s
116 X-Git-Oldrev: %(oldrev)s
117 X-Git-Newrev: %(newrev)s
118 Auto-Submitted: auto-generated
119 """
120
121 REFCHANGE_INTRO_TEMPLATE = """\
122 This is an automated email from the git hooks/post-receive script.
123
124 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
125 in repository %(repo_shortname)s.
126
127 """
128
129
130 FOOTER_TEMPLATE = """\
131
132 -- \n\
133 To stop receiving notification emails like this one, please contact
134 %(administrator)s.
135 """
136
137
138 REWIND_ONLY_TEMPLATE = """\
139 This update removed existing revisions from the reference, leaving the
140 reference pointing at a previous point in the repository history.
141
142 * -- * -- N %(refname)s (%(newrev_short)s)
143 \\
144 O -- O -- O (%(oldrev_short)s)
145
146 Any revisions marked "omits" are not gone; other references still
147 refer to them. Any revisions marked "discards" are gone forever.
148 """
149
150
151 NON_FF_TEMPLATE = """\
152 This update added new revisions after undoing existing revisions.
153 That is to say, some revisions that were in the old version of the
154 %(refname_type)s are not in the new version. This situation occurs
155 when a user --force pushes a change and generates a repository
156 containing something like this:
157
158 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
159 \\
160 N -- N -- N %(refname)s (%(newrev_short)s)
161
162 You should already have received notification emails for all of the O
163 revisions, and so the following emails describe only the N revisions
164 from the common base, B.
165
166 Any revisions marked "omits" are not gone; other references still
167 refer to them. Any revisions marked "discards" are gone forever.
168 """
169
170
171 NO_NEW_REVISIONS_TEMPLATE = """\
172 No new revisions were added by this update.
173 """
174
175
176 DISCARDED_REVISIONS_TEMPLATE = """\
177 This change permanently discards the following revisions:
178 """
179
180
181 NO_DISCARDED_REVISIONS_TEMPLATE = """\
182 The revisions that were on this %(refname_type)s are still contained in
183 other references; therefore, this change does not discard any commits
184 from the repository.
185 """
186
187
188 NEW_REVISIONS_TEMPLATE = """\
189 The %(tot)s revisions listed above as "new" are entirely new to this
190 repository and will be described in separate emails. The revisions
191 listed as "adds" were already present in the repository and have only
192 been added to this reference.
193
194 """
195
196
197 TAG_CREATED_TEMPLATE = """\
198 at %(newrev_short)-9s (%(newrev_type)s)
199 """
200
201
202 TAG_UPDATED_TEMPLATE = """\
203 *** WARNING: tag %(short_refname)s was modified! ***
204
205 from %(oldrev_short)-9s (%(oldrev_type)s)
206 to %(newrev_short)-9s (%(newrev_type)s)
207 """
208
209
210 TAG_DELETED_TEMPLATE = """\
211 *** WARNING: tag %(short_refname)s was deleted! ***
212
213 """
214
215
216 # The template used in summary tables. It looks best if this uses the
217 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
218 BRIEF_SUMMARY_TEMPLATE = """\
219 %(action)10s %(rev_short)-9s %(text)s
220 """
221
222
223 NON_COMMIT_UPDATE_TEMPLATE = """\
224 This is an unusual reference change because the reference did not
225 refer to a commit either before or after the change. We do not know
226 how to provide full information about this reference change.
227 """
228
229
230 REVISION_HEADER_TEMPLATE = """\
231 Date: %(send_date)s
232 To: %(recipients)s
233 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
234 MIME-Version: 1.0
235 Content-Type: text/plain; charset=%(charset)s
236 Content-Transfer-Encoding: 8bit
237 From: %(fromaddr)s
238 Reply-To: %(reply_to)s
239 In-Reply-To: %(reply_to_msgid)s
240 References: %(reply_to_msgid)s
241 X-Git-Host: %(fqdn)s
242 X-Git-Repo: %(repo_shortname)s
243 X-Git-Refname: %(refname)s
244 X-Git-Reftype: %(refname_type)s
245 X-Git-Rev: %(rev)s
246 Auto-Submitted: auto-generated
247 """
248
249 REVISION_INTRO_TEMPLATE = """\
250 This is an automated email from the git hooks/post-receive script.
251
252 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
253 in repository %(repo_shortname)s.
254
255 """
256
257
258 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
259
260
261 class CommandError(Exception):
262 def __init__(self, cmd, retcode):
263 self.cmd = cmd
264 self.retcode = retcode
265 Exception.__init__(
266 self,
267 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
268 )
269
270
271 class ConfigurationException(Exception):
272 pass
273
274
275 # The "git" program (this could be changed to include a full path):
276 GIT_EXECUTABLE = 'git'
277
278
279 # How "git" should be invoked (including global arguments), as a list
280 # of words. This variable is usually initialized automatically by
281 # read_git_output() via choose_git_command(), but if a value is set
282 # here then it will be used unconditionally.
283 GIT_CMD = None
284
285
286 def choose_git_command():
287 """Decide how to invoke git, and record the choice in GIT_CMD."""
288
289 global GIT_CMD
290
291 if GIT_CMD is None:
292 try:
293 # Check to see whether the "-c" option is accepted (it was
294 # only added in Git 1.7.2). We don't actually use the
295 # output of "git --version", though if we needed more
296 # specific version information this would be the place to
297 # do it.
298 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
299 read_output(cmd)
300 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
301 except CommandError:
302 GIT_CMD = [GIT_EXECUTABLE]
303
304
305 def read_git_output(args, input=None, keepends=False, **kw):
306 """Read the output of a Git command."""
307
308 if GIT_CMD is None:
309 choose_git_command()
310
311 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
312
313
314 def read_output(cmd, input=None, keepends=False, **kw):
315 if input:
316 stdin = subprocess.PIPE
317 else:
318 stdin = None
319 p = subprocess.Popen(
320 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
321 )
322 (out, err) = p.communicate(input)
323 retcode = p.wait()
324 if retcode:
325 raise CommandError(cmd, retcode)
326 if not keepends:
327 out = out.rstrip('\n\r')
328 return out
329
330
331 def read_git_lines(args, keepends=False, **kw):
332 """Return the lines output by Git command.
333
334 Return as single lines, with newlines stripped off."""
335
336 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
337
338
339 def header_encode(text, header_name=None):
340 """Encode and line-wrap the value of an email header field."""
341
342 try:
343 if isinstance(text, str):
344 text = text.decode(ENCODING, 'replace')
345 return Header(text, header_name=header_name).encode()
346 except UnicodeEncodeError:
347 return Header(text, header_name=header_name, charset=CHARSET,
348 errors='replace').encode()
349
350
351 def addr_header_encode(text, header_name=None):
352 """Encode and line-wrap the value of an email header field containing
353 email addresses."""
354
355 # (cg) It seems that From: names (or perhaps Subject: headers) that contain
356 # a UTF8 character before the first space confuse the older python Header()
357 # class. This does not appear to be needed in newer python versions, but
358 # this little workaround seems to help on 2.7.1 on valstar mga1. mga#12374
359 try:
360 unicode(text)
361 except UnicodeDecodeError:
362 text = text.decode('utf-8')
363
364 return Header(
365 ', '.join(
366 formataddr((header_encode(name), emailaddr))
367 for name, emailaddr in getaddresses([text])
368 ),
369 header_name=header_name
370 ).encode()
371
372
373 class Config(object):
374 def __init__(self, section, git_config=None):
375 """Represent a section of the git configuration.
376
377 If git_config is specified, it is passed to "git config" in
378 the GIT_CONFIG environment variable, meaning that "git config"
379 will read the specified path rather than the Git default
380 config paths."""
381
382 self.section = section
383 if git_config:
384 self.env = os.environ.copy()
385 self.env['GIT_CONFIG'] = git_config
386 else:
387 self.env = None
388
389 @staticmethod
390 def _split(s):
391 """Split NUL-terminated values."""
392
393 words = s.split('\0')
394 assert words[-1] == ''
395 return words[:-1]
396
397 def get(self, name, default=None):
398 try:
399 values = self._split(read_git_output(
400 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
401 env=self.env, keepends=True,
402 ))
403 assert len(values) == 1
404 return values[0]
405 except CommandError:
406 return default
407
408 def get_bool(self, name, default=None):
409 try:
410 value = read_git_output(
411 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
412 env=self.env,
413 )
414 except CommandError:
415 return default
416 return value == 'true'
417
418 def get_all(self, name, default=None):
419 """Read a (possibly multivalued) setting from the configuration.
420
421 Return the result as a list of values, or default if the name
422 is unset."""
423
424 try:
425 return self._split(read_git_output(
426 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
427 env=self.env, keepends=True,
428 ))
429 except CommandError, e:
430 if e.retcode == 1:
431 # "the section or key is invalid"; i.e., there is no
432 # value for the specified key.
433 return default
434 else:
435 raise
436
437 def get_recipients(self, name, default=None):
438 """Read a recipients list from the configuration.
439
440 Return the result as a comma-separated list of email
441 addresses, or default if the option is unset. If the setting
442 has multiple values, concatenate them with comma separators."""
443
444 lines = self.get_all(name, default=None)
445 if lines is None:
446 return default
447 return ', '.join(line.strip() for line in lines)
448
449 def set(self, name, value):
450 read_git_output(
451 ['config', '%s.%s' % (self.section, name), value],
452 env=self.env,
453 )
454
455 def add(self, name, value):
456 read_git_output(
457 ['config', '--add', '%s.%s' % (self.section, name), value],
458 env=self.env,
459 )
460
461 def has_key(self, name):
462 return self.get_all(name, default=None) is not None
463
464 def unset_all(self, name):
465 try:
466 read_git_output(
467 ['config', '--unset-all', '%s.%s' % (self.section, name)],
468 env=self.env,
469 )
470 except CommandError, e:
471 if e.retcode == 5:
472 # The name doesn't exist, which is what we wanted anyway...
473 pass
474 else:
475 raise
476
477 def set_recipients(self, name, value):
478 self.unset_all(name)
479 for pair in getaddresses([value]):
480 self.add(name, formataddr(pair))
481
482
483 def generate_summaries(*log_args):
484 """Generate a brief summary for each revision requested.
485
486 log_args are strings that will be passed directly to "git log" as
487 revision selectors. Iterate over (sha1_short, subject) for each
488 commit specified by log_args (subject is the first line of the
489 commit message as a string without EOLs)."""
490
491 cmd = [
492 'log', '--abbrev', '--format=%h %s',
493 ] + list(log_args) + ['--']
494 for line in read_git_lines(cmd):
495 yield tuple(line.split(' ', 1))
496
497
498 def limit_lines(lines, max_lines):
499 for (index, line) in enumerate(lines):
500 if index < max_lines:
501 yield line
502
503 if index >= max_lines:
504 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
505
506
507 def limit_linelength(lines, max_linelength):
508 for line in lines:
509 # Don't forget that lines always include a trailing newline.
510 if len(line) > max_linelength + 1:
511 line = line[:max_linelength - 7] + ' [...]\n'
512 yield line
513
514
515 class CommitSet(object):
516 """A (constant) set of object names.
517
518 The set should be initialized with full SHA1 object names. The
519 __contains__() method returns True iff its argument is an
520 abbreviation of any the names in the set."""
521
522 def __init__(self, names):
523 self._names = sorted(names)
524
525 def __len__(self):
526 return len(self._names)
527
528 def __contains__(self, sha1_abbrev):
529 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
530
531 i = bisect.bisect_left(self._names, sha1_abbrev)
532 return i < len(self) and self._names[i].startswith(sha1_abbrev)
533
534
535 class GitObject(object):
536 def __init__(self, sha1, type=None):
537 if sha1 == ZEROS:
538 self.sha1 = self.type = self.commit_sha1 = None
539 else:
540 self.sha1 = sha1
541 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
542
543 if self.type == 'commit':
544 self.commit_sha1 = self.sha1
545 elif self.type == 'tag':
546 try:
547 self.commit_sha1 = read_git_output(
548 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
549 )
550 except CommandError:
551 # Cannot deref tag to determine commit_sha1
552 self.commit_sha1 = None
553 else:
554 self.commit_sha1 = None
555
556 self.short = read_git_output(['rev-parse', '--short', sha1])
557
558 def get_summary(self):
559 """Return (sha1_short, subject) for this commit."""
560
561 if not self.sha1:
562 raise ValueError('Empty commit has no summary')
563
564 return iter(generate_summaries('--no-walk', self.sha1)).next()
565
566 def __eq__(self, other):
567 return isinstance(other, GitObject) and self.sha1 == other.sha1
568
569 def __hash__(self):
570 return hash(self.sha1)
571
572 def __nonzero__(self):
573 return bool(self.sha1)
574
575 def __str__(self):
576 return self.sha1 or ZEROS
577
578
579 class Change(object):
580 """A Change that has been made to the Git repository.
581
582 Abstract class from which both Revisions and ReferenceChanges are
583 derived. A Change knows how to generate a notification email
584 describing itself."""
585
586 def __init__(self, environment):
587 self.environment = environment
588 self._values = None
589
590 def _compute_values(self):
591 """Return a dictionary {keyword : expansion} for this Change.
592
593 Derived classes overload this method to add more entries to
594 the return value. This method is used internally by
595 get_values(). The return value should always be a new
596 dictionary."""
597
598 return self.environment.get_values()
599
600 def get_values(self, **extra_values):
601 """Return a dictionary {keyword : expansion} for this Change.
602
603 Return a dictionary mapping keywords to the values that they
604 should be expanded to for this Change (used when interpolating
605 template strings). If any keyword arguments are supplied, add
606 those to the return value as well. The return value is always
607 a new dictionary."""
608
609 if self._values is None:
610 self._values = self._compute_values()
611
612 values = self._values.copy()
613 if extra_values:
614 values.update(extra_values)
615 return values
616
617 def expand(self, template, **extra_values):
618 """Expand template.
619
620 Expand the template (which should be a string) using string
621 interpolation of the values for this Change. If any keyword
622 arguments are provided, also include those in the keywords
623 available for interpolation."""
624
625 return template % self.get_values(**extra_values)
626
627 def expand_lines(self, template, **extra_values):
628 """Break template into lines and expand each line."""
629
630 values = self.get_values(**extra_values)
631 for line in template.splitlines(True):
632 yield line % values
633
634 def expand_header_lines(self, template, **extra_values):
635 """Break template into lines and expand each line as an RFC 2822 header.
636
637 Encode values and split up lines that are too long. Silently
638 skip lines that contain references to unknown variables."""
639
640 values = self.get_values(**extra_values)
641 for line in template.splitlines():
642 (name, value) = line.split(':', 1)
643
644 try:
645 value = value % values
646 except KeyError, e:
647 if DEBUG:
648 sys.stderr.write(
649 'Warning: unknown variable %r in the following line; line skipped:\n'
650 ' %s\n'
651 % (e.args[0], line,)
652 )
653 else:
654 if name.lower() in ADDR_HEADERS:
655 value = addr_header_encode(value, name)
656 else:
657 value = header_encode(value, name)
658 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
659 yield splitline
660
661 def generate_email_header(self):
662 """Generate the RFC 2822 email headers for this Change, a line at a time.
663
664 The output should not include the trailing blank line."""
665
666 raise NotImplementedError()
667
668 def generate_email_intro(self):
669 """Generate the email intro for this Change, a line at a time.
670
671 The output will be used as the standard boilerplate at the top
672 of the email body."""
673
674 raise NotImplementedError()
675
676 def generate_email_body(self):
677 """Generate the main part of the email body, a line at a time.
678
679 The text in the body might be truncated after a specified
680 number of lines (see multimailhook.emailmaxlines)."""
681
682 raise NotImplementedError()
683
684 def generate_email_footer(self):
685 """Generate the footer of the email, a line at a time.
686
687 The footer is always included, irrespective of
688 multimailhook.emailmaxlines."""
689
690 raise NotImplementedError()
691
692 def generate_email(self, push, body_filter=None, extra_header_values={}):
693 """Generate an email describing this change.
694
695 Iterate over the lines (including the header lines) of an
696 email describing this change. If body_filter is not None,
697 then use it to filter the lines that are intended for the
698 email body.
699
700 The extra_header_values field is received as a dict and not as
701 **kwargs, to allow passing other keyword arguments in the
702 future (e.g. passing extra values to generate_email_intro()"""
703
704 for line in self.generate_email_header(**extra_header_values):
705 yield line
706 yield '\n'
707 for line in self.generate_email_intro():
708 yield line
709
710 body = self.generate_email_body(push)
711 if body_filter is not None:
712 body = body_filter(body)
713 for line in body:
714 yield line
715
716 for line in self.generate_email_footer():
717 yield line
718
719
720 class Revision(Change):
721 """A Change consisting of a single git commit."""
722
723 def __init__(self, reference_change, rev, num, tot):
724 Change.__init__(self, reference_change.environment)
725 self.reference_change = reference_change
726 self.rev = rev
727 self.change_type = self.reference_change.change_type
728 self.refname = self.reference_change.refname
729 self.num = num
730 self.tot = tot
731 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
732 self.recipients = self.environment.get_revision_recipients(self)
733
734 def _compute_values(self):
735 values = Change._compute_values(self)
736
737 oneline = read_git_output(
738 ['log', '--format=%s', '--no-walk', self.rev.sha1]
739 )
740
741 values['rev'] = self.rev.sha1
742 values['rev_short'] = self.rev.short
743 values['change_type'] = self.change_type
744 values['refname'] = self.refname
745 values['short_refname'] = self.reference_change.short_refname
746 values['refname_type'] = self.reference_change.refname_type
747 values['reply_to_msgid'] = self.reference_change.msgid
748 values['num'] = self.num
749 values['tot'] = self.tot
750 values['recipients'] = self.recipients
751 values['oneline'] = oneline
752 values['author'] = self.author
753
754 reply_to = self.environment.get_reply_to_commit(self)
755 if reply_to:
756 values['reply_to'] = reply_to
757
758 return values
759
760 def generate_email_header(self, **extra_values):
761 for line in self.expand_header_lines(
762 REVISION_HEADER_TEMPLATE, **extra_values
763 ):
764 yield line
765
766 def generate_email_intro(self):
767 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
768 yield line
769
770 def generate_email_body(self, push):
771 """Show this revision."""
772
773 return read_git_lines(
774 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
775 keepends=True,
776 )
777
778 def generate_email_footer(self):
779 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
780
781
782 class ReferenceChange(Change):
783 """A Change to a Git reference.
784
785 An abstract class representing a create, update, or delete of a
786 Git reference. Derived classes handle specific types of reference
787 (e.g., tags vs. branches). These classes generate the main
788 reference change email summarizing the reference change and
789 whether it caused any any commits to be added or removed.
790
791 ReferenceChange objects are usually created using the static
792 create() method, which has the logic to decide which derived class
793 to instantiate."""
794
795 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
796
797 @staticmethod
798 def create(environment, oldrev, newrev, refname):
799 """Return a ReferenceChange object representing the change.
800
801 Return an object that represents the type of change that is being
802 made. oldrev and newrev should be SHA1s or ZEROS."""
803
804 old = GitObject(oldrev)
805 new = GitObject(newrev)
806 rev = new or old
807
808 # The revision type tells us what type the commit is, combined with
809 # the location of the ref we can decide between
810 # - working branch
811 # - tracking branch
812 # - unannotated tag
813 # - annotated tag
814 m = ReferenceChange.REF_RE.match(refname)
815 if m:
816 area = m.group('area')
817 short_refname = m.group('shortname')
818 else:
819 area = ''
820 short_refname = refname
821
822 if rev.type == 'tag':
823 # Annotated tag:
824 klass = AnnotatedTagChange
825 elif rev.type == 'commit':
826 if area == 'tags':
827 # Non-annotated tag:
828 klass = NonAnnotatedTagChange
829 elif area == 'heads':
830 # Branch:
831 klass = BranchChange
832 elif area == 'remotes':
833 # Tracking branch:
834 sys.stderr.write(
835 '*** Push-update of tracking branch %r\n'
836 '*** - incomplete email generated.\n'
837 % (refname,)
838 )
839 klass = OtherReferenceChange
840 else:
841 # Some other reference namespace:
842 sys.stderr.write(
843 '*** Push-update of strange reference %r\n'
844 '*** - incomplete email generated.\n'
845 % (refname,)
846 )
847 klass = OtherReferenceChange
848 else:
849 # Anything else (is there anything else?)
850 sys.stderr.write(
851 '*** Unknown type of update to %r (%s)\n'
852 '*** - incomplete email generated.\n'
853 % (refname, rev.type,)
854 )
855 klass = OtherReferenceChange
856
857 return klass(
858 environment,
859 refname=refname, short_refname=short_refname,
860 old=old, new=new, rev=rev,
861 )
862
863 def __init__(self, environment, refname, short_refname, old, new, rev):
864 Change.__init__(self, environment)
865 self.change_type = {
866 (False, True) : 'create',
867 (True, True) : 'update',
868 (True, False) : 'delete',
869 }[bool(old), bool(new)]
870 self.refname = refname
871 self.short_refname = short_refname
872 self.old = old
873 self.new = new
874 self.rev = rev
875 self.msgid = make_msgid()
876 self.diffopts = environment.diffopts
877 self.logopts = environment.logopts
878 self.commitlogopts = environment.commitlogopts
879 self.showlog = environment.refchange_showlog
880
881 def _compute_values(self):
882 values = Change._compute_values(self)
883
884 values['change_type'] = self.change_type
885 values['refname_type'] = self.refname_type
886 values['refname'] = self.refname
887 values['short_refname'] = self.short_refname
888 values['msgid'] = self.msgid
889 values['recipients'] = self.recipients
890 values['oldrev'] = str(self.old)
891 values['oldrev_short'] = self.old.short
892 values['newrev'] = str(self.new)
893 values['newrev_short'] = self.new.short
894
895 if self.old:
896 values['oldrev_type'] = self.old.type
897 if self.new:
898 values['newrev_type'] = self.new.type
899
900 reply_to = self.environment.get_reply_to_refchange(self)
901 if reply_to:
902 values['reply_to'] = reply_to
903
904 return values
905
906 def get_subject(self):
907 template = {
908 'create' : REF_CREATED_SUBJECT_TEMPLATE,
909 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
910 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
911 }[self.change_type]
912 return self.expand(template)
913
914 def generate_email_header(self, **extra_values):
915 if 'subject' not in extra_values:
916 extra_values['subject'] = self.get_subject()
917
918 for line in self.expand_header_lines(
919 REFCHANGE_HEADER_TEMPLATE, **extra_values
920 ):
921 yield line
922
923 def generate_email_intro(self):
924 for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
925 yield line
926
927 def generate_email_body(self, push):
928 """Call the appropriate body-generation routine.
929
930 Call one of generate_create_summary() /
931 generate_update_summary() / generate_delete_summary()."""
932
933 change_summary = {
934 'create' : self.generate_create_summary,
935 'delete' : self.generate_delete_summary,
936 'update' : self.generate_update_summary,
937 }[self.change_type](push)
938 for line in change_summary:
939 yield line
940
941 for line in self.generate_revision_change_summary(push):
942 yield line
943
944 def generate_email_footer(self):
945 return self.expand_lines(FOOTER_TEMPLATE)
946
947 def generate_revision_change_log(self, new_commits_list):
948 if self.showlog:
949 yield '\n'
950 yield 'Detailed log of new commits:\n\n'
951 for line in read_git_lines(
952 ['log', '--no-walk']
953 + self.logopts
954 + new_commits_list
955 + ['--'],
956 keepends=True,
957 ):
958 yield line
959
960 def generate_revision_change_summary(self, push):
961 """Generate a summary of the revisions added/removed by this change."""
962
963 if self.new.commit_sha1 and not self.old.commit_sha1:
964 # A new reference was created. List the new revisions
965 # brought by the new reference (i.e., those revisions that
966 # were not in the repository before this reference
967 # change).
968 sha1s = list(push.get_new_commits(self))
969 sha1s.reverse()
970 tot = len(sha1s)
971 new_revisions = [
972 Revision(self, GitObject(sha1), num=i+1, tot=tot)
973 for (i, sha1) in enumerate(sha1s)
974 ]
975
976 if new_revisions:
977 yield self.expand('This %(refname_type)s includes the following new commits:\n')
978 yield '\n'
979 for r in new_revisions:
980 (sha1, subject) = r.rev.get_summary()
981 yield r.expand(
982 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
983 )
984 yield '\n'
985 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
986 yield line
987 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
988 yield line
989 else:
990 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
991 yield line
992
993 elif self.new.commit_sha1 and self.old.commit_sha1:
994 # A reference was changed to point at a different commit.
995 # List the revisions that were removed and/or added *from
996 # that reference* by this reference change, along with a
997 # diff between the trees for its old and new values.
998
999 # List of the revisions that were added to the branch by
1000 # this update. Note this list can include revisions that
1001 # have already had notification emails; we want such
1002 # revisions in the summary even though we will not send
1003 # new notification emails for them.
1004 adds = list(generate_summaries(
1005 '--topo-order', '--reverse', '%s..%s'
1006 % (self.old.commit_sha1, self.new.commit_sha1,)
1007 ))
1008
1009 # List of the revisions that were removed from the branch
1010 # by this update. This will be empty except for
1011 # non-fast-forward updates.
1012 discards = list(generate_summaries(
1013 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1014 ))
1015
1016 if adds:
1017 new_commits_list = push.get_new_commits(self)
1018 else:
1019 new_commits_list = []
1020 new_commits = CommitSet(new_commits_list)
1021
1022 if discards:
1023 discarded_commits = CommitSet(push.get_discarded_commits(self))
1024 else:
1025 discarded_commits = CommitSet([])
1026
1027 if discards and adds:
1028 for (sha1, subject) in discards:
1029 if sha1 in discarded_commits:
1030 action = 'discards'
1031 else:
1032 action = 'omits'
1033 yield self.expand(
1034 BRIEF_SUMMARY_TEMPLATE, action=action,
1035 rev_short=sha1, text=subject,
1036 )
1037 for (sha1, subject) in adds:
1038 if sha1 in new_commits:
1039 action = 'new'
1040 else:
1041 action = 'adds'
1042 yield self.expand(
1043 BRIEF_SUMMARY_TEMPLATE, action=action,
1044 rev_short=sha1, text=subject,
1045 )
1046 yield '\n'
1047 for line in self.expand_lines(NON_FF_TEMPLATE):
1048 yield line
1049
1050 elif discards:
1051 for (sha1, subject) in discards:
1052 if sha1 in discarded_commits:
1053 action = 'discards'
1054 else:
1055 action = 'omits'
1056 yield self.expand(
1057 BRIEF_SUMMARY_TEMPLATE, action=action,
1058 rev_short=sha1, text=subject,
1059 )
1060 yield '\n'
1061 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1062 yield line
1063
1064 elif adds:
1065 (sha1, subject) = self.old.get_summary()
1066 yield self.expand(
1067 BRIEF_SUMMARY_TEMPLATE, action='from',
1068 rev_short=sha1, text=subject,
1069 )
1070 for (sha1, subject) in adds:
1071 if sha1 in new_commits:
1072 action = 'new'
1073 else:
1074 action = 'adds'
1075 yield self.expand(
1076 BRIEF_SUMMARY_TEMPLATE, action=action,
1077 rev_short=sha1, text=subject,
1078 )
1079
1080 yield '\n'
1081
1082 if new_commits:
1083 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1084 yield line
1085 for line in self.generate_revision_change_log(new_commits_list):
1086 yield line
1087 else:
1088 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1089 yield line
1090
1091 # The diffstat is shown from the old revision to the new
1092 # revision. This is to show the truth of what happened in
1093 # this change. There's no point showing the stat from the
1094 # base to the new revision because the base is effectively a
1095 # random revision at this point - the user will be interested
1096 # in what this revision changed - including the undoing of
1097 # previous revisions in the case of non-fast-forward updates.
1098 yield '\n'
1099 yield 'Summary of changes:\n'
1100 for line in read_git_lines(
1101 ['diff-tree']
1102 + self.diffopts
1103 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1104 keepends=True,
1105 ):
1106 yield line
1107
1108 elif self.old.commit_sha1 and not self.new.commit_sha1:
1109 # A reference was deleted. List the revisions that were
1110 # removed from the repository by this reference change.
1111
1112 sha1s = list(push.get_discarded_commits(self))
1113 tot = len(sha1s)
1114 discarded_revisions = [
1115 Revision(self, GitObject(sha1), num=i+1, tot=tot)
1116 for (i, sha1) in enumerate(sha1s)
1117 ]
1118
1119 if discarded_revisions:
1120 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1121 yield line
1122 yield '\n'
1123 for r in discarded_revisions:
1124 (sha1, subject) = r.rev.get_summary()
1125 yield r.expand(
1126 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1127 )
1128 else:
1129 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1130 yield line
1131
1132 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1133 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1134 yield line
1135
1136 def generate_create_summary(self, push):
1137 """Called for the creation of a reference."""
1138
1139 # This is a new reference and so oldrev is not valid
1140 (sha1, subject) = self.new.get_summary()
1141 yield self.expand(
1142 BRIEF_SUMMARY_TEMPLATE, action='at',
1143 rev_short=sha1, text=subject,
1144 )
1145 yield '\n'
1146
1147 def generate_update_summary(self, push):
1148 """Called for the change of a pre-existing branch."""
1149
1150 return iter([])
1151
1152 def generate_delete_summary(self, push):
1153 """Called for the deletion of any type of reference."""
1154
1155 (sha1, subject) = self.old.get_summary()
1156 yield self.expand(
1157 BRIEF_SUMMARY_TEMPLATE, action='was',
1158 rev_short=sha1, text=subject,
1159 )
1160 yield '\n'
1161
1162
1163 class BranchChange(ReferenceChange):
1164 refname_type = 'branch'
1165
1166 def __init__(self, environment, refname, short_refname, old, new, rev):
1167 ReferenceChange.__init__(
1168 self, environment,
1169 refname=refname, short_refname=short_refname,
1170 old=old, new=new, rev=rev,
1171 )
1172 self.recipients = environment.get_refchange_recipients(self)
1173
1174
1175 class AnnotatedTagChange(ReferenceChange):
1176 refname_type = 'annotated tag'
1177
1178 def __init__(self, environment, refname, short_refname, old, new, rev):
1179 ReferenceChange.__init__(
1180 self, environment,
1181 refname=refname, short_refname=short_refname,
1182 old=old, new=new, rev=rev,
1183 )
1184 self.recipients = environment.get_announce_recipients(self)
1185 self.show_shortlog = environment.announce_show_shortlog
1186
1187 ANNOTATED_TAG_FORMAT = (
1188 '%(*objectname)\n'
1189 '%(*objecttype)\n'
1190 '%(taggername)\n'
1191 '%(taggerdate)'
1192 )
1193
1194 def describe_tag(self, push):
1195 """Describe the new value of an annotated tag."""
1196
1197 # Use git for-each-ref to pull out the individual fields from
1198 # the tag
1199 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1200 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1201 )
1202
1203 yield self.expand(
1204 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1205 rev_short=tagobject, text='(%s)' % (tagtype,),
1206 )
1207 if tagtype == 'commit':
1208 # If the tagged object is a commit, then we assume this is a
1209 # release, and so we calculate which tag this tag is
1210 # replacing
1211 try:
1212 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1213 except CommandError:
1214 prevtag = None
1215 if prevtag:
1216 yield ' replaces %s\n' % (prevtag,)
1217 else:
1218 prevtag = None
1219 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1220
1221 yield ' tagged by %s\n' % (tagger,)
1222 yield ' on %s\n' % (tagged,)
1223 yield '\n'
1224
1225 # Show the content of the tag message; this might contain a
1226 # change log or release notes so is worth displaying.
1227 yield LOGBEGIN
1228 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1229 contents = contents[contents.index('\n') + 1:]
1230 if contents and contents[-1][-1:] != '\n':
1231 contents.append('\n')
1232 for line in contents:
1233 yield line
1234
1235 if self.show_shortlog and tagtype == 'commit':
1236 # Only commit tags make sense to have rev-list operations
1237 # performed on them
1238 yield '\n'
1239 if prevtag:
1240 # Show changes since the previous release
1241 revlist = read_git_output(
1242 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1243 keepends=True,
1244 )
1245 else:
1246 # No previous tag, show all the changes since time
1247 # began
1248 revlist = read_git_output(
1249 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1250 keepends=True,
1251 )
1252 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1253 yield line
1254
1255 yield LOGEND
1256 yield '\n'
1257
1258 def generate_create_summary(self, push):
1259 """Called for the creation of an annotated tag."""
1260
1261 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1262 yield line
1263
1264 for line in self.describe_tag(push):
1265 yield line
1266
1267 def generate_update_summary(self, push):
1268 """Called for the update of an annotated tag.
1269
1270 This is probably a rare event and may not even be allowed."""
1271
1272 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1273 yield line
1274
1275 for line in self.describe_tag(push):
1276 yield line
1277
1278 def generate_delete_summary(self, push):
1279 """Called when a non-annotated reference is updated."""
1280
1281 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1282 yield line
1283
1284 yield self.expand(' tag was %(oldrev_short)s\n')
1285 yield '\n'
1286
1287
1288 class NonAnnotatedTagChange(ReferenceChange):
1289 refname_type = 'tag'
1290
1291 def __init__(self, environment, refname, short_refname, old, new, rev):
1292 ReferenceChange.__init__(
1293 self, environment,
1294 refname=refname, short_refname=short_refname,
1295 old=old, new=new, rev=rev,
1296 )
1297 self.recipients = environment.get_refchange_recipients(self)
1298
1299 def generate_create_summary(self, push):
1300 """Called for the creation of an annotated tag."""
1301
1302 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1303 yield line
1304
1305 def generate_update_summary(self, push):
1306 """Called when a non-annotated reference is updated."""
1307
1308 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1309 yield line
1310
1311 def generate_delete_summary(self, push):
1312 """Called when a non-annotated reference is updated."""
1313
1314 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1315 yield line
1316
1317 for line in ReferenceChange.generate_delete_summary(self, push):
1318 yield line
1319
1320
1321 class OtherReferenceChange(ReferenceChange):
1322 refname_type = 'reference'
1323
1324 def __init__(self, environment, refname, short_refname, old, new, rev):
1325 # We use the full refname as short_refname, because otherwise
1326 # the full name of the reference would not be obvious from the
1327 # text of the email.
1328 ReferenceChange.__init__(
1329 self, environment,
1330 refname=refname, short_refname=refname,
1331 old=old, new=new, rev=rev,
1332 )
1333 self.recipients = environment.get_refchange_recipients(self)
1334
1335
1336 class Mailer(object):
1337 """An object that can send emails."""
1338
1339 def send(self, lines, to_addrs):
1340 """Send an email consisting of lines.
1341
1342 lines must be an iterable over the lines constituting the
1343 header and body of the email. to_addrs is a list of recipient
1344 addresses (can be needed even if lines already contains a
1345 "To:" field). It can be either a string (comma-separated list
1346 of email addresses) or a Python list of individual email
1347 addresses.
1348
1349 """
1350
1351 raise NotImplementedError()
1352
1353
1354 class SendMailer(Mailer):
1355 """Send emails using 'sendmail -oi -t'."""
1356
1357 SENDMAIL_CANDIDATES = [
1358 '/usr/sbin/sendmail',
1359 '/usr/lib/sendmail',
1360 ]
1361
1362 @staticmethod
1363 def find_sendmail():
1364 for path in SendMailer.SENDMAIL_CANDIDATES:
1365 if os.access(path, os.X_OK):
1366 return path
1367 else:
1368 raise ConfigurationException(
1369 'No sendmail executable found. '
1370 'Try setting multimailhook.sendmailCommand.'
1371 )
1372
1373 def __init__(self, command=None, envelopesender=None):
1374 """Construct a SendMailer instance.
1375
1376 command should be the command and arguments used to invoke
1377 sendmail, as a list of strings. If an envelopesender is
1378 provided, it will also be passed to the command, via '-f
1379 envelopesender'."""
1380
1381 if command:
1382 self.command = command[:]
1383 else:
1384 self.command = [self.find_sendmail(), '-oi', '-t']
1385
1386 if envelopesender:
1387 self.command.extend(['-f', envelopesender])
1388
1389 def send(self, lines, to_addrs):
1390 try:
1391 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1392 except OSError, e:
1393 sys.stderr.write(
1394 '*** Cannot execute command: %s\n' % ' '.join(self.command)
1395 + '*** %s\n' % str(e)
1396 + '*** Try setting multimailhook.mailer to "smtp"\n'
1397 '*** to send emails without using the sendmail command.\n'
1398 )
1399 sys.exit(1)
1400 try:
1401 p.stdin.writelines(lines)
1402 except:
1403 sys.stderr.write(
1404 '*** Error while generating commit email\n'
1405 '*** - mail sending aborted.\n'
1406 )
1407 p.terminate()
1408 raise
1409 else:
1410 p.stdin.close()
1411 retcode = p.wait()
1412 if retcode:
1413 raise CommandError(self.command, retcode)
1414
1415
1416 class SMTPMailer(Mailer):
1417 """Send emails using Python's smtplib."""
1418
1419 def __init__(self, envelopesender, smtpserver):
1420 if not envelopesender:
1421 sys.stderr.write(
1422 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1423 'please set either multimailhook.envelopeSender or user.email\n'
1424 )
1425 sys.exit(1)
1426 self.envelopesender = envelopesender
1427 self.smtpserver = smtpserver
1428 try:
1429 self.smtp = smtplib.SMTP(self.smtpserver)
1430 except Exception, e:
1431 sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1432 sys.stderr.write('*** %s\n' % str(e))
1433 sys.exit(1)
1434
1435 def __del__(self):
1436 self.smtp.quit()
1437
1438 def send(self, lines, to_addrs):
1439 try:
1440 msg = ''.join(lines)
1441 # turn comma-separated list into Python list if needed.
1442 if isinstance(to_addrs, basestring):
1443 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1444 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1445 except Exception, e:
1446 sys.stderr.write('*** Error sending email***\n')
1447 sys.stderr.write('*** %s\n' % str(e))
1448 self.smtp.quit()
1449 sys.exit(1)
1450
1451
1452 class OutputMailer(Mailer):
1453 """Write emails to an output stream, bracketed by lines of '=' characters.
1454
1455 This is intended for debugging purposes."""
1456
1457 SEPARATOR = '=' * 75 + '\n'
1458
1459 def __init__(self, f):
1460 self.f = f
1461
1462 def send(self, lines, to_addrs):
1463 self.f.write(self.SEPARATOR)
1464 self.f.writelines(lines)
1465 self.f.write(self.SEPARATOR)
1466
1467
1468 def get_git_dir():
1469 """Determine GIT_DIR.
1470
1471 Determine GIT_DIR either from the GIT_DIR environment variable or
1472 from the working directory, using Git's usual rules."""
1473
1474 try:
1475 return read_git_output(['rev-parse', '--git-dir'])
1476 except CommandError:
1477 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1478 sys.exit(1)
1479
1480
1481 class Environment(object):
1482 """Describes the environment in which the push is occurring.
1483
1484 An Environment object encapsulates information about the local
1485 environment. For example, it knows how to determine:
1486
1487 * the name of the repository to which the push occurred
1488
1489 * what user did the push
1490
1491 * what users want to be informed about various types of changes.
1492
1493 An Environment object is expected to have the following methods:
1494
1495 get_repo_shortname()
1496
1497 Return a short name for the repository, for display
1498 purposes.
1499
1500 get_repo_path()
1501
1502 Return the absolute path to the Git repository.
1503
1504 get_emailprefix()
1505
1506 Return a string that will be prefixed to every email's
1507 subject.
1508
1509 get_pusher()
1510
1511 Return the username of the person who pushed the changes.
1512 This value is used in the email body to indicate who
1513 pushed the change.
1514
1515 get_pusher_email() (may return None)
1516
1517 Return the email address of the person who pushed the
1518 changes. The value should be a single RFC 2822 email
1519 address as a string; e.g., "Joe User <user@example.com>"
1520 if available, otherwise "user@example.com". If set, the
1521 value is used as the Reply-To address for refchange
1522 emails. If it is impossible to determine the pusher's
1523 email, this attribute should be set to None (in which case
1524 no Reply-To header will be output).
1525
1526 get_sender()
1527
1528 Return the address to be used as the 'From' email address
1529 in the email envelope.
1530
1531 get_fromaddr()
1532
1533 Return the 'From' email address used in the email 'From:'
1534 headers. (May be a full RFC 2822 email address like 'Joe
1535 User <user@example.com>'.)
1536
1537 get_administrator()
1538
1539 Return the name and/or email of the repository
1540 administrator. This value is used in the footer as the
1541 person to whom requests to be removed from the
1542 notification list should be sent. Ideally, it should
1543 include a valid email address.
1544
1545 get_reply_to_refchange()
1546 get_reply_to_commit()
1547
1548 Return the address to use in the email "Reply-To" header,
1549 as a string. These can be an RFC 2822 email address, or
1550 None to omit the "Reply-To" header.
1551 get_reply_to_refchange() is used for refchange emails;
1552 get_reply_to_commit() is used for individual commit
1553 emails.
1554
1555 They should also define the following attributes:
1556
1557 announce_show_shortlog (bool)
1558
1559 True iff announce emails should include a shortlog.
1560
1561 refchange_showlog (bool)
1562
1563 True iff refchanges emails should include a detailed log.
1564
1565 diffopts (list of strings)
1566
1567 The options that should be passed to 'git diff' for the
1568 summary email. The value should be a list of strings
1569 representing words to be passed to the command.
1570
1571 logopts (list of strings)
1572
1573 Analogous to diffopts, but contains options passed to
1574 'git log' when generating the detailed log for a set of
1575 commits (see refchange_showlog)
1576
1577 commitlogopts (list of strings)
1578
1579 The options that should be passed to 'git log' for each
1580 commit mail. The value should be a list of strings
1581 representing words to be passed to the command.
1582
1583 """
1584
1585 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1586
1587 def __init__(self, osenv=None):
1588 self.osenv = osenv or os.environ
1589 self.announce_show_shortlog = False
1590 self.maxcommitemails = 500
1591 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1592 self.logopts = []
1593 self.refchange_showlog = False
1594 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1595
1596 self.COMPUTED_KEYS = [
1597 'administrator',
1598 'charset',
1599 'emailprefix',
1600 'fromaddr',
1601 'pusher',
1602 'pusher_email',
1603 'repo_path',
1604 'repo_shortname',
1605 'sender',
1606 ]
1607
1608 self._values = None
1609
1610 def get_repo_shortname(self):
1611 """Use the last part of the repo path, with ".git" stripped off if present."""
1612
1613 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1614 m = self.REPO_NAME_RE.match(basename)
1615 if m:
1616 return m.group('name')
1617 else:
1618 return basename
1619
1620 def get_pusher(self):
1621 raise NotImplementedError()
1622
1623 def get_pusher_email(self):
1624 return None
1625
1626 def get_administrator(self):
1627 return 'the administrator of this repository'
1628
1629 def get_emailprefix(self):
1630 return ''
1631
1632 def get_repo_path(self):
1633 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1634 path = get_git_dir()
1635 else:
1636 path = read_git_output(['rev-parse', '--show-toplevel'])
1637 return os.path.abspath(path)
1638
1639 def get_charset(self):
1640 return CHARSET
1641
1642 def get_values(self):
1643 """Return a dictionary {keyword : expansion} for this Environment.
1644
1645 This method is called by Change._compute_values(). The keys
1646 in the returned dictionary are available to be used in any of
1647 the templates. The dictionary is created by calling
1648 self.get_NAME() for each of the attributes named in
1649 COMPUTED_KEYS and recording those that do not return None.
1650 The return value is always a new dictionary."""
1651
1652 if self._values is None:
1653 values = {}
1654
1655 for key in self.COMPUTED_KEYS:
1656 value = getattr(self, 'get_%s' % (key,))()
1657 if value is not None:
1658 values[key] = value
1659
1660 self._values = values
1661
1662 return self._values.copy()
1663
1664 def get_refchange_recipients(self, refchange):
1665 """Return the recipients for notifications about refchange.
1666
1667 Return the list of email addresses to which notifications
1668 about the specified ReferenceChange should be sent."""
1669
1670 raise NotImplementedError()
1671
1672 def get_announce_recipients(self, annotated_tag_change):
1673 """Return the recipients for notifications about annotated_tag_change.
1674
1675 Return the list of email addresses to which notifications
1676 about the specified AnnotatedTagChange should be sent."""
1677
1678 raise NotImplementedError()
1679
1680 def get_reply_to_refchange(self, refchange):
1681 return self.get_pusher_email()
1682
1683 def get_revision_recipients(self, revision):
1684 """Return the recipients for messages about revision.
1685
1686 Return the list of email addresses to which notifications
1687 about the specified Revision should be sent. This method
1688 could be overridden, for example, to take into account the
1689 contents of the revision when deciding whom to notify about
1690 it. For example, there could be a scheme for users to express
1691 interest in particular files or subdirectories, and only
1692 receive notification emails for revisions that affecting those
1693 files."""
1694
1695 raise NotImplementedError()
1696
1697 def get_reply_to_commit(self, revision):
1698 return revision.author
1699
1700 def filter_body(self, lines):
1701 """Filter the lines intended for an email body.
1702
1703 lines is an iterable over the lines that would go into the
1704 email body. Filter it (e.g., limit the number of lines, the
1705 line length, character set, etc.), returning another iterable.
1706 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
1707 for classes implementing this functionality."""
1708
1709 return lines
1710
1711
1712 class ConfigEnvironmentMixin(Environment):
1713 """A mixin that sets self.config to its constructor's config argument.
1714
1715 This class's constructor consumes the "config" argument.
1716
1717 Mixins that need to inspect the config should inherit from this
1718 class (1) to make sure that "config" is still in the constructor
1719 arguments with its own constructor runs and/or (2) to be sure that
1720 self.config is set after construction."""
1721
1722 def __init__(self, config, **kw):
1723 super(ConfigEnvironmentMixin, self).__init__(**kw)
1724 self.config = config
1725
1726
1727 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
1728 """An Environment that reads most of its information from "git config"."""
1729
1730 def __init__(self, config, **kw):
1731 super(ConfigOptionsEnvironmentMixin, self).__init__(
1732 config=config, **kw
1733 )
1734
1735 self.announce_show_shortlog = config.get_bool(
1736 'announceshortlog', default=self.announce_show_shortlog
1737 )
1738
1739 self.refchange_showlog = config.get_bool(
1740 'refchangeshowlog', default=self.refchange_showlog
1741 )
1742
1743 maxcommitemails = config.get('maxcommitemails')
1744 if maxcommitemails is not None:
1745 try:
1746 self.maxcommitemails = int(maxcommitemails)
1747 except ValueError:
1748 sys.stderr.write(
1749 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
1750 + '*** Expected a number. Ignoring.\n'
1751 )
1752
1753 diffopts = config.get('diffopts')
1754 if diffopts is not None:
1755 self.diffopts = shlex.split(diffopts)
1756
1757 logopts = config.get('logopts')
1758 if logopts is not None:
1759 self.logopts = shlex.split(logopts)
1760
1761 commitlogopts = config.get('commitlogopts')
1762 if commitlogopts is not None:
1763 self.commitlogopts = shlex.split(commitlogopts)
1764
1765 reply_to = config.get('replyTo')
1766 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1767 if (
1768 self.__reply_to_refchange is not None
1769 and self.__reply_to_refchange.lower() == 'author'
1770 ):
1771 raise ConfigurationException(
1772 '"author" is not an allowed setting for replyToRefchange'
1773 )
1774 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
1775
1776 def get_administrator(self):
1777 return (
1778 self.config.get('administrator')
1779 or self.get_sender()
1780 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1781 )
1782
1783 def get_repo_shortname(self):
1784 return (
1785 self.config.get('reponame')
1786 or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
1787 )
1788
1789 def get_emailprefix(self):
1790 emailprefix = self.config.get('emailprefix')
1791 if emailprefix and emailprefix.strip():
1792 return emailprefix.strip() + ' '
1793 else:
1794 return '[%s] ' % (self.get_repo_shortname(),)
1795
1796 def get_sender(self):
1797 return self.config.get('envelopesender')
1798
1799 def get_fromaddr(self):
1800 fromaddr = self.config.get('from')
1801 if fromaddr:
1802 return fromaddr
1803 else:
1804 config = Config('user')
1805 fromname = config.get('name', default='')
1806 fromemail = config.get('email', default='')
1807 if fromemail:
1808 return formataddr([fromname, fromemail])
1809 else:
1810 return self.get_sender()
1811
1812 def get_reply_to_refchange(self, refchange):
1813 if self.__reply_to_refchange is None:
1814 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
1815 elif self.__reply_to_refchange.lower() == 'pusher':
1816 return self.get_pusher_email()
1817 elif self.__reply_to_refchange.lower() == 'none':
1818 return None
1819 else:
1820 return self.__reply_to_refchange
1821
1822 def get_reply_to_commit(self, revision):
1823 if self.__reply_to_commit is None:
1824 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
1825 elif self.__reply_to_commit.lower() == 'author':
1826 return revision.get_author()
1827 elif self.__reply_to_commit.lower() == 'pusher':
1828 return self.get_pusher_email()
1829 elif self.__reply_to_commit.lower() == 'none':
1830 return None
1831 else:
1832 return self.__reply_to_commit
1833
1834
1835 class FilterLinesEnvironmentMixin(Environment):
1836 """Handle encoding and maximum line length of body lines.
1837
1838 emailmaxlinelength (int or None)
1839
1840 The maximum length of any single line in the email body.
1841 Longer lines are truncated at that length with ' [...]'
1842 appended.
1843
1844 strict_utf8 (bool)
1845
1846 If this field is set to True, then the email body text is
1847 expected to be UTF-8. Any invalid characters are
1848 converted to U+FFFD, the Unicode replacement character
1849 (encoded as UTF-8, of course).
1850
1851 """
1852
1853 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
1854 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
1855 self.__strict_utf8 = strict_utf8
1856 self.__emailmaxlinelength = emailmaxlinelength
1857
1858 def filter_body(self, lines):
1859 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
1860 if self.__strict_utf8:
1861 lines = (line.decode(ENCODING, 'replace') for line in lines)
1862 # Limit the line length in Unicode-space to avoid
1863 # splitting characters:
1864 if self.__emailmaxlinelength:
1865 lines = limit_linelength(lines, self.__emailmaxlinelength)
1866 lines = (line.encode(ENCODING, 'replace') for line in lines)
1867 elif self.__emailmaxlinelength:
1868 lines = limit_linelength(lines, self.__emailmaxlinelength)
1869
1870 return lines
1871
1872
1873 class ConfigFilterLinesEnvironmentMixin(
1874 ConfigEnvironmentMixin,
1875 FilterLinesEnvironmentMixin,
1876 ):
1877 """Handle encoding and maximum line length based on config."""
1878
1879 def __init__(self, config, **kw):
1880 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
1881 if strict_utf8 is not None:
1882 kw['strict_utf8'] = strict_utf8
1883
1884 emailmaxlinelength = config.get('emailmaxlinelength')
1885 if emailmaxlinelength is not None:
1886 kw['emailmaxlinelength'] = int(emailmaxlinelength)
1887
1888 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
1889 config=config, **kw
1890 )
1891
1892
1893 class MaxlinesEnvironmentMixin(Environment):
1894 """Limit the email body to a specified number of lines."""
1895
1896 def __init__(self, emailmaxlines, **kw):
1897 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
1898 self.__emailmaxlines = emailmaxlines
1899
1900 def filter_body(self, lines):
1901 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
1902 if self.__emailmaxlines:
1903 lines = limit_lines(lines, self.__emailmaxlines)
1904 return lines
1905
1906
1907 class ConfigMaxlinesEnvironmentMixin(
1908 ConfigEnvironmentMixin,
1909 MaxlinesEnvironmentMixin,
1910 ):
1911 """Limit the email body to the number of lines specified in config."""
1912
1913 def __init__(self, config, **kw):
1914 emailmaxlines = int(config.get('emailmaxlines', default='0'))
1915 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
1916 config=config,
1917 emailmaxlines=emailmaxlines,
1918 **kw
1919 )
1920
1921
1922 class FQDNEnvironmentMixin(Environment):
1923 """A mixin that sets the host's FQDN to its constructor argument."""
1924
1925 def __init__(self, fqdn, **kw):
1926 super(FQDNEnvironmentMixin, self).__init__(**kw)
1927 self.COMPUTED_KEYS += ['fqdn']
1928 self.__fqdn = fqdn
1929
1930 def get_fqdn(self):
1931 """Return the fully-qualified domain name for this host.
1932
1933 Return None if it is unavailable or unwanted."""
1934
1935 return self.__fqdn
1936
1937
1938 class ConfigFQDNEnvironmentMixin(
1939 ConfigEnvironmentMixin,
1940 FQDNEnvironmentMixin,
1941 ):
1942 """Read the FQDN from the config."""
1943
1944 def __init__(self, config, **kw):
1945 fqdn = config.get('fqdn')
1946 super(ConfigFQDNEnvironmentMixin, self).__init__(
1947 config=config,
1948 fqdn=fqdn,
1949 **kw
1950 )
1951
1952
1953 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
1954 """Get the FQDN by calling socket.getfqdn()."""
1955
1956 def __init__(self, **kw):
1957 super(ComputeFQDNEnvironmentMixin, self).__init__(
1958 fqdn=socket.getfqdn(),
1959 **kw
1960 )
1961
1962
1963 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1964 """Deduce pusher_email from pusher by appending an emaildomain."""
1965
1966 def __init__(self, **kw):
1967 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
1968 self.__emaildomain = self.config.get('emaildomain')
1969
1970 def get_pusher_email(self):
1971 if self.__emaildomain:
1972 # Derive the pusher's full email address in the default way:
1973 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
1974 else:
1975 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1976
1977
1978 class StaticRecipientsEnvironmentMixin(Environment):
1979 """Set recipients statically based on constructor parameters."""
1980
1981 def __init__(
1982 self,
1983 refchange_recipients, announce_recipients, revision_recipients,
1984 **kw
1985 ):
1986 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
1987
1988 # The recipients for various types of notification emails, as
1989 # RFC 2822 email addresses separated by commas (or the empty
1990 # string if no recipients are configured). Although there is
1991 # a mechanism to choose the recipient lists based on on the
1992 # actual *contents* of the change being reported, we only
1993 # choose based on the *type* of the change. Therefore we can
1994 # compute them once and for all:
1995 if not (refchange_recipients
1996 or announce_recipients
1997 or revision_recipients):
1998 raise ConfigurationException('No email recipients configured!')
1999 self.__refchange_recipients = refchange_recipients
2000 self.__announce_recipients = announce_recipients
2001 self.__revision_recipients = revision_recipients
2002
2003 def get_refchange_recipients(self, refchange):
2004 return self.__refchange_recipients
2005
2006 def get_announce_recipients(self, annotated_tag_change):
2007 return self.__announce_recipients
2008
2009 def get_revision_recipients(self, revision):
2010 return self.__revision_recipients
2011
2012
2013 class ConfigRecipientsEnvironmentMixin(
2014 ConfigEnvironmentMixin,
2015 StaticRecipientsEnvironmentMixin
2016 ):
2017 """Determine recipients statically based on config."""
2018
2019 def __init__(self, config, **kw):
2020 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2021 config=config,
2022 refchange_recipients=self._get_recipients(
2023 config, 'refchangelist', 'mailinglist',
2024 ),
2025 announce_recipients=self._get_recipients(
2026 config, 'announcelist', 'refchangelist', 'mailinglist',
2027 ),
2028 revision_recipients=self._get_recipients(
2029 config, 'commitlist', 'mailinglist',
2030 ),
2031 **kw
2032 )
2033
2034 def _get_recipients(self, config, *names):
2035 """Return the recipients for a particular type of message.
2036
2037 Return the list of email addresses to which a particular type
2038 of notification email should be sent, by looking at the config
2039 value for "multimailhook.$name" for each of names. Use the
2040 value from the first name that is configured. The return
2041 value is a (possibly empty) string containing RFC 2822 email
2042 addresses separated by commas. If no configuration could be
2043 found, raise a ConfigurationException."""
2044
2045 for name in names:
2046 retval = config.get_recipients(name)
2047 if retval is not None:
2048 return retval
2049 else:
2050 return ''
2051
2052
2053 class ProjectdescEnvironmentMixin(Environment):
2054 """Make a "projectdesc" value available for templates.
2055
2056 By default, it is set to the first line of $GIT_DIR/description
2057 (if that file is present and appears to be set meaningfully)."""
2058
2059 def __init__(self, **kw):
2060 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2061 self.COMPUTED_KEYS += ['projectdesc']
2062
2063 def get_projectdesc(self):
2064 """Return a one-line descripition of the project."""
2065
2066 git_dir = get_git_dir()
2067 try:
2068 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2069 if projectdesc and not projectdesc.startswith('Unnamed repository'):
2070 return projectdesc
2071 except IOError:
2072 pass
2073
2074 return 'UNNAMED PROJECT'
2075
2076
2077 class GenericEnvironmentMixin(Environment):
2078 def get_pusher(self):
2079 return self.osenv.get('USER', 'unknown user')
2080
2081
2082 class GenericEnvironment(
2083 ProjectdescEnvironmentMixin,
2084 ConfigMaxlinesEnvironmentMixin,
2085 ComputeFQDNEnvironmentMixin,
2086 ConfigFilterLinesEnvironmentMixin,
2087 ConfigRecipientsEnvironmentMixin,
2088 PusherDomainEnvironmentMixin,
2089 ConfigOptionsEnvironmentMixin,
2090 GenericEnvironmentMixin,
2091 Environment,
2092 ):
2093 pass
2094
2095
2096 class GitoliteEnvironmentMixin(Environment):
2097 def get_repo_shortname(self):
2098 # The gitolite environment variable $GL_REPO is a pretty good
2099 # repo_shortname (though it's probably not as good as a value
2100 # the user might have explicitly put in his config).
2101 return (
2102 self.osenv.get('GL_REPO', None)
2103 or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2104 )
2105
2106 def get_pusher(self):
2107 return self.osenv.get('GL_USER', 'unknown user')
2108
2109
2110 class IncrementalDateTime(object):
2111 """Simple wrapper to give incremental date/times.
2112
2113 Each call will result in a date/time a second later than the
2114 previous call. This can be used to falsify email headers, to
2115 increase the likelihood that email clients sort the emails
2116 correctly."""
2117
2118 def __init__(self):
2119 self.time = time.time()
2120
2121 def next(self):
2122 formatted = formatdate(self.time, True)
2123 self.time += 1
2124 return formatted
2125
2126
2127 class GitoliteEnvironment(
2128 ProjectdescEnvironmentMixin,
2129 ConfigMaxlinesEnvironmentMixin,
2130 ComputeFQDNEnvironmentMixin,
2131 ConfigFilterLinesEnvironmentMixin,
2132 ConfigRecipientsEnvironmentMixin,
2133 PusherDomainEnvironmentMixin,
2134 ConfigOptionsEnvironmentMixin,
2135 GitoliteEnvironmentMixin,
2136 Environment,
2137 ):
2138 pass
2139
2140
2141 class Push(object):
2142 """Represent an entire push (i.e., a group of ReferenceChanges).
2143
2144 It is easy to figure out what commits were added to a *branch* by
2145 a Reference change:
2146
2147 git rev-list change.old..change.new
2148
2149 or removed from a *branch*:
2150
2151 git rev-list change.new..change.old
2152
2153 But it is not quite so trivial to determine which entirely new
2154 commits were added to the *repository* by a push and which old
2155 commits were discarded by a push. A big part of the job of this
2156 class is to figure out these things, and to make sure that new
2157 commits are only detailed once even if they were added to multiple
2158 references.
2159
2160 The first step is to determine the "other" references--those
2161 unaffected by the current push. They are computed by
2162 Push._compute_other_ref_sha1s() by listing all references then
2163 removing any affected by this push.
2164
2165 The commits contained in the repository before this push were
2166
2167 git rev-list other1 other2 other3 ... change1.old change2.old ...
2168
2169 Where "changeN.old" is the old value of one of the references
2170 affected by this push.
2171
2172 The commits contained in the repository after this push are
2173
2174 git rev-list other1 other2 other3 ... change1.new change2.new ...
2175
2176 The commits added by this push are the difference between these
2177 two sets, which can be written
2178
2179 git rev-list \
2180 ^other1 ^other2 ... \
2181 ^change1.old ^change2.old ... \
2182 change1.new change2.new ...
2183
2184 The commits removed by this push can be computed by
2185
2186 git rev-list \
2187 ^other1 ^other2 ... \
2188 ^change1.new ^change2.new ... \
2189 change1.old change2.old ...
2190
2191 The last point is that it is possible that other pushes are
2192 occurring simultaneously to this one, so reference values can
2193 change at any time. It is impossible to eliminate all race
2194 conditions, but we reduce the window of time during which problems
2195 can occur by translating reference names to SHA1s as soon as
2196 possible and working with SHA1s thereafter (because SHA1s are
2197 immutable)."""
2198
2199 # A map {(changeclass, changetype) : integer} specifying the order
2200 # that reference changes will be processed if multiple reference
2201 # changes are included in a single push. The order is significant
2202 # mostly because new commit notifications are threaded together
2203 # with the first reference change that includes the commit. The
2204 # following order thus causes commits to be grouped with branch
2205 # changes (as opposed to tag changes) if possible.
2206 SORT_ORDER = dict(
2207 (value, i) for (i, value) in enumerate([
2208 (BranchChange, 'update'),
2209 (BranchChange, 'create'),
2210 (AnnotatedTagChange, 'update'),
2211 (AnnotatedTagChange, 'create'),
2212 (NonAnnotatedTagChange, 'update'),
2213 (NonAnnotatedTagChange, 'create'),
2214 (BranchChange, 'delete'),
2215 (AnnotatedTagChange, 'delete'),
2216 (NonAnnotatedTagChange, 'delete'),
2217 (OtherReferenceChange, 'update'),
2218 (OtherReferenceChange, 'create'),
2219 (OtherReferenceChange, 'delete'),
2220 ])
2221 )
2222
2223 def __init__(self, changes):
2224 self.changes = sorted(changes, key=self._sort_key)
2225
2226 # The SHA-1s of commits referred to by references unaffected
2227 # by this push:
2228 other_ref_sha1s = self._compute_other_ref_sha1s()
2229
2230 self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2231 other_ref_sha1s.union(
2232 change.old.sha1
2233 for change in self.changes
2234 if change.old.type in ['commit', 'tag']
2235 )
2236 )
2237 self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2238 other_ref_sha1s.union(
2239 change.new.sha1
2240 for change in self.changes
2241 if change.new.type in ['commit', 'tag']
2242 )
2243 )
2244
2245 @classmethod
2246 def _sort_key(klass, change):
2247 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2248
2249 def _compute_other_ref_sha1s(self):
2250 """Return the GitObjects referred to by references unaffected by this push."""
2251
2252 # The refnames being changed by this push:
2253 updated_refs = set(
2254 change.refname
2255 for change in self.changes
2256 )
2257
2258 # The SHA-1s of commits referred to by all references in this
2259 # repository *except* updated_refs:
2260 sha1s = set()
2261 fmt = (
2262 '%(objectname) %(objecttype) %(refname)\n'
2263 '%(*objectname) %(*objecttype) %(refname)'
2264 )
2265 for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2266 (sha1, type, name) = line.split(' ', 2)
2267 if sha1 and type == 'commit' and name not in updated_refs:
2268 sha1s.add(sha1)
2269
2270 return sha1s
2271
2272 def _compute_rev_exclusion_spec(self, sha1s):
2273 """Return an exclusion specification for 'git rev-list'.
2274
2275 git_objects is an iterable over GitObject instances. Return a
2276 string that can be passed to the standard input of 'git
2277 rev-list --stdin' to exclude all of the commits referred to by
2278 git_objects."""
2279
2280 return ''.join(
2281 ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2282 )
2283
2284 def get_new_commits(self, reference_change=None):
2285 """Return a list of commits added by this push.
2286
2287 Return a list of the object names of commits that were added
2288 by the part of this push represented by reference_change. If
2289 reference_change is None, then return a list of *all* commits
2290 added by this push."""
2291
2292 if not reference_change:
2293 new_revs = sorted(
2294 change.new.sha1
2295 for change in self.changes
2296 if change.new
2297 )
2298 elif not reference_change.new.commit_sha1:
2299 return []
2300 else:
2301 new_revs = [reference_change.new.commit_sha1]
2302
2303 cmd = ['rev-list', '--stdin'] + new_revs
2304 return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2305
2306 def get_discarded_commits(self, reference_change):
2307 """Return a list of commits discarded by this push.
2308
2309 Return a list of the object names of commits that were
2310 entirely discarded from the repository by the part of this
2311 push represented by reference_change."""
2312
2313 if not reference_change.old.commit_sha1:
2314 return []
2315 else:
2316 old_revs = [reference_change.old.commit_sha1]
2317
2318 cmd = ['rev-list', '--stdin'] + old_revs
2319 return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2320
2321 def send_emails(self, mailer, body_filter=None):
2322 """Use send all of the notification emails needed for this push.
2323
2324 Use send all of the notification emails (including reference
2325 change emails and commit emails) needed for this push. Send
2326 the emails using mailer. If body_filter is not None, then use
2327 it to filter the lines that are intended for the email
2328 body."""
2329
2330 # The sha1s of commits that were introduced by this push.
2331 # They will be removed from this set as they are processed, to
2332 # guarantee that one (and only one) email is generated for
2333 # each new commit.
2334 unhandled_sha1s = set(self.get_new_commits())
2335 send_date = IncrementalDateTime()
2336 for change in self.changes:
2337 # Check if we've got anyone to send to
2338 if change.recipients:
2339 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2340 extra_values = {'send_date' : send_date.next()}
2341 mailer.send(
2342 change.generate_email(self, body_filter, extra_values),
2343 change.recipients,
2344 )
2345
2346 sha1s = []
2347 for sha1 in reversed(list(self.get_new_commits(change))):
2348 if sha1 in unhandled_sha1s:
2349 sha1s.append(sha1)
2350 unhandled_sha1s.remove(sha1)
2351
2352 max_emails = change.environment.maxcommitemails
2353 if max_emails and len(sha1s) > max_emails:
2354 sys.stderr.write(
2355 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2356 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2357 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2358 )
2359 return
2360
2361 for (num, sha1) in enumerate(sha1s):
2362 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2363 if rev.recipients:
2364 extra_values = {'send_date' : send_date.next()}
2365 mailer.send(
2366 rev.generate_email(self, body_filter, extra_values),
2367 rev.recipients,
2368 )
2369
2370 # Consistency check:
2371 if unhandled_sha1s:
2372 sys.stderr.write(
2373 'ERROR: No emails were sent for the following new commits:\n'
2374 ' %s\n'
2375 % ('\n '.join(sorted(unhandled_sha1s)),)
2376 )
2377
2378
2379 def run_as_post_receive_hook(environment, mailer):
2380 changes = []
2381 for line in sys.stdin:
2382 (oldrev, newrev, refname) = line.strip().split(' ', 2)
2383 changes.append(
2384 ReferenceChange.create(environment, oldrev, newrev, refname)
2385 )
2386 push = Push(changes)
2387 push.send_emails(mailer, body_filter=environment.filter_body)
2388
2389
2390 def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2391 changes = [
2392 ReferenceChange.create(
2393 environment,
2394 read_git_output(['rev-parse', '--verify', oldrev]),
2395 read_git_output(['rev-parse', '--verify', newrev]),
2396 refname,
2397 ),
2398 ]
2399 push = Push(changes)
2400 push.send_emails(mailer, body_filter=environment.filter_body)
2401
2402
2403 def choose_mailer(config, environment):
2404 mailer = config.get('mailer', default='sendmail')
2405
2406 if mailer == 'smtp':
2407 smtpserver = config.get('smtpserver', default='localhost')
2408 mailer = SMTPMailer(
2409 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2410 smtpserver=smtpserver,
2411 )
2412 elif mailer == 'sendmail':
2413 command = config.get('sendmailcommand')
2414 if command:
2415 command = shlex.split(command)
2416 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2417 else:
2418 sys.stderr.write(
2419 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2420 + 'please use one of "smtp" or "sendmail".\n'
2421 )
2422 sys.exit(1)
2423 return mailer
2424
2425
2426 KNOWN_ENVIRONMENTS = {
2427 'generic' : GenericEnvironmentMixin,
2428 'gitolite' : GitoliteEnvironmentMixin,
2429 }
2430
2431
2432 def choose_environment(config, osenv=None, env=None, recipients=None):
2433 if not osenv:
2434 osenv = os.environ
2435
2436 environment_mixins = [
2437 ProjectdescEnvironmentMixin,
2438 ConfigMaxlinesEnvironmentMixin,
2439 ComputeFQDNEnvironmentMixin,
2440 ConfigFilterLinesEnvironmentMixin,
2441 PusherDomainEnvironmentMixin,
2442 ConfigOptionsEnvironmentMixin,
2443 ]
2444 environment_kw = {
2445 'osenv' : osenv,
2446 'config' : config,
2447 }
2448
2449 if not env:
2450 env = config.get('environment')
2451
2452 if not env:
2453 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2454 env = 'gitolite'
2455 else:
2456 env = 'generic'
2457
2458 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2459
2460 if recipients:
2461 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2462 environment_kw['refchange_recipients'] = recipients
2463 environment_kw['announce_recipients'] = recipients
2464 environment_kw['revision_recipients'] = recipients
2465 else:
2466 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2467
2468 environment_klass = type(
2469 'EffectiveEnvironment',
2470 tuple(environment_mixins) + (Environment,),
2471 {},
2472 )
2473 return environment_klass(**environment_kw)
2474
2475
2476 def main(args):
2477 parser = optparse.OptionParser(
2478 description=__doc__,
2479 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2480 )
2481
2482 parser.add_option(
2483 '--environment', '--env', action='store', type='choice',
2484 choices=['generic', 'gitolite'], default=None,
2485 help=(
2486 'Choose type of environment is in use. Default is taken from '
2487 'multimailhook.environment if set; otherwise "generic".'
2488 ),
2489 )
2490 parser.add_option(
2491 '--stdout', action='store_true', default=False,
2492 help='Output emails to stdout rather than sending them.',
2493 )
2494 parser.add_option(
2495 '--recipients', action='store', default=None,
2496 help='Set list of email recipients for all types of emails.',
2497 )
2498 parser.add_option(
2499 '--show-env', action='store_true', default=False,
2500 help=(
2501 'Write to stderr the values determined for the environment '
2502 '(intended for debugging purposes).'
2503 ),
2504 )
2505
2506 (options, args) = parser.parse_args(args)
2507
2508 config = Config('multimailhook')
2509
2510 try:
2511 environment = choose_environment(
2512 config, osenv=os.environ,
2513 env=options.environment,
2514 recipients=options.recipients,
2515 )
2516
2517 if options.show_env:
2518 sys.stderr.write('Environment values:\n')
2519 for (k,v) in sorted(environment.get_values().items()):
2520 sys.stderr.write(' %s : %r\n' % (k,v))
2521 sys.stderr.write('\n')
2522
2523 if options.stdout:
2524 mailer = OutputMailer(sys.stdout)
2525 else:
2526 mailer = choose_mailer(config, environment)
2527
2528 # Dual mode: if arguments were specified on the command line, run
2529 # like an update hook; otherwise, run as a post-receive hook.
2530 if args:
2531 if len(args) != 3:
2532 parser.error('Need zero or three non-option arguments')
2533 (refname, oldrev, newrev) = args
2534 run_as_update_hook(environment, mailer, refname, oldrev, newrev)
2535 else:
2536 run_as_post_receive_hook(environment, mailer)
2537 except ConfigurationException, e:
2538 sys.exit(str(e))
2539
2540
2541 if __name__ == '__main__':
2542 main(sys.argv[1:])

Properties

Name Value
svn:eol-style native
svn:executable *
svn:mime-type text/x-python

  ViewVC Help
Powered by ViewVC 1.1.30