]>
Commit | Line | Data |
---|---|---|
4b1fd356 MM |
1 | #! /usr/bin/env python |
2 | ||
7c554311 | 3 | __version__ = '1.4.0' |
bc501f69 | 4 | |
7c554311 | 5 | # Copyright (c) 2015-2016 Matthieu Moy and others |
b513f71f | 6 | # Copyright (c) 2012-2014 Michael Haggerty and others |
bc501f69 MH |
7 | # Derived from contrib/hooks/post-receive-email, which is |
8 | # Copyright (c) 2007 Andy Parkins | |
9 | # and also includes contributions by other authors. | |
10 | # | |
11 | # This file is part of git-multimail. | |
12 | # | |
13 | # git-multimail is free software: you can redistribute it and/or | |
14 | # modify it under the terms of the GNU General Public License version | |
15 | # 2 as published by the Free Software Foundation. | |
16 | # | |
17 | # This program is distributed in the hope that it will be useful, but | |
18 | # WITHOUT ANY WARRANTY; without even the implied warranty of | |
19 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |
20 | # General Public License for more details. | |
21 | # | |
22 | # You should have received a copy of the GNU General Public License | |
23 | # along with this program. If not, see | |
24 | # <http://www.gnu.org/licenses/>. | |
25 | ||
26 | """Generate notification emails for pushes to a git repository. | |
27 | ||
28 | This hook sends emails describing changes introduced by pushes to a | |
29 | git repository. For each reference that was changed, it emits one | |
30 | ReferenceChange email summarizing how the reference was changed, | |
31 | followed by one Revision email for each new commit that was introduced | |
32 | by the reference change. | |
33 | ||
34 | Each commit is announced in exactly one Revision email. If the same | |
35 | commit is merged into another branch in the same or a later push, then | |
36 | the ReferenceChange email will list the commit's SHA1 and its one-line | |
37 | summary, but no new Revision email will be generated. | |
38 | ||
39 | This script is designed to be used as a "post-receive" hook in a git | |
40 | repository (see githooks(5)). It can also be used as an "update" | |
41 | script, but this usage is not completely reliable and is deprecated. | |
42 | ||
43 | To help with debugging, this script accepts a --stdout option, which | |
44 | causes the emails to be written to standard output rather than sent | |
45 | using sendmail. | |
46 | ||
47 | See the accompanying README file for the complete documentation. | |
48 | ||
49 | """ | |
50 | ||
51 | import sys | |
52 | import os | |
53 | import re | |
54 | import bisect | |
b513f71f | 55 | import socket |
bc501f69 MH |
56 | import subprocess |
57 | import shlex | |
58 | import optparse | |
7c554311 | 59 | import logging |
bc501f69 | 60 | import smtplib |
4453d76c MM |
61 | try: |
62 | import ssl | |
63 | except ImportError: | |
64 | # Python < 2.6 do not have ssl, but that's OK if we don't use it. | |
65 | pass | |
b513f71f | 66 | import time |
4b1fd356 MM |
67 | import cgi |
68 | ||
69 | PYTHON3 = sys.version_info >= (3, 0) | |
70 | ||
71 | if sys.version_info <= (2, 5): | |
72 | def all(iterable): | |
73 | for element in iterable: | |
74 | if not element: | |
75 | return False | |
76 | return True | |
77 | ||
78 | ||
79 | def is_ascii(s): | |
80 | return all(ord(c) < 128 and ord(c) > 0 for c in s) | |
81 | ||
82 | ||
83 | if PYTHON3: | |
4453d76c MM |
84 | def is_string(s): |
85 | return isinstance(s, str) | |
86 | ||
4b1fd356 MM |
87 | def str_to_bytes(s): |
88 | return s.encode(ENCODING) | |
89 | ||
7c554311 MM |
90 | def bytes_to_str(s, errors='strict'): |
91 | return s.decode(ENCODING, errors) | |
4b1fd356 MM |
92 | |
93 | unicode = str | |
94 | ||
95 | def write_str(f, msg): | |
96 | # Try outputing with the default encoding. If it fails, | |
97 | # try UTF-8. | |
98 | try: | |
99 | f.buffer.write(msg.encode(sys.getdefaultencoding())) | |
100 | except UnicodeEncodeError: | |
101 | f.buffer.write(msg.encode(ENCODING)) | |
7c554311 MM |
102 | |
103 | def read_line(f): | |
104 | # Try reading with the default encoding. If it fails, | |
105 | # try UTF-8. | |
106 | out = f.buffer.readline() | |
107 | try: | |
108 | return out.decode(sys.getdefaultencoding()) | |
109 | except UnicodeEncodeError: | |
110 | return out.decode(ENCODING) | |
4b1fd356 | 111 | else: |
4453d76c MM |
112 | def is_string(s): |
113 | try: | |
114 | return isinstance(s, basestring) | |
115 | except NameError: # Silence Pyflakes warning | |
116 | raise | |
117 | ||
4b1fd356 MM |
118 | def str_to_bytes(s): |
119 | return s | |
120 | ||
7c554311 | 121 | def bytes_to_str(s, errors='strict'): |
4b1fd356 MM |
122 | return s |
123 | ||
124 | def write_str(f, msg): | |
125 | f.write(msg) | |
126 | ||
7c554311 MM |
127 | def read_line(f): |
128 | return f.readline() | |
129 | ||
4b1fd356 MM |
130 | def next(it): |
131 | return it.next() | |
132 | ||
bc501f69 MH |
133 | |
134 | try: | |
4b1fd356 | 135 | from email.charset import Charset |
bc501f69 MH |
136 | from email.utils import make_msgid |
137 | from email.utils import getaddresses | |
138 | from email.utils import formataddr | |
b513f71f | 139 | from email.utils import formatdate |
bc501f69 MH |
140 | from email.header import Header |
141 | except ImportError: | |
142 | # Prior to Python 2.5, the email module used different names: | |
4b1fd356 | 143 | from email.Charset import Charset |
bc501f69 MH |
144 | from email.Utils import make_msgid |
145 | from email.Utils import getaddresses | |
146 | from email.Utils import formataddr | |
b513f71f | 147 | from email.Utils import formatdate |
bc501f69 MH |
148 | from email.Header import Header |
149 | ||
150 | ||
151 | DEBUG = False | |
152 | ||
153 | ZEROS = '0' * 40 | |
154 | LOGBEGIN = '- Log -----------------------------------------------------------------\n' | |
155 | LOGEND = '-----------------------------------------------------------------------\n' | |
156 | ||
b513f71f | 157 | ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender']) |
bc501f69 MH |
158 | |
159 | # It is assumed in many places that the encoding is uniformly UTF-8, | |
160 | # so changing these constants is unsupported. But define them here | |
161 | # anyway, to make it easier to find (at least most of) the places | |
162 | # where the encoding is important. | |
163 | (ENCODING, CHARSET) = ('UTF-8', 'utf-8') | |
164 | ||
165 | ||
166 | REF_CREATED_SUBJECT_TEMPLATE = ( | |
167 | '%(emailprefix)s%(refname_type)s %(short_refname)s created' | |
168 | ' (now %(newrev_short)s)' | |
169 | ) | |
170 | REF_UPDATED_SUBJECT_TEMPLATE = ( | |
171 | '%(emailprefix)s%(refname_type)s %(short_refname)s updated' | |
172 | ' (%(oldrev_short)s -> %(newrev_short)s)' | |
173 | ) | |
174 | REF_DELETED_SUBJECT_TEMPLATE = ( | |
175 | '%(emailprefix)s%(refname_type)s %(short_refname)s deleted' | |
176 | ' (was %(oldrev_short)s)' | |
177 | ) | |
178 | ||
5b1d901c MM |
179 | COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( |
180 | '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s' | |
181 | ) | |
182 | ||
bc501f69 | 183 | REFCHANGE_HEADER_TEMPLATE = """\ |
b513f71f | 184 | Date: %(send_date)s |
bc501f69 MH |
185 | To: %(recipients)s |
186 | Subject: %(subject)s | |
187 | MIME-Version: 1.0 | |
4b1fd356 | 188 | Content-Type: text/%(contenttype)s; charset=%(charset)s |
bc501f69 MH |
189 | Content-Transfer-Encoding: 8bit |
190 | Message-ID: %(msgid)s | |
191 | From: %(fromaddr)s | |
192 | Reply-To: %(reply_to)s | |
b513f71f | 193 | X-Git-Host: %(fqdn)s |
bc501f69 MH |
194 | X-Git-Repo: %(repo_shortname)s |
195 | X-Git-Refname: %(refname)s | |
196 | X-Git-Reftype: %(refname_type)s | |
197 | X-Git-Oldrev: %(oldrev)s | |
198 | X-Git-Newrev: %(newrev)s | |
4b1fd356 MM |
199 | X-Git-NotificationType: ref_changed |
200 | X-Git-Multimail-Version: %(multimail_version)s | |
bc501f69 MH |
201 | Auto-Submitted: auto-generated |
202 | """ | |
203 | ||
204 | REFCHANGE_INTRO_TEMPLATE = """\ | |
205 | This is an automated email from the git hooks/post-receive script. | |
206 | ||
207 | %(pusher)s pushed a change to %(refname_type)s %(short_refname)s | |
208 | in repository %(repo_shortname)s. | |
209 | ||
210 | """ | |
211 | ||
212 | ||
213 | FOOTER_TEMPLATE = """\ | |
214 | ||
215 | -- \n\ | |
216 | To stop receiving notification emails like this one, please contact | |
217 | %(administrator)s. | |
218 | """ | |
219 | ||
220 | ||
221 | REWIND_ONLY_TEMPLATE = """\ | |
222 | This update removed existing revisions from the reference, leaving the | |
223 | reference pointing at a previous point in the repository history. | |
224 | ||
225 | * -- * -- N %(refname)s (%(newrev_short)s) | |
226 | \\ | |
227 | O -- O -- O (%(oldrev_short)s) | |
228 | ||
7c554311 MM |
229 | Any revisions marked "omit" are not gone; other references still |
230 | refer to them. Any revisions marked "discard" are gone forever. | |
bc501f69 MH |
231 | """ |
232 | ||
233 | ||
234 | NON_FF_TEMPLATE = """\ | |
235 | This update added new revisions after undoing existing revisions. | |
236 | That is to say, some revisions that were in the old version of the | |
237 | %(refname_type)s are not in the new version. This situation occurs | |
238 | when a user --force pushes a change and generates a repository | |
239 | containing something like this: | |
240 | ||
241 | * -- * -- B -- O -- O -- O (%(oldrev_short)s) | |
242 | \\ | |
243 | N -- N -- N %(refname)s (%(newrev_short)s) | |
244 | ||
245 | You should already have received notification emails for all of the O | |
246 | revisions, and so the following emails describe only the N revisions | |
247 | from the common base, B. | |
248 | ||
7c554311 MM |
249 | Any revisions marked "omit" are not gone; other references still |
250 | refer to them. Any revisions marked "discard" are gone forever. | |
bc501f69 MH |
251 | """ |
252 | ||
253 | ||
254 | NO_NEW_REVISIONS_TEMPLATE = """\ | |
255 | No new revisions were added by this update. | |
256 | """ | |
257 | ||
258 | ||
259 | DISCARDED_REVISIONS_TEMPLATE = """\ | |
260 | This change permanently discards the following revisions: | |
261 | """ | |
262 | ||
263 | ||
264 | NO_DISCARDED_REVISIONS_TEMPLATE = """\ | |
265 | The revisions that were on this %(refname_type)s are still contained in | |
266 | other references; therefore, this change does not discard any commits | |
267 | from the repository. | |
268 | """ | |
269 | ||
270 | ||
271 | NEW_REVISIONS_TEMPLATE = """\ | |
272 | The %(tot)s revisions listed above as "new" are entirely new to this | |
273 | repository and will be described in separate emails. The revisions | |
7c554311 | 274 | listed as "add" were already present in the repository and have only |
bc501f69 MH |
275 | been added to this reference. |
276 | ||
277 | """ | |
278 | ||
279 | ||
280 | TAG_CREATED_TEMPLATE = """\ | |
7c554311 | 281 | at %(newrev_short)-8s (%(newrev_type)s) |
bc501f69 MH |
282 | """ |
283 | ||
284 | ||
285 | TAG_UPDATED_TEMPLATE = """\ | |
286 | *** WARNING: tag %(short_refname)s was modified! *** | |
287 | ||
7c554311 MM |
288 | from %(oldrev_short)-8s (%(oldrev_type)s) |
289 | to %(newrev_short)-8s (%(newrev_type)s) | |
bc501f69 MH |
290 | """ |
291 | ||
292 | ||
293 | TAG_DELETED_TEMPLATE = """\ | |
294 | *** WARNING: tag %(short_refname)s was deleted! *** | |
295 | ||
296 | """ | |
297 | ||
298 | ||
299 | # The template used in summary tables. It looks best if this uses the | |
300 | # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. | |
301 | BRIEF_SUMMARY_TEMPLATE = """\ | |
7c554311 | 302 | %(action)8s %(rev_short)-8s %(text)s |
bc501f69 MH |
303 | """ |
304 | ||
305 | ||
306 | NON_COMMIT_UPDATE_TEMPLATE = """\ | |
307 | This is an unusual reference change because the reference did not | |
308 | refer to a commit either before or after the change. We do not know | |
309 | how to provide full information about this reference change. | |
310 | """ | |
311 | ||
312 | ||
313 | REVISION_HEADER_TEMPLATE = """\ | |
b513f71f | 314 | Date: %(send_date)s |
bc501f69 | 315 | To: %(recipients)s |
5b1d901c | 316 | Cc: %(cc_recipients)s |
bc501f69 MH |
317 | Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s |
318 | MIME-Version: 1.0 | |
4b1fd356 | 319 | Content-Type: text/%(contenttype)s; charset=%(charset)s |
bc501f69 MH |
320 | Content-Transfer-Encoding: 8bit |
321 | From: %(fromaddr)s | |
322 | Reply-To: %(reply_to)s | |
323 | In-Reply-To: %(reply_to_msgid)s | |
324 | References: %(reply_to_msgid)s | |
b513f71f | 325 | X-Git-Host: %(fqdn)s |
bc501f69 MH |
326 | X-Git-Repo: %(repo_shortname)s |
327 | X-Git-Refname: %(refname)s | |
328 | X-Git-Reftype: %(refname_type)s | |
329 | X-Git-Rev: %(rev)s | |
4b1fd356 MM |
330 | X-Git-NotificationType: diff |
331 | X-Git-Multimail-Version: %(multimail_version)s | |
bc501f69 MH |
332 | Auto-Submitted: auto-generated |
333 | """ | |
334 | ||
335 | REVISION_INTRO_TEMPLATE = """\ | |
336 | This is an automated email from the git hooks/post-receive script. | |
337 | ||
338 | %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s | |
339 | in repository %(repo_shortname)s. | |
340 | ||
341 | """ | |
342 | ||
4453d76c MM |
343 | LINK_TEXT_TEMPLATE = """\ |
344 | View the commit online: | |
345 | %(browse_url)s | |
346 | ||
347 | """ | |
348 | ||
349 | LINK_HTML_TEMPLATE = """\ | |
350 | <p><a href="%(browse_url)s">View the commit online</a>.</p> | |
351 | """ | |
352 | ||
bc501f69 MH |
353 | |
354 | REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE | |
355 | ||
356 | ||
5b1d901c MM |
357 | # Combined, meaning refchange+revision email (for single-commit additions) |
358 | COMBINED_HEADER_TEMPLATE = """\ | |
359 | Date: %(send_date)s | |
360 | To: %(recipients)s | |
361 | Subject: %(subject)s | |
362 | MIME-Version: 1.0 | |
4b1fd356 | 363 | Content-Type: text/%(contenttype)s; charset=%(charset)s |
5b1d901c MM |
364 | Content-Transfer-Encoding: 8bit |
365 | Message-ID: %(msgid)s | |
366 | From: %(fromaddr)s | |
367 | Reply-To: %(reply_to)s | |
368 | X-Git-Host: %(fqdn)s | |
369 | X-Git-Repo: %(repo_shortname)s | |
370 | X-Git-Refname: %(refname)s | |
371 | X-Git-Reftype: %(refname_type)s | |
372 | X-Git-Oldrev: %(oldrev)s | |
373 | X-Git-Newrev: %(newrev)s | |
374 | X-Git-Rev: %(rev)s | |
4b1fd356 MM |
375 | X-Git-NotificationType: ref_changed_plus_diff |
376 | X-Git-Multimail-Version: %(multimail_version)s | |
5b1d901c MM |
377 | Auto-Submitted: auto-generated |
378 | """ | |
379 | ||
380 | COMBINED_INTRO_TEMPLATE = """\ | |
381 | This is an automated email from the git hooks/post-receive script. | |
382 | ||
383 | %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s | |
384 | in repository %(repo_shortname)s. | |
385 | ||
386 | """ | |
387 | ||
388 | COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE | |
389 | ||
390 | ||
bc501f69 MH |
391 | class CommandError(Exception): |
392 | def __init__(self, cmd, retcode): | |
393 | self.cmd = cmd | |
394 | self.retcode = retcode | |
395 | Exception.__init__( | |
396 | self, | |
397 | 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,) | |
398 | ) | |
399 | ||
400 | ||
401 | class ConfigurationException(Exception): | |
402 | pass | |
403 | ||
404 | ||
b513f71f MH |
405 | # The "git" program (this could be changed to include a full path): |
406 | GIT_EXECUTABLE = 'git' | |
407 | ||
408 | ||
409 | # How "git" should be invoked (including global arguments), as a list | |
410 | # of words. This variable is usually initialized automatically by | |
411 | # read_git_output() via choose_git_command(), but if a value is set | |
412 | # here then it will be used unconditionally. | |
413 | GIT_CMD = None | |
414 | ||
415 | ||
416 | def choose_git_command(): | |
417 | """Decide how to invoke git, and record the choice in GIT_CMD.""" | |
418 | ||
419 | global GIT_CMD | |
420 | ||
421 | if GIT_CMD is None: | |
422 | try: | |
423 | # Check to see whether the "-c" option is accepted (it was | |
424 | # only added in Git 1.7.2). We don't actually use the | |
425 | # output of "git --version", though if we needed more | |
426 | # specific version information this would be the place to | |
427 | # do it. | |
428 | cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version'] | |
429 | read_output(cmd) | |
430 | GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] | |
431 | except CommandError: | |
432 | GIT_CMD = [GIT_EXECUTABLE] | |
433 | ||
434 | ||
bc501f69 MH |
435 | def read_git_output(args, input=None, keepends=False, **kw): |
436 | """Read the output of a Git command.""" | |
437 | ||
b513f71f MH |
438 | if GIT_CMD is None: |
439 | choose_git_command() | |
440 | ||
441 | return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw) | |
bc501f69 MH |
442 | |
443 | ||
444 | def read_output(cmd, input=None, keepends=False, **kw): | |
445 | if input: | |
446 | stdin = subprocess.PIPE | |
4b1fd356 | 447 | input = str_to_bytes(input) |
bc501f69 MH |
448 | else: |
449 | stdin = None | |
7c554311 MM |
450 | errors = 'strict' |
451 | if 'errors' in kw: | |
452 | errors = kw['errors'] | |
453 | del kw['errors'] | |
bc501f69 | 454 | p = subprocess.Popen( |
7c554311 MM |
455 | tuple(str_to_bytes(w) for w in cmd), |
456 | stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw | |
bc501f69 MH |
457 | ) |
458 | (out, err) = p.communicate(input) | |
7c554311 | 459 | out = bytes_to_str(out, errors=errors) |
bc501f69 MH |
460 | retcode = p.wait() |
461 | if retcode: | |
462 | raise CommandError(cmd, retcode) | |
463 | if not keepends: | |
464 | out = out.rstrip('\n\r') | |
465 | return out | |
466 | ||
467 | ||
468 | def read_git_lines(args, keepends=False, **kw): | |
469 | """Return the lines output by Git command. | |
470 | ||
471 | Return as single lines, with newlines stripped off.""" | |
472 | ||
473 | return read_git_output(args, keepends=True, **kw).splitlines(keepends) | |
474 | ||
475 | ||
5b1d901c MM |
476 | def git_rev_list_ish(cmd, spec, args=None, **kw): |
477 | """Common functionality for invoking a 'git rev-list'-like command. | |
478 | ||
479 | Parameters: | |
480 | * cmd is the Git command to run, e.g., 'rev-list' or 'log'. | |
481 | * spec is a list of revision arguments to pass to the named | |
482 | command. If None, this function returns an empty list. | |
483 | * args is a list of extra arguments passed to the named command. | |
484 | * All other keyword arguments (if any) are passed to the | |
485 | underlying read_git_lines() function. | |
486 | ||
487 | Return the output of the Git command in the form of a list, one | |
488 | entry per output line. | |
489 | """ | |
490 | if spec is None: | |
491 | return [] | |
492 | if args is None: | |
493 | args = [] | |
494 | args = [cmd, '--stdin'] + args | |
495 | spec_stdin = ''.join(s + '\n' for s in spec) | |
496 | return read_git_lines(args, input=spec_stdin, **kw) | |
497 | ||
498 | ||
499 | def git_rev_list(spec, **kw): | |
500 | """Run 'git rev-list' with the given list of revision arguments. | |
501 | ||
502 | See git_rev_list_ish() for parameter and return value | |
503 | documentation. | |
504 | """ | |
505 | return git_rev_list_ish('rev-list', spec, **kw) | |
506 | ||
507 | ||
508 | def git_log(spec, **kw): | |
509 | """Run 'git log' with the given list of revision arguments. | |
510 | ||
511 | See git_rev_list_ish() for parameter and return value | |
512 | documentation. | |
513 | """ | |
514 | return git_rev_list_ish('log', spec, **kw) | |
515 | ||
516 | ||
b513f71f MH |
517 | def header_encode(text, header_name=None): |
518 | """Encode and line-wrap the value of an email header field.""" | |
519 | ||
4b1fd356 MM |
520 | # Convert to unicode, if required. |
521 | if not isinstance(text, unicode): | |
522 | text = unicode(text, 'utf-8') | |
523 | ||
524 | if is_ascii(text): | |
525 | charset = 'ascii' | |
526 | else: | |
527 | charset = 'utf-8' | |
528 | ||
529 | return Header(text, header_name=header_name, charset=Charset(charset)).encode() | |
b513f71f MH |
530 | |
531 | ||
532 | def addr_header_encode(text, header_name=None): | |
533 | """Encode and line-wrap the value of an email header field containing | |
534 | email addresses.""" | |
535 | ||
4b1fd356 MM |
536 | # Convert to unicode, if required. |
537 | if not isinstance(text, unicode): | |
538 | text = unicode(text, 'utf-8') | |
539 | ||
540 | text = ', '.join( | |
541 | formataddr((header_encode(name), emailaddr)) | |
542 | for name, emailaddr in getaddresses([text]) | |
543 | ) | |
544 | ||
545 | if is_ascii(text): | |
546 | charset = 'ascii' | |
547 | else: | |
548 | charset = 'utf-8' | |
549 | ||
550 | return Header(text, header_name=header_name, charset=Charset(charset)).encode() | |
b513f71f MH |
551 | |
552 | ||
bc501f69 MH |
553 | class Config(object): |
554 | def __init__(self, section, git_config=None): | |
555 | """Represent a section of the git configuration. | |
556 | ||
557 | If git_config is specified, it is passed to "git config" in | |
558 | the GIT_CONFIG environment variable, meaning that "git config" | |
559 | will read the specified path rather than the Git default | |
560 | config paths.""" | |
561 | ||
562 | self.section = section | |
563 | if git_config: | |
564 | self.env = os.environ.copy() | |
565 | self.env['GIT_CONFIG'] = git_config | |
566 | else: | |
567 | self.env = None | |
568 | ||
569 | @staticmethod | |
570 | def _split(s): | |
571 | """Split NUL-terminated values.""" | |
572 | ||
573 | words = s.split('\0') | |
574 | assert words[-1] == '' | |
575 | return words[:-1] | |
576 | ||
4453d76c MM |
577 | @staticmethod |
578 | def add_config_parameters(c): | |
579 | """Add configuration parameters to Git. | |
580 | ||
581 | c is either an str or a list of str, each element being of the | |
582 | form 'var=val' or 'var', with the same syntax and meaning as | |
583 | the argument of 'git -c var=val'. | |
584 | """ | |
585 | if isinstance(c, str): | |
586 | c = (c,) | |
587 | parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '') | |
588 | if parameters: | |
589 | parameters += ' ' | |
590 | # git expects GIT_CONFIG_PARAMETERS to be of the form | |
591 | # "'name1=value1' 'name2=value2' 'name3=value3'" | |
592 | # including everything inside the double quotes (but not the double | |
593 | # quotes themselves). Spacing is critical. Also, if a value contains | |
594 | # a literal single quote that quote must be represented using the | |
595 | # four character sequence: '\'' | |
596 | parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c) | |
597 | os.environ['GIT_CONFIG_PARAMETERS'] = parameters | |
598 | ||
bc501f69 MH |
599 | def get(self, name, default=None): |
600 | try: | |
601 | values = self._split(read_git_output( | |
5b1d901c MM |
602 | ['config', '--get', '--null', '%s.%s' % (self.section, name)], |
603 | env=self.env, keepends=True, | |
604 | )) | |
bc501f69 MH |
605 | assert len(values) == 1 |
606 | return values[0] | |
607 | except CommandError: | |
608 | return default | |
609 | ||
610 | def get_bool(self, name, default=None): | |
611 | try: | |
612 | value = read_git_output( | |
613 | ['config', '--get', '--bool', '%s.%s' % (self.section, name)], | |
614 | env=self.env, | |
615 | ) | |
616 | except CommandError: | |
617 | return default | |
618 | return value == 'true' | |
619 | ||
620 | def get_all(self, name, default=None): | |
621 | """Read a (possibly multivalued) setting from the configuration. | |
622 | ||
623 | Return the result as a list of values, or default if the name | |
624 | is unset.""" | |
625 | ||
626 | try: | |
627 | return self._split(read_git_output( | |
628 | ['config', '--get-all', '--null', '%s.%s' % (self.section, name)], | |
629 | env=self.env, keepends=True, | |
630 | )) | |
4b1fd356 MM |
631 | except CommandError: |
632 | t, e, traceback = sys.exc_info() | |
bc501f69 MH |
633 | if e.retcode == 1: |
634 | # "the section or key is invalid"; i.e., there is no | |
635 | # value for the specified key. | |
636 | return default | |
637 | else: | |
638 | raise | |
639 | ||
bc501f69 MH |
640 | def set(self, name, value): |
641 | read_git_output( | |
642 | ['config', '%s.%s' % (self.section, name), value], | |
643 | env=self.env, | |
644 | ) | |
645 | ||
646 | def add(self, name, value): | |
647 | read_git_output( | |
648 | ['config', '--add', '%s.%s' % (self.section, name), value], | |
649 | env=self.env, | |
650 | ) | |
651 | ||
5b1d901c | 652 | def __contains__(self, name): |
bc501f69 MH |
653 | return self.get_all(name, default=None) is not None |
654 | ||
5b1d901c MM |
655 | # We don't use this method anymore internally, but keep it here in |
656 | # case somebody is calling it from their own code: | |
657 | def has_key(self, name): | |
658 | return name in self | |
659 | ||
bc501f69 MH |
660 | def unset_all(self, name): |
661 | try: | |
662 | read_git_output( | |
663 | ['config', '--unset-all', '%s.%s' % (self.section, name)], | |
664 | env=self.env, | |
665 | ) | |
4b1fd356 MM |
666 | except CommandError: |
667 | t, e, traceback = sys.exc_info() | |
bc501f69 MH |
668 | if e.retcode == 5: |
669 | # The name doesn't exist, which is what we wanted anyway... | |
670 | pass | |
671 | else: | |
672 | raise | |
673 | ||
674 | def set_recipients(self, name, value): | |
675 | self.unset_all(name) | |
676 | for pair in getaddresses([value]): | |
677 | self.add(name, formataddr(pair)) | |
678 | ||
679 | ||
680 | def generate_summaries(*log_args): | |
681 | """Generate a brief summary for each revision requested. | |
682 | ||
683 | log_args are strings that will be passed directly to "git log" as | |
684 | revision selectors. Iterate over (sha1_short, subject) for each | |
685 | commit specified by log_args (subject is the first line of the | |
686 | commit message as a string without EOLs).""" | |
687 | ||
688 | cmd = [ | |
689 | 'log', '--abbrev', '--format=%h %s', | |
690 | ] + list(log_args) + ['--'] | |
691 | for line in read_git_lines(cmd): | |
692 | yield tuple(line.split(' ', 1)) | |
693 | ||
694 | ||
695 | def limit_lines(lines, max_lines): | |
696 | for (index, line) in enumerate(lines): | |
697 | if index < max_lines: | |
698 | yield line | |
699 | ||
700 | if index >= max_lines: | |
701 | yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,) | |
702 | ||
703 | ||
704 | def limit_linelength(lines, max_linelength): | |
705 | for line in lines: | |
706 | # Don't forget that lines always include a trailing newline. | |
707 | if len(line) > max_linelength + 1: | |
708 | line = line[:max_linelength - 7] + ' [...]\n' | |
709 | yield line | |
710 | ||
711 | ||
712 | class CommitSet(object): | |
713 | """A (constant) set of object names. | |
714 | ||
715 | The set should be initialized with full SHA1 object names. The | |
716 | __contains__() method returns True iff its argument is an | |
717 | abbreviation of any the names in the set.""" | |
718 | ||
719 | def __init__(self, names): | |
720 | self._names = sorted(names) | |
721 | ||
722 | def __len__(self): | |
723 | return len(self._names) | |
724 | ||
725 | def __contains__(self, sha1_abbrev): | |
726 | """Return True iff this set contains sha1_abbrev (which might be abbreviated).""" | |
727 | ||
728 | i = bisect.bisect_left(self._names, sha1_abbrev) | |
729 | return i < len(self) and self._names[i].startswith(sha1_abbrev) | |
730 | ||
731 | ||
732 | class GitObject(object): | |
733 | def __init__(self, sha1, type=None): | |
734 | if sha1 == ZEROS: | |
735 | self.sha1 = self.type = self.commit_sha1 = None | |
736 | else: | |
737 | self.sha1 = sha1 | |
738 | self.type = type or read_git_output(['cat-file', '-t', self.sha1]) | |
739 | ||
740 | if self.type == 'commit': | |
741 | self.commit_sha1 = self.sha1 | |
742 | elif self.type == 'tag': | |
743 | try: | |
744 | self.commit_sha1 = read_git_output( | |
745 | ['rev-parse', '--verify', '%s^0' % (self.sha1,)] | |
746 | ) | |
747 | except CommandError: | |
748 | # Cannot deref tag to determine commit_sha1 | |
749 | self.commit_sha1 = None | |
750 | else: | |
751 | self.commit_sha1 = None | |
752 | ||
753 | self.short = read_git_output(['rev-parse', '--short', sha1]) | |
754 | ||
755 | def get_summary(self): | |
756 | """Return (sha1_short, subject) for this commit.""" | |
757 | ||
758 | if not self.sha1: | |
759 | raise ValueError('Empty commit has no summary') | |
760 | ||
4b1fd356 | 761 | return next(iter(generate_summaries('--no-walk', self.sha1))) |
bc501f69 MH |
762 | |
763 | def __eq__(self, other): | |
764 | return isinstance(other, GitObject) and self.sha1 == other.sha1 | |
765 | ||
766 | def __hash__(self): | |
767 | return hash(self.sha1) | |
768 | ||
769 | def __nonzero__(self): | |
770 | return bool(self.sha1) | |
771 | ||
4b1fd356 MM |
772 | def __bool__(self): |
773 | """Python 2 backward compatibility""" | |
774 | return self.__nonzero__() | |
775 | ||
bc501f69 MH |
776 | def __str__(self): |
777 | return self.sha1 or ZEROS | |
778 | ||
779 | ||
780 | class Change(object): | |
781 | """A Change that has been made to the Git repository. | |
782 | ||
783 | Abstract class from which both Revisions and ReferenceChanges are | |
784 | derived. A Change knows how to generate a notification email | |
785 | describing itself.""" | |
786 | ||
787 | def __init__(self, environment): | |
788 | self.environment = environment | |
789 | self._values = None | |
4b1fd356 MM |
790 | self._contains_html_diff = False |
791 | ||
792 | def _contains_diff(self): | |
793 | # We do contain a diff, should it be rendered in HTML? | |
794 | if self.environment.commit_email_format == "html": | |
795 | self._contains_html_diff = True | |
bc501f69 MH |
796 | |
797 | def _compute_values(self): | |
5b1d901c | 798 | """Return a dictionary {keyword: expansion} for this Change. |
bc501f69 MH |
799 | |
800 | Derived classes overload this method to add more entries to | |
801 | the return value. This method is used internally by | |
802 | get_values(). The return value should always be a new | |
803 | dictionary.""" | |
804 | ||
4b1fd356 MM |
805 | values = self.environment.get_values() |
806 | fromaddr = self.environment.get_fromaddr(change=self) | |
807 | if fromaddr is not None: | |
808 | values['fromaddr'] = fromaddr | |
809 | values['multimail_version'] = get_version() | |
810 | return values | |
bc501f69 | 811 | |
4453d76c MM |
812 | # Aliases usable in template strings. Tuple of pairs (destination, |
813 | # source). | |
814 | VALUES_ALIAS = ( | |
815 | ("id", "newrev"), | |
816 | ) | |
817 | ||
bc501f69 | 818 | def get_values(self, **extra_values): |
5b1d901c | 819 | """Return a dictionary {keyword: expansion} for this Change. |
bc501f69 MH |
820 | |
821 | Return a dictionary mapping keywords to the values that they | |
822 | should be expanded to for this Change (used when interpolating | |
823 | template strings). If any keyword arguments are supplied, add | |
824 | those to the return value as well. The return value is always | |
825 | a new dictionary.""" | |
826 | ||
827 | if self._values is None: | |
828 | self._values = self._compute_values() | |
829 | ||
830 | values = self._values.copy() | |
831 | if extra_values: | |
832 | values.update(extra_values) | |
4453d76c MM |
833 | |
834 | for alias, val in self.VALUES_ALIAS: | |
835 | values[alias] = values[val] | |
bc501f69 MH |
836 | return values |
837 | ||
838 | def expand(self, template, **extra_values): | |
839 | """Expand template. | |
840 | ||
841 | Expand the template (which should be a string) using string | |
842 | interpolation of the values for this Change. If any keyword | |
843 | arguments are provided, also include those in the keywords | |
844 | available for interpolation.""" | |
845 | ||
846 | return template % self.get_values(**extra_values) | |
847 | ||
4453d76c | 848 | def expand_lines(self, template, html_escape_val=False, **extra_values): |
bc501f69 MH |
849 | """Break template into lines and expand each line.""" |
850 | ||
851 | values = self.get_values(**extra_values) | |
4453d76c MM |
852 | if html_escape_val: |
853 | for k in values: | |
854 | if is_string(values[k]): | |
855 | values[k] = cgi.escape(values[k], True) | |
bc501f69 MH |
856 | for line in template.splitlines(True): |
857 | yield line % values | |
858 | ||
859 | def expand_header_lines(self, template, **extra_values): | |
860 | """Break template into lines and expand each line as an RFC 2822 header. | |
861 | ||
862 | Encode values and split up lines that are too long. Silently | |
863 | skip lines that contain references to unknown variables.""" | |
864 | ||
865 | values = self.get_values(**extra_values) | |
4b1fd356 | 866 | if self._contains_html_diff: |
4453d76c | 867 | self._content_type = 'html' |
4b1fd356 | 868 | else: |
4453d76c MM |
869 | self._content_type = 'plain' |
870 | values['contenttype'] = self._content_type | |
4b1fd356 | 871 | |
bc501f69 | 872 | for line in template.splitlines(): |
4b1fd356 | 873 | (name, value) = line.split(': ', 1) |
bc501f69 MH |
874 | |
875 | try: | |
876 | value = value % values | |
4b1fd356 MM |
877 | except KeyError: |
878 | t, e, traceback = sys.exc_info() | |
bc501f69 | 879 | if DEBUG: |
5b1d901c | 880 | self.environment.log_warning( |
bc501f69 MH |
881 | 'Warning: unknown variable %r in the following line; line skipped:\n' |
882 | ' %s\n' | |
883 | % (e.args[0], line,) | |
884 | ) | |
885 | else: | |
b513f71f MH |
886 | if name.lower() in ADDR_HEADERS: |
887 | value = addr_header_encode(value, name) | |
888 | else: | |
889 | value = header_encode(value, name) | |
890 | for splitline in ('%s: %s\n' % (name, value)).splitlines(True): | |
bc501f69 MH |
891 | yield splitline |
892 | ||
893 | def generate_email_header(self): | |
894 | """Generate the RFC 2822 email headers for this Change, a line at a time. | |
895 | ||
896 | The output should not include the trailing blank line.""" | |
897 | ||
898 | raise NotImplementedError() | |
899 | ||
4453d76c MM |
900 | def generate_browse_link(self, base_url): |
901 | """Generate a link to an online repository browser.""" | |
902 | return iter(()) | |
903 | ||
904 | def generate_email_intro(self, html_escape_val=False): | |
bc501f69 MH |
905 | """Generate the email intro for this Change, a line at a time. |
906 | ||
907 | The output will be used as the standard boilerplate at the top | |
908 | of the email body.""" | |
909 | ||
910 | raise NotImplementedError() | |
911 | ||
912 | def generate_email_body(self): | |
913 | """Generate the main part of the email body, a line at a time. | |
914 | ||
915 | The text in the body might be truncated after a specified | |
916 | number of lines (see multimailhook.emailmaxlines).""" | |
917 | ||
918 | raise NotImplementedError() | |
919 | ||
4453d76c | 920 | def generate_email_footer(self, html_escape_val): |
bc501f69 MH |
921 | """Generate the footer of the email, a line at a time. |
922 | ||
923 | The footer is always included, irrespective of | |
924 | multimailhook.emailmaxlines.""" | |
925 | ||
926 | raise NotImplementedError() | |
927 | ||
4b1fd356 MM |
928 | def _wrap_for_html(self, lines): |
929 | """Wrap the lines in HTML <pre> tag when using HTML format. | |
930 | ||
931 | Escape special HTML characters and add <pre> and </pre> tags around | |
932 | the given lines if we should be generating HTML as indicated by | |
933 | self._contains_html_diff being set to true. | |
934 | """ | |
935 | if self._contains_html_diff: | |
936 | yield "<pre style='margin:0'>\n" | |
937 | ||
938 | for line in lines: | |
939 | yield cgi.escape(line) | |
940 | ||
941 | yield '</pre>\n' | |
942 | else: | |
943 | for line in lines: | |
944 | yield line | |
945 | ||
b513f71f | 946 | def generate_email(self, push, body_filter=None, extra_header_values={}): |
bc501f69 MH |
947 | """Generate an email describing this change. |
948 | ||
949 | Iterate over the lines (including the header lines) of an | |
950 | email describing this change. If body_filter is not None, | |
951 | then use it to filter the lines that are intended for the | |
b513f71f MH |
952 | email body. |
953 | ||
954 | The extra_header_values field is received as a dict and not as | |
955 | **kwargs, to allow passing other keyword arguments in the | |
956 | future (e.g. passing extra values to generate_email_intro()""" | |
bc501f69 | 957 | |
b513f71f | 958 | for line in self.generate_email_header(**extra_header_values): |
bc501f69 MH |
959 | yield line |
960 | yield '\n' | |
4453d76c MM |
961 | html_escape_val = (self.environment.html_in_intro and |
962 | self._contains_html_diff) | |
963 | intro = self.generate_email_intro(html_escape_val) | |
964 | if not self.environment.html_in_intro: | |
965 | intro = self._wrap_for_html(intro) | |
966 | for line in intro: | |
bc501f69 MH |
967 | yield line |
968 | ||
4453d76c MM |
969 | if self.environment.commitBrowseURL: |
970 | for line in self.generate_browse_link(self.environment.commitBrowseURL): | |
971 | yield line | |
972 | ||
bc501f69 MH |
973 | body = self.generate_email_body(push) |
974 | if body_filter is not None: | |
975 | body = body_filter(body) | |
4b1fd356 MM |
976 | |
977 | diff_started = False | |
978 | if self._contains_html_diff: | |
979 | # "white-space: pre" is the default, but we need to | |
980 | # specify it again in case the message is viewed in a | |
981 | # webmail which wraps it in an element setting white-space | |
982 | # to something else (Zimbra does this and sets | |
983 | # white-space: pre-line). | |
984 | yield '<pre style="white-space: pre; background: #F8F8F8">' | |
bc501f69 | 985 | for line in body: |
4b1fd356 MM |
986 | if self._contains_html_diff: |
987 | # This is very, very naive. It would be much better to really | |
988 | # parse the diff, i.e. look at how many lines do we have in | |
989 | # the hunk headers instead of blindly highlighting everything | |
990 | # that looks like it might be part of a diff. | |
991 | bgcolor = '' | |
992 | fgcolor = '' | |
993 | if line.startswith('--- a/'): | |
994 | diff_started = True | |
995 | bgcolor = 'e0e0ff' | |
996 | elif line.startswith('diff ') or line.startswith('index '): | |
997 | diff_started = True | |
998 | fgcolor = '808080' | |
999 | elif diff_started: | |
1000 | if line.startswith('+++ '): | |
1001 | bgcolor = 'e0e0ff' | |
1002 | elif line.startswith('@@'): | |
1003 | bgcolor = 'e0e0e0' | |
1004 | elif line.startswith('+'): | |
1005 | bgcolor = 'e0ffe0' | |
1006 | elif line.startswith('-'): | |
1007 | bgcolor = 'ffe0e0' | |
1008 | elif line.startswith('commit '): | |
1009 | fgcolor = '808000' | |
1010 | elif line.startswith(' '): | |
1011 | fgcolor = '404040' | |
1012 | ||
1013 | # Chop the trailing LF, we don't want it inside <pre>. | |
1014 | line = cgi.escape(line[:-1]) | |
1015 | ||
1016 | if bgcolor or fgcolor: | |
1017 | style = 'display:block; white-space:pre;' | |
1018 | if bgcolor: | |
1019 | style += 'background:#' + bgcolor + ';' | |
1020 | if fgcolor: | |
1021 | style += 'color:#' + fgcolor + ';' | |
1022 | # Use a <span style='display:block> to color the | |
1023 | # whole line. The newline must be inside the span | |
1024 | # to display properly both in Firefox and in | |
1025 | # text-based browser. | |
1026 | line = "<span style='%s'>%s\n</span>" % (style, line) | |
1027 | else: | |
1028 | line = line + '\n' | |
1029 | ||
bc501f69 | 1030 | yield line |
4b1fd356 MM |
1031 | if self._contains_html_diff: |
1032 | yield '</pre>' | |
4453d76c MM |
1033 | html_escape_val = (self.environment.html_in_footer and |
1034 | self._contains_html_diff) | |
1035 | footer = self.generate_email_footer(html_escape_val) | |
1036 | if not self.environment.html_in_footer: | |
1037 | footer = self._wrap_for_html(footer) | |
1038 | for line in footer: | |
bc501f69 MH |
1039 | yield line |
1040 | ||
7c554311 MM |
1041 | def get_specific_fromaddr(self): |
1042 | """For kinds of Changes which specify it, return the kind-specific | |
1043 | From address to use.""" | |
4b1fd356 MM |
1044 | return None |
1045 | ||
bc501f69 MH |
1046 | |
1047 | class Revision(Change): | |
1048 | """A Change consisting of a single git commit.""" | |
1049 | ||
5b1d901c MM |
1050 | CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') |
1051 | ||
bc501f69 MH |
1052 | def __init__(self, reference_change, rev, num, tot): |
1053 | Change.__init__(self, reference_change.environment) | |
1054 | self.reference_change = reference_change | |
1055 | self.rev = rev | |
1056 | self.change_type = self.reference_change.change_type | |
1057 | self.refname = self.reference_change.refname | |
1058 | self.num = num | |
1059 | self.tot = tot | |
1060 | self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1]) | |
1061 | self.recipients = self.environment.get_revision_recipients(self) | |
1062 | ||
5b1d901c MM |
1063 | self.cc_recipients = '' |
1064 | if self.environment.get_scancommitforcc(): | |
1065 | self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients()) | |
1066 | if self.cc_recipients: | |
1067 | self.environment.log_msg( | |
7c554311 | 1068 | 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1)) |
5b1d901c MM |
1069 | |
1070 | def _cc_recipients(self): | |
1071 | cc_recipients = [] | |
1072 | message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1]) | |
1073 | lines = message.strip().split('\n') | |
1074 | for line in lines: | |
1075 | m = re.match(self.CC_RE, line) | |
1076 | if m: | |
1077 | cc_recipients.append(m.group('to')) | |
1078 | ||
1079 | return cc_recipients | |
1080 | ||
bc501f69 MH |
1081 | def _compute_values(self): |
1082 | values = Change._compute_values(self) | |
1083 | ||
1084 | oneline = read_git_output( | |
1085 | ['log', '--format=%s', '--no-walk', self.rev.sha1] | |
1086 | ) | |
1087 | ||
7c554311 MM |
1088 | max_subject_length = self.environment.get_max_subject_length() |
1089 | if max_subject_length > 0 and len(oneline) > max_subject_length: | |
1090 | oneline = oneline[:max_subject_length - 6] + ' [...]' | |
1091 | ||
bc501f69 MH |
1092 | values['rev'] = self.rev.sha1 |
1093 | values['rev_short'] = self.rev.short | |
1094 | values['change_type'] = self.change_type | |
1095 | values['refname'] = self.refname | |
4453d76c | 1096 | values['newrev'] = self.rev.sha1 |
bc501f69 MH |
1097 | values['short_refname'] = self.reference_change.short_refname |
1098 | values['refname_type'] = self.reference_change.refname_type | |
1099 | values['reply_to_msgid'] = self.reference_change.msgid | |
1100 | values['num'] = self.num | |
1101 | values['tot'] = self.tot | |
1102 | values['recipients'] = self.recipients | |
5b1d901c MM |
1103 | if self.cc_recipients: |
1104 | values['cc_recipients'] = self.cc_recipients | |
bc501f69 MH |
1105 | values['oneline'] = oneline |
1106 | values['author'] = self.author | |
1107 | ||
1108 | reply_to = self.environment.get_reply_to_commit(self) | |
1109 | if reply_to: | |
1110 | values['reply_to'] = reply_to | |
1111 | ||
1112 | return values | |
1113 | ||
b513f71f MH |
1114 | def generate_email_header(self, **extra_values): |
1115 | for line in self.expand_header_lines( | |
5b1d901c MM |
1116 | REVISION_HEADER_TEMPLATE, **extra_values |
1117 | ): | |
bc501f69 MH |
1118 | yield line |
1119 | ||
4453d76c MM |
1120 | def generate_browse_link(self, base_url): |
1121 | if '%(' not in base_url: | |
1122 | base_url += '%(id)s' | |
1123 | url = "".join(self.expand_lines(base_url)) | |
1124 | if self._content_type == 'html': | |
1125 | for line in self.expand_lines(LINK_HTML_TEMPLATE, | |
1126 | html_escape_val=True, | |
1127 | browse_url=url): | |
1128 | yield line | |
1129 | elif self._content_type == 'plain': | |
1130 | for line in self.expand_lines(LINK_TEXT_TEMPLATE, | |
1131 | html_escape_val=False, | |
1132 | browse_url=url): | |
1133 | yield line | |
1134 | else: | |
1135 | raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.") | |
1136 | ||
1137 | def generate_email_intro(self, html_escape_val=False): | |
1138 | for line in self.expand_lines(REVISION_INTRO_TEMPLATE, | |
1139 | html_escape_val=html_escape_val): | |
bc501f69 MH |
1140 | yield line |
1141 | ||
1142 | def generate_email_body(self, push): | |
1143 | """Show this revision.""" | |
1144 | ||
4b1fd356 MM |
1145 | for line in read_git_lines( |
1146 | ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1], | |
1147 | keepends=True, | |
7c554311 | 1148 | errors='replace'): |
4b1fd356 MM |
1149 | if line.startswith('Date: ') and self.environment.date_substitute: |
1150 | yield self.environment.date_substitute + line[len('Date: '):] | |
1151 | else: | |
1152 | yield line | |
bc501f69 | 1153 | |
4453d76c MM |
1154 | def generate_email_footer(self, html_escape_val): |
1155 | return self.expand_lines(REVISION_FOOTER_TEMPLATE, | |
1156 | html_escape_val=html_escape_val) | |
bc501f69 | 1157 | |
4b1fd356 MM |
1158 | def generate_email(self, push, body_filter=None, extra_header_values={}): |
1159 | self._contains_diff() | |
1160 | return Change.generate_email(self, push, body_filter, extra_header_values) | |
1161 | ||
7c554311 | 1162 | def get_specific_fromaddr(self): |
4b1fd356 MM |
1163 | return self.environment.from_commit |
1164 | ||
bc501f69 MH |
1165 | |
1166 | class ReferenceChange(Change): | |
1167 | """A Change to a Git reference. | |
1168 | ||
1169 | An abstract class representing a create, update, or delete of a | |
1170 | Git reference. Derived classes handle specific types of reference | |
1171 | (e.g., tags vs. branches). These classes generate the main | |
1172 | reference change email summarizing the reference change and | |
1173 | whether it caused any any commits to be added or removed. | |
1174 | ||
1175 | ReferenceChange objects are usually created using the static | |
1176 | create() method, which has the logic to decide which derived class | |
1177 | to instantiate.""" | |
1178 | ||
1179 | REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$') | |
1180 | ||
1181 | @staticmethod | |
1182 | def create(environment, oldrev, newrev, refname): | |
1183 | """Return a ReferenceChange object representing the change. | |
1184 | ||
1185 | Return an object that represents the type of change that is being | |
1186 | made. oldrev and newrev should be SHA1s or ZEROS.""" | |
1187 | ||
1188 | old = GitObject(oldrev) | |
1189 | new = GitObject(newrev) | |
1190 | rev = new or old | |
1191 | ||
1192 | # The revision type tells us what type the commit is, combined with | |
1193 | # the location of the ref we can decide between | |
1194 | # - working branch | |
1195 | # - tracking branch | |
1196 | # - unannotated tag | |
1197 | # - annotated tag | |
1198 | m = ReferenceChange.REF_RE.match(refname) | |
1199 | if m: | |
1200 | area = m.group('area') | |
1201 | short_refname = m.group('shortname') | |
1202 | else: | |
1203 | area = '' | |
1204 | short_refname = refname | |
1205 | ||
1206 | if rev.type == 'tag': | |
1207 | # Annotated tag: | |
1208 | klass = AnnotatedTagChange | |
1209 | elif rev.type == 'commit': | |
1210 | if area == 'tags': | |
1211 | # Non-annotated tag: | |
1212 | klass = NonAnnotatedTagChange | |
1213 | elif area == 'heads': | |
1214 | # Branch: | |
1215 | klass = BranchChange | |
1216 | elif area == 'remotes': | |
1217 | # Tracking branch: | |
5b1d901c | 1218 | environment.log_warning( |
bc501f69 | 1219 | '*** Push-update of tracking branch %r\n' |
7c554311 | 1220 | '*** - incomplete email generated.' |
5b1d901c | 1221 | % (refname,) |
bc501f69 MH |
1222 | ) |
1223 | klass = OtherReferenceChange | |
1224 | else: | |
1225 | # Some other reference namespace: | |
5b1d901c | 1226 | environment.log_warning( |
bc501f69 | 1227 | '*** Push-update of strange reference %r\n' |
7c554311 | 1228 | '*** - incomplete email generated.' |
5b1d901c | 1229 | % (refname,) |
bc501f69 MH |
1230 | ) |
1231 | klass = OtherReferenceChange | |
1232 | else: | |
1233 | # Anything else (is there anything else?) | |
5b1d901c | 1234 | environment.log_warning( |
bc501f69 | 1235 | '*** Unknown type of update to %r (%s)\n' |
7c554311 | 1236 | '*** - incomplete email generated.' |
5b1d901c | 1237 | % (refname, rev.type,) |
bc501f69 MH |
1238 | ) |
1239 | klass = OtherReferenceChange | |
1240 | ||
1241 | return klass( | |
1242 | environment, | |
1243 | refname=refname, short_refname=short_refname, | |
1244 | old=old, new=new, rev=rev, | |
1245 | ) | |
1246 | ||
1247 | def __init__(self, environment, refname, short_refname, old, new, rev): | |
1248 | Change.__init__(self, environment) | |
1249 | self.change_type = { | |
5b1d901c MM |
1250 | (False, True): 'create', |
1251 | (True, True): 'update', | |
1252 | (True, False): 'delete', | |
bc501f69 MH |
1253 | }[bool(old), bool(new)] |
1254 | self.refname = refname | |
1255 | self.short_refname = short_refname | |
1256 | self.old = old | |
1257 | self.new = new | |
1258 | self.rev = rev | |
1259 | self.msgid = make_msgid() | |
1260 | self.diffopts = environment.diffopts | |
5b1d901c | 1261 | self.graphopts = environment.graphopts |
bc501f69 | 1262 | self.logopts = environment.logopts |
b513f71f | 1263 | self.commitlogopts = environment.commitlogopts |
5b1d901c | 1264 | self.showgraph = environment.refchange_showgraph |
bc501f69 MH |
1265 | self.showlog = environment.refchange_showlog |
1266 | ||
5b1d901c MM |
1267 | self.header_template = REFCHANGE_HEADER_TEMPLATE |
1268 | self.intro_template = REFCHANGE_INTRO_TEMPLATE | |
1269 | self.footer_template = FOOTER_TEMPLATE | |
1270 | ||
bc501f69 MH |
1271 | def _compute_values(self): |
1272 | values = Change._compute_values(self) | |
1273 | ||
1274 | values['change_type'] = self.change_type | |
1275 | values['refname_type'] = self.refname_type | |
1276 | values['refname'] = self.refname | |
1277 | values['short_refname'] = self.short_refname | |
1278 | values['msgid'] = self.msgid | |
1279 | values['recipients'] = self.recipients | |
1280 | values['oldrev'] = str(self.old) | |
1281 | values['oldrev_short'] = self.old.short | |
1282 | values['newrev'] = str(self.new) | |
1283 | values['newrev_short'] = self.new.short | |
1284 | ||
1285 | if self.old: | |
1286 | values['oldrev_type'] = self.old.type | |
1287 | if self.new: | |
1288 | values['newrev_type'] = self.new.type | |
1289 | ||
1290 | reply_to = self.environment.get_reply_to_refchange(self) | |
1291 | if reply_to: | |
1292 | values['reply_to'] = reply_to | |
1293 | ||
1294 | return values | |
1295 | ||
5b1d901c MM |
1296 | def send_single_combined_email(self, known_added_sha1s): |
1297 | """Determine if a combined refchange/revision email should be sent | |
1298 | ||
1299 | If there is only a single new (non-merge) commit added by a | |
1300 | change, it is useful to combine the ReferenceChange and | |
1301 | Revision emails into one. In such a case, return the single | |
1302 | revision; otherwise, return None. | |
1303 | ||
1304 | This method is overridden in BranchChange.""" | |
1305 | ||
1306 | return None | |
1307 | ||
1308 | def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): | |
1309 | """Generate an email describing this change AND specified revision. | |
1310 | ||
1311 | Iterate over the lines (including the header lines) of an | |
1312 | email describing this change. If body_filter is not None, | |
1313 | then use it to filter the lines that are intended for the | |
1314 | email body. | |
1315 | ||
1316 | The extra_header_values field is received as a dict and not as | |
1317 | **kwargs, to allow passing other keyword arguments in the | |
1318 | future (e.g. passing extra values to generate_email_intro() | |
1319 | ||
1320 | This method is overridden in BranchChange.""" | |
1321 | ||
1322 | raise NotImplementedError | |
1323 | ||
bc501f69 MH |
1324 | def get_subject(self): |
1325 | template = { | |
5b1d901c MM |
1326 | 'create': REF_CREATED_SUBJECT_TEMPLATE, |
1327 | 'update': REF_UPDATED_SUBJECT_TEMPLATE, | |
1328 | 'delete': REF_DELETED_SUBJECT_TEMPLATE, | |
bc501f69 MH |
1329 | }[self.change_type] |
1330 | return self.expand(template) | |
1331 | ||
b513f71f MH |
1332 | def generate_email_header(self, **extra_values): |
1333 | if 'subject' not in extra_values: | |
1334 | extra_values['subject'] = self.get_subject() | |
1335 | ||
bc501f69 | 1336 | for line in self.expand_header_lines( |
5b1d901c MM |
1337 | self.header_template, **extra_values |
1338 | ): | |
bc501f69 MH |
1339 | yield line |
1340 | ||
4453d76c MM |
1341 | def generate_email_intro(self, html_escape_val=False): |
1342 | for line in self.expand_lines(self.intro_template, | |
1343 | html_escape_val=html_escape_val): | |
bc501f69 MH |
1344 | yield line |
1345 | ||
1346 | def generate_email_body(self, push): | |
1347 | """Call the appropriate body-generation routine. | |
1348 | ||
1349 | Call one of generate_create_summary() / | |
1350 | generate_update_summary() / generate_delete_summary().""" | |
1351 | ||
1352 | change_summary = { | |
5b1d901c MM |
1353 | 'create': self.generate_create_summary, |
1354 | 'delete': self.generate_delete_summary, | |
1355 | 'update': self.generate_update_summary, | |
bc501f69 MH |
1356 | }[self.change_type](push) |
1357 | for line in change_summary: | |
1358 | yield line | |
1359 | ||
1360 | for line in self.generate_revision_change_summary(push): | |
1361 | yield line | |
1362 | ||
4453d76c MM |
1363 | def generate_email_footer(self, html_escape_val): |
1364 | return self.expand_lines(self.footer_template, | |
1365 | html_escape_val=html_escape_val) | |
5b1d901c MM |
1366 | |
1367 | def generate_revision_change_graph(self, push): | |
1368 | if self.showgraph: | |
1369 | args = ['--graph'] + self.graphopts | |
1370 | for newold in ('new', 'old'): | |
1371 | has_newold = False | |
1372 | spec = push.get_commits_spec(newold, self) | |
1373 | for line in git_log(spec, args=args, keepends=True): | |
1374 | if not has_newold: | |
1375 | has_newold = True | |
1376 | yield '\n' | |
1377 | yield 'Graph of %s commits:\n\n' % ( | |
1378 | {'new': 'new', 'old': 'discarded'}[newold],) | |
1379 | yield ' ' + line | |
1380 | if has_newold: | |
1381 | yield '\n' | |
bc501f69 MH |
1382 | |
1383 | def generate_revision_change_log(self, new_commits_list): | |
1384 | if self.showlog: | |
1385 | yield '\n' | |
1386 | yield 'Detailed log of new commits:\n\n' | |
1387 | for line in read_git_lines( | |
4b1fd356 MM |
1388 | ['log', '--no-walk'] + |
1389 | self.logopts + | |
1390 | new_commits_list + | |
1391 | ['--'], | |
bc501f69 | 1392 | keepends=True, |
5b1d901c | 1393 | ): |
bc501f69 MH |
1394 | yield line |
1395 | ||
5b1d901c MM |
1396 | def generate_new_revision_summary(self, tot, new_commits_list, push): |
1397 | for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): | |
1398 | yield line | |
1399 | for line in self.generate_revision_change_graph(push): | |
1400 | yield line | |
1401 | for line in self.generate_revision_change_log(new_commits_list): | |
1402 | yield line | |
1403 | ||
bc501f69 MH |
1404 | def generate_revision_change_summary(self, push): |
1405 | """Generate a summary of the revisions added/removed by this change.""" | |
1406 | ||
1407 | if self.new.commit_sha1 and not self.old.commit_sha1: | |
1408 | # A new reference was created. List the new revisions | |
1409 | # brought by the new reference (i.e., those revisions that | |
1410 | # were not in the repository before this reference | |
1411 | # change). | |
1412 | sha1s = list(push.get_new_commits(self)) | |
1413 | sha1s.reverse() | |
1414 | tot = len(sha1s) | |
1415 | new_revisions = [ | |
5b1d901c | 1416 | Revision(self, GitObject(sha1), num=i + 1, tot=tot) |
bc501f69 MH |
1417 | for (i, sha1) in enumerate(sha1s) |
1418 | ] | |
1419 | ||
1420 | if new_revisions: | |
1421 | yield self.expand('This %(refname_type)s includes the following new commits:\n') | |
1422 | yield '\n' | |
1423 | for r in new_revisions: | |
1424 | (sha1, subject) = r.rev.get_summary() | |
1425 | yield r.expand( | |
1426 | BRIEF_SUMMARY_TEMPLATE, action='new', text=subject, | |
1427 | ) | |
1428 | yield '\n' | |
5b1d901c MM |
1429 | for line in self.generate_new_revision_summary( |
1430 | tot, [r.rev.sha1 for r in new_revisions], push): | |
bc501f69 MH |
1431 | yield line |
1432 | else: | |
1433 | for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): | |
1434 | yield line | |
1435 | ||
1436 | elif self.new.commit_sha1 and self.old.commit_sha1: | |
1437 | # A reference was changed to point at a different commit. | |
1438 | # List the revisions that were removed and/or added *from | |
1439 | # that reference* by this reference change, along with a | |
1440 | # diff between the trees for its old and new values. | |
1441 | ||
1442 | # List of the revisions that were added to the branch by | |
1443 | # this update. Note this list can include revisions that | |
1444 | # have already had notification emails; we want such | |
1445 | # revisions in the summary even though we will not send | |
1446 | # new notification emails for them. | |
1447 | adds = list(generate_summaries( | |
5b1d901c MM |
1448 | '--topo-order', '--reverse', '%s..%s' |
1449 | % (self.old.commit_sha1, self.new.commit_sha1,) | |
1450 | )) | |
bc501f69 MH |
1451 | |
1452 | # List of the revisions that were removed from the branch | |
1453 | # by this update. This will be empty except for | |
1454 | # non-fast-forward updates. | |
1455 | discards = list(generate_summaries( | |
5b1d901c MM |
1456 | '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,) |
1457 | )) | |
bc501f69 MH |
1458 | |
1459 | if adds: | |
1460 | new_commits_list = push.get_new_commits(self) | |
1461 | else: | |
1462 | new_commits_list = [] | |
1463 | new_commits = CommitSet(new_commits_list) | |
1464 | ||
1465 | if discards: | |
1466 | discarded_commits = CommitSet(push.get_discarded_commits(self)) | |
1467 | else: | |
1468 | discarded_commits = CommitSet([]) | |
1469 | ||
1470 | if discards and adds: | |
1471 | for (sha1, subject) in discards: | |
1472 | if sha1 in discarded_commits: | |
7c554311 | 1473 | action = 'discard' |
bc501f69 | 1474 | else: |
7c554311 | 1475 | action = 'omit' |
bc501f69 MH |
1476 | yield self.expand( |
1477 | BRIEF_SUMMARY_TEMPLATE, action=action, | |
1478 | rev_short=sha1, text=subject, | |
1479 | ) | |
1480 | for (sha1, subject) in adds: | |
1481 | if sha1 in new_commits: | |
1482 | action = 'new' | |
1483 | else: | |
7c554311 | 1484 | action = 'add' |
bc501f69 MH |
1485 | yield self.expand( |
1486 | BRIEF_SUMMARY_TEMPLATE, action=action, | |
1487 | rev_short=sha1, text=subject, | |
1488 | ) | |
1489 | yield '\n' | |
1490 | for line in self.expand_lines(NON_FF_TEMPLATE): | |
1491 | yield line | |
1492 | ||
1493 | elif discards: | |
1494 | for (sha1, subject) in discards: | |
1495 | if sha1 in discarded_commits: | |
7c554311 | 1496 | action = 'discard' |
bc501f69 | 1497 | else: |
7c554311 | 1498 | action = 'omit' |
bc501f69 MH |
1499 | yield self.expand( |
1500 | BRIEF_SUMMARY_TEMPLATE, action=action, | |
1501 | rev_short=sha1, text=subject, | |
1502 | ) | |
1503 | yield '\n' | |
1504 | for line in self.expand_lines(REWIND_ONLY_TEMPLATE): | |
1505 | yield line | |
1506 | ||
1507 | elif adds: | |
1508 | (sha1, subject) = self.old.get_summary() | |
1509 | yield self.expand( | |
1510 | BRIEF_SUMMARY_TEMPLATE, action='from', | |
1511 | rev_short=sha1, text=subject, | |
1512 | ) | |
1513 | for (sha1, subject) in adds: | |
1514 | if sha1 in new_commits: | |
1515 | action = 'new' | |
1516 | else: | |
7c554311 | 1517 | action = 'add' |
bc501f69 MH |
1518 | yield self.expand( |
1519 | BRIEF_SUMMARY_TEMPLATE, action=action, | |
1520 | rev_short=sha1, text=subject, | |
1521 | ) | |
1522 | ||
1523 | yield '\n' | |
1524 | ||
1525 | if new_commits: | |
5b1d901c MM |
1526 | for line in self.generate_new_revision_summary( |
1527 | len(new_commits), new_commits_list, push): | |
bc501f69 MH |
1528 | yield line |
1529 | else: | |
1530 | for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): | |
1531 | yield line | |
5b1d901c MM |
1532 | for line in self.generate_revision_change_graph(push): |
1533 | yield line | |
bc501f69 MH |
1534 | |
1535 | # The diffstat is shown from the old revision to the new | |
1536 | # revision. This is to show the truth of what happened in | |
1537 | # this change. There's no point showing the stat from the | |
1538 | # base to the new revision because the base is effectively a | |
1539 | # random revision at this point - the user will be interested | |
1540 | # in what this revision changed - including the undoing of | |
1541 | # previous revisions in the case of non-fast-forward updates. | |
1542 | yield '\n' | |
1543 | yield 'Summary of changes:\n' | |
1544 | for line in read_git_lines( | |
4b1fd356 MM |
1545 | ['diff-tree'] + |
1546 | self.diffopts + | |
1547 | ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)], | |
5b1d901c MM |
1548 | keepends=True, |
1549 | ): | |
bc501f69 MH |
1550 | yield line |
1551 | ||
1552 | elif self.old.commit_sha1 and not self.new.commit_sha1: | |
1553 | # A reference was deleted. List the revisions that were | |
1554 | # removed from the repository by this reference change. | |
1555 | ||
1556 | sha1s = list(push.get_discarded_commits(self)) | |
1557 | tot = len(sha1s) | |
1558 | discarded_revisions = [ | |
5b1d901c | 1559 | Revision(self, GitObject(sha1), num=i + 1, tot=tot) |
bc501f69 MH |
1560 | for (i, sha1) in enumerate(sha1s) |
1561 | ] | |
1562 | ||
1563 | if discarded_revisions: | |
1564 | for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE): | |
1565 | yield line | |
1566 | yield '\n' | |
1567 | for r in discarded_revisions: | |
1568 | (sha1, subject) = r.rev.get_summary() | |
1569 | yield r.expand( | |
7c554311 | 1570 | BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject, |
bc501f69 | 1571 | ) |
5b1d901c MM |
1572 | for line in self.generate_revision_change_graph(push): |
1573 | yield line | |
bc501f69 MH |
1574 | else: |
1575 | for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE): | |
1576 | yield line | |
1577 | ||
1578 | elif not self.old.commit_sha1 and not self.new.commit_sha1: | |
1579 | for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE): | |
1580 | yield line | |
1581 | ||
1582 | def generate_create_summary(self, push): | |
1583 | """Called for the creation of a reference.""" | |
1584 | ||
1585 | # This is a new reference and so oldrev is not valid | |
1586 | (sha1, subject) = self.new.get_summary() | |
1587 | yield self.expand( | |
1588 | BRIEF_SUMMARY_TEMPLATE, action='at', | |
1589 | rev_short=sha1, text=subject, | |
1590 | ) | |
1591 | yield '\n' | |
1592 | ||
1593 | def generate_update_summary(self, push): | |
1594 | """Called for the change of a pre-existing branch.""" | |
1595 | ||
1596 | return iter([]) | |
1597 | ||
1598 | def generate_delete_summary(self, push): | |
1599 | """Called for the deletion of any type of reference.""" | |
1600 | ||
1601 | (sha1, subject) = self.old.get_summary() | |
1602 | yield self.expand( | |
1603 | BRIEF_SUMMARY_TEMPLATE, action='was', | |
1604 | rev_short=sha1, text=subject, | |
1605 | ) | |
1606 | yield '\n' | |
1607 | ||
7c554311 | 1608 | def get_specific_fromaddr(self): |
4b1fd356 MM |
1609 | return self.environment.from_refchange |
1610 | ||
bc501f69 MH |
1611 | |
1612 | class BranchChange(ReferenceChange): | |
1613 | refname_type = 'branch' | |
1614 | ||
1615 | def __init__(self, environment, refname, short_refname, old, new, rev): | |
1616 | ReferenceChange.__init__( | |
1617 | self, environment, | |
1618 | refname=refname, short_refname=short_refname, | |
1619 | old=old, new=new, rev=rev, | |
1620 | ) | |
1621 | self.recipients = environment.get_refchange_recipients(self) | |
5b1d901c MM |
1622 | self._single_revision = None |
1623 | ||
1624 | def send_single_combined_email(self, known_added_sha1s): | |
1625 | if not self.environment.combine_when_single_commit: | |
1626 | return None | |
1627 | ||
1628 | # In the sadly-all-too-frequent usecase of people pushing only | |
1629 | # one of their commits at a time to a repository, users feel | |
1630 | # the reference change summary emails are noise rather than | |
1631 | # important signal. This is because, in this particular | |
1632 | # usecase, there is a reference change summary email for each | |
1633 | # new commit, and all these summaries do is point out that | |
1634 | # there is one new commit (which can readily be inferred by | |
1635 | # the existence of the individual revision email that is also | |
1636 | # sent). In such cases, our users prefer there to be a combined | |
1637 | # reference change summary/new revision email. | |
1638 | # | |
1639 | # So, if the change is an update and it doesn't discard any | |
1640 | # commits, and it adds exactly one non-merge commit (gerrit | |
1641 | # forces a workflow where every commit is individually merged | |
1642 | # and the git-multimail hook fired off for just this one | |
1643 | # change), then we send a combined refchange/revision email. | |
1644 | try: | |
1645 | # If this change is a reference update that doesn't discard | |
1646 | # any commits... | |
1647 | if self.change_type != 'update': | |
1648 | return None | |
1649 | ||
1650 | if read_git_lines( | |
1651 | ['merge-base', self.old.sha1, self.new.sha1] | |
1652 | ) != [self.old.sha1]: | |
1653 | return None | |
1654 | ||
1655 | # Check if this update introduced exactly one non-merge | |
1656 | # commit: | |
1657 | ||
1658 | def split_line(line): | |
1659 | """Split line into (sha1, [parent,...]).""" | |
1660 | ||
1661 | words = line.split() | |
1662 | return (words[0], words[1:]) | |
1663 | ||
1664 | # Get the new commits introduced by the push as a list of | |
1665 | # (sha1, [parent,...]) | |
1666 | new_commits = [ | |
1667 | split_line(line) | |
1668 | for line in read_git_lines( | |
1669 | [ | |
1670 | 'log', '-3', '--format=%H %P', | |
1671 | '%s..%s' % (self.old.sha1, self.new.sha1), | |
1672 | ] | |
1673 | ) | |
1674 | ] | |
1675 | ||
1676 | if not new_commits: | |
1677 | return None | |
1678 | ||
1679 | # If the newest commit is a merge, save it for a later check | |
1680 | # but otherwise ignore it | |
1681 | merge = None | |
1682 | tot = len(new_commits) | |
1683 | if len(new_commits[0][1]) > 1: | |
1684 | merge = new_commits[0][0] | |
1685 | del new_commits[0] | |
1686 | ||
1687 | # Our primary check: we can't combine if more than one commit | |
1688 | # is introduced. We also currently only combine if the new | |
1689 | # commit is a non-merge commit, though it may make sense to | |
1690 | # combine if it is a merge as well. | |
1691 | if not ( | |
4b1fd356 MM |
1692 | len(new_commits) == 1 and |
1693 | len(new_commits[0][1]) == 1 and | |
1694 | new_commits[0][0] in known_added_sha1s | |
5b1d901c MM |
1695 | ): |
1696 | return None | |
1697 | ||
1698 | # We do not want to combine revision and refchange emails if | |
1699 | # those go to separate locations. | |
1700 | rev = Revision(self, GitObject(new_commits[0][0]), 1, tot) | |
1701 | if rev.recipients != self.recipients: | |
1702 | return None | |
1703 | ||
1704 | # We ignored the newest commit if it was just a merge of the one | |
1705 | # commit being introduced. But we don't want to ignore that | |
1706 | # merge commit it it involved conflict resolutions. Check that. | |
1707 | if merge and merge != read_git_output(['diff-tree', '--cc', merge]): | |
1708 | return None | |
1709 | ||
1710 | # We can combine the refchange and one new revision emails | |
1711 | # into one. Return the Revision that a combined email should | |
1712 | # be sent about. | |
1713 | return rev | |
1714 | except CommandError: | |
1715 | # Cannot determine number of commits in old..new or new..old; | |
1716 | # don't combine reference/revision emails: | |
1717 | return None | |
1718 | ||
1719 | def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): | |
1720 | values = revision.get_values() | |
1721 | if extra_header_values: | |
1722 | values.update(extra_header_values) | |
1723 | if 'subject' not in extra_header_values: | |
1724 | values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values) | |
1725 | ||
1726 | self._single_revision = revision | |
4b1fd356 | 1727 | self._contains_diff() |
5b1d901c MM |
1728 | self.header_template = COMBINED_HEADER_TEMPLATE |
1729 | self.intro_template = COMBINED_INTRO_TEMPLATE | |
1730 | self.footer_template = COMBINED_FOOTER_TEMPLATE | |
17130a70 MM |
1731 | |
1732 | def revision_gen_link(base_url): | |
1733 | # revision is used only to generate the body, and | |
1734 | # _content_type is set while generating headers. Get it | |
1735 | # from the BranchChange object. | |
1736 | revision._content_type = self._content_type | |
1737 | return revision.generate_browse_link(base_url) | |
1738 | self.generate_browse_link = revision_gen_link | |
5b1d901c MM |
1739 | for line in self.generate_email(push, body_filter, values): |
1740 | yield line | |
1741 | ||
1742 | def generate_email_body(self, push): | |
1743 | '''Call the appropriate body generation routine. | |
1744 | ||
1745 | If this is a combined refchange/revision email, the special logic | |
1746 | for handling this combined email comes from this function. For | |
1747 | other cases, we just use the normal handling.''' | |
1748 | ||
1749 | # If self._single_revision isn't set; don't override | |
1750 | if not self._single_revision: | |
1751 | for line in super(BranchChange, self).generate_email_body(push): | |
1752 | yield line | |
1753 | return | |
1754 | ||
1755 | # This is a combined refchange/revision email; we first provide | |
1756 | # some info from the refchange portion, and then call the revision | |
1757 | # generate_email_body function to handle the revision portion. | |
1758 | adds = list(generate_summaries( | |
1759 | '--topo-order', '--reverse', '%s..%s' | |
1760 | % (self.old.commit_sha1, self.new.commit_sha1,) | |
1761 | )) | |
1762 | ||
1763 | yield self.expand("The following commit(s) were added to %(refname)s by this push:\n") | |
1764 | for (sha1, subject) in adds: | |
1765 | yield self.expand( | |
1766 | BRIEF_SUMMARY_TEMPLATE, action='new', | |
1767 | rev_short=sha1, text=subject, | |
1768 | ) | |
1769 | ||
1770 | yield self._single_revision.rev.short + " is described below\n" | |
1771 | yield '\n' | |
1772 | ||
1773 | for line in self._single_revision.generate_email_body(push): | |
1774 | yield line | |
bc501f69 MH |
1775 | |
1776 | ||
1777 | class AnnotatedTagChange(ReferenceChange): | |
1778 | refname_type = 'annotated tag' | |
1779 | ||
1780 | def __init__(self, environment, refname, short_refname, old, new, rev): | |
1781 | ReferenceChange.__init__( | |
1782 | self, environment, | |
1783 | refname=refname, short_refname=short_refname, | |
1784 | old=old, new=new, rev=rev, | |
1785 | ) | |
1786 | self.recipients = environment.get_announce_recipients(self) | |
1787 | self.show_shortlog = environment.announce_show_shortlog | |
1788 | ||
1789 | ANNOTATED_TAG_FORMAT = ( | |
1790 | '%(*objectname)\n' | |
1791 | '%(*objecttype)\n' | |
1792 | '%(taggername)\n' | |
1793 | '%(taggerdate)' | |
1794 | ) | |
1795 | ||
1796 | def describe_tag(self, push): | |
1797 | """Describe the new value of an annotated tag.""" | |
1798 | ||
1799 | # Use git for-each-ref to pull out the individual fields from | |
1800 | # the tag | |
1801 | [tagobject, tagtype, tagger, tagged] = read_git_lines( | |
1802 | ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname], | |
1803 | ) | |
1804 | ||
1805 | yield self.expand( | |
1806 | BRIEF_SUMMARY_TEMPLATE, action='tagging', | |
1807 | rev_short=tagobject, text='(%s)' % (tagtype,), | |
1808 | ) | |
1809 | if tagtype == 'commit': | |
1810 | # If the tagged object is a commit, then we assume this is a | |
1811 | # release, and so we calculate which tag this tag is | |
1812 | # replacing | |
1813 | try: | |
1814 | prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)]) | |
1815 | except CommandError: | |
1816 | prevtag = None | |
1817 | if prevtag: | |
7c554311 | 1818 | yield ' replaces %s\n' % (prevtag,) |
bc501f69 MH |
1819 | else: |
1820 | prevtag = None | |
7c554311 | 1821 | yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),) |
bc501f69 | 1822 | |
7c554311 MM |
1823 | yield ' by %s\n' % (tagger,) |
1824 | yield ' on %s\n' % (tagged,) | |
bc501f69 MH |
1825 | yield '\n' |
1826 | ||
1827 | # Show the content of the tag message; this might contain a | |
1828 | # change log or release notes so is worth displaying. | |
1829 | yield LOGBEGIN | |
1830 | contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True)) | |
1831 | contents = contents[contents.index('\n') + 1:] | |
1832 | if contents and contents[-1][-1:] != '\n': | |
1833 | contents.append('\n') | |
1834 | for line in contents: | |
1835 | yield line | |
1836 | ||
1837 | if self.show_shortlog and tagtype == 'commit': | |
1838 | # Only commit tags make sense to have rev-list operations | |
1839 | # performed on them | |
1840 | yield '\n' | |
1841 | if prevtag: | |
1842 | # Show changes since the previous release | |
1843 | revlist = read_git_output( | |
1844 | ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)], | |
1845 | keepends=True, | |
1846 | ) | |
1847 | else: | |
1848 | # No previous tag, show all the changes since time | |
1849 | # began | |
1850 | revlist = read_git_output( | |
1851 | ['rev-list', '--pretty=short', '%s' % (self.new,)], | |
1852 | keepends=True, | |
1853 | ) | |
1854 | for line in read_git_lines(['shortlog'], input=revlist, keepends=True): | |
1855 | yield line | |
1856 | ||
1857 | yield LOGEND | |
1858 | yield '\n' | |
1859 | ||
1860 | def generate_create_summary(self, push): | |
1861 | """Called for the creation of an annotated tag.""" | |
1862 | ||
1863 | for line in self.expand_lines(TAG_CREATED_TEMPLATE): | |
1864 | yield line | |
1865 | ||
1866 | for line in self.describe_tag(push): | |
1867 | yield line | |
1868 | ||
1869 | def generate_update_summary(self, push): | |
1870 | """Called for the update of an annotated tag. | |
1871 | ||
1872 | This is probably a rare event and may not even be allowed.""" | |
1873 | ||
1874 | for line in self.expand_lines(TAG_UPDATED_TEMPLATE): | |
1875 | yield line | |
1876 | ||
1877 | for line in self.describe_tag(push): | |
1878 | yield line | |
1879 | ||
1880 | def generate_delete_summary(self, push): | |
1881 | """Called when a non-annotated reference is updated.""" | |
1882 | ||
1883 | for line in self.expand_lines(TAG_DELETED_TEMPLATE): | |
1884 | yield line | |
1885 | ||
1886 | yield self.expand(' tag was %(oldrev_short)s\n') | |
1887 | yield '\n' | |
1888 | ||
1889 | ||
1890 | class NonAnnotatedTagChange(ReferenceChange): | |
1891 | refname_type = 'tag' | |
1892 | ||
1893 | def __init__(self, environment, refname, short_refname, old, new, rev): | |
1894 | ReferenceChange.__init__( | |
1895 | self, environment, | |
1896 | refname=refname, short_refname=short_refname, | |
1897 | old=old, new=new, rev=rev, | |
1898 | ) | |
1899 | self.recipients = environment.get_refchange_recipients(self) | |
1900 | ||
1901 | def generate_create_summary(self, push): | |
1902 | """Called for the creation of an annotated tag.""" | |
1903 | ||
1904 | for line in self.expand_lines(TAG_CREATED_TEMPLATE): | |
1905 | yield line | |
1906 | ||
1907 | def generate_update_summary(self, push): | |
1908 | """Called when a non-annotated reference is updated.""" | |
1909 | ||
1910 | for line in self.expand_lines(TAG_UPDATED_TEMPLATE): | |
1911 | yield line | |
1912 | ||
1913 | def generate_delete_summary(self, push): | |
1914 | """Called when a non-annotated reference is updated.""" | |
1915 | ||
1916 | for line in self.expand_lines(TAG_DELETED_TEMPLATE): | |
1917 | yield line | |
1918 | ||
1919 | for line in ReferenceChange.generate_delete_summary(self, push): | |
1920 | yield line | |
1921 | ||
1922 | ||
1923 | class OtherReferenceChange(ReferenceChange): | |
1924 | refname_type = 'reference' | |
1925 | ||
1926 | def __init__(self, environment, refname, short_refname, old, new, rev): | |
1927 | # We use the full refname as short_refname, because otherwise | |
1928 | # the full name of the reference would not be obvious from the | |
1929 | # text of the email. | |
1930 | ReferenceChange.__init__( | |
1931 | self, environment, | |
1932 | refname=refname, short_refname=refname, | |
1933 | old=old, new=new, rev=rev, | |
1934 | ) | |
1935 | self.recipients = environment.get_refchange_recipients(self) | |
1936 | ||
1937 | ||
1938 | class Mailer(object): | |
1939 | """An object that can send emails.""" | |
1940 | ||
7c554311 MM |
1941 | def __init__(self, environment): |
1942 | self.environment = environment | |
1943 | ||
bc501f69 MH |
1944 | def send(self, lines, to_addrs): |
1945 | """Send an email consisting of lines. | |
1946 | ||
1947 | lines must be an iterable over the lines constituting the | |
1948 | header and body of the email. to_addrs is a list of recipient | |
1949 | addresses (can be needed even if lines already contains a | |
1950 | "To:" field). It can be either a string (comma-separated list | |
1951 | of email addresses) or a Python list of individual email | |
1952 | addresses. | |
1953 | ||
1954 | """ | |
1955 | ||
1956 | raise NotImplementedError() | |
1957 | ||
1958 | ||
1959 | class SendMailer(Mailer): | |
b513f71f | 1960 | """Send emails using 'sendmail -oi -t'.""" |
bc501f69 MH |
1961 | |
1962 | SENDMAIL_CANDIDATES = [ | |
1963 | '/usr/sbin/sendmail', | |
1964 | '/usr/lib/sendmail', | |
1965 | ] | |
1966 | ||
1967 | @staticmethod | |
1968 | def find_sendmail(): | |
1969 | for path in SendMailer.SENDMAIL_CANDIDATES: | |
1970 | if os.access(path, os.X_OK): | |
1971 | return path | |
1972 | else: | |
1973 | raise ConfigurationException( | |
1974 | 'No sendmail executable found. ' | |
1975 | 'Try setting multimailhook.sendmailCommand.' | |
1976 | ) | |
1977 | ||
7c554311 | 1978 | def __init__(self, environment, command=None, envelopesender=None): |
bc501f69 MH |
1979 | """Construct a SendMailer instance. |
1980 | ||
1981 | command should be the command and arguments used to invoke | |
1982 | sendmail, as a list of strings. If an envelopesender is | |
1983 | provided, it will also be passed to the command, via '-f | |
1984 | envelopesender'.""" | |
7c554311 | 1985 | super(SendMailer, self).__init__(environment) |
bc501f69 MH |
1986 | if command: |
1987 | self.command = command[:] | |
1988 | else: | |
b513f71f | 1989 | self.command = [self.find_sendmail(), '-oi', '-t'] |
bc501f69 MH |
1990 | |
1991 | if envelopesender: | |
1992 | self.command.extend(['-f', envelopesender]) | |
1993 | ||
1994 | def send(self, lines, to_addrs): | |
1995 | try: | |
1996 | p = subprocess.Popen(self.command, stdin=subprocess.PIPE) | |
4b1fd356 | 1997 | except OSError: |
7c554311 | 1998 | self.environment.get_logger().error( |
4b1fd356 MM |
1999 | '*** Cannot execute command: %s\n' % ' '.join(self.command) + |
2000 | '*** %s\n' % sys.exc_info()[1] + | |
2001 | '*** Try setting multimailhook.mailer to "smtp"\n' + | |
bc501f69 MH |
2002 | '*** to send emails without using the sendmail command.\n' |
2003 | ) | |
2004 | sys.exit(1) | |
2005 | try: | |
4b1fd356 | 2006 | lines = (str_to_bytes(line) for line in lines) |
bc501f69 | 2007 | p.stdin.writelines(lines) |
4b1fd356 | 2008 | except Exception: |
7c554311 | 2009 | self.environment.get_logger().error( |
bc501f69 MH |
2010 | '*** Error while generating commit email\n' |
2011 | '*** - mail sending aborted.\n' | |
2012 | ) | |
7c554311 | 2013 | if hasattr(p, 'terminate'): |
5b1d901c MM |
2014 | # subprocess.terminate() is not available in Python 2.4 |
2015 | p.terminate() | |
7c554311 MM |
2016 | else: |
2017 | import signal | |
2018 | os.kill(p.pid, signal.SIGTERM) | |
4b1fd356 | 2019 | raise |
bc501f69 MH |
2020 | else: |
2021 | p.stdin.close() | |
2022 | retcode = p.wait() | |
2023 | if retcode: | |
2024 | raise CommandError(self.command, retcode) | |
2025 | ||
2026 | ||
2027 | class SMTPMailer(Mailer): | |
2028 | """Send emails using Python's smtplib.""" | |
2029 | ||
7c554311 MM |
2030 | def __init__(self, environment, |
2031 | envelopesender, smtpserver, | |
5b1d901c MM |
2032 | smtpservertimeout=10.0, smtpserverdebuglevel=0, |
2033 | smtpencryption='none', | |
2034 | smtpuser='', smtppass='', | |
4453d76c | 2035 | smtpcacerts='' |
5b1d901c | 2036 | ): |
7c554311 | 2037 | super(SMTPMailer, self).__init__(environment) |
bc501f69 | 2038 | if not envelopesender: |
7c554311 | 2039 | self.environment.get_logger().error( |
bc501f69 MH |
2040 | 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n' |
2041 | 'please set either multimailhook.envelopeSender or user.email\n' | |
2042 | ) | |
2043 | sys.exit(1) | |
5b1d901c MM |
2044 | if smtpencryption == 'ssl' and not (smtpuser and smtppass): |
2045 | raise ConfigurationException( | |
2046 | 'Cannot use SMTPMailer with security option ssl ' | |
2047 | 'without options username and password.' | |
2048 | ) | |
bc501f69 MH |
2049 | self.envelopesender = envelopesender |
2050 | self.smtpserver = smtpserver | |
5b1d901c MM |
2051 | self.smtpservertimeout = smtpservertimeout |
2052 | self.smtpserverdebuglevel = smtpserverdebuglevel | |
2053 | self.security = smtpencryption | |
2054 | self.username = smtpuser | |
2055 | self.password = smtppass | |
4453d76c | 2056 | self.smtpcacerts = smtpcacerts |
bc501f69 | 2057 | try: |
5bdb7a78 MM |
2058 | def call(klass, server, timeout): |
2059 | try: | |
2060 | return klass(server, timeout=timeout) | |
2061 | except TypeError: | |
2062 | # Old Python versions do not have timeout= argument. | |
2063 | return klass(server) | |
5b1d901c | 2064 | if self.security == 'none': |
5bdb7a78 | 2065 | self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) |
5b1d901c | 2066 | elif self.security == 'ssl': |
4453d76c MM |
2067 | if self.smtpcacerts: |
2068 | raise smtplib.SMTPException( | |
2069 | "Checking certificate is not supported for ssl, prefer starttls" | |
2070 | ) | |
5bdb7a78 | 2071 | self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout) |
5b1d901c | 2072 | elif self.security == 'tls': |
4453d76c | 2073 | if 'ssl' not in sys.modules: |
7c554311 | 2074 | self.environment.get_logger().error( |
4453d76c MM |
2075 | '*** Your Python version does not have the ssl library installed\n' |
2076 | '*** smtpEncryption=tls is not available.\n' | |
2077 | '*** Either upgrade Python to 2.6 or later\n' | |
2078 | ' or use git_multimail.py version 1.2.\n') | |
5b1d901c MM |
2079 | if ':' not in self.smtpserver: |
2080 | self.smtpserver += ':587' # default port for TLS | |
5bdb7a78 | 2081 | self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) |
4453d76c MM |
2082 | # start: ehlo + starttls |
2083 | # equivalent to | |
2084 | # self.smtp.ehlo() | |
2085 | # self.smtp.starttls() | |
2086 | # with acces to the ssl layer | |
5b1d901c | 2087 | self.smtp.ehlo() |
4453d76c MM |
2088 | if not self.smtp.has_extn("starttls"): |
2089 | raise smtplib.SMTPException("STARTTLS extension not supported by server") | |
2090 | resp, reply = self.smtp.docmd("STARTTLS") | |
2091 | if resp != 220: | |
2092 | raise smtplib.SMTPException("Wrong answer to the STARTTLS command") | |
2093 | if self.smtpcacerts: | |
2094 | self.smtp.sock = ssl.wrap_socket( | |
2095 | self.smtp.sock, | |
2096 | ca_certs=self.smtpcacerts, | |
2097 | cert_reqs=ssl.CERT_REQUIRED | |
2098 | ) | |
2099 | else: | |
2100 | self.smtp.sock = ssl.wrap_socket( | |
2101 | self.smtp.sock, | |
2102 | cert_reqs=ssl.CERT_NONE | |
2103 | ) | |
7c554311 | 2104 | self.environment.get_logger().error( |
4453d76c MM |
2105 | '*** Warning, the server certificat is not verified (smtp) ***\n' |
2106 | '*** set the option smtpCACerts ***\n' | |
2107 | ) | |
2108 | if not hasattr(self.smtp.sock, "read"): | |
2109 | # using httplib.FakeSocket with Python 2.5.x or earlier | |
2110 | self.smtp.sock.read = self.smtp.sock.recv | |
2111 | self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock) | |
2112 | self.smtp.helo_resp = None | |
2113 | self.smtp.ehlo_resp = None | |
2114 | self.smtp.esmtp_features = {} | |
2115 | self.smtp.does_esmtp = 0 | |
2116 | # end: ehlo + starttls | |
5b1d901c MM |
2117 | self.smtp.ehlo() |
2118 | else: | |
2119 | sys.stdout.write('*** Error: Control reached an invalid option. ***') | |
2120 | sys.exit(1) | |
2121 | if self.smtpserverdebuglevel > 0: | |
2122 | sys.stdout.write( | |
2123 | "*** Setting debug on for SMTP server connection (%s) ***\n" | |
2124 | % self.smtpserverdebuglevel) | |
2125 | self.smtp.set_debuglevel(self.smtpserverdebuglevel) | |
4b1fd356 | 2126 | except Exception: |
7c554311 | 2127 | self.environment.get_logger().error( |
5b1d901c | 2128 | '*** Error establishing SMTP connection to %s ***\n' |
7c554311 MM |
2129 | '*** %s\n' |
2130 | % (self.smtpserver, sys.exc_info()[1])) | |
bc501f69 MH |
2131 | sys.exit(1) |
2132 | ||
2133 | def __del__(self): | |
5b1d901c MM |
2134 | if hasattr(self, 'smtp'): |
2135 | self.smtp.quit() | |
4453d76c | 2136 | del self.smtp |
bc501f69 MH |
2137 | |
2138 | def send(self, lines, to_addrs): | |
2139 | try: | |
5b1d901c | 2140 | if self.username or self.password: |
5b1d901c | 2141 | self.smtp.login(self.username, self.password) |
bc501f69 MH |
2142 | msg = ''.join(lines) |
2143 | # turn comma-separated list into Python list if needed. | |
4453d76c | 2144 | if is_string(to_addrs): |
bc501f69 MH |
2145 | to_addrs = [email for (name, email) in getaddresses([to_addrs])] |
2146 | self.smtp.sendmail(self.envelopesender, to_addrs, msg) | |
4453d76c | 2147 | except smtplib.SMTPResponseException: |
4453d76c | 2148 | err = sys.exc_info()[1] |
7c554311 MM |
2149 | self.environment.get_logger().error( |
2150 | '*** Error sending email ***\n' | |
2151 | '*** Error %d: %s\n' | |
2152 | % (err.smtp_code, bytes_to_str(err.smtp_error))) | |
4453d76c MM |
2153 | try: |
2154 | smtp = self.smtp | |
2155 | # delete the field before quit() so that in case of | |
2156 | # error, self.smtp is deleted anyway. | |
2157 | del self.smtp | |
2158 | smtp.quit() | |
2159 | except: | |
7c554311 MM |
2160 | self.environment.get_logger().error( |
2161 | '*** Error closing the SMTP connection ***\n' | |
2162 | '*** Exiting anyway ... ***\n' | |
2163 | '*** %s\n' % sys.exc_info()[1]) | |
bc501f69 MH |
2164 | sys.exit(1) |
2165 | ||
2166 | ||
2167 | class OutputMailer(Mailer): | |
2168 | """Write emails to an output stream, bracketed by lines of '=' characters. | |
2169 | ||
2170 | This is intended for debugging purposes.""" | |
2171 | ||
2172 | SEPARATOR = '=' * 75 + '\n' | |
2173 | ||
2174 | def __init__(self, f): | |
2175 | self.f = f | |
2176 | ||
2177 | def send(self, lines, to_addrs): | |
4b1fd356 MM |
2178 | write_str(self.f, self.SEPARATOR) |
2179 | for line in lines: | |
2180 | write_str(self.f, line) | |
2181 | write_str(self.f, self.SEPARATOR) | |
bc501f69 MH |
2182 | |
2183 | ||
2184 | def get_git_dir(): | |
2185 | """Determine GIT_DIR. | |
2186 | ||
2187 | Determine GIT_DIR either from the GIT_DIR environment variable or | |
2188 | from the working directory, using Git's usual rules.""" | |
2189 | ||
2190 | try: | |
2191 | return read_git_output(['rev-parse', '--git-dir']) | |
2192 | except CommandError: | |
2193 | sys.stderr.write('fatal: git_multimail: not in a git directory\n') | |
2194 | sys.exit(1) | |
2195 | ||
2196 | ||
2197 | class Environment(object): | |
2198 | """Describes the environment in which the push is occurring. | |
2199 | ||
2200 | An Environment object encapsulates information about the local | |
2201 | environment. For example, it knows how to determine: | |
2202 | ||
2203 | * the name of the repository to which the push occurred | |
2204 | ||
2205 | * what user did the push | |
2206 | ||
2207 | * what users want to be informed about various types of changes. | |
2208 | ||
2209 | An Environment object is expected to have the following methods: | |
2210 | ||
2211 | get_repo_shortname() | |
2212 | ||
2213 | Return a short name for the repository, for display | |
2214 | purposes. | |
2215 | ||
2216 | get_repo_path() | |
2217 | ||
2218 | Return the absolute path to the Git repository. | |
2219 | ||
2220 | get_emailprefix() | |
2221 | ||
2222 | Return a string that will be prefixed to every email's | |
2223 | subject. | |
2224 | ||
2225 | get_pusher() | |
2226 | ||
2227 | Return the username of the person who pushed the changes. | |
2228 | This value is used in the email body to indicate who | |
2229 | pushed the change. | |
2230 | ||
2231 | get_pusher_email() (may return None) | |
2232 | ||
2233 | Return the email address of the person who pushed the | |
2234 | changes. The value should be a single RFC 2822 email | |
2235 | address as a string; e.g., "Joe User <user@example.com>" | |
2236 | if available, otherwise "user@example.com". If set, the | |
2237 | value is used as the Reply-To address for refchange | |
2238 | emails. If it is impossible to determine the pusher's | |
2239 | email, this attribute should be set to None (in which case | |
2240 | no Reply-To header will be output). | |
2241 | ||
2242 | get_sender() | |
2243 | ||
2244 | Return the address to be used as the 'From' email address | |
2245 | in the email envelope. | |
2246 | ||
4b1fd356 | 2247 | get_fromaddr(change=None) |
bc501f69 MH |
2248 | |
2249 | Return the 'From' email address used in the email 'From:' | |
4b1fd356 MM |
2250 | headers. If the change is known when this function is |
2251 | called, it is passed in as the 'change' parameter. (May | |
2252 | be a full RFC 2822 email address like 'Joe User | |
2253 | <user@example.com>'.) | |
bc501f69 MH |
2254 | |
2255 | get_administrator() | |
2256 | ||
2257 | Return the name and/or email of the repository | |
2258 | administrator. This value is used in the footer as the | |
2259 | person to whom requests to be removed from the | |
2260 | notification list should be sent. Ideally, it should | |
2261 | include a valid email address. | |
2262 | ||
2263 | get_reply_to_refchange() | |
2264 | get_reply_to_commit() | |
2265 | ||
2266 | Return the address to use in the email "Reply-To" header, | |
2267 | as a string. These can be an RFC 2822 email address, or | |
2268 | None to omit the "Reply-To" header. | |
2269 | get_reply_to_refchange() is used for refchange emails; | |
2270 | get_reply_to_commit() is used for individual commit | |
2271 | emails. | |
2272 | ||
4b1fd356 MM |
2273 | get_ref_filter_regex() |
2274 | ||
2275 | Return a tuple -- a compiled regex, and a boolean indicating | |
2276 | whether the regex picks refs to include (if False, the regex | |
2277 | matches on refs to exclude). | |
2278 | ||
2279 | get_default_ref_ignore_regex() | |
2280 | ||
2281 | Return a regex that should be ignored for both what emails | |
2282 | to send and when computing what commits are considered new | |
2283 | to the repository. Default is "^refs/notes/". | |
2284 | ||
7c554311 MM |
2285 | get_max_subject_length() |
2286 | ||
2287 | Return an int giving the maximal length for the subject | |
2288 | (git log --oneline). | |
2289 | ||
bc501f69 MH |
2290 | They should also define the following attributes: |
2291 | ||
2292 | announce_show_shortlog (bool) | |
2293 | ||
2294 | True iff announce emails should include a shortlog. | |
2295 | ||
4b1fd356 MM |
2296 | commit_email_format (string) |
2297 | ||
2298 | If "html", generate commit emails in HTML instead of plain text | |
2299 | used by default. | |
2300 | ||
4453d76c MM |
2301 | html_in_intro (bool) |
2302 | html_in_footer (bool) | |
2303 | ||
2304 | When generating HTML emails, the introduction (respectively, | |
2305 | the footer) will be HTML-escaped iff html_in_intro (respectively, | |
2306 | the footer) is true. When false, only the values used to expand | |
2307 | the template are escaped. | |
2308 | ||
5b1d901c MM |
2309 | refchange_showgraph (bool) |
2310 | ||
2311 | True iff refchanges emails should include a detailed graph. | |
2312 | ||
bc501f69 MH |
2313 | refchange_showlog (bool) |
2314 | ||
2315 | True iff refchanges emails should include a detailed log. | |
2316 | ||
2317 | diffopts (list of strings) | |
2318 | ||
2319 | The options that should be passed to 'git diff' for the | |
2320 | summary email. The value should be a list of strings | |
2321 | representing words to be passed to the command. | |
2322 | ||
5b1d901c MM |
2323 | graphopts (list of strings) |
2324 | ||
2325 | Analogous to diffopts, but contains options passed to | |
2326 | 'git log --graph' when generating the detailed graph for | |
2327 | a set of commits (see refchange_showgraph) | |
2328 | ||
bc501f69 MH |
2329 | logopts (list of strings) |
2330 | ||
2331 | Analogous to diffopts, but contains options passed to | |
2332 | 'git log' when generating the detailed log for a set of | |
2333 | commits (see refchange_showlog) | |
2334 | ||
b513f71f MH |
2335 | commitlogopts (list of strings) |
2336 | ||
2337 | The options that should be passed to 'git log' for each | |
2338 | commit mail. The value should be a list of strings | |
2339 | representing words to be passed to the command. | |
2340 | ||
4b1fd356 MM |
2341 | date_substitute (string) |
2342 | ||
2343 | String to be used in substitution for 'Date:' at start of | |
2344 | line in the output of 'git log'. | |
2345 | ||
5b1d901c MM |
2346 | quiet (bool) |
2347 | On success do not write to stderr | |
2348 | ||
2349 | stdout (bool) | |
2350 | Write email to stdout rather than emailing. Useful for debugging | |
2351 | ||
2352 | combine_when_single_commit (bool) | |
2353 | ||
2354 | True if a combined email should be produced when a single | |
2355 | new commit is pushed to a branch, False otherwise. | |
2356 | ||
4b1fd356 MM |
2357 | from_refchange, from_commit (strings) |
2358 | ||
2359 | Addresses to use for the From: field for refchange emails | |
2360 | and commit emails respectively. Set from | |
2361 | multimailhook.fromRefchange and multimailhook.fromCommit | |
2362 | by ConfigEnvironmentMixin. | |
2363 | ||
7c554311 MM |
2364 | log_file, error_log_file, debug_log_file (string) |
2365 | ||
2366 | Name of a file to which logs should be sent. | |
2367 | ||
2368 | verbose (int) | |
2369 | ||
2370 | How verbose the system should be. | |
2371 | - 0 (default): show info, errors, ... | |
2372 | - 1 : show basic debug info | |
bc501f69 MH |
2373 | """ |
2374 | ||
2375 | REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$') | |
2376 | ||
2377 | def __init__(self, osenv=None): | |
2378 | self.osenv = osenv or os.environ | |
2379 | self.announce_show_shortlog = False | |
4b1fd356 | 2380 | self.commit_email_format = "text" |
4453d76c MM |
2381 | self.html_in_intro = False |
2382 | self.html_in_footer = False | |
2383 | self.commitBrowseURL = None | |
bc501f69 MH |
2384 | self.maxcommitemails = 500 |
2385 | self.diffopts = ['--stat', '--summary', '--find-copies-harder'] | |
5b1d901c | 2386 | self.graphopts = ['--oneline', '--decorate'] |
bc501f69 | 2387 | self.logopts = [] |
5b1d901c | 2388 | self.refchange_showgraph = False |
bc501f69 | 2389 | self.refchange_showlog = False |
b513f71f | 2390 | self.commitlogopts = ['-C', '--stat', '-p', '--cc'] |
4b1fd356 | 2391 | self.date_substitute = 'AuthorDate: ' |
5b1d901c MM |
2392 | self.quiet = False |
2393 | self.stdout = False | |
2394 | self.combine_when_single_commit = True | |
7c554311 | 2395 | self.logger = None |
bc501f69 MH |
2396 | |
2397 | self.COMPUTED_KEYS = [ | |
2398 | 'administrator', | |
2399 | 'charset', | |
2400 | 'emailprefix', | |
bc501f69 MH |
2401 | 'pusher', |
2402 | 'pusher_email', | |
2403 | 'repo_path', | |
2404 | 'repo_shortname', | |
2405 | 'sender', | |
2406 | ] | |
2407 | ||
2408 | self._values = None | |
2409 | ||
7c554311 MM |
2410 | def get_logger(self): |
2411 | """Get (possibly creates) the logger associated to this environment.""" | |
2412 | if self.logger is None: | |
2413 | self.logger = Logger(self) | |
2414 | return self.logger | |
2415 | ||
bc501f69 MH |
2416 | def get_repo_shortname(self): |
2417 | """Use the last part of the repo path, with ".git" stripped off if present.""" | |
2418 | ||
2419 | basename = os.path.basename(os.path.abspath(self.get_repo_path())) | |
2420 | m = self.REPO_NAME_RE.match(basename) | |
2421 | if m: | |
2422 | return m.group('name') | |
2423 | else: | |
2424 | return basename | |
2425 | ||
2426 | def get_pusher(self): | |
2427 | raise NotImplementedError() | |
2428 | ||
2429 | def get_pusher_email(self): | |
2430 | return None | |
2431 | ||
4b1fd356 | 2432 | def get_fromaddr(self, change=None): |
5b1d901c MM |
2433 | config = Config('user') |
2434 | fromname = config.get('name', default='') | |
2435 | fromemail = config.get('email', default='') | |
2436 | if fromemail: | |
2437 | return formataddr([fromname, fromemail]) | |
2438 | return self.get_sender() | |
2439 | ||
bc501f69 MH |
2440 | def get_administrator(self): |
2441 | return 'the administrator of this repository' | |
2442 | ||
2443 | def get_emailprefix(self): | |
2444 | return '' | |
2445 | ||
2446 | def get_repo_path(self): | |
2447 | if read_git_output(['rev-parse', '--is-bare-repository']) == 'true': | |
2448 | path = get_git_dir() | |
2449 | else: | |
2450 | path = read_git_output(['rev-parse', '--show-toplevel']) | |
2451 | return os.path.abspath(path) | |
2452 | ||
2453 | def get_charset(self): | |
2454 | return CHARSET | |
2455 | ||
2456 | def get_values(self): | |
5b1d901c | 2457 | """Return a dictionary {keyword: expansion} for this Environment. |
bc501f69 MH |
2458 | |
2459 | This method is called by Change._compute_values(). The keys | |
2460 | in the returned dictionary are available to be used in any of | |
2461 | the templates. The dictionary is created by calling | |
2462 | self.get_NAME() for each of the attributes named in | |
2463 | COMPUTED_KEYS and recording those that do not return None. | |
2464 | The return value is always a new dictionary.""" | |
2465 | ||
2466 | if self._values is None: | |
4453d76c | 2467 | values = {'': ''} # %()s expands to the empty string. |
bc501f69 MH |
2468 | |
2469 | for key in self.COMPUTED_KEYS: | |
2470 | value = getattr(self, 'get_%s' % (key,))() | |
2471 | if value is not None: | |
2472 | values[key] = value | |
2473 | ||
2474 | self._values = values | |
2475 | ||
2476 | return self._values.copy() | |
2477 | ||
2478 | def get_refchange_recipients(self, refchange): | |
2479 | """Return the recipients for notifications about refchange. | |
2480 | ||
2481 | Return the list of email addresses to which notifications | |
2482 | about the specified ReferenceChange should be sent.""" | |
2483 | ||
2484 | raise NotImplementedError() | |
2485 | ||
2486 | def get_announce_recipients(self, annotated_tag_change): | |
2487 | """Return the recipients for notifications about annotated_tag_change. | |
2488 | ||
2489 | Return the list of email addresses to which notifications | |
2490 | about the specified AnnotatedTagChange should be sent.""" | |
2491 | ||
2492 | raise NotImplementedError() | |
2493 | ||
2494 | def get_reply_to_refchange(self, refchange): | |
2495 | return self.get_pusher_email() | |
2496 | ||
2497 | def get_revision_recipients(self, revision): | |
2498 | """Return the recipients for messages about revision. | |
2499 | ||
2500 | Return the list of email addresses to which notifications | |
2501 | about the specified Revision should be sent. This method | |
2502 | could be overridden, for example, to take into account the | |
2503 | contents of the revision when deciding whom to notify about | |
2504 | it. For example, there could be a scheme for users to express | |
2505 | interest in particular files or subdirectories, and only | |
2506 | receive notification emails for revisions that affecting those | |
2507 | files.""" | |
2508 | ||
2509 | raise NotImplementedError() | |
2510 | ||
2511 | def get_reply_to_commit(self, revision): | |
2512 | return revision.author | |
2513 | ||
4b1fd356 MM |
2514 | def get_default_ref_ignore_regex(self): |
2515 | # The commit messages of git notes are essentially meaningless | |
2516 | # and "filenames" in git notes commits are an implementational | |
2517 | # detail that might surprise users at first. As such, we | |
2518 | # would need a completely different method for handling emails | |
2519 | # of git notes in order for them to be of benefit for users, | |
2520 | # which we simply do not have right now. | |
2521 | return "^refs/notes/" | |
2522 | ||
7c554311 MM |
2523 | def get_max_subject_length(self): |
2524 | """Return the maximal subject line (git log --oneline) length. | |
2525 | Longer subject lines will be truncated.""" | |
2526 | raise NotImplementedError() | |
2527 | ||
bc501f69 MH |
2528 | def filter_body(self, lines): |
2529 | """Filter the lines intended for an email body. | |
2530 | ||
2531 | lines is an iterable over the lines that would go into the | |
2532 | email body. Filter it (e.g., limit the number of lines, the | |
2533 | line length, character set, etc.), returning another iterable. | |
2534 | See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin | |
2535 | for classes implementing this functionality.""" | |
2536 | ||
2537 | return lines | |
2538 | ||
5b1d901c MM |
2539 | def log_msg(self, msg): |
2540 | """Write the string msg on a log file or on stderr. | |
2541 | ||
2542 | Sends the text to stderr by default, override to change the behavior.""" | |
7c554311 | 2543 | self.get_logger().info(msg) |
5b1d901c MM |
2544 | |
2545 | def log_warning(self, msg): | |
2546 | """Write the string msg on a log file or on stderr. | |
2547 | ||
2548 | Sends the text to stderr by default, override to change the behavior.""" | |
7c554311 | 2549 | self.get_logger().warning(msg) |
5b1d901c MM |
2550 | |
2551 | def log_error(self, msg): | |
2552 | """Write the string msg on a log file or on stderr. | |
2553 | ||
2554 | Sends the text to stderr by default, override to change the behavior.""" | |
7c554311 MM |
2555 | self.get_logger().error(msg) |
2556 | ||
2557 | def check(self): | |
2558 | pass | |
5b1d901c | 2559 | |
bc501f69 MH |
2560 | |
2561 | class ConfigEnvironmentMixin(Environment): | |
2562 | """A mixin that sets self.config to its constructor's config argument. | |
2563 | ||
2564 | This class's constructor consumes the "config" argument. | |
2565 | ||
2566 | Mixins that need to inspect the config should inherit from this | |
2567 | class (1) to make sure that "config" is still in the constructor | |
2568 | arguments with its own constructor runs and/or (2) to be sure that | |
2569 | self.config is set after construction.""" | |
2570 | ||
2571 | def __init__(self, config, **kw): | |
2572 | super(ConfigEnvironmentMixin, self).__init__(**kw) | |
2573 | self.config = config | |
2574 | ||
2575 | ||
2576 | class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): | |
2577 | """An Environment that reads most of its information from "git config".""" | |
2578 | ||
4b1fd356 MM |
2579 | @staticmethod |
2580 | def forbid_field_values(name, value, forbidden): | |
2581 | for forbidden_val in forbidden: | |
2582 | if value is not None and value.lower() == forbidden: | |
2583 | raise ConfigurationException( | |
2584 | '"%s" is not an allowed setting for %s' % (value, name) | |
2585 | ) | |
2586 | ||
bc501f69 MH |
2587 | def __init__(self, config, **kw): |
2588 | super(ConfigOptionsEnvironmentMixin, self).__init__( | |
2589 | config=config, **kw | |
2590 | ) | |
2591 | ||
5b1d901c MM |
2592 | for var, cfg in ( |
2593 | ('announce_show_shortlog', 'announceshortlog'), | |
2594 | ('refchange_showgraph', 'refchangeShowGraph'), | |
2595 | ('refchange_showlog', 'refchangeshowlog'), | |
2596 | ('quiet', 'quiet'), | |
2597 | ('stdout', 'stdout'), | |
2598 | ): | |
2599 | val = config.get_bool(cfg) | |
2600 | if val is not None: | |
2601 | setattr(self, var, val) | |
bc501f69 | 2602 | |
4b1fd356 MM |
2603 | commit_email_format = config.get('commitEmailFormat') |
2604 | if commit_email_format is not None: | |
2605 | if commit_email_format != "html" and commit_email_format != "text": | |
2606 | self.log_warning( | |
2607 | '*** Unknown value for multimailhook.commitEmailFormat: %s\n' % | |
2608 | commit_email_format + | |
2609 | '*** Expected either "text" or "html". Ignoring.\n' | |
2610 | ) | |
2611 | else: | |
2612 | self.commit_email_format = commit_email_format | |
2613 | ||
4453d76c MM |
2614 | html_in_intro = config.get_bool('htmlInIntro') |
2615 | if html_in_intro is not None: | |
2616 | self.html_in_intro = html_in_intro | |
2617 | ||
2618 | html_in_footer = config.get_bool('htmlInFooter') | |
2619 | if html_in_footer is not None: | |
2620 | self.html_in_footer = html_in_footer | |
2621 | ||
2622 | self.commitBrowseURL = config.get('commitBrowseURL') | |
2623 | ||
bc501f69 MH |
2624 | maxcommitemails = config.get('maxcommitemails') |
2625 | if maxcommitemails is not None: | |
2626 | try: | |
2627 | self.maxcommitemails = int(maxcommitemails) | |
2628 | except ValueError: | |
5b1d901c | 2629 | self.log_warning( |
4b1fd356 MM |
2630 | '*** Malformed value for multimailhook.maxCommitEmails: %s\n' |
2631 | % maxcommitemails + | |
2632 | '*** Expected a number. Ignoring.\n' | |
bc501f69 MH |
2633 | ) |
2634 | ||
2635 | diffopts = config.get('diffopts') | |
2636 | if diffopts is not None: | |
2637 | self.diffopts = shlex.split(diffopts) | |
2638 | ||
5b1d901c MM |
2639 | graphopts = config.get('graphOpts') |
2640 | if graphopts is not None: | |
2641 | self.graphopts = shlex.split(graphopts) | |
2642 | ||
bc501f69 MH |
2643 | logopts = config.get('logopts') |
2644 | if logopts is not None: | |
2645 | self.logopts = shlex.split(logopts) | |
2646 | ||
b513f71f MH |
2647 | commitlogopts = config.get('commitlogopts') |
2648 | if commitlogopts is not None: | |
2649 | self.commitlogopts = shlex.split(commitlogopts) | |
2650 | ||
4b1fd356 MM |
2651 | date_substitute = config.get('dateSubstitute') |
2652 | if date_substitute == 'none': | |
2653 | self.date_substitute = None | |
2654 | elif date_substitute is not None: | |
2655 | self.date_substitute = date_substitute | |
2656 | ||
bc501f69 MH |
2657 | reply_to = config.get('replyTo') |
2658 | self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to) | |
4b1fd356 MM |
2659 | self.forbid_field_values('replyToRefchange', |
2660 | self.__reply_to_refchange, | |
2661 | ['author']) | |
bc501f69 MH |
2662 | self.__reply_to_commit = config.get('replyToCommit', default=reply_to) |
2663 | ||
4b1fd356 MM |
2664 | self.from_refchange = config.get('fromRefchange') |
2665 | self.forbid_field_values('fromRefchange', | |
2666 | self.from_refchange, | |
2667 | ['author', 'none']) | |
2668 | self.from_commit = config.get('fromCommit') | |
2669 | self.forbid_field_values('fromCommit', | |
2670 | self.from_commit, | |
2671 | ['none']) | |
2672 | ||
5b1d901c MM |
2673 | combine = config.get_bool('combineWhenSingleCommit') |
2674 | if combine is not None: | |
2675 | self.combine_when_single_commit = combine | |
2676 | ||
7c554311 MM |
2677 | self.log_file = config.get('logFile', default=None) |
2678 | self.error_log_file = config.get('errorLogFile', default=None) | |
2679 | self.debug_log_file = config.get('debugLogFile', default=None) | |
2680 | if config.get_bool('Verbose', default=False): | |
2681 | self.verbose = 1 | |
2682 | else: | |
2683 | self.verbose = 0 | |
2684 | ||
bc501f69 MH |
2685 | def get_administrator(self): |
2686 | return ( | |
4b1fd356 MM |
2687 | self.config.get('administrator') or |
2688 | self.get_sender() or | |
2689 | super(ConfigOptionsEnvironmentMixin, self).get_administrator() | |
bc501f69 MH |
2690 | ) |
2691 | ||
2692 | def get_repo_shortname(self): | |
2693 | return ( | |
4b1fd356 MM |
2694 | self.config.get('reponame') or |
2695 | super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname() | |
bc501f69 MH |
2696 | ) |
2697 | ||
2698 | def get_emailprefix(self): | |
2699 | emailprefix = self.config.get('emailprefix') | |
5b1d901c MM |
2700 | if emailprefix is not None: |
2701 | emailprefix = emailprefix.strip() | |
2702 | if emailprefix: | |
7c554311 | 2703 | emailprefix += ' ' |
bc501f69 | 2704 | else: |
7c554311 MM |
2705 | emailprefix = '[%(repo_shortname)s] ' |
2706 | short_name = self.get_repo_shortname() | |
2707 | try: | |
2708 | return emailprefix % {'repo_shortname': short_name} | |
2709 | except: | |
2710 | self.get_logger().error( | |
2711 | '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix + | |
2712 | '*** %s\n' % sys.exc_info()[1] + | |
2713 | "*** Only the '%(repo_shortname)s' placeholder is allowed\n" | |
2714 | ) | |
2715 | raise ConfigurationException( | |
2716 | '"%s" is not an allowed setting for emailPrefix' % emailprefix | |
2717 | ) | |
bc501f69 MH |
2718 | |
2719 | def get_sender(self): | |
2720 | return self.config.get('envelopesender') | |
2721 | ||
4b1fd356 MM |
2722 | def process_addr(self, addr, change): |
2723 | if addr.lower() == 'author': | |
2724 | if hasattr(change, 'author'): | |
2725 | return change.author | |
2726 | else: | |
2727 | return None | |
2728 | elif addr.lower() == 'pusher': | |
2729 | return self.get_pusher_email() | |
2730 | elif addr.lower() == 'none': | |
2731 | return None | |
2732 | else: | |
2733 | return addr | |
2734 | ||
2735 | def get_fromaddr(self, change=None): | |
bc501f69 | 2736 | fromaddr = self.config.get('from') |
4b1fd356 | 2737 | if change: |
7c554311 MM |
2738 | specific_fromaddr = change.get_specific_fromaddr() |
2739 | if specific_fromaddr: | |
2740 | fromaddr = specific_fromaddr | |
4b1fd356 MM |
2741 | if fromaddr: |
2742 | fromaddr = self.process_addr(fromaddr, change) | |
bc501f69 MH |
2743 | if fromaddr: |
2744 | return fromaddr | |
4b1fd356 | 2745 | return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change) |
bc501f69 MH |
2746 | |
2747 | def get_reply_to_refchange(self, refchange): | |
2748 | if self.__reply_to_refchange is None: | |
2749 | return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange) | |
bc501f69 | 2750 | else: |
4b1fd356 | 2751 | return self.process_addr(self.__reply_to_refchange, refchange) |
bc501f69 MH |
2752 | |
2753 | def get_reply_to_commit(self, revision): | |
2754 | if self.__reply_to_commit is None: | |
2755 | return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision) | |
bc501f69 | 2756 | else: |
4b1fd356 | 2757 | return self.process_addr(self.__reply_to_commit, revision) |
bc501f69 | 2758 | |
5b1d901c MM |
2759 | def get_scancommitforcc(self): |
2760 | return self.config.get('scancommitforcc') | |
2761 | ||
bc501f69 MH |
2762 | |
2763 | class FilterLinesEnvironmentMixin(Environment): | |
2764 | """Handle encoding and maximum line length of body lines. | |
2765 | ||
7c554311 | 2766 | email_max_line_length (int or None) |
bc501f69 MH |
2767 | |
2768 | The maximum length of any single line in the email body. | |
2769 | Longer lines are truncated at that length with ' [...]' | |
2770 | appended. | |
2771 | ||
2772 | strict_utf8 (bool) | |
2773 | ||
2774 | If this field is set to True, then the email body text is | |
2775 | expected to be UTF-8. Any invalid characters are | |
2776 | converted to U+FFFD, the Unicode replacement character | |
2777 | (encoded as UTF-8, of course). | |
2778 | ||
2779 | """ | |
2780 | ||
7c554311 MM |
2781 | def __init__(self, strict_utf8=True, |
2782 | email_max_line_length=500, max_subject_length=500, | |
2783 | **kw): | |
bc501f69 MH |
2784 | super(FilterLinesEnvironmentMixin, self).__init__(**kw) |
2785 | self.__strict_utf8 = strict_utf8 | |
7c554311 MM |
2786 | self.__email_max_line_length = email_max_line_length |
2787 | self.__max_subject_length = max_subject_length | |
bc501f69 MH |
2788 | |
2789 | def filter_body(self, lines): | |
2790 | lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) | |
2791 | if self.__strict_utf8: | |
4b1fd356 MM |
2792 | if not PYTHON3: |
2793 | lines = (line.decode(ENCODING, 'replace') for line in lines) | |
bc501f69 MH |
2794 | # Limit the line length in Unicode-space to avoid |
2795 | # splitting characters: | |
7c554311 MM |
2796 | if self.__email_max_line_length > 0: |
2797 | lines = limit_linelength(lines, self.__email_max_line_length) | |
4b1fd356 MM |
2798 | if not PYTHON3: |
2799 | lines = (line.encode(ENCODING, 'replace') for line in lines) | |
7c554311 MM |
2800 | elif self.__email_max_line_length: |
2801 | lines = limit_linelength(lines, self.__email_max_line_length) | |
bc501f69 MH |
2802 | |
2803 | return lines | |
2804 | ||
7c554311 MM |
2805 | def get_max_subject_length(self): |
2806 | return self.__max_subject_length | |
2807 | ||
bc501f69 MH |
2808 | |
2809 | class ConfigFilterLinesEnvironmentMixin( | |
5b1d901c MM |
2810 | ConfigEnvironmentMixin, |
2811 | FilterLinesEnvironmentMixin, | |
2812 | ): | |
bc501f69 MH |
2813 | """Handle encoding and maximum line length based on config.""" |
2814 | ||
2815 | def __init__(self, config, **kw): | |
2816 | strict_utf8 = config.get_bool('emailstrictutf8', default=None) | |
2817 | if strict_utf8 is not None: | |
2818 | kw['strict_utf8'] = strict_utf8 | |
2819 | ||
7c554311 MM |
2820 | email_max_line_length = config.get('emailmaxlinelength') |
2821 | if email_max_line_length is not None: | |
2822 | kw['email_max_line_length'] = int(email_max_line_length) | |
2823 | ||
2824 | max_subject_length = config.get('subjectMaxLength', default=email_max_line_length) | |
2825 | if max_subject_length is not None: | |
2826 | kw['max_subject_length'] = int(max_subject_length) | |
bc501f69 MH |
2827 | |
2828 | super(ConfigFilterLinesEnvironmentMixin, self).__init__( | |
2829 | config=config, **kw | |
2830 | ) | |
2831 | ||
2832 | ||
2833 | class MaxlinesEnvironmentMixin(Environment): | |
2834 | """Limit the email body to a specified number of lines.""" | |
2835 | ||
2836 | def __init__(self, emailmaxlines, **kw): | |
2837 | super(MaxlinesEnvironmentMixin, self).__init__(**kw) | |
2838 | self.__emailmaxlines = emailmaxlines | |
2839 | ||
2840 | def filter_body(self, lines): | |
2841 | lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines) | |
7c554311 | 2842 | if self.__emailmaxlines > 0: |
bc501f69 MH |
2843 | lines = limit_lines(lines, self.__emailmaxlines) |
2844 | return lines | |
2845 | ||
2846 | ||
2847 | class ConfigMaxlinesEnvironmentMixin( | |
5b1d901c MM |
2848 | ConfigEnvironmentMixin, |
2849 | MaxlinesEnvironmentMixin, | |
2850 | ): | |
bc501f69 MH |
2851 | """Limit the email body to the number of lines specified in config.""" |
2852 | ||
2853 | def __init__(self, config, **kw): | |
2854 | emailmaxlines = int(config.get('emailmaxlines', default='0')) | |
2855 | super(ConfigMaxlinesEnvironmentMixin, self).__init__( | |
2856 | config=config, | |
2857 | emailmaxlines=emailmaxlines, | |
2858 | **kw | |
2859 | ) | |
2860 | ||
2861 | ||
b513f71f MH |
2862 | class FQDNEnvironmentMixin(Environment): |
2863 | """A mixin that sets the host's FQDN to its constructor argument.""" | |
2864 | ||
2865 | def __init__(self, fqdn, **kw): | |
2866 | super(FQDNEnvironmentMixin, self).__init__(**kw) | |
2867 | self.COMPUTED_KEYS += ['fqdn'] | |
2868 | self.__fqdn = fqdn | |
2869 | ||
2870 | def get_fqdn(self): | |
2871 | """Return the fully-qualified domain name for this host. | |
2872 | ||
2873 | Return None if it is unavailable or unwanted.""" | |
2874 | ||
2875 | return self.__fqdn | |
2876 | ||
2877 | ||
2878 | class ConfigFQDNEnvironmentMixin( | |
5b1d901c MM |
2879 | ConfigEnvironmentMixin, |
2880 | FQDNEnvironmentMixin, | |
2881 | ): | |
b513f71f MH |
2882 | """Read the FQDN from the config.""" |
2883 | ||
2884 | def __init__(self, config, **kw): | |
2885 | fqdn = config.get('fqdn') | |
2886 | super(ConfigFQDNEnvironmentMixin, self).__init__( | |
2887 | config=config, | |
2888 | fqdn=fqdn, | |
2889 | **kw | |
2890 | ) | |
2891 | ||
2892 | ||
2893 | class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin): | |
2894 | """Get the FQDN by calling socket.getfqdn().""" | |
2895 | ||
2896 | def __init__(self, **kw): | |
2897 | super(ComputeFQDNEnvironmentMixin, self).__init__( | |
2898 | fqdn=socket.getfqdn(), | |
2899 | **kw | |
2900 | ) | |
2901 | ||
2902 | ||
bc501f69 MH |
2903 | class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin): |
2904 | """Deduce pusher_email from pusher by appending an emaildomain.""" | |
2905 | ||
2906 | def __init__(self, **kw): | |
2907 | super(PusherDomainEnvironmentMixin, self).__init__(**kw) | |
2908 | self.__emaildomain = self.config.get('emaildomain') | |
2909 | ||
2910 | def get_pusher_email(self): | |
2911 | if self.__emaildomain: | |
2912 | # Derive the pusher's full email address in the default way: | |
2913 | return '%s@%s' % (self.get_pusher(), self.__emaildomain) | |
2914 | else: | |
2915 | return super(PusherDomainEnvironmentMixin, self).get_pusher_email() | |
2916 | ||
2917 | ||
2918 | class StaticRecipientsEnvironmentMixin(Environment): | |
2919 | """Set recipients statically based on constructor parameters.""" | |
2920 | ||
2921 | def __init__( | |
5b1d901c MM |
2922 | self, |
2923 | refchange_recipients, announce_recipients, revision_recipients, scancommitforcc, | |
2924 | **kw | |
2925 | ): | |
bc501f69 MH |
2926 | super(StaticRecipientsEnvironmentMixin, self).__init__(**kw) |
2927 | ||
2928 | # The recipients for various types of notification emails, as | |
2929 | # RFC 2822 email addresses separated by commas (or the empty | |
2930 | # string if no recipients are configured). Although there is | |
2931 | # a mechanism to choose the recipient lists based on on the | |
2932 | # actual *contents* of the change being reported, we only | |
2933 | # choose based on the *type* of the change. Therefore we can | |
2934 | # compute them once and for all: | |
2935 | self.__refchange_recipients = refchange_recipients | |
2936 | self.__announce_recipients = announce_recipients | |
2937 | self.__revision_recipients = revision_recipients | |
2938 | ||
7c554311 MM |
2939 | def check(self): |
2940 | if not (self.get_refchange_recipients(None) or | |
2941 | self.get_announce_recipients(None) or | |
2942 | self.get_revision_recipients(None) or | |
2943 | self.get_scancommitforcc()): | |
2944 | raise ConfigurationException('No email recipients configured!') | |
2945 | super(StaticRecipientsEnvironmentMixin, self).check() | |
2946 | ||
bc501f69 | 2947 | def get_refchange_recipients(self, refchange): |
7c554311 MM |
2948 | if self.__refchange_recipients is None: |
2949 | return super(StaticRecipientsEnvironmentMixin, | |
2950 | self).get_refchange_recipients(refchange) | |
bc501f69 MH |
2951 | return self.__refchange_recipients |
2952 | ||
2953 | def get_announce_recipients(self, annotated_tag_change): | |
7c554311 MM |
2954 | if self.__announce_recipients is None: |
2955 | return super(StaticRecipientsEnvironmentMixin, | |
2956 | self).get_refchange_recipients(annotated_tag_change) | |
bc501f69 MH |
2957 | return self.__announce_recipients |
2958 | ||
2959 | def get_revision_recipients(self, revision): | |
7c554311 MM |
2960 | if self.__revision_recipients is None: |
2961 | return super(StaticRecipientsEnvironmentMixin, | |
2962 | self).get_refchange_recipients(revision) | |
bc501f69 MH |
2963 | return self.__revision_recipients |
2964 | ||
2965 | ||
7c554311 MM |
2966 | class CLIRecipientsEnvironmentMixin(Environment): |
2967 | """Mixin storing recipients information comming from the | |
2968 | command-line.""" | |
2969 | ||
2970 | def __init__(self, cli_recipients=None, **kw): | |
2971 | super(CLIRecipientsEnvironmentMixin, self).__init__(**kw) | |
2972 | self.__cli_recipients = cli_recipients | |
2973 | ||
2974 | def get_refchange_recipients(self, refchange): | |
2975 | if self.__cli_recipients is None: | |
2976 | return super(CLIRecipientsEnvironmentMixin, | |
2977 | self).get_refchange_recipients(refchange) | |
2978 | return self.__cli_recipients | |
2979 | ||
2980 | def get_announce_recipients(self, annotated_tag_change): | |
2981 | if self.__cli_recipients is None: | |
2982 | return super(CLIRecipientsEnvironmentMixin, | |
2983 | self).get_announce_recipients(annotated_tag_change) | |
2984 | return self.__cli_recipients | |
2985 | ||
2986 | def get_revision_recipients(self, revision): | |
2987 | if self.__cli_recipients is None: | |
2988 | return super(CLIRecipientsEnvironmentMixin, | |
2989 | self).get_revision_recipients(revision) | |
2990 | return self.__cli_recipients | |
2991 | ||
2992 | ||
bc501f69 | 2993 | class ConfigRecipientsEnvironmentMixin( |
5b1d901c MM |
2994 | ConfigEnvironmentMixin, |
2995 | StaticRecipientsEnvironmentMixin | |
2996 | ): | |
bc501f69 MH |
2997 | """Determine recipients statically based on config.""" |
2998 | ||
2999 | def __init__(self, config, **kw): | |
3000 | super(ConfigRecipientsEnvironmentMixin, self).__init__( | |
3001 | config=config, | |
3002 | refchange_recipients=self._get_recipients( | |
3003 | config, 'refchangelist', 'mailinglist', | |
3004 | ), | |
3005 | announce_recipients=self._get_recipients( | |
3006 | config, 'announcelist', 'refchangelist', 'mailinglist', | |
3007 | ), | |
3008 | revision_recipients=self._get_recipients( | |
3009 | config, 'commitlist', 'mailinglist', | |
3010 | ), | |
5b1d901c | 3011 | scancommitforcc=config.get('scancommitforcc'), |
bc501f69 MH |
3012 | **kw |
3013 | ) | |
3014 | ||
3015 | def _get_recipients(self, config, *names): | |
3016 | """Return the recipients for a particular type of message. | |
3017 | ||
3018 | Return the list of email addresses to which a particular type | |
3019 | of notification email should be sent, by looking at the config | |
3020 | value for "multimailhook.$name" for each of names. Use the | |
3021 | value from the first name that is configured. The return | |
3022 | value is a (possibly empty) string containing RFC 2822 email | |
3023 | addresses separated by commas. If no configuration could be | |
3024 | found, raise a ConfigurationException.""" | |
3025 | ||
3026 | for name in names: | |
4b1fd356 MM |
3027 | lines = config.get_all(name) |
3028 | if lines is not None: | |
3029 | lines = [line.strip() for line in lines] | |
3030 | # Single "none" is a special value equivalen to empty string. | |
3031 | if lines == ['none']: | |
3032 | lines = [''] | |
3033 | return ', '.join(lines) | |
bc501f69 | 3034 | else: |
b513f71f | 3035 | return '' |
bc501f69 MH |
3036 | |
3037 | ||
4b1fd356 MM |
3038 | class StaticRefFilterEnvironmentMixin(Environment): |
3039 | """Set branch filter statically based on constructor parameters.""" | |
3040 | ||
3041 | def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex, | |
3042 | ref_filter_do_send_regex, ref_filter_dont_send_regex, | |
3043 | **kw): | |
3044 | super(StaticRefFilterEnvironmentMixin, self).__init__(**kw) | |
3045 | ||
3046 | if ref_filter_incl_regex and ref_filter_excl_regex: | |
3047 | raise ConfigurationException( | |
3048 | "Cannot specify both a ref inclusion and exclusion regex.") | |
3049 | self.__is_inclusion_filter = bool(ref_filter_incl_regex) | |
3050 | default_exclude = self.get_default_ref_ignore_regex() | |
3051 | if ref_filter_incl_regex: | |
3052 | ref_filter_regex = ref_filter_incl_regex | |
3053 | elif ref_filter_excl_regex: | |
3054 | ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude | |
3055 | else: | |
3056 | ref_filter_regex = default_exclude | |
3057 | try: | |
3058 | self.__compiled_regex = re.compile(ref_filter_regex) | |
3059 | except Exception: | |
3060 | raise ConfigurationException( | |
3061 | 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1])) | |
3062 | ||
3063 | if ref_filter_do_send_regex and ref_filter_dont_send_regex: | |
3064 | raise ConfigurationException( | |
3065 | "Cannot specify both a ref doSend and dontSend regex.") | |
7c554311 MM |
3066 | self.__is_do_send_filter = bool(ref_filter_do_send_regex) |
3067 | if ref_filter_do_send_regex: | |
3068 | ref_filter_send_regex = ref_filter_do_send_regex | |
3069 | elif ref_filter_dont_send_regex: | |
3070 | ref_filter_send_regex = ref_filter_dont_send_regex | |
4b1fd356 | 3071 | else: |
7c554311 MM |
3072 | ref_filter_send_regex = '.*' |
3073 | self.__is_do_send_filter = True | |
3074 | try: | |
3075 | self.__send_compiled_regex = re.compile(ref_filter_send_regex) | |
3076 | except Exception: | |
3077 | raise ConfigurationException( | |
3078 | 'Invalid Ref Filter Regex "%s": %s' % | |
3079 | (ref_filter_send_regex, sys.exc_info()[1])) | |
4b1fd356 MM |
3080 | |
3081 | def get_ref_filter_regex(self, send_filter=False): | |
3082 | if send_filter: | |
3083 | return self.__send_compiled_regex, self.__is_do_send_filter | |
3084 | else: | |
3085 | return self.__compiled_regex, self.__is_inclusion_filter | |
3086 | ||
3087 | ||
3088 | class ConfigRefFilterEnvironmentMixin( | |
3089 | ConfigEnvironmentMixin, | |
3090 | StaticRefFilterEnvironmentMixin | |
3091 | ): | |
3092 | """Determine branch filtering statically based on config.""" | |
3093 | ||
3094 | def _get_regex(self, config, key): | |
3095 | """Get a list of whitespace-separated regex. The refFilter* config | |
3096 | variables are multivalued (hence the use of get_all), and we | |
3097 | allow each entry to be a whitespace-separated list (hence the | |
3098 | split on each line). The whole thing is glued into a single regex.""" | |
3099 | values = config.get_all(key) | |
3100 | if values is None: | |
3101 | return values | |
3102 | items = [] | |
3103 | for line in values: | |
3104 | for i in line.split(): | |
3105 | items.append(i) | |
3106 | if items == []: | |
3107 | return None | |
3108 | return '|'.join(items) | |
3109 | ||
3110 | def __init__(self, config, **kw): | |
3111 | super(ConfigRefFilterEnvironmentMixin, self).__init__( | |
3112 | config=config, | |
3113 | ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'), | |
3114 | ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'), | |
3115 | ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'), | |
3116 | ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'), | |
3117 | **kw | |
3118 | ) | |
3119 | ||
3120 | ||
bc501f69 MH |
3121 | class ProjectdescEnvironmentMixin(Environment): |
3122 | """Make a "projectdesc" value available for templates. | |
3123 | ||
3124 | By default, it is set to the first line of $GIT_DIR/description | |
3125 | (if that file is present and appears to be set meaningfully).""" | |
3126 | ||
3127 | def __init__(self, **kw): | |
3128 | super(ProjectdescEnvironmentMixin, self).__init__(**kw) | |
3129 | self.COMPUTED_KEYS += ['projectdesc'] | |
3130 | ||
3131 | def get_projectdesc(self): | |
3132 | """Return a one-line descripition of the project.""" | |
3133 | ||
3134 | git_dir = get_git_dir() | |
3135 | try: | |
3136 | projectdesc = open(os.path.join(git_dir, 'description')).readline().strip() | |
3137 | if projectdesc and not projectdesc.startswith('Unnamed repository'): | |
3138 | return projectdesc | |
3139 | except IOError: | |
3140 | pass | |
3141 | ||
3142 | return 'UNNAMED PROJECT' | |
3143 | ||
3144 | ||
3145 | class GenericEnvironmentMixin(Environment): | |
3146 | def get_pusher(self): | |
5b1d901c | 3147 | return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user')) |
bc501f69 MH |
3148 | |
3149 | ||
7c554311 MM |
3150 | class GitoliteEnvironmentHighPrecMixin(Environment): |
3151 | def get_pusher(self): | |
3152 | return self.osenv.get('GL_USER', 'unknown user') | |
bc501f69 MH |
3153 | |
3154 | ||
7c554311 | 3155 | class GitoliteEnvironmentLowPrecMixin(Environment): |
bc501f69 MH |
3156 | def get_repo_shortname(self): |
3157 | # The gitolite environment variable $GL_REPO is a pretty good | |
3158 | # repo_shortname (though it's probably not as good as a value | |
3159 | # the user might have explicitly put in his config). | |
3160 | return ( | |
4b1fd356 | 3161 | self.osenv.get('GL_REPO', None) or |
7c554311 | 3162 | super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname() |
bc501f69 MH |
3163 | ) |
3164 | ||
4b1fd356 | 3165 | def get_fromaddr(self, change=None): |
5b1d901c MM |
3166 | GL_USER = self.osenv.get('GL_USER') |
3167 | if GL_USER is not None: | |
3168 | # Find the path to gitolite.conf. Note that gitolite v3 | |
3169 | # did away with the GL_ADMINDIR and GL_CONF environment | |
3170 | # variables (they are now hard-coded). | |
3171 | GL_ADMINDIR = self.osenv.get( | |
3172 | 'GL_ADMINDIR', | |
3173 | os.path.expanduser(os.path.join('~', '.gitolite'))) | |
3174 | GL_CONF = self.osenv.get( | |
3175 | 'GL_CONF', | |
3176 | os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf')) | |
3177 | if os.path.isfile(GL_CONF): | |
3178 | f = open(GL_CONF, 'rU') | |
3179 | try: | |
3180 | in_user_emails_section = False | |
4b1fd356 | 3181 | re_template = r'^\s*#\s*%s\s*$' |
5b1d901c | 3182 | re_begin, re_user, re_end = ( |
4b1fd356 | 3183 | re.compile(re_template % x) |
5b1d901c MM |
3184 | for x in ( |
3185 | r'BEGIN\s+USER\s+EMAILS', | |
3186 | re.escape(GL_USER) + r'\s+(.*)', | |
3187 | r'END\s+USER\s+EMAILS', | |
3188 | )) | |
3189 | for l in f: | |
3190 | l = l.rstrip('\n') | |
3191 | if not in_user_emails_section: | |
3192 | if re_begin.match(l): | |
3193 | in_user_emails_section = True | |
3194 | continue | |
3195 | if re_end.match(l): | |
3196 | break | |
3197 | m = re_user.match(l) | |
3198 | if m: | |
3199 | return m.group(1) | |
3200 | finally: | |
3201 | f.close() | |
7c554311 | 3202 | return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change) |
5b1d901c | 3203 | |
bc501f69 | 3204 | |
b513f71f MH |
3205 | class IncrementalDateTime(object): |
3206 | """Simple wrapper to give incremental date/times. | |
3207 | ||
3208 | Each call will result in a date/time a second later than the | |
3209 | previous call. This can be used to falsify email headers, to | |
3210 | increase the likelihood that email clients sort the emails | |
3211 | correctly.""" | |
3212 | ||
3213 | def __init__(self): | |
3214 | self.time = time.time() | |
4b1fd356 | 3215 | self.next = self.__next__ # Python 2 backward compatibility |
b513f71f | 3216 | |
4b1fd356 | 3217 | def __next__(self): |
b513f71f MH |
3218 | formatted = formatdate(self.time, True) |
3219 | self.time += 1 | |
3220 | return formatted | |
3221 | ||
3222 | ||
7c554311 | 3223 | class StashEnvironmentHighPrecMixin(Environment): |
4b1fd356 | 3224 | def __init__(self, user=None, repo=None, **kw): |
7c554311 MM |
3225 | super(StashEnvironmentHighPrecMixin, |
3226 | self).__init__(user=user, repo=repo, **kw) | |
4b1fd356 MM |
3227 | self.__user = user |
3228 | self.__repo = repo | |
3229 | ||
4b1fd356 MM |
3230 | def get_pusher(self): |
3231 | return re.match('(.*?)\s*<', self.__user).group(1) | |
3232 | ||
3233 | def get_pusher_email(self): | |
3234 | return self.__user | |
3235 | ||
4b1fd356 | 3236 | |
7c554311 MM |
3237 | class StashEnvironmentLowPrecMixin(Environment): |
3238 | def __init__(self, user=None, repo=None, **kw): | |
3239 | super(StashEnvironmentLowPrecMixin, self).__init__(**kw) | |
3240 | self.__repo = repo | |
3241 | self.__user = user | |
4b1fd356 | 3242 | |
7c554311 MM |
3243 | def get_repo_shortname(self): |
3244 | return self.__repo | |
3245 | ||
3246 | def get_fromaddr(self, change=None): | |
3247 | return self.__user | |
4b1fd356 MM |
3248 | |
3249 | ||
7c554311 | 3250 | class GerritEnvironmentHighPrecMixin(Environment): |
4b1fd356 | 3251 | def __init__(self, project=None, submitter=None, update_method=None, **kw): |
7c554311 MM |
3252 | super(GerritEnvironmentHighPrecMixin, |
3253 | self).__init__(submitter=submitter, project=project, **kw) | |
4b1fd356 MM |
3254 | self.__project = project |
3255 | self.__submitter = submitter | |
3256 | self.__update_method = update_method | |
3257 | "Make an 'update_method' value available for templates." | |
3258 | self.COMPUTED_KEYS += ['update_method'] | |
3259 | ||
4b1fd356 MM |
3260 | def get_pusher(self): |
3261 | if self.__submitter: | |
3262 | if self.__submitter.find('<') != -1: | |
3263 | # Submitter has a configured email, we transformed | |
3264 | # __submitter into an RFC 2822 string already. | |
3265 | return re.match('(.*?)\s*<', self.__submitter).group(1) | |
3266 | else: | |
3267 | # Submitter has no configured email, it's just his name. | |
3268 | return self.__submitter | |
3269 | else: | |
3270 | # If we arrive here, this means someone pushed "Submit" from | |
3271 | # the gerrit web UI for the CR (or used one of the programmatic | |
3272 | # APIs to do the same, such as gerrit review) and the | |
3273 | # merge/push was done by the Gerrit user. It was technically | |
3274 | # triggered by someone else, but sadly we have no way of | |
3275 | # determining who that someone else is at this point. | |
3276 | return 'Gerrit' # 'unknown user'? | |
3277 | ||
3278 | def get_pusher_email(self): | |
3279 | if self.__submitter: | |
3280 | return self.__submitter | |
3281 | else: | |
7c554311 | 3282 | return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email() |
4b1fd356 MM |
3283 | |
3284 | def get_default_ref_ignore_regex(self): | |
7c554311 | 3285 | default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex() |
4b1fd356 MM |
3286 | return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/' |
3287 | ||
3288 | def get_revision_recipients(self, revision): | |
3289 | # Merge commits created by Gerrit when users hit "Submit this patchset" | |
3290 | # in the Web UI (or do equivalently with REST APIs or the gerrit review | |
3291 | # command) are not something users want to see an individual email for. | |
3292 | # Filter them out. | |
3293 | committer = read_git_output(['log', '--no-walk', '--format=%cN', | |
3294 | revision.rev.sha1]) | |
3295 | if committer == 'Gerrit Code Review': | |
3296 | return [] | |
3297 | else: | |
7c554311 | 3298 | return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision) |
4b1fd356 MM |
3299 | |
3300 | def get_update_method(self): | |
3301 | return self.__update_method | |
3302 | ||
3303 | ||
7c554311 MM |
3304 | class GerritEnvironmentLowPrecMixin(Environment): |
3305 | def __init__(self, project=None, submitter=None, **kw): | |
3306 | super(GerritEnvironmentLowPrecMixin, self).__init__(**kw) | |
3307 | self.__project = project | |
3308 | self.__submitter = submitter | |
3309 | ||
3310 | def get_repo_shortname(self): | |
3311 | return self.__project | |
3312 | ||
3313 | def get_fromaddr(self, change=None): | |
3314 | if self.__submitter and self.__submitter.find('<') != -1: | |
3315 | return self.__submitter | |
3316 | else: | |
3317 | return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change) | |
4b1fd356 MM |
3318 | |
3319 | ||
bc501f69 MH |
3320 | class Push(object): |
3321 | """Represent an entire push (i.e., a group of ReferenceChanges). | |
3322 | ||
3323 | It is easy to figure out what commits were added to a *branch* by | |
3324 | a Reference change: | |
3325 | ||
3326 | git rev-list change.old..change.new | |
3327 | ||
3328 | or removed from a *branch*: | |
3329 | ||
3330 | git rev-list change.new..change.old | |
3331 | ||
3332 | But it is not quite so trivial to determine which entirely new | |
3333 | commits were added to the *repository* by a push and which old | |
3334 | commits were discarded by a push. A big part of the job of this | |
3335 | class is to figure out these things, and to make sure that new | |
3336 | commits are only detailed once even if they were added to multiple | |
3337 | references. | |
3338 | ||
3339 | The first step is to determine the "other" references--those | |
5b1d901c MM |
3340 | unaffected by the current push. They are computed by listing all |
3341 | references then removing any affected by this push. The results | |
3342 | are stored in Push._other_ref_sha1s. | |
bc501f69 MH |
3343 | |
3344 | The commits contained in the repository before this push were | |
3345 | ||
3346 | git rev-list other1 other2 other3 ... change1.old change2.old ... | |
3347 | ||
3348 | Where "changeN.old" is the old value of one of the references | |
3349 | affected by this push. | |
3350 | ||
3351 | The commits contained in the repository after this push are | |
3352 | ||
3353 | git rev-list other1 other2 other3 ... change1.new change2.new ... | |
3354 | ||
3355 | The commits added by this push are the difference between these | |
3356 | two sets, which can be written | |
3357 | ||
3358 | git rev-list \ | |
3359 | ^other1 ^other2 ... \ | |
3360 | ^change1.old ^change2.old ... \ | |
3361 | change1.new change2.new ... | |
3362 | ||
3363 | The commits removed by this push can be computed by | |
3364 | ||
3365 | git rev-list \ | |
3366 | ^other1 ^other2 ... \ | |
3367 | ^change1.new ^change2.new ... \ | |
3368 | change1.old change2.old ... | |
3369 | ||
3370 | The last point is that it is possible that other pushes are | |
3371 | occurring simultaneously to this one, so reference values can | |
3372 | change at any time. It is impossible to eliminate all race | |
3373 | conditions, but we reduce the window of time during which problems | |
3374 | can occur by translating reference names to SHA1s as soon as | |
3375 | possible and working with SHA1s thereafter (because SHA1s are | |
3376 | immutable).""" | |
3377 | ||
5b1d901c | 3378 | # A map {(changeclass, changetype): integer} specifying the order |
bc501f69 MH |
3379 | # that reference changes will be processed if multiple reference |
3380 | # changes are included in a single push. The order is significant | |
3381 | # mostly because new commit notifications are threaded together | |
3382 | # with the first reference change that includes the commit. The | |
3383 | # following order thus causes commits to be grouped with branch | |
3384 | # changes (as opposed to tag changes) if possible. | |
3385 | SORT_ORDER = dict( | |
3386 | (value, i) for (i, value) in enumerate([ | |
3387 | (BranchChange, 'update'), | |
3388 | (BranchChange, 'create'), | |
3389 | (AnnotatedTagChange, 'update'), | |
3390 | (AnnotatedTagChange, 'create'), | |
3391 | (NonAnnotatedTagChange, 'update'), | |
3392 | (NonAnnotatedTagChange, 'create'), | |
3393 | (BranchChange, 'delete'), | |
3394 | (AnnotatedTagChange, 'delete'), | |
3395 | (NonAnnotatedTagChange, 'delete'), | |
3396 | (OtherReferenceChange, 'update'), | |
3397 | (OtherReferenceChange, 'create'), | |
3398 | (OtherReferenceChange, 'delete'), | |
3399 | ]) | |
3400 | ) | |
3401 | ||
4b1fd356 | 3402 | def __init__(self, environment, changes, ignore_other_refs=False): |
bc501f69 | 3403 | self.changes = sorted(changes, key=self._sort_key) |
5b1d901c MM |
3404 | self.__other_ref_sha1s = None |
3405 | self.__cached_commits_spec = {} | |
4b1fd356 | 3406 | self.environment = environment |
bc501f69 | 3407 | |
5b1d901c MM |
3408 | if ignore_other_refs: |
3409 | self.__other_ref_sha1s = set() | |
bc501f69 | 3410 | |
5b1d901c MM |
3411 | @classmethod |
3412 | def _sort_key(klass, change): | |
3413 | return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,) | |
3414 | ||
3415 | @property | |
3416 | def _other_ref_sha1s(self): | |
3417 | """The GitObjects referred to by references unaffected by this push. | |
3418 | """ | |
3419 | if self.__other_ref_sha1s is None: | |
3420 | # The refnames being changed by this push: | |
3421 | updated_refs = set( | |
3422 | change.refname | |
bc501f69 | 3423 | for change in self.changes |
bc501f69 | 3424 | ) |
5b1d901c MM |
3425 | |
3426 | # The SHA-1s of commits referred to by all references in this | |
3427 | # repository *except* updated_refs: | |
3428 | sha1s = set() | |
3429 | fmt = ( | |
3430 | '%(objectname) %(objecttype) %(refname)\n' | |
3431 | '%(*objectname) %(*objecttype) %(refname)' | |
bc501f69 | 3432 | ) |
4b1fd356 MM |
3433 | ref_filter_regex, is_inclusion_filter = \ |
3434 | self.environment.get_ref_filter_regex() | |
5b1d901c MM |
3435 | for line in read_git_lines( |
3436 | ['for-each-ref', '--format=%s' % (fmt,)]): | |
3437 | (sha1, type, name) = line.split(' ', 2) | |
4b1fd356 MM |
3438 | if (sha1 and type == 'commit' and |
3439 | name not in updated_refs and | |
3440 | include_ref(name, ref_filter_regex, is_inclusion_filter)): | |
5b1d901c | 3441 | sha1s.add(sha1) |
bc501f69 | 3442 | |
5b1d901c MM |
3443 | self.__other_ref_sha1s = sha1s |
3444 | ||
3445 | return self.__other_ref_sha1s | |
3446 | ||
3447 | def _get_commits_spec_incl(self, new_or_old, reference_change=None): | |
3448 | """Get new or old SHA-1 from one or each of the changed refs. | |
bc501f69 | 3449 | |
5b1d901c MM |
3450 | Return a list of SHA-1 commit identifier strings suitable as |
3451 | arguments to 'git rev-list' (or 'git log' or ...). The | |
3452 | returned identifiers are either the old or new values from one | |
3453 | or all of the changed references, depending on the values of | |
3454 | new_or_old and reference_change. | |
bc501f69 | 3455 | |
5b1d901c MM |
3456 | new_or_old is either the string 'new' or the string 'old'. If |
3457 | 'new', the returned SHA-1 identifiers are the new values from | |
3458 | each changed reference. If 'old', the SHA-1 identifiers are | |
3459 | the old values from each changed reference. | |
3460 | ||
3461 | If reference_change is specified and not None, only the new or | |
3462 | old reference from the specified reference is included in the | |
3463 | return value. | |
3464 | ||
3465 | This function returns None if there are no matching revisions | |
3466 | (e.g., because a branch was deleted and new_or_old is 'new'). | |
3467 | """ | |
3468 | ||
3469 | if not reference_change: | |
3470 | incl_spec = sorted( | |
3471 | getattr(change, new_or_old).sha1 | |
3472 | for change in self.changes | |
3473 | if getattr(change, new_or_old) | |
3474 | ) | |
3475 | if not incl_spec: | |
3476 | incl_spec = None | |
3477 | elif not getattr(reference_change, new_or_old).commit_sha1: | |
3478 | incl_spec = None | |
3479 | else: | |
3480 | incl_spec = [getattr(reference_change, new_or_old).commit_sha1] | |
3481 | return incl_spec | |
3482 | ||
3483 | def _get_commits_spec_excl(self, new_or_old): | |
3484 | """Get exclusion revisions for determining new or discarded commits. | |
3485 | ||
3486 | Return a list of strings suitable as arguments to 'git | |
3487 | rev-list' (or 'git log' or ...) that will exclude all | |
3488 | commits that, depending on the value of new_or_old, were | |
3489 | either previously in the repository (useful for determining | |
3490 | which commits are new to the repository) or currently in the | |
3491 | repository (useful for determining which commits were | |
3492 | discarded from the repository). | |
3493 | ||
3494 | new_or_old is either the string 'new' or the string 'old'. If | |
3495 | 'new', the commits to be excluded are those that were in the | |
3496 | repository before the push. If 'old', the commits to be | |
3497 | excluded are those that are currently in the repository. """ | |
3498 | ||
3499 | old_or_new = {'old': 'new', 'new': 'old'}[new_or_old] | |
3500 | excl_revs = self._other_ref_sha1s.union( | |
3501 | getattr(change, old_or_new).sha1 | |
bc501f69 | 3502 | for change in self.changes |
5b1d901c | 3503 | if getattr(change, old_or_new).type in ['commit', 'tag'] |
bc501f69 | 3504 | ) |
5b1d901c | 3505 | return ['^' + sha1 for sha1 in sorted(excl_revs)] |
bc501f69 | 3506 | |
5b1d901c MM |
3507 | def get_commits_spec(self, new_or_old, reference_change=None): |
3508 | """Get rev-list arguments for added or discarded commits. | |
bc501f69 | 3509 | |
5b1d901c MM |
3510 | Return a list of strings suitable as arguments to 'git |
3511 | rev-list' (or 'git log' or ...) that select those commits | |
3512 | that, depending on the value of new_or_old, are either new to | |
3513 | the repository or were discarded from the repository. | |
bc501f69 | 3514 | |
5b1d901c MM |
3515 | new_or_old is either the string 'new' or the string 'old'. If |
3516 | 'new', the returned list is used to select commits that are | |
3517 | new to the repository. If 'old', the returned value is used | |
3518 | to select the commits that have been discarded from the | |
3519 | repository. | |
bc501f69 | 3520 | |
5b1d901c MM |
3521 | If reference_change is specified and not None, the new or |
3522 | discarded commits are limited to those that are reachable from | |
3523 | the new or old value of the specified reference. | |
bc501f69 | 3524 | |
5b1d901c MM |
3525 | This function returns None if there are no added (or discarded) |
3526 | revisions. | |
3527 | """ | |
3528 | key = (new_or_old, reference_change) | |
3529 | if key not in self.__cached_commits_spec: | |
3530 | ret = self._get_commits_spec_incl(new_or_old, reference_change) | |
3531 | if ret is not None: | |
3532 | ret.extend(self._get_commits_spec_excl(new_or_old)) | |
3533 | self.__cached_commits_spec[key] = ret | |
3534 | return self.__cached_commits_spec[key] | |
bc501f69 MH |
3535 | |
3536 | def get_new_commits(self, reference_change=None): | |
3537 | """Return a list of commits added by this push. | |
3538 | ||
3539 | Return a list of the object names of commits that were added | |
3540 | by the part of this push represented by reference_change. If | |
3541 | reference_change is None, then return a list of *all* commits | |
3542 | added by this push.""" | |
3543 | ||
5b1d901c MM |
3544 | spec = self.get_commits_spec('new', reference_change) |
3545 | return git_rev_list(spec) | |
bc501f69 MH |
3546 | |
3547 | def get_discarded_commits(self, reference_change): | |
3548 | """Return a list of commits discarded by this push. | |
3549 | ||
3550 | Return a list of the object names of commits that were | |
3551 | entirely discarded from the repository by the part of this | |
3552 | push represented by reference_change.""" | |
3553 | ||
5b1d901c MM |
3554 | spec = self.get_commits_spec('old', reference_change) |
3555 | return git_rev_list(spec) | |
bc501f69 MH |
3556 | |
3557 | def send_emails(self, mailer, body_filter=None): | |
3558 | """Use send all of the notification emails needed for this push. | |
3559 | ||
3560 | Use send all of the notification emails (including reference | |
3561 | change emails and commit emails) needed for this push. Send | |
3562 | the emails using mailer. If body_filter is not None, then use | |
3563 | it to filter the lines that are intended for the email | |
3564 | body.""" | |
3565 | ||
3566 | # The sha1s of commits that were introduced by this push. | |
3567 | # They will be removed from this set as they are processed, to | |
3568 | # guarantee that one (and only one) email is generated for | |
3569 | # each new commit. | |
3570 | unhandled_sha1s = set(self.get_new_commits()) | |
b513f71f | 3571 | send_date = IncrementalDateTime() |
bc501f69 | 3572 | for change in self.changes: |
5b1d901c MM |
3573 | sha1s = [] |
3574 | for sha1 in reversed(list(self.get_new_commits(change))): | |
3575 | if sha1 in unhandled_sha1s: | |
3576 | sha1s.append(sha1) | |
3577 | unhandled_sha1s.remove(sha1) | |
3578 | ||
bc501f69 MH |
3579 | # Check if we've got anyone to send to |
3580 | if not change.recipients: | |
5b1d901c | 3581 | change.environment.log_warning( |
bc501f69 | 3582 | '*** no recipients configured so no email will be sent\n' |
7c554311 | 3583 | '*** for %r update %s->%s' |
bc501f69 MH |
3584 | % (change.refname, change.old.sha1, change.new.sha1,) |
3585 | ) | |
3586 | else: | |
5b1d901c MM |
3587 | if not change.environment.quiet: |
3588 | change.environment.log_msg( | |
7c554311 | 3589 | 'Sending notification emails to: %s' % (change.recipients,)) |
4b1fd356 | 3590 | extra_values = {'send_date': next(send_date)} |
bc501f69 | 3591 | |
5b1d901c MM |
3592 | rev = change.send_single_combined_email(sha1s) |
3593 | if rev: | |
3594 | mailer.send( | |
3595 | change.generate_combined_email(self, rev, body_filter, extra_values), | |
3596 | rev.recipients, | |
3597 | ) | |
3598 | # This change is now fully handled; no need to handle | |
3599 | # individual revisions any further. | |
3600 | continue | |
3601 | else: | |
3602 | mailer.send( | |
3603 | change.generate_email(self, body_filter, extra_values), | |
3604 | change.recipients, | |
3605 | ) | |
bc501f69 MH |
3606 | |
3607 | max_emails = change.environment.maxcommitemails | |
3608 | if max_emails and len(sha1s) > max_emails: | |
5b1d901c | 3609 | change.environment.log_warning( |
4b1fd356 MM |
3610 | '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) + |
3611 | '*** Try setting multimailhook.maxCommitEmails to a greater value\n' + | |
7c554311 | 3612 | '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails |
bc501f69 MH |
3613 | ) |
3614 | return | |
3615 | ||
3616 | for (num, sha1) in enumerate(sha1s): | |
5b1d901c MM |
3617 | rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s)) |
3618 | if not rev.recipients and rev.cc_recipients: | |
7c554311 | 3619 | change.environment.log_msg('*** Replacing Cc: with To:') |
5b1d901c MM |
3620 | rev.recipients = rev.cc_recipients |
3621 | rev.cc_recipients = None | |
bc501f69 | 3622 | if rev.recipients: |
4b1fd356 | 3623 | extra_values = {'send_date': next(send_date)} |
b513f71f MH |
3624 | mailer.send( |
3625 | rev.generate_email(self, body_filter, extra_values), | |
3626 | rev.recipients, | |
3627 | ) | |
bc501f69 MH |
3628 | |
3629 | # Consistency check: | |
3630 | if unhandled_sha1s: | |
5b1d901c | 3631 | change.environment.log_error( |
bc501f69 | 3632 | 'ERROR: No emails were sent for the following new commits:\n' |
7c554311 | 3633 | ' %s' |
bc501f69 MH |
3634 | % ('\n '.join(sorted(unhandled_sha1s)),) |
3635 | ) | |
3636 | ||
3637 | ||
4b1fd356 MM |
3638 | def include_ref(refname, ref_filter_regex, is_inclusion_filter): |
3639 | does_match = bool(ref_filter_regex.search(refname)) | |
3640 | if is_inclusion_filter: | |
3641 | return does_match | |
3642 | else: # exclusion filter -- we include the ref if the regex doesn't match | |
3643 | return not does_match | |
3644 | ||
3645 | ||
bc501f69 | 3646 | def run_as_post_receive_hook(environment, mailer): |
7c554311 MM |
3647 | environment.check() |
3648 | send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) | |
3649 | ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) | |
bc501f69 | 3650 | changes = [] |
7c554311 MM |
3651 | while True: |
3652 | line = read_line(sys.stdin) | |
3653 | if line == '': | |
3654 | break | |
bc501f69 | 3655 | (oldrev, newrev, refname) = line.strip().split(' ', 2) |
7c554311 MM |
3656 | environment.get_logger().debug( |
3657 | "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" % | |
3658 | (oldrev, newrev, refname)) | |
3659 | ||
4b1fd356 MM |
3660 | if not include_ref(refname, ref_filter_regex, is_inclusion_filter): |
3661 | continue | |
7c554311 MM |
3662 | if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): |
3663 | continue | |
bc501f69 MH |
3664 | changes.append( |
3665 | ReferenceChange.create(environment, oldrev, newrev, refname) | |
3666 | ) | |
4b1fd356 MM |
3667 | if changes: |
3668 | push = Push(environment, changes) | |
3669 | push.send_emails(mailer, body_filter=environment.filter_body) | |
4453d76c MM |
3670 | if hasattr(mailer, '__del__'): |
3671 | mailer.__del__() | |
bc501f69 MH |
3672 | |
3673 | ||
5b1d901c | 3674 | def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False): |
7c554311 MM |
3675 | environment.check() |
3676 | send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) | |
3677 | ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) | |
4b1fd356 MM |
3678 | if not include_ref(refname, ref_filter_regex, is_inclusion_filter): |
3679 | return | |
7c554311 MM |
3680 | if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): |
3681 | return | |
bc501f69 MH |
3682 | changes = [ |
3683 | ReferenceChange.create( | |
3684 | environment, | |
3685 | read_git_output(['rev-parse', '--verify', oldrev]), | |
3686 | read_git_output(['rev-parse', '--verify', newrev]), | |
3687 | refname, | |
3688 | ), | |
3689 | ] | |
4b1fd356 | 3690 | push = Push(environment, changes, force_send) |
bc501f69 | 3691 | push.send_emails(mailer, body_filter=environment.filter_body) |
4453d76c MM |
3692 | if hasattr(mailer, '__del__'): |
3693 | mailer.__del__() | |
bc501f69 MH |
3694 | |
3695 | ||
7c554311 MM |
3696 | def check_ref_filter(environment): |
3697 | send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True) | |
3698 | ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False) | |
3699 | ||
3700 | def inc_exc_lusion(b): | |
3701 | if b: | |
3702 | return 'inclusion' | |
3703 | else: | |
3704 | return 'exclusion' | |
3705 | ||
3706 | if send_filter_regex: | |
3707 | sys.stdout.write("DoSend/DontSend filter regex (" + | |
3708 | (inc_exc_lusion(send_is_inclusion)) + | |
3709 | '): ' + send_filter_regex.pattern + | |
3710 | '\n') | |
3711 | if send_filter_regex: | |
3712 | sys.stdout.write("Include/Exclude filter regex (" + | |
3713 | (inc_exc_lusion(ref_is_inclusion)) + | |
3714 | '): ' + ref_filter_regex.pattern + | |
3715 | '\n') | |
3716 | sys.stdout.write(os.linesep) | |
3717 | ||
3718 | sys.stdout.write( | |
3719 | "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n" | |
3720 | "or refFilterExclusionRegex. No emails will be sent for commits included\n" | |
3721 | "in these refs.\n" | |
3722 | "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n" | |
3723 | "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n" | |
3724 | "refFilterExclusionRegex. Emails will be sent for commits included in these\n" | |
3725 | "refs only when the commit reaches a ref which isn't excluded.\n" | |
3726 | "Refs marked as DO-SEND are not excluded by any filter. Emails will\n" | |
3727 | "be sent normally for commits included in these refs.\n") | |
3728 | ||
3729 | sys.stdout.write(os.linesep) | |
3730 | ||
3731 | for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']): | |
3732 | sys.stdout.write(refname) | |
3733 | if not include_ref(refname, ref_filter_regex, ref_is_inclusion): | |
3734 | sys.stdout.write(' EXCLUDE') | |
3735 | elif not include_ref(refname, send_filter_regex, send_is_inclusion): | |
3736 | sys.stdout.write(' DONT-SEND') | |
3737 | else: | |
3738 | sys.stdout.write(' DO-SEND') | |
3739 | ||
3740 | sys.stdout.write(os.linesep) | |
3741 | ||
3742 | ||
3743 | def show_env(environment, out): | |
3744 | out.write('Environment values:\n') | |
3745 | for (k, v) in sorted(environment.get_values().items()): | |
3746 | if k: # Don't show the {'' : ''} pair. | |
3747 | out.write(' %s : %r\n' % (k, v)) | |
3748 | out.write('\n') | |
3749 | # Flush to avoid interleaving with further log output | |
3750 | out.flush() | |
3751 | ||
3752 | ||
3753 | def check_setup(environment): | |
3754 | environment.check() | |
3755 | show_env(environment, sys.stdout) | |
3756 | sys.stdout.write("Now, checking that git-multimail's standard input " | |
3757 | "is properly set ..." + os.linesep) | |
3758 | sys.stdout.write("Please type some text and then press Return" + os.linesep) | |
3759 | stdin = sys.stdin.readline() | |
3760 | sys.stdout.write("You have just entered:" + os.linesep) | |
3761 | sys.stdout.write(stdin) | |
3762 | sys.stdout.write("git-multimail seems properly set up." + os.linesep) | |
3763 | ||
3764 | ||
bc501f69 MH |
3765 | def choose_mailer(config, environment): |
3766 | mailer = config.get('mailer', default='sendmail') | |
3767 | ||
3768 | if mailer == 'smtp': | |
3769 | smtpserver = config.get('smtpserver', default='localhost') | |
5b1d901c MM |
3770 | smtpservertimeout = float(config.get('smtpservertimeout', default=10.0)) |
3771 | smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0)) | |
3772 | smtpencryption = config.get('smtpencryption', default='none') | |
3773 | smtpuser = config.get('smtpuser', default='') | |
3774 | smtppass = config.get('smtppass', default='') | |
4453d76c | 3775 | smtpcacerts = config.get('smtpcacerts', default='') |
bc501f69 | 3776 | mailer = SMTPMailer( |
7c554311 | 3777 | environment, |
bc501f69 | 3778 | envelopesender=(environment.get_sender() or environment.get_fromaddr()), |
5b1d901c MM |
3779 | smtpserver=smtpserver, smtpservertimeout=smtpservertimeout, |
3780 | smtpserverdebuglevel=smtpserverdebuglevel, | |
3781 | smtpencryption=smtpencryption, | |
3782 | smtpuser=smtpuser, | |
3783 | smtppass=smtppass, | |
4453d76c | 3784 | smtpcacerts=smtpcacerts |
bc501f69 MH |
3785 | ) |
3786 | elif mailer == 'sendmail': | |
3787 | command = config.get('sendmailcommand') | |
3788 | if command: | |
3789 | command = shlex.split(command) | |
7c554311 MM |
3790 | mailer = SendMailer(environment, |
3791 | command=command, envelopesender=environment.get_sender()) | |
bc501f69 | 3792 | else: |
5b1d901c | 3793 | environment.log_error( |
4b1fd356 | 3794 | 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer + |
7c554311 | 3795 | 'please use one of "smtp" or "sendmail".' |
bc501f69 MH |
3796 | ) |
3797 | sys.exit(1) | |
3798 | return mailer | |
3799 | ||
3800 | ||
3801 | KNOWN_ENVIRONMENTS = { | |
7c554311 MM |
3802 | 'generic': {'highprec': GenericEnvironmentMixin}, |
3803 | 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin, | |
3804 | 'lowprec': GitoliteEnvironmentLowPrecMixin}, | |
3805 | 'stash': {'highprec': StashEnvironmentHighPrecMixin, | |
3806 | 'lowprec': StashEnvironmentLowPrecMixin}, | |
3807 | 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin, | |
3808 | 'lowprec': GerritEnvironmentLowPrecMixin}, | |
bc501f69 MH |
3809 | } |
3810 | ||
3811 | ||
4b1fd356 MM |
3812 | def choose_environment(config, osenv=None, env=None, recipients=None, |
3813 | hook_info=None): | |
7c554311 MM |
3814 | env_name = choose_environment_name(config, env, osenv) |
3815 | environment_klass = build_environment_klass(env_name) | |
3816 | env = build_environment(environment_klass, env_name, config, | |
3817 | osenv, recipients, hook_info) | |
3818 | return env | |
3819 | ||
3820 | ||
3821 | def choose_environment_name(config, env, osenv): | |
bc501f69 MH |
3822 | if not osenv: |
3823 | osenv = os.environ | |
3824 | ||
bc501f69 MH |
3825 | if not env: |
3826 | env = config.get('environment') | |
3827 | ||
3828 | if not env: | |
3829 | if 'GL_USER' in osenv and 'GL_REPO' in osenv: | |
3830 | env = 'gitolite' | |
3831 | else: | |
3832 | env = 'generic' | |
7c554311 MM |
3833 | return env |
3834 | ||
3835 | ||
3836 | COMMON_ENVIRONMENT_MIXINS = [ | |
3837 | ConfigRecipientsEnvironmentMixin, | |
3838 | CLIRecipientsEnvironmentMixin, | |
3839 | ConfigRefFilterEnvironmentMixin, | |
3840 | ProjectdescEnvironmentMixin, | |
3841 | ConfigMaxlinesEnvironmentMixin, | |
3842 | ComputeFQDNEnvironmentMixin, | |
3843 | ConfigFilterLinesEnvironmentMixin, | |
3844 | PusherDomainEnvironmentMixin, | |
3845 | ConfigOptionsEnvironmentMixin, | |
3846 | ] | |
3847 | ||
3848 | ||
3849 | def build_environment_klass(env_name): | |
3850 | if 'class' in KNOWN_ENVIRONMENTS[env_name]: | |
3851 | return KNOWN_ENVIRONMENTS[env_name]['class'] | |
3852 | ||
3853 | environment_mixins = [] | |
3854 | known_env = KNOWN_ENVIRONMENTS[env_name] | |
3855 | if 'highprec' in known_env: | |
3856 | high_prec_mixin = known_env['highprec'] | |
3857 | environment_mixins.append(high_prec_mixin) | |
3858 | environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS | |
3859 | if 'lowprec' in known_env: | |
3860 | low_prec_mixin = known_env['lowprec'] | |
3861 | environment_mixins.append(low_prec_mixin) | |
3862 | environment_mixins.append(Environment) | |
3863 | klass_name = env_name.capitalize() + 'Environement' | |
3864 | environment_klass = type( | |
3865 | klass_name, | |
3866 | tuple(environment_mixins), | |
3867 | {}, | |
3868 | ) | |
3869 | KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass | |
3870 | return environment_klass | |
3871 | ||
bc501f69 | 3872 | |
7c554311 MM |
3873 | GerritEnvironment = build_environment_klass('gerrit') |
3874 | StashEnvironment = build_environment_klass('stash') | |
3875 | GitoliteEnvironment = build_environment_klass('gitolite') | |
3876 | GenericEnvironment = build_environment_klass('generic') | |
3877 | ||
3878 | ||
3879 | def build_environment(environment_klass, env, config, | |
3880 | osenv, recipients, hook_info): | |
3881 | environment_kw = { | |
3882 | 'osenv': osenv, | |
3883 | 'config': config, | |
3884 | } | |
4b1fd356 MM |
3885 | |
3886 | if env == 'stash': | |
3887 | environment_kw['user'] = hook_info['stash_user'] | |
3888 | environment_kw['repo'] = hook_info['stash_repo'] | |
3889 | elif env == 'gerrit': | |
3890 | environment_kw['project'] = hook_info['project'] | |
3891 | environment_kw['submitter'] = hook_info['submitter'] | |
3892 | environment_kw['update_method'] = hook_info['update_method'] | |
bc501f69 | 3893 | |
7c554311 | 3894 | environment_kw['cli_recipients'] = recipients |
bc501f69 | 3895 | |
bc501f69 MH |
3896 | return environment_klass(**environment_kw) |
3897 | ||
3898 | ||
4b1fd356 MM |
3899 | def get_version(): |
3900 | oldcwd = os.getcwd() | |
3901 | try: | |
3902 | try: | |
3903 | os.chdir(os.path.dirname(os.path.realpath(__file__))) | |
3904 | git_version = read_git_output(['describe', '--tags', 'HEAD']) | |
3905 | if git_version == __version__: | |
3906 | return git_version | |
3907 | else: | |
3908 | return '%s (%s)' % (__version__, git_version) | |
3909 | except: | |
3910 | pass | |
3911 | finally: | |
3912 | os.chdir(oldcwd) | |
3913 | return __version__ | |
3914 | ||
3915 | ||
7c554311 MM |
3916 | def compute_gerrit_options(options, args, required_gerrit_options, |
3917 | raw_refname): | |
4b1fd356 MM |
3918 | if None in required_gerrit_options: |
3919 | raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, " | |
3920 | "and --project; or none of them.") | |
3921 | ||
3922 | if options.environment not in (None, 'gerrit'): | |
3923 | raise SystemExit("Non-gerrit environments incompatible with --oldrev, " | |
3924 | "--newrev, --refname, and --project") | |
3925 | options.environment = 'gerrit' | |
3926 | ||
3927 | if args: | |
3928 | raise SystemExit("Error: Positional parameters not allowed with " | |
3929 | "--oldrev, --newrev, and --refname.") | |
3930 | ||
3931 | # Gerrit oddly omits 'refs/heads/' in the refname when calling | |
3932 | # ref-updated hook; put it back. | |
3933 | git_dir = get_git_dir() | |
7c554311 | 3934 | if (not os.path.exists(os.path.join(git_dir, raw_refname)) and |
4b1fd356 | 3935 | os.path.exists(os.path.join(git_dir, 'refs', 'heads', |
7c554311 | 3936 | raw_refname))): |
4b1fd356 MM |
3937 | options.refname = 'refs/heads/' + options.refname |
3938 | ||
4b1fd356 MM |
3939 | # New revisions can appear in a gerrit repository either due to someone |
3940 | # pushing directly (in which case options.submitter will be set), or they | |
3941 | # can press "Submit this patchset" in the web UI for some CR (in which | |
3942 | # case options.submitter will not be set and gerrit will not have provided | |
3943 | # us the information about who pressed the button). | |
3944 | # | |
3945 | # Note for the nit-picky: I'm lumping in REST API calls and the ssh | |
3946 | # gerrit review command in with "Submit this patchset" button, since they | |
3947 | # have the same effect. | |
3948 | if options.submitter: | |
3949 | update_method = 'pushed' | |
3950 | # The submitter argument is almost an RFC 2822 email address; change it | |
3951 | # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is | |
3952 | options.submitter = options.submitter.replace('(', '<').replace(')', '>') | |
3953 | else: | |
3954 | update_method = 'submitted' | |
3955 | # Gerrit knew who submitted this patchset, but threw that information | |
3956 | # away when it invoked this hook. However, *IF* Gerrit created a | |
3957 | # merge to bring the patchset in (project 'Submit Type' is either | |
3958 | # "Always Merge", or is "Merge if Necessary" and happens to be | |
3959 | # necessary for this particular CR), then it will have the committer | |
3960 | # of that merge be 'Gerrit Code Review' and the author will be the | |
3961 | # person who requested the submission of the CR. Since this is fairly | |
3962 | # likely for most gerrit installations (of a reasonable size), it's | |
3963 | # worth the extra effort to try to determine the actual submitter. | |
3964 | rev_info = read_git_lines(['log', '--no-walk', '--merges', | |
3965 | '--format=%cN%n%aN <%aE>', options.newrev]) | |
3966 | if rev_info and rev_info[0] == 'Gerrit Code Review': | |
3967 | options.submitter = rev_info[1] | |
3968 | ||
3969 | # We pass back refname, oldrev, newrev as args because then the | |
3970 | # gerrit ref-updated hook is much like the git update hook | |
3971 | return (options, | |
3972 | [options.refname, options.oldrev, options.newrev], | |
3973 | {'project': options.project, 'submitter': options.submitter, | |
3974 | 'update_method': update_method}) | |
3975 | ||
3976 | ||
3977 | def check_hook_specific_args(options, args): | |
7c554311 MM |
3978 | raw_refname = options.refname |
3979 | # Convert each string option unicode for Python3. | |
3980 | if PYTHON3: | |
3981 | opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname', | |
3982 | 'project', 'submitter', 'stash_user', 'stash_repo'] | |
3983 | for opt in opts: | |
3984 | if not hasattr(options, opt): | |
3985 | continue | |
3986 | obj = getattr(options, opt) | |
3987 | if obj: | |
3988 | enc = obj.encode('utf-8', 'surrogateescape') | |
3989 | dec = enc.decode('utf-8', 'replace') | |
3990 | setattr(options, opt, dec) | |
3991 | ||
4b1fd356 MM |
3992 | # First check for stash arguments |
3993 | if (options.stash_user is None) != (options.stash_repo is None): | |
3994 | raise SystemExit("Error: Specify both of --stash-user and " | |
3995 | "--stash-repo or neither.") | |
3996 | if options.stash_user: | |
3997 | options.environment = 'stash' | |
3998 | return options, args, {'stash_user': options.stash_user, | |
3999 | 'stash_repo': options.stash_repo} | |
4000 | ||
4001 | # Finally, check for gerrit specific arguments | |
4002 | required_gerrit_options = (options.oldrev, options.newrev, options.refname, | |
4003 | options.project) | |
4004 | if required_gerrit_options != (None,) * 4: | |
7c554311 MM |
4005 | return compute_gerrit_options(options, args, required_gerrit_options, |
4006 | raw_refname) | |
4b1fd356 MM |
4007 | |
4008 | # No special options in use, just return what we started with | |
4009 | return options, args, {} | |
4010 | ||
4011 | ||
7c554311 MM |
4012 | class Logger(object): |
4013 | def parse_verbose(self, verbose): | |
4014 | if verbose > 0: | |
4015 | return logging.DEBUG | |
4016 | else: | |
4017 | return logging.INFO | |
4018 | ||
4019 | def create_log_file(self, environment, name, path, verbosity): | |
4020 | log_file = logging.getLogger(name) | |
4021 | file_handler = logging.FileHandler(path) | |
4022 | log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") | |
4023 | file_handler.setFormatter(log_fmt) | |
4024 | log_file.addHandler(file_handler) | |
4025 | log_file.setLevel(verbosity) | |
4026 | return log_file | |
4027 | ||
4028 | def __init__(self, environment): | |
4029 | self.environment = environment | |
4030 | self.loggers = [] | |
4031 | stderr_log = logging.getLogger('git_multimail.stderr') | |
4032 | ||
4033 | class EncodedStderr(object): | |
4034 | def write(self, x): | |
4035 | write_str(sys.stderr, x) | |
4036 | ||
4037 | def flush(self): | |
4038 | sys.stderr.flush() | |
4039 | ||
4040 | stderr_handler = logging.StreamHandler(EncodedStderr()) | |
4041 | stderr_log.addHandler(stderr_handler) | |
4042 | stderr_log.setLevel(self.parse_verbose(environment.verbose)) | |
4043 | self.loggers.append(stderr_log) | |
4044 | ||
4045 | if environment.debug_log_file is not None: | |
4046 | debug_log_file = self.create_log_file( | |
4047 | environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG) | |
4048 | self.loggers.append(debug_log_file) | |
4049 | ||
4050 | if environment.log_file is not None: | |
4051 | log_file = self.create_log_file( | |
4052 | environment, 'git_multimail.file', environment.log_file, logging.INFO) | |
4053 | self.loggers.append(log_file) | |
4054 | ||
4055 | if environment.error_log_file is not None: | |
4056 | error_log_file = self.create_log_file( | |
4057 | environment, 'git_multimail.error', environment.error_log_file, logging.ERROR) | |
4058 | self.loggers.append(error_log_file) | |
4059 | ||
4060 | def info(self, msg): | |
4061 | for l in self.loggers: | |
4062 | l.info(msg) | |
4063 | ||
4064 | def debug(self, msg): | |
4065 | for l in self.loggers: | |
4066 | l.debug(msg) | |
4067 | ||
4068 | def warning(self, msg): | |
4069 | for l in self.loggers: | |
4070 | l.warning(msg) | |
4071 | ||
4072 | def error(self, msg): | |
4073 | for l in self.loggers: | |
4074 | l.error(msg) | |
4075 | ||
4076 | ||
bc501f69 MH |
4077 | def main(args): |
4078 | parser = optparse.OptionParser( | |
4079 | description=__doc__, | |
4080 | usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV', | |
4081 | ) | |
4082 | ||
4083 | parser.add_option( | |
4084 | '--environment', '--env', action='store', type='choice', | |
4b1fd356 | 4085 | choices=list(KNOWN_ENVIRONMENTS.keys()), default=None, |
bc501f69 MH |
4086 | help=( |
4087 | 'Choose type of environment is in use. Default is taken from ' | |
4088 | 'multimailhook.environment if set; otherwise "generic".' | |
4089 | ), | |
4090 | ) | |
4091 | parser.add_option( | |
4092 | '--stdout', action='store_true', default=False, | |
4093 | help='Output emails to stdout rather than sending them.', | |
4094 | ) | |
4095 | parser.add_option( | |
4096 | '--recipients', action='store', default=None, | |
4097 | help='Set list of email recipients for all types of emails.', | |
4098 | ) | |
4099 | parser.add_option( | |
4100 | '--show-env', action='store_true', default=False, | |
4101 | help=( | |
4102 | 'Write to stderr the values determined for the environment ' | |
7c554311 | 4103 | '(intended for debugging purposes), then proceed normally.' |
bc501f69 MH |
4104 | ), |
4105 | ) | |
5b1d901c MM |
4106 | parser.add_option( |
4107 | '--force-send', action='store_true', default=False, | |
4108 | help=( | |
4109 | 'Force sending refchange email when using as an update hook. ' | |
4110 | 'This is useful to work around the unreliable new commits ' | |
4111 | 'detection in this mode.' | |
4112 | ), | |
4113 | ) | |
4b1fd356 MM |
4114 | parser.add_option( |
4115 | '-c', metavar="<name>=<value>", action='append', | |
4116 | help=( | |
4117 | 'Pass a configuration parameter through to git. The value given ' | |
4118 | 'will override values from configuration files. See the -c option ' | |
4119 | 'of git(1) for more details. (Only works with git >= 1.7.3)' | |
4120 | ), | |
4121 | ) | |
4122 | parser.add_option( | |
4123 | '--version', '-v', action='store_true', default=False, | |
4124 | help=( | |
4125 | "Display git-multimail's version" | |
4126 | ), | |
4127 | ) | |
7c554311 MM |
4128 | |
4129 | parser.add_option( | |
4130 | '--python-version', action='store_true', default=False, | |
4131 | help=( | |
4132 | "Display the version of Python used by git-multimail" | |
4133 | ), | |
4134 | ) | |
4135 | ||
4136 | parser.add_option( | |
4137 | '--check-ref-filter', action='store_true', default=False, | |
4138 | help=( | |
4139 | 'List refs and show information on how git-multimail ' | |
4140 | 'will process them.' | |
4141 | ) | |
4142 | ) | |
4143 | ||
4b1fd356 MM |
4144 | # The following options permit this script to be run as a gerrit |
4145 | # ref-updated hook. See e.g. | |
4146 | # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt | |
4147 | # We suppress help for these items, since these are specific to gerrit, | |
4148 | # and we don't want users directly using them any way other than how the | |
4149 | # gerrit ref-updated hook is called. | |
4150 | parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP) | |
4151 | parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP) | |
4152 | parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP) | |
4153 | parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP) | |
4154 | parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP) | |
4155 | ||
4156 | # The following allow this to be run as a stash asynchronous post-receive | |
4157 | # hook (almost identical to a git post-receive hook but triggered also for | |
4158 | # merges of pull requests from the UI). We suppress help for these items, | |
4159 | # since these are specific to stash. | |
4160 | parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP) | |
4161 | parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP) | |
bc501f69 MH |
4162 | |
4163 | (options, args) = parser.parse_args(args) | |
4b1fd356 MM |
4164 | (options, args, hook_info) = check_hook_specific_args(options, args) |
4165 | ||
4166 | if options.version: | |
4167 | sys.stdout.write('git-multimail version ' + get_version() + '\n') | |
4168 | return | |
4169 | ||
7c554311 MM |
4170 | if options.python_version: |
4171 | sys.stdout.write('Python version ' + sys.version + '\n') | |
4172 | return | |
4173 | ||
4b1fd356 | 4174 | if options.c: |
4453d76c | 4175 | Config.add_config_parameters(options.c) |
bc501f69 MH |
4176 | |
4177 | config = Config('multimailhook') | |
4178 | ||
7c554311 | 4179 | environment = None |
bc501f69 MH |
4180 | try: |
4181 | environment = choose_environment( | |
4182 | config, osenv=os.environ, | |
4183 | env=options.environment, | |
4184 | recipients=options.recipients, | |
4b1fd356 | 4185 | hook_info=hook_info, |
bc501f69 MH |
4186 | ) |
4187 | ||
4188 | if options.show_env: | |
7c554311 | 4189 | show_env(environment, sys.stderr) |
bc501f69 | 4190 | |
5b1d901c | 4191 | if options.stdout or environment.stdout: |
bc501f69 MH |
4192 | mailer = OutputMailer(sys.stdout) |
4193 | else: | |
4194 | mailer = choose_mailer(config, environment) | |
4195 | ||
7c554311 MM |
4196 | must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP') |
4197 | if must_check_setup == '': | |
4198 | must_check_setup = False | |
4199 | if options.check_ref_filter: | |
4200 | check_ref_filter(environment) | |
4201 | elif must_check_setup: | |
4202 | check_setup(environment) | |
bc501f69 MH |
4203 | # Dual mode: if arguments were specified on the command line, run |
4204 | # like an update hook; otherwise, run as a post-receive hook. | |
7c554311 | 4205 | elif args: |
bc501f69 MH |
4206 | if len(args) != 3: |
4207 | parser.error('Need zero or three non-option arguments') | |
4208 | (refname, oldrev, newrev) = args | |
7c554311 MM |
4209 | environment.get_logger().debug( |
4210 | "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" % | |
4211 | (refname, oldrev, newrev, options.force_send)) | |
5b1d901c | 4212 | run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send) |
bc501f69 MH |
4213 | else: |
4214 | run_as_post_receive_hook(environment, mailer) | |
4b1fd356 MM |
4215 | except ConfigurationException: |
4216 | sys.exit(sys.exc_info()[1]) | |
7c554311 MM |
4217 | except SystemExit: |
4218 | raise | |
4b1fd356 MM |
4219 | except Exception: |
4220 | t, e, tb = sys.exc_info() | |
4221 | import traceback | |
7c554311 MM |
4222 | sys.stderr.write('\n') # Avoid mixing message with previous output |
4223 | msg = ( | |
4224 | 'Exception \'' + t.__name__ + | |
4225 | '\' raised. Please report this as a bug to\n' | |
4226 | 'https://github.com/git-multimail/git-multimail/issues\n' | |
4227 | 'with the information below:\n\n' | |
4228 | 'git-multimail version ' + get_version() + '\n' | |
4229 | 'Python version ' + sys.version + '\n' + | |
4230 | traceback.format_exc()) | |
4231 | try: | |
4232 | environment.get_logger().error(msg) | |
4233 | except: | |
4234 | sys.stderr.write(msg) | |
4b1fd356 | 4235 | sys.exit(1) |
bc501f69 MH |
4236 | |
4237 | if __name__ == '__main__': | |
4238 | main(sys.argv[1:]) |