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