/[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 3447 - (show annotations) (download) (as text)
Mon Apr 21 19:44:34 2014 UTC (9 years, 11 months ago) by colin
File MIME type: text/x-python
File size: 84399 byte(s)
mgagit: Don't report any error message when no change email address configured.

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

Properties

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

  ViewVC Help
Powered by ViewVC 1.1.30