diff options
Diffstat (limited to 'contrib/hooks/multimail')
-rw-r--r-- | contrib/hooks/multimail/CHANGES | 285 | ||||
-rw-r--r-- | contrib/hooks/multimail/CONTRIBUTING.rst | 60 | ||||
-rw-r--r-- | contrib/hooks/multimail/README.Git | 15 | ||||
-rw-r--r-- | contrib/hooks/multimail/README.migrate-from-post-receive-email | 145 | ||||
-rw-r--r-- | contrib/hooks/multimail/README.rst | 774 | ||||
-rw-r--r-- | contrib/hooks/multimail/doc/customizing-emails.rst | 56 | ||||
-rw-r--r-- | contrib/hooks/multimail/doc/gerrit.rst | 56 | ||||
-rw-r--r-- | contrib/hooks/multimail/doc/gitolite.rst | 118 | ||||
-rw-r--r-- | contrib/hooks/multimail/doc/troubleshooting.rst | 78 | ||||
-rwxr-xr-x | contrib/hooks/multimail/git_multimail.py | 4346 | ||||
-rwxr-xr-x | contrib/hooks/multimail/migrate-mailhook-config | 274 | ||||
-rwxr-xr-x | contrib/hooks/multimail/post-receive.example | 101 |
12 files changed, 6308 insertions, 0 deletions
diff --git a/contrib/hooks/multimail/CHANGES b/contrib/hooks/multimail/CHANGES new file mode 100644 index 000000000000..35791fd02c21 --- /dev/null +++ b/contrib/hooks/multimail/CHANGES @@ -0,0 +1,285 @@ +Release 1.5.0 +============= + +Backward-incompatible change +---------------------------- + +The name of classes for environment was misnamed as `*Environement`. +It is now `*Environment`. + +New features +------------ + +* A Thread-Index header is now added to each email sent (except for + combined emails where it would not make sense), so that MS Outlook + properly groups messages by threads even though they have a + different subject line. Unfortunately, even adding this header the + threading still seems to be unreliable, but it is unclear whether + this is an issue on our side or on MS Outlook's side (see discussion + here: https://github.com/git-multimail/git-multimail/pull/194). + +* A new variable multimailhook.ExcludeMergeRevisions was added to send + notification emails only for non-merge commits. + +* For gitolite environment, it is now possible to specify the mail map + in a separate file in addition to gitolite.conf, using the variable + multimailhook.MailaddressMap. + +Internal changes +---------------- + +* The testsuite now uses GIT_PRINT_SHA1_ELLIPSIS where needed for + compatibility with recent Git versions. Only tests are affected. + +* We don't try to install pyflakes in the continuous integration job + for old Python versions where it's no longer available. + +* Stop using the deprecated cgi.escape in Python 3. + +* New flake8 warnings have been fixed. + +* Python 3.6 is now tested against on Travis-CI. + +* A bunch of lgtm.com warnings have been fixed. + +Bug fixes +--------- + +* SMTPMailer logs in only once now. It used to re-login for each email + sent which triggered errors for some SMTP servers. + +* migrate-mailhook-config was broken by internal refactoring, it + should now work again. + +This version was tested with Python 2.6 to 3.7. It was tested with Git +1.7.10.406.gdc801, 2.15.1 and 2.20.1.98.gecbdaf0. + +Release 1.4.0 +============= + +New features to troubleshoot a git-multimail installation +--------------------------------------------------------- + +* One can now perform a basic check of git-multimail's setup by + running the hook with the environment variable + GIT_MULTIMAIL_CHECK_SETUP set to a non-empty string. See + doc/troubleshooting.rst for details. + +* A new log files system was added. See the multimailhook.logFile, + multimailhook.errorLogFile and multimailhook.debugLogFile variables. + +* git_multimail.py can now be made more verbose using + multimailhook.verbose. + +* A new option --check-ref-filter is now available to help debugging + the refFilter* options. + +Formatting emails +----------------- + +* Formatting of emails was made slightly more compact, to reduce the + odds of having long subject lines truncated or wrapped in short list + of commits. + +* multimailhook.emailPrefix may now use the '%(repo_shortname)s' + placeholder for the repository's short name. + +* A new option multimailhook.subjectMaxLength is available to truncate + overly long subject lines. + +Bug fixes and minor changes +--------------------------- + +* Options refFilterDoSendRegex and refFilterDontSendRegex were + essentially broken. They should work now. + +* The behavior when both refFilter{Do,Dont}SendRegex and + refFilter{Exclusion,Inclusion}Regex are set have been slightly + changed. Exclusion/Inclusion is now strictly stronger than + DoSend/DontSend. + +* The management of precedence when a setting can be computed in + multiple ways has been considerably refactored and modified. + multimailhook.from and multimailhook.reponame now have precedence + over the environment-specific settings ($GL_REPO/$GL_USER for + gitolite, --stash-user/repo for Stash, --submitter/--project for + Gerrit). + +* The coverage of the testsuite has been considerably improved. All + configuration variables now appear at least once in the testsuite. + +This version was tested with Python 2.6 to 3.5. It also mostly works +with Python 2.4, but there is one known breakage in the testsuite +related to non-ascii characters. It was tested with Git +1.7.10.406.gdc801, 1.8.5.6, 2.1.4, and 2.10.0.rc0.1.g07c9292. + +Release 1.3.1 (bugfix-only release) +=================================== + +* Generate links to commits in combined emails (it was done only for + commit emails in 1.3.0). + +* Fix broken links on PyPi. + +Release 1.3.0 +============= + +* New options multimailhook.htmlInIntro and multimailhook.htmlInFooter + now allow using HTML in the introduction and footer of emails (e.g. + for a more pleasant formatting or to insert a link to the commit on + a web interface). + +* A new option multimailhook.commitBrowseURL gives a simpler (and less + flexible) way to add a link to a web interface for commit emails + than multimailhook.htmlInIntro and multimailhook.htmlInFooter. + +* A new public function config.add_config_parameters was added to + allow custom hooks to set specific Git configuration variables + without modifying the configuration files. See an example in + post-receive.example. + +* Error handling for SMTP has been improved (we used to print Python + backtraces for legitimate errors). + +* The SMTP mailer can now check TLS certificates when the newly added + configuration variable multimailhook.smtpCACerts. + +* Python 3 portability has been improved. + +* The documentation's formatting has been improved. + +* The testsuite has been improved (we now use pyflakes to check for + errors in the code). + +This version has been tested with Python 2.4 and 2.6 to 3.5, and Git +v1.7.10-406-gdc801e7, 2.1.4 and 2.8.1.339.g3ad15fd. + +No change since 1.3 RC1. + +Release 1.2.0 +============= + +* It is now possible to exclude some refs (e.g. exclude some branches + or tags). See refFilterDoSendRegex, refFilterDontSendRegex, + refFilterInclusionRegex and refFilterExclusionRegex. + +* New commitEmailFormat option which can be set to "html" to generate + simple colorized diffs using HTML for the commit emails. + +* git-multimail can now be ran as a Gerrit ref-updated hook, or from + Atlassian BitBucket Server (formerly known as Atlassian Stash). + +* The From: field is now more customizeable. It can be set + independently for refchange emails and commit emails (see + fromCommit, fromRefChange). The special values pusher and author can + be used in these configuration variable. + +* A new command-line option, --version, was added. The version is also + available in the X-Git-Multimail-Version header of sent emails. + +* Set X-Git-NotificationType header to differentiate the various types + of notifications. Current values are: diff, ref_changed_plus_diff, + ref_changed. + +* Preliminary support for Python 3. The testsuite passes with Python 3, + but it has not received as much testing as the Python 2 version yet. + +* Several encoding-related fixes. UTF-8 characters work in more + situations (but non-ascii characters in email address are still not + supported). + +* The testsuite and its documentation has been greatly improved. + +Plus all the bugfixes from version 1.1.1. + +This version has been tested with Python 2.4 and 2.6 to 3.5, and Git +v1.7.10-406-gdc801e7, git-1.8.2.3 and 2.6.0. Git versions prior to +v1.7.10-406-gdc801e7 probably work, but cannot run the testsuite +properly. + +Release 1.1.1 (bugfix-only release) +=================================== + +* The SMTP mailer was not working with Python 2.4. + +Release 1.1.0 +============= + +* When a single commit is pushed, omit the reference changed email. + Set multimailhook.combineWhenSingleCommit to false to disable this + new feature. + +* In gitolite environments, the pusher's email address can be used as + the From address by creating a specially formatted comment block in + gitolite.conf (see multimailhook.from in README). + +* Support for SMTP authentication and SSL/TLS encryption was added, + see smtpUser, smtpPass, smtpEncryption in README. + +* A new option scanCommitForCc was added to allow git-multimail to + search the commit message for 'Cc: ...' lines, and add the + corresponding emails in Cc. + +* If $USER is not set, use the variable $USERNAME. This is needed on + Windows platform to recognize the pusher. + +* The emailPrefix variable can now be set to an empty string to remove + the prefix. + +* A short tutorial was added in doc/gitolite.rst to set up + git-multimail with gitolite. + +* The post-receive file was renamed to post-receive.example. It has + always been an example (the standard way to call git-multimail is to + call git_multimail.py), but it was unclear to many users. + +* A new refchangeShowGraph option was added to make it possible to + include both a graph and a log in the summary emails. The options + to control the graph formatting can be set via the new graphOpts + option. + +* New option --force-send was added to disable new commit detection + for update hook. One use-case is to run git_multimail.py after + running "git fetch" to send emails about commits that have just been + fetched (the detection of new commits was unreliable in this mode). + +* The testing infrastructure was considerably improved (continuous + integration with travis-ci, automatic check of PEP8 and RST syntax, + many improvements to the test scripts). + +This version has been tested with Python 2.4 to 2.7, and Git 1.7.1 to +2.4. + +Release 1.0.0 +============= + +* Fix encoding of non-ASCII email addresses in email headers. + +* Fix backwards-compatibility bugs for older Python 2.x versions. + +* Fix a backwards-compatibility bug for Git 1.7.1. + +* Add an option commitDiffOpts to customize logs for revisions. + +* Pass "-oi" to sendmail by default to prevent premature termination + on a line containing only ".". + +* Stagger email "Date:" values in an attempt to help mail clients + thread the emails in the right order. + +* If a mailing list setting is missing, just skip sending the + corresponding email (with a warning) instead of failing. + +* Add a X-Git-Host header that can be used for email filtering. + +* Allow the sender's fully-qualified domain name to be configured. + +* Minor documentation improvements. + +* Add this CHANGES file. + + +Release 0.9.0 +============= + +* Initial release. diff --git a/contrib/hooks/multimail/CONTRIBUTING.rst b/contrib/hooks/multimail/CONTRIBUTING.rst new file mode 100644 index 000000000000..de20a5428730 --- /dev/null +++ b/contrib/hooks/multimail/CONTRIBUTING.rst @@ -0,0 +1,60 @@ +Contributing +============ + +git-multimail is an open-source project, built by volunteers. We would +welcome your help! + +The current maintainers are `Matthieu Moy <http://matthieu-moy.fr>`__ and +`Michael Haggerty <https://github.com/mhagger>`__. + +Please note that although a copy of git-multimail is distributed in +the "contrib" section of the main Git project, development takes place +in a separate `git-multimail repository on GitHub`_. + +Whenever enough changes to git-multimail have accumulated, a new +code-drop of git-multimail will be submitted for inclusion in the Git +project. + +We use the GitHub issue tracker to keep track of bugs and feature +requests, and we use GitHub pull requests to exchange patches (though, +if you prefer, you can send patches via the Git mailing list with CC +to the maintainers). Please sign off your patches as per the `Git +project practice +<https://github.com/git/git/blob/master/Documentation/SubmittingPatches#L234>`__. + +Please vote for issues you would like to be addressed in priority +(click "add your reaction" and then the "+1" thumbs-up button on the +GitHub issue). + +General discussion of git-multimail can take place on the main `Git +mailing list`_. + +Please CC emails regarding git-multimail to the maintainers so that we +don't overlook them. + +Help needed: testers/maintainer for specific environments/OS +------------------------------------------------------------ + +The current maintainer uses and tests git-multimail on Linux with the +Generic environment. More testers, or better contributors are needed +to test git-multimail on other real-life setups: + +* Mac OS X, Windows: git-multimail is currently not supported on these + platforms. But since we have no external dependencies and try to + write code as portable as possible, it is possible that + git-multimail already runs there and if not, it is likely that it + could be ported easily. + + Patches to improve support for Windows and OS X are welcome. + Ideally, there would be a sub-maintainer for each OS who would test + at least once before each release (around twice a year). + +* Gerrit, Stash, Gitolite environments: although the testsuite + contains tests for these environments, a tester/maintainer for each + environment would be welcome to test and report failure (or success) + on real-life environments periodically (here also, feedback before + each release would be highly appreciated). + + +.. _`git-multimail repository on GitHub`: https://github.com/git-multimail/git-multimail +.. _`Git mailing list`: git@vger.kernel.org diff --git a/contrib/hooks/multimail/README.Git b/contrib/hooks/multimail/README.Git new file mode 100644 index 000000000000..044444245d09 --- /dev/null +++ b/contrib/hooks/multimail/README.Git @@ -0,0 +1,15 @@ +This copy of git-multimail is distributed as part of the "contrib" +section of the Git project as a convenience to Git users. +git-multimail is developed as an independent project at the following +website: + + https://github.com/git-multimail/git-multimail + +The version in this directory was obtained from the upstream project +on January 07 2019 and consists of the "git-multimail" subdirectory from +revision + + 04e80e6c40be465cc62b6c246f0fcb8fd2cfd454 refs/tags/1.5.0 + +Please see the README file in this directory for information about how +to report bugs or contribute to git-multimail. diff --git a/contrib/hooks/multimail/README.migrate-from-post-receive-email b/contrib/hooks/multimail/README.migrate-from-post-receive-email new file mode 100644 index 000000000000..1e6a976699aa --- /dev/null +++ b/contrib/hooks/multimail/README.migrate-from-post-receive-email @@ -0,0 +1,145 @@ +git-multimail is close to, but not exactly, a plug-in replacement for +the old Git project script contrib/hooks/post-receive-email. This +document describes the differences and explains how to configure +git-multimail to get behavior closest to that of post-receive-email. + +If you are in a hurry +===================== + +A script called migrate-mailhook-config is included with +git-multimail. If you run this script within a Git repository that is +configured to use post-receive-email, it will convert the +configuration settings into the approximate equivalent settings for +git-multimail. For more information, run + + migrate-mailhook-config --help + + +Configuration differences +========================= + +* The names of the config options for git-multimail are in namespace + "multimailhook.*" instead of "hooks.*". (Editorial comment: + post-receive-email should never have used such a generic top-level + namespace.) + +* In emails about new annotated tags, post-receive-email includes a + shortlog of all changes since the previous annotated tag. To get + this behavior with git-multimail, you need to set + multimailhook.announceshortlog to true: + + git config multimailhook.announceshortlog true + +* multimailhook.commitlist -- This is a new configuration variable. + Recipients listed here will receive a separate email for each new + commit. However, if this variable is *not* set, it defaults to the + value of multimailhook.mailinglist. Therefore, if you *don't* want + the members of multimailhook.mailinglist to receive one email per + commit, then set this value to the empty string: + + git config multimailhook.commitlist '' + +* multimailhook.emailprefix -- If this value is not set, then the + subjects of generated emails are prefixed with the short name of the + repository enclosed in square brackets; e.g., "[myrepo]". + post-receive-email defaults to prefix "[SCM]" if this option is not + set. So if you were using the old default and want to retain it + (for example, to avoid having to change your email filters), set + this variable explicitly to the old value: + + git config multimailhook.emailprefix "[SCM]" + +* The "multimailhook.showrev" configuration option is not supported. + Its main use is obsoleted by the one-email-per-commit feature of + git-multimail. + + +Other differences +================= + +This section describes other differences in the behavior of +git-multimail vs. post-receive-email. For full details, please refer +to the main README file: + +* One email per commit. For each reference change, the script first + outputs one email summarizing the reference change (including + one-line summaries of the new commits), then it outputs a separate + email for each new commit that was introduced, including patches. + These one-email-per-commit emails go to the addresses listed in + multimailhook.commitlist. post-receive-email sends only one email + for each *reference* that is changed, no matter how many commits + were added to the reference. + +* Better algorithm for detecting new commits. post-receive-email + processes one reference change at a time, which causes it to fail to + describe new commits that were included in multiple branches. For + example, if a single push adds the "*" commits in the diagram below, + then post-receive-email would never include the details of the two + commits that are common to "master" and "branch" in its + notifications. + + o---o---o---*---*---* <-- master + \ + *---* <-- branch + + git-multimail analyzes all reference modifications to determine + which commits were not present before the change, therefore avoiding + that error. + +* In reference change emails, git-multimail tells which commits have + been added to the reference vs. are entirely new to the repository, + and which commits that have been omitted from the reference + vs. entirely discarded from the repository. + +* The environment in which Git is running can be configured via an + "Environment" abstraction. + +* Built-in support for Gitolite-managed repositories. + +* Instead of using full SHA1 object names in emails, git-multimail + mostly uses abbreviated SHA1s, plus one-line log message summaries + where appropriate. + +* In the schematic diagrams that explain non-fast-forward commits, + git-multimail shows the names of the branches involved. + +* The emails generated by git-multimail include the name of the Git + repository that was modified; this is convenient for recipients who + are monitoring multiple repositories. + +* git-multimail allows the email "From" addresses to be configured. + +* The recipients lists (multimailhook.mailinglist, + multimailhook.refchangelist, multimailhook.announcelist, and + multimailhook.commitlist) can be comma-separated values and/or + multivalued settings in the config file; e.g., + + [multimailhook] + mailinglist = mr.brown@example.com, mr.black@example.com + announcelist = Him <him@example.com> + announcelist = Jim <jim@example.com> + announcelist = pop@example.com + + This might make it easier to maintain short recipients lists without + requiring full-fledged mailing list software. + +* By default, git-multimail sets email "Reply-To" headers to reply to + the pusher (for reference updates) and to the author (for commit + notifications). By default, the pusher's email address is + constructed by appending "multimailhook.emaildomain" to the pusher's + username. + +* The generated emails contain a configurable footer. By default, it + lists the name of the administrator who should be contacted to + unsubscribe from notification emails. + +* New option multimailhook.emailmaxlinelength to limit the length of + lines in the main part of the email body. The default limit is 500 + characters. + +* New option multimailhook.emailstrictutf8 to ensure that the main + part of the email body is valid UTF-8. Invalid characters are + turned into the Unicode replacement character, U+FFFD. By default + this option is turned on. + +* Written in Python. Easier to add new features. diff --git a/contrib/hooks/multimail/README.rst b/contrib/hooks/multimail/README.rst new file mode 100644 index 000000000000..7c0fc4a6ef00 --- /dev/null +++ b/contrib/hooks/multimail/README.rst @@ -0,0 +1,774 @@ +git-multimail version 1.5.0 +=========================== + +.. image:: https://travis-ci.org/git-multimail/git-multimail.svg?branch=master + :target: https://travis-ci.org/git-multimail/git-multimail + +git-multimail is a tool for sending notification emails on pushes to a +Git repository. It includes a Python module called ``git_multimail.py``, +which can either be used as a hook script directly or can be imported +as a Python module into another script. + +git-multimail is derived from the Git project's old +contrib/hooks/post-receive-email, and is mostly compatible with that +script. See README.migrate-from-post-receive-email for details about +the differences and for how to migrate from post-receive-email to +git-multimail. + +git-multimail, like the rest of the Git project, is licensed under +GPLv2 (see the COPYING file for details). + +Please note: although, as a convenience, git-multimail may be +distributed along with the main Git project, development of +git-multimail takes place in its own, separate project. Please, read +`<CONTRIBUTING.rst>`__ for more information. + + +By default, for each push received by the repository, git-multimail: + +1. Outputs one email summarizing each reference that was changed. + These "reference change" (called "refchange" below) emails describe + the nature of the change (e.g., was the reference created, deleted, + fast-forwarded, etc.) and include a one-line summary of each commit + that was added to the reference. + +2. Outputs one email for each new commit that was introduced by the + reference change. These "commit" emails include a list of the + files changed by the commit, followed by the diffs of files + modified by the commit. The commit emails are threaded to the + corresponding reference change email via "In-Reply-To". This style + (similar to the "git format-patch" style used on the Git mailing + list) makes it easy to scan through the emails, jump to patches + that need further attention, and write comments about specific + commits. Commits are handled in reverse topological order (i.e., + parents shown before children). For example:: + + [git] branch master updated + + [git] 01/08: doc: fix xref link from api docs to manual pages + + [git] 02/08: api-credentials.txt: show the big picture first + + [git] 03/08: api-credentials.txt: mention credential.helper explicitly + + [git] 04/08: api-credentials.txt: add "see also" section + + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&' + + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix' + + [git] 07/08: Merge branch 'mm/api-credentials-doc' + + [git] 08/08: Git 1.7.11-rc2 + + By default, each commit appears in exactly one commit email, the + first time that it is pushed to the repository. If a commit is later + merged into another branch, then a one-line summary of the commit + is included in the reference change email (as usual), but no + additional commit email is generated. See + `multimailhook.refFilter(Inclusion|Exclusion|DoSend|DontSend)Regex` + below to configure which branches and tags are watched by the hook. + + By default, reference change emails have their "Reply-To" field set + to the person who pushed the change, and commit emails have their + "Reply-To" field set to the author of the commit. + +3. Output one "announce" mail for each new annotated tag, including + information about the tag and optionally a shortlog describing the + changes since the previous tag. Such emails might be useful if you + use annotated tags to mark releases of your project. + + +Requirements +------------ + +* Python 2.x, version 2.4 or later. No non-standard Python modules + are required. git-multimail has preliminary support for Python 3 + (but it has been better tested with Python 2). + +* The ``git`` command must be in your PATH. git-multimail is known to + work with Git versions back to 1.7.1. (Earlier versions have not + been tested; if you do so, please report your results.) + +* To send emails using the default configuration, a standard sendmail + program must be located at '/usr/sbin/sendmail' or + '/usr/lib/sendmail' and must be configured correctly to send emails. + If this is not the case, set multimailhook.sendmailCommand, or see + the multimailhook.mailer configuration variable below for how to + configure git-multimail to send emails via an SMTP server. + +* git-multimail is currently tested only on Linux. It may or may not + work on other platforms such as Windows and Mac OS. See + `<CONTRIBUTING.rst>`__ to improve the situation. + + +Invocation +---------- + +``git_multimail.py`` is designed to be used as a ``post-receive`` hook in a +Git repository (see githooks(5)). Link or copy it to +$GIT_DIR/hooks/post-receive within the repository for which email +notifications are desired. Usually it should be installed on the +central repository for a project, to which all commits are eventually +pushed. + +For use on pre-v1.5.1 Git servers, ``git_multimail.py`` can also work as +an ``update`` hook, taking its arguments on the command line. To use +this script in this manner, link or copy it to $GIT_DIR/hooks/update. +Please note that the script is not completely reliable in this mode +[1]_. + +Alternatively, ``git_multimail.py`` can be imported as a Python module +into your own Python post-receive script. This method is a bit more +work, but allows the behavior of the hook to be customized using +arbitrary Python code. For example, you can use a custom environment +(perhaps inheriting from GenericEnvironment or GitoliteEnvironment) to + +* change how the user who did the push is determined + +* read users' email addresses from an LDAP server or from a database + +* decide which users should be notified about which commits based on + the contents of the commits (e.g., for users who want to be notified + only about changes affecting particular files or subdirectories) + +Or you can change how emails are sent by writing your own Mailer +class. The ``post-receive`` script in this directory demonstrates how +to use ``git_multimail.py`` as a Python module. (If you make interesting +changes of this type, please consider sharing them with the +community.) + + +Troubleshooting/FAQ +------------------- + +Please read `<doc/troubleshooting.rst>`__ for frequently asked +questions and common issues with git-multimail. + + +Configuration +------------- + +By default, git-multimail mostly takes its configuration from the +following ``git config`` settings: + +multimailhook.environment + This describes the general environment of the repository. In most + cases, you do not need to specify a value for this variable: + `git-multimail` will autodetect which environment to use. + Currently supported values: + + generic + the username of the pusher is read from $USER or $USERNAME and + the repository name is derived from the repository's path. + + gitolite + Environment to use when ``git-multimail`` is ran as a gitolite_ + hook. + + The username of the pusher is read from $GL_USER, the repository + name is read from $GL_REPO, and the From: header value is + optionally read from gitolite.conf (see multimailhook.from). + + For more information about gitolite and git-multimail, read + `<doc/gitolite.rst>`__ + + stash + Environment to use when ``git-multimail`` is ran as an Atlassian + BitBucket Server (formerly known as Atlassian Stash) hook. + + **Warning:** this mode was provided by a third-party contributor + and never tested by the git-multimail maintainers. It is + provided as-is and may or may not work for you. + + This value is automatically assumed when the stash-specific + flags (``--stash-user`` and ``--stash-repo``) are specified on + the command line. When this environment is active, the username + and repo come from these two command line flags, which must be + specified. + + gerrit + Environment to use when ``git-multimail`` is ran as a + ``ref-updated`` Gerrit hook. + + This value is used when the gerrit-specific command line flags + (``--oldrev``, ``--newrev``, ``--refname``, ``--project``) for + gerrit's ref-updated hook are present. When this environment is + active, the username of the pusher is taken from the + ``--submitter`` argument if that command line option is passed, + otherwise 'Gerrit' is used. The repository name is taken from + the ``--project`` option on the command line, which must be passed. + + For more information about gerrit and git-multimail, read + `<doc/gerrit.rst>`__ + + If none of these environments is suitable for your setup, then you + can implement a Python class that inherits from Environment and + instantiate it via a script that looks like the example + post-receive script. + + The environment value can be specified on the command line using + the ``--environment`` option. If it is not specified on the + command line or by ``multimailhook.environment``, the value is + guessed as follows: + + * If stash-specific (respectively gerrit-specific) command flags + are present on the command-line, then ``stash`` (respectively + ``gerrit``) is used. + + * If the environment variables $GL_USER and $GL_REPO are set, then + ``gitolite`` is used. + + * If none of the above apply, then ``generic`` is used. + +multimailhook.repoName + A short name of this Git repository, to be used in various places + in the notification email text. The default is to use $GL_REPO + for gitolite repositories, or otherwise to derive this value from + the repository path name. + +multimailhook.mailingList + The list of email addresses to which notification emails should be + sent, as RFC 2822 email addresses separated by commas. This + configuration option can be multivalued. Leave it unset or set it + to the empty string to not send emails by default. The next few + settings can be used to configure specific address lists for + specific types of notification email. + +multimailhook.refchangeList + The list of email addresses to which summary emails about + reference changes should be sent, as RFC 2822 email addresses + separated by commas. This configuration option can be + multivalued. The default is the value in + multimailhook.mailingList. Set this value to "none" (or the empty + string) to prevent reference change emails from being sent even if + multimailhook.mailingList is set. + +multimailhook.announceList + The list of email addresses to which emails about new annotated + tags should be sent, as RFC 2822 email addresses separated by + commas. This configuration option can be multivalued. The + default is the value in multimailhook.refchangeList or + multimailhook.mailingList. Set this value to "none" (or the empty + string) to prevent annotated tag announcement emails from being sent + even if one of the other values is set. + +multimailhook.commitList + The list of email addresses to which emails about individual new + commits should be sent, as RFC 2822 email addresses separated by + commas. This configuration option can be multivalued. The + default is the value in multimailhook.mailingList. Set this value + to "none" (or the empty string) to prevent notification emails about + individual commits from being sent even if + multimailhook.mailingList is set. + +multimailhook.announceShortlog + If this option is set to true, then emails about changes to + annotated tags include a shortlog of changes since the previous + tag. This can be useful if the annotated tags represent releases; + then the shortlog will be a kind of rough summary of what has + happened since the last release. But if your tagging policy is + not so straightforward, then the shortlog might be confusing + rather than useful. Default is false. + +multimailhook.commitEmailFormat + The format of email messages for the individual commits, can be "text" or + "html". In the latter case, the emails will include diffs using colorized + HTML instead of plain text used by default. Note that this currently the + ref change emails are always sent in plain text. + + Note that when using "html", the formatting is done by parsing the + output of ``git log`` with ``-p``. When using + ``multimailhook.commitLogOpts`` to specify a ``--format`` for + ``git log``, one may get false positive (e.g. lines in the body of + the message starting with ``+++`` or ``---`` colored in red or + green). + + By default, all the message is HTML-escaped. See + ``multimailhook.htmlInIntro`` to change this behavior. + +multimailhook.commitBrowseURL + Used to generate a link to an online repository browser in commit + emails. This variable must be a string. Format directives like + ``%(<variable>)s`` will be expanded the same way as template + strings. In particular, ``%(id)s`` will be replaced by the full + Git commit identifier (40-chars hexadecimal). + + If the string does not contain any format directive, then + ``%(id)s`` will be automatically added to the string. If you don't + want ``%(id)s`` to be automatically added, use the empty format + directive ``%()s`` anywhere in the string. + + For example, a suitable value for the git-multimail project itself + would be + ``https://github.com/git-multimail/git-multimail/commit/%(id)s``. + +multimailhook.htmlInIntro, multimailhook.htmlInFooter + When generating an HTML message, git-multimail escapes any HTML + sequence by default. This means that if a template contains HTML + like ``<a href="foo">link</a>``, the reader will see the HTML + source code and not a proper link. + + Set ``multimailhook.htmlInIntro`` to true to allow writing HTML + formatting in introduction templates. Similarly, set + ``multimailhook.htmlInFooter`` for HTML in the footer. + + Variables expanded in the template are still escaped. For example, + if a repository's path contains a ``<``, it will be rendered as + such in the message. + + Read `<doc/customizing-emails.rst>`__ for more details and + examples. + +multimailhook.refchangeShowGraph + If this option is set to true, then summary emails about reference + changes will additionally include: + + * a graph of the added commits (if any) + + * a graph of the discarded commits (if any) + + The log is generated by running ``git log --graph`` with the options + specified in graphOpts. The default is false. + +multimailhook.refchangeShowLog + If this option is set to true, then summary emails about reference + changes will include a detailed log of the added commits in + addition to the one line summary. The log is generated by running + ``git log`` with the options specified in multimailhook.logOpts. + Default is false. + +multimailhook.mailer + This option changes the way emails are sent. Accepted values are: + + * **sendmail (the default)**: use the command ``/usr/sbin/sendmail`` or + ``/usr/lib/sendmail`` (or sendmailCommand, if configured). This + mode can be further customized via the following options: + + multimailhook.sendmailCommand + The command used by mailer ``sendmail`` to send emails. Shell + quoting is allowed in the value of this setting, but remember that + Git requires double-quotes to be escaped; e.g.:: + + git config multimailhook.sendmailcommand '/usr/sbin/sendmail -oi -t -F \"Git Repo\"' + + Default is '/usr/sbin/sendmail -oi -t' or + '/usr/lib/sendmail -oi -t' (depending on which file is + present and executable). + + multimailhook.envelopeSender + If set then pass this value to sendmail via the -f option to set + the envelope sender address. + + * **smtp**: use Python's smtplib. This is useful when the sendmail + command is not available on the system. This mode can be + further customized via the following options: + + multimailhook.smtpServer + The name of the SMTP server to connect to. The value can + also include a colon and a port number; e.g., + ``mail.example.com:25``. Default is 'localhost' using port 25. + + multimailhook.smtpUser, multimailhook.smtpPass + Server username and password. Required if smtpEncryption is 'ssl'. + Note that the username and password currently need to be + set cleartext in the configuration file, which is not + recommended. If you need to use this option, be sure your + configuration file is read-only. + + multimailhook.envelopeSender + The sender address to be passed to the SMTP server. If + unset, then the value of multimailhook.from is used. + + multimailhook.smtpServerTimeout + Timeout in seconds. Default is 10. + + multimailhook.smtpEncryption + Set the security type. Allowed values: ``none``, ``ssl``, ``tls`` (starttls). + Default is ``none``. + + multimailhook.smtpCACerts + Set the path to a list of trusted CA certificate to verify the + server certificate, only supported when ``smtpEncryption`` is + ``tls``. If unset or empty, the server certificate is not + verified. If it targets a file containing a list of trusted CA + certificates (PEM format) these CAs will be used to verify the + server certificate. For debian, you can set + ``/etc/ssl/certs/ca-certificates.crt`` for using the system + trusted CAs. For self-signed server, you can add your server + certificate to the system store:: + + cd /usr/local/share/ca-certificates/ + openssl s_client -starttls smtp \ + -connect mail.example.net:587 -showcerts \ + </dev/null 2>/dev/null \ + | openssl x509 -outform PEM >mail.example.net.crt + update-ca-certificates + + and used the updated ``/etc/ssl/certs/ca-certificates.crt``. Or + directly use your ``/path/to/mail.example.net.crt``. Default is + unset. + + multimailhook.smtpServerDebugLevel + Integer number. Set to greater than 0 to activate debugging. + +multimailhook.from, multimailhook.fromCommit, multimailhook.fromRefchange + If set, use this value in the From: field of generated emails. + ``fromCommit`` is used for commit emails, ``fromRefchange`` is + used for refchange emails, and ``from`` is used as fall-back in + all cases. + + The value for these variables can be either: + + - An email address, which will be used directly. + + - The value ``pusher``, in which case the pusher's address (if + available) will be used. + + - The value ``author`` (meaningful only for ``fromCommit``), in which + case the commit author's address will be used. + + If config values are unset, the value of the From: header is + determined as follows: + + 1. (gitolite environment only) + 1.a) If ``multimailhook.MailaddressMap`` is set, and is a path + to an existing file (if relative, it is considered relative to + the place where ``gitolite.conf`` is located), then this file + should contain lines like:: + + username Firstname Lastname <email@example.com> + + git-multimail will then look for a line where ``$GL_USER`` + matches the ``username`` part, and use the rest of the line for + the ``From:`` header. + + 1.b) Parse gitolite.conf, looking for a block of comments that + looks like this:: + + # BEGIN USER EMAILS + # username Firstname Lastname <email@example.com> + # END USER EMAILS + + If that block exists, and there is a line between the BEGIN + USER EMAILS and END USER EMAILS lines where the first field + matches the gitolite username ($GL_USER), use the rest of the + line for the From: header. + + 2. If the user.email configuration setting is set, use its value + (and the value of user.name, if set). + + 3. Use the value of multimailhook.envelopeSender. + +multimailhook.MailaddressMap + (gitolite environment only) + File to look for a ``From:`` address based on the user doing the + push. Defaults to unset. See ``multimailhook.from`` for details. + +multimailhook.administrator + The name and/or email address of the administrator of the Git + repository; used in FOOTER_TEMPLATE. Default is + multimailhook.envelopesender if it is set; otherwise a generic + string is used. + +multimailhook.emailPrefix + All emails have this string prepended to their subjects, to aid + email filtering (though filtering based on the X-Git-* email + headers is probably more robust). Default is the short name of + the repository in square brackets; e.g., ``[myrepo]``. Set this + value to the empty string to suppress the email prefix. You may + use the placeholder ``%(repo_shortname)s`` for the short name of + the repository. + +multimailhook.emailMaxLines + The maximum number of lines that should be included in the body of + a generated email. If not specified, there is no limit. Lines + beyond the limit are suppressed and counted, and a final line is + added indicating the number of suppressed lines. + +multimailhook.emailMaxLineLength + The maximum length of a line in the email body. Lines longer than + this limit are truncated to this length with a trailing ``[...]`` + added to indicate the missing text. The default is 500, because + (a) diffs with longer lines are probably from binary files, for + which a diff is useless, and (b) even if a text file has such long + lines, the diffs are probably unreadable anyway. To disable line + truncation, set this option to 0. + +multimailhook.subjectMaxLength + The maximum length of the subject line (i.e. the ``oneline`` field + in templates, not including the prefix). Lines longer than this + limit are truncated to this length with a trailing ``[...]`` added + to indicate the missing text. This option The default is to use + ``multimailhook.emailMaxLineLength``. This option avoids sending + emails with overly long subject lines, but should not be needed if + the commit messages follow the Git convention (one short subject + line, then a blank line, then the message body). To disable line + truncation, set this option to 0. + +multimailhook.maxCommitEmails + The maximum number of commit emails to send for a given change. + When the number of patches is larger that this value, only the + summary refchange email is sent. This can avoid accidental + mailbombing, for example on an initial push. To disable commit + emails limit, set this option to 0. The default is 500. + +multimailhook.excludeMergeRevisions + When sending out revision emails, do not consider merge commits (the + functional equivalent of `rev-list --no-merges`). + The default is `false` (send merge commit emails). + +multimailhook.emailStrictUTF8 + If this boolean option is set to `true`, then the main part of the + email body is forced to be valid UTF-8. Any characters that are + not valid UTF-8 are converted to the Unicode replacement + character, U+FFFD. The default is `true`. + + This option is ineffective with Python 3, where non-UTF-8 + characters are unconditionally replaced. + +multimailhook.diffOpts + Options passed to ``git diff-tree`` when generating the summary + information for ReferenceChange emails. Default is ``--stat + --summary --find-copies-harder``. Add -p to those options to + include a unified diff of changes in addition to the usual summary + output. Shell quoting is allowed; see ``multimailhook.logOpts`` for + details. + +multimailhook.graphOpts + Options passed to ``git log --graph`` when generating graphs for the + reference change summary emails (used only if refchangeShowGraph + is true). The default is '--oneline --decorate'. + + Shell quoting is allowed; see logOpts for details. + +multimailhook.logOpts + Options passed to ``git log`` to generate additional info for + reference change emails (used only if refchangeShowLog is set). + For example, adding -p will show each commit's complete diff. The + default is empty. + + Shell quoting is allowed; for example, a log format that contains + spaces can be specified using something like:: + + git config multimailhook.logopts '--pretty=format:"%h %aN <%aE>%n%s%n%n%b%n"' + + If you want to set this by editing your configuration file + directly, remember that Git requires double-quotes to be escaped + (see git-config(1) for more information):: + + [multimailhook] + logopts = --pretty=format:\"%h %aN <%aE>%n%s%n%n%b%n\" + +multimailhook.commitLogOpts + Options passed to ``git log`` to generate additional info for + revision change emails. For example, adding --ignore-all-spaces + will suppress whitespace changes. The default options are ``-C + --stat -p --cc``. Shell quoting is allowed; see + multimailhook.logOpts for details. + +multimailhook.dateSubstitute + String to use as a substitute for ``Date:`` in the output of ``git + log`` while formatting commit messages. This is useful to avoid + emitting a line that can be interpreted by mailers as the start of + a cited message (Zimbra webmail in particular). Defaults to + ``CommitDate:``. Set to an empty string or ``none`` to deactivate + the behavior. + +multimailhook.emailDomain + Domain name appended to the username of the person doing the push + to convert it into an email address + (via ``"%s@%s" % (username, emaildomain)``). More complicated + schemes can be implemented by overriding Environment and + overriding its get_pusher_email() method. + +multimailhook.replyTo, multimailhook.replyToCommit, multimailhook.replyToRefchange + Addresses to use in the Reply-To: field for commit emails + (replyToCommit) and refchange emails (replyToRefchange). + multimailhook.replyTo is used as default when replyToCommit or + replyToRefchange is not set. The shortcuts ``pusher`` and + ``author`` are allowed with the same semantics as for + ``multimailhook.from``. In addition, the value ``none`` can be + used to omit the ``Reply-To:`` field. + + The default is ``pusher`` for refchange emails, and ``author`` for + commit emails. + +multimailhook.quiet + Do not output the list of email recipients from the hook + +multimailhook.stdout + For debugging, send emails to stdout rather than to the + mailer. Equivalent to the --stdout command line option + +multimailhook.scanCommitForCc + If this option is set to true, than recipients from lines in commit body + that starts with ``CC:`` will be added to CC list. + Default: false + +multimailhook.combineWhenSingleCommit + If this option is set to true and a single new commit is pushed to + a branch, combine the summary and commit email messages into a + single email. + Default: true + +multimailhook.refFilterInclusionRegex, multimailhook.refFilterExclusionRegex, multimailhook.refFilterDoSendRegex, multimailhook.refFilterDontSendRegex + **Warning:** these options are experimental. They should work, but + the user-interface is not stable yet (in particular, the option + names may change). If you want to participate in stabilizing the + feature, please contact the maintainers and/or send pull-requests. + If you are happy with the current shape of the feature, please + report it too. + + Regular expressions that can be used to limit refs for which email + updates will be sent. It is an error to specify both an inclusion + and an exclusion regex. If a ``refFilterInclusionRegex`` is + specified, emails will only be sent for refs which match this + regex. If a ``refFilterExclusionRegex`` regex is specified, + emails will be sent for all refs except those that match this + regex (or that match a predefined regex specific to the + environment, such as "^refs/notes" for most environments and + "^refs/notes|^refs/changes" for the gerrit environment). + + The expressions are matched against the complete refname, and is + considered to match if any substring matches. For example, to + filter-out all tags, set ``refFilterExclusionRegex`` to + ``^refs/tags/`` (note the leading ``^`` but no trailing ``$``). If + you set ``refFilterExclusionRegex`` to ``master``, then any ref + containing ``master`` will be excluded (the ``master`` branch, but + also ``refs/tags/master`` or ``refs/heads/foo-master-bar``). + + ``refFilterDoSendRegex`` and ``refFilterDontSendRegex`` are + analogous to ``refFilterInclusionRegex`` and + ``refFilterExclusionRegex`` with one difference: with + ``refFilterDoSendRegex`` and ``refFilterDontSendRegex``, commits + introduced by one excluded ref will not be considered as new when + they reach an included ref. Typically, if you add a branch ``foo`` + to ``refFilterDontSendRegex``, push commits to this branch, and + later merge branch ``foo`` into ``master``, then the notification + email for ``master`` will contain a commit email only for the + merge commit. If you include ``foo`` in + ``refFilterExclusionRegex``, then at the time of merge, you will + receive one commit email per commit in the branch. + + These variables can be multi-valued, like:: + + [multimailhook] + refFilterExclusionRegex = ^refs/tags/ + refFilterExclusionRegex = ^refs/heads/master$ + + You can also provide a whitespace-separated list like:: + + [multimailhook] + refFilterExclusionRegex = ^refs/tags/ ^refs/heads/master$ + + Both examples exclude tags and the master branch, and are + equivalent to:: + + [multimailhook] + refFilterExclusionRegex = ^refs/tags/|^refs/heads/master$ + + ``refFilterInclusionRegex`` and ``refFilterExclusionRegex`` are + strictly stronger than ``refFilterDoSendRegex`` and + ``refFilterDontSendRegex``. In other words, adding a ref to a + DoSend/DontSend regex has no effect if it is already excluded by a + Exclusion/Inclusion regex. + +multimailhook.logFile, multimailhook.errorLogFile, multimailhook.debugLogFile + + When set, these variable designate path to files where + git-multimail will log some messages. Normal messages and error + messages are sent to ``logFile``, and error messages are also sent + to ``errorLogFile``. Debug messages and all other messages are + sent to ``debugLogFile``. The recommended way is to set only one + of these variables, but it is also possible to set several of them + (part of the information is then duplicated in several log files, + for example errors are duplicated to all log files). + + Relative path are relative to the Git repository where the push is + done. + +multimailhook.verbose + + Verbosity level of git-multimail on its standard output. By + default, show only error and info messages. If set to true, show + also debug messages. + +Email filtering aids +-------------------- + +All emails include extra headers to enable fine tuned filtering and +give information for debugging. All emails include the headers +``X-Git-Host``, ``X-Git-Repo``, ``X-Git-Refname``, and ``X-Git-Reftype``. +ReferenceChange emails also include headers ``X-Git-Oldrev`` and ``X-Git-Newrev``; +Revision emails also include header ``X-Git-Rev``. + + +Customizing email contents +-------------------------- + +git-multimail mostly generates emails by expanding templates. The +templates can be customized. To avoid the need to edit +``git_multimail.py`` directly, the preferred way to change the templates +is to write a separate Python script that imports ``git_multimail.py`` as +a module, then replaces the templates in place. See the provided +post-receive script for an example of how this is done. + + +Customizing git-multimail for your environment +---------------------------------------------- + +git-multimail is mostly customized via an "environment" that describes +the local environment in which Git is running. Two types of +environment are built in: + +GenericEnvironment + a stand-alone Git repository. + +GitoliteEnvironment + a Git repository that is managed by gitolite_. For such + repositories, the identity of the pusher is read from + environment variable $GL_USER, the name of the repository is read + from $GL_REPO (if it is not overridden by multimailhook.reponame), + and the From: header value is optionally read from gitolite.conf + (see multimailhook.from). + +By default, git-multimail assumes GitoliteEnvironment if $GL_USER and +$GL_REPO are set, and otherwise assumes GenericEnvironment. +Alternatively, you can choose one of these two environments explicitly +by setting a ``multimailhook.environment`` config setting (which can +have the value `generic` or `gitolite`) or by passing an --environment +option to the script. + +If you need to customize the script in ways that are not supported by +the existing environments, you can define your own environment class +class using arbitrary Python code. To do so, you need to import +``git_multimail.py`` as a Python module, as demonstrated by the example +post-receive script. Then implement your environment class; it should +usually inherit from one of the existing Environment classes and +possibly one or more of the EnvironmentMixin classes. Then set the +``environment`` variable to an instance of your own environment class +and pass it to ``run_as_post_receive_hook()``. + +The standard environment classes, GenericEnvironment and +GitoliteEnvironment, are in fact themselves put together out of a +number of mixin classes, each of which handles one aspect of the +customization. For the finest control over your configuration, you +can specify exactly which mixin classes your own environment class +should inherit from, and override individual methods (or even add your +own mixin classes) to implement entirely new behaviors. If you +implement any mixins that might be useful to other people, please +consider sharing them with the community! + + +Getting involved +---------------- + +Please, read `<CONTRIBUTING.rst>`__ for instructions on how to +contribute to git-multimail. + + +Footnotes +--------- + +.. [1] Because of the way information is passed to update hooks, the + script's method of determining whether a commit has already + been seen does not work when it is used as an ``update`` script. + In particular, no notification email will be generated for a + new commit that is added to multiple references in the same + push. A workaround is to use --force-send to force sending the + emails. + +.. _gitolite: https://github.com/sitaramc/gitolite diff --git a/contrib/hooks/multimail/doc/customizing-emails.rst b/contrib/hooks/multimail/doc/customizing-emails.rst new file mode 100644 index 000000000000..3f5b67f768db --- /dev/null +++ b/contrib/hooks/multimail/doc/customizing-emails.rst @@ -0,0 +1,56 @@ +Customizing the content and formatting of emails +================================================ + +Overloading template strings +---------------------------- + +The content of emails is generated based on template strings defined +in ``git_multimail.py``. You can customize these template strings +without changing the script itself, by defining a Python wrapper +around it. The python wrapper should ``import git_multimail`` and then +override the ``git_multimail.*`` strings like this:: + + import sys # needed for sys.argv + + # Import and customize git_multimail: + import git_multimail + git_multimail.REVISION_INTRO_TEMPLATE = """...""" + git_multimail.COMBINED_INTRO_TEMPLATE = git_multimail.REVISION_INTRO_TEMPLATE + + # start git_multimail itself: + git_multimail.main(sys.argv[1:]) + +The template strings can use any value already used in the existing +templates (read the source code). + +Using HTML in template strings +------------------------------ + +If ``multimailhook.commitEmailFormat`` is set to HTML, then +git-multimail will generate HTML emails for commit notifications. The +log and diff will be formatted automatically by git-multimail. By +default, any HTML special character in the templates will be escaped. + +To use HTML formatting in the introduction of the email, set +``multimailhook.htmlInIntro`` to ``true``. Then, the template can +contain any HTML tags, that will be sent as-is in the email. For +example, to add some formatting and a link to the online commit, use +a format like:: + + git_multimail.REVISION_INTRO_TEMPLATE = """\ + <span style="color:#808080">This is an automated email from the git hooks/post-receive script.</span><br /><br /> + + <strong>%(pusher)s</strong> pushed a commit to %(refname_type)s %(short_refname)s + in repository %(repo_shortname)s.<br /> + + <a href="https://github.com/git-multimail/git-multimail/commit/%(newrev)s">View on GitHub</a>. + """ + +Note that the values expanded from ``%(variable)s`` in the format +strings will still be escaped. + +For a less flexible but easier to set up way to add a link to commit +emails, see ``multimailhook.commitBrowseURL``. + +Similarly, one can set ``multimailhook.htmlInFooter`` and override any +of the ``*_FOOTER*`` template strings. diff --git a/contrib/hooks/multimail/doc/gerrit.rst b/contrib/hooks/multimail/doc/gerrit.rst new file mode 100644 index 000000000000..8011d05dec03 --- /dev/null +++ b/contrib/hooks/multimail/doc/gerrit.rst @@ -0,0 +1,56 @@ +Setting up git-multimail on Gerrit +================================== + +Gerrit has its own email-sending system, but you may prefer using +``git-multimail`` instead. It supports Gerrit natively as a Gerrit +``ref-updated`` hook (Warning: `Gerrit hooks +<https://gerrit-review.googlesource.com/Documentation/config-hooks.html>`__ +are distinct from Git hooks). Setting up ``git-multimail`` on a Gerrit +installation can be done following the instructions below. + +The explanations show an easy way to set up ``git-multimail``, +but leave ``git-multimail`` installed and unconfigured for a while. If +you run Gerrit on a production server, it is advised that you +execute the step "Set up the hook" last to avoid confusing your users +in the meantime. + +Set up the hook +--------------- + +Create a directory ``$site_path/hooks/`` if it does not exist (if you +don't know what ``$site_path`` is, run ``gerrit.sh status`` and look +for a ``GERRIT_SITE`` line). Either copy ``git_multimail.py`` to +``$site_path/hooks/ref-updated`` or create a wrapper script like +this:: + + #! /bin/sh + exec /path/to/git_multimail.py "$@" + +In both cases, make sure the file is named exactly +``$site_path/hooks/ref-updated`` and is executable. + +(Alternatively, you may configure the ``[hooks]`` section of +gerrit.config) + +Configuration +------------- + +Log on the gerrit server and edit ``$site_path/git/$project/config`` +to configure ``git-multimail``. + +Troubleshooting +--------------- + +Warning: this will disable ``git-multimail`` during the debug, and +could confuse your users. Don't run on a production server. + +To debug configuration issues with ``git-multimail``, you can add the +``--stdout`` option when calling ``git_multimail.py`` like this:: + + #!/bin/sh + exec /path/to/git-multimail/git-multimail/git_multimail.py \ + --stdout "$@" >> /tmp/log.txt + +and try pushing from a test repository. You should see the source of +the email that would have been sent in the output of ``git push`` in +the file ``/tmp/log.txt``. diff --git a/contrib/hooks/multimail/doc/gitolite.rst b/contrib/hooks/multimail/doc/gitolite.rst new file mode 100644 index 000000000000..505483310552 --- /dev/null +++ b/contrib/hooks/multimail/doc/gitolite.rst @@ -0,0 +1,118 @@ +Setting up git-multimail on gitolite +==================================== + +``git-multimail`` supports gitolite 3 natively. +The explanations below show an easy way to set up ``git-multimail``, +but leave ``git-multimail`` installed and unconfigured for a while. If +you run gitolite on a production server, it is advised that you +execute the step "Set up the hook" last to avoid confusing your users +in the meantime. + +Set up the hook +--------------- + +Log in as your gitolite user. + +Create a file ``.gitolite/hooks/common/post-receive`` on your gitolite +account containing (adapt the path, obviously):: + + #!/bin/sh + exec /path/to/git-multimail/git-multimail/git_multimail.py "$@" + +Make sure it's executable (``chmod +x``). Record the hook in +gitolite:: + + gitolite setup + +Configuration +------------- + +First, you have to allow the admin to set Git configuration variables. + +As gitolite user, edit the line containing ``GIT_CONFIG_KEYS`` in file +``.gitolite.rc``, to make it look like:: + + GIT_CONFIG_KEYS => 'multimailhook\..*', + +You can now log out and return to your normal user. + +In the ``gitolite-admin`` clone, edit the file ``conf/gitolite.conf`` +and add:: + + repo @all + # Not strictly needed as git_multimail.py will chose gitolite if + # $GL_USER is set. + config multimailhook.environment = gitolite + config multimailhook.mailingList = # Where emails should be sent + config multimailhook.from = # From address to use + +Note that by default, gitolite forbids ``<`` and ``>`` in variable +values (for security/paranoia reasons, see +`compensating for UNSAFE_PATT +<http://gitolite.com/gitolite/git-config/index.html#compensating-for-unsafe95patt>`__ +in gitolite's documentation for explanations and a way to disable +this). As a consequence, you will not be able to use ``First Last +<First.Last@example.com>`` as recipient email, but specifying +``First.Last@example.com`` alone works. + +Obviously, you can customize all parameters on a per-repository basis by +adding these ``config multimailhook.*`` lines in the section +corresponding to a repository or set of repositories. + +To activate ``git-multimail`` on a per-repository basis, do not set +``multimailhook.mailingList`` in the ``@all`` section and set it only +for repositories for which you want ``git-multimail``. + +Alternatively, you can set up the ``From:`` field on a per-user basis +by adding a ``BEGIN USER EMAILS``/``END USER EMAILS`` section (see +``../README``). + +Specificities of Gitolite for Configuration +------------------------------------------- + +Empty configuration variables +............................. + +With gitolite, the syntax ``config multimailhook.commitList = ""`` +unsets the variable instead of setting it to an empty string (see +`here +<http://gitolite.com/gitolite/git-config.html#an-important-warning-about-deleting-a-config-line>`__). +As a result, there is no way to set a variable to the empty string. +In all most places where an empty value is required, git-multimail +now allows to specify special ``"none"`` value (case-sensitive) to +mean the same. + +Alternatively, one can use ``" "`` (a single space) instead of ``""``. +In most cases (in particular ``multimailhook.*List`` variables), this +will be equivalent to an empty string. + +If you have a use-case where ``"none"`` is not an acceptable value and +you need ``" "`` or ``""`` instead, please report it as a bug to +git-multimail. + +Allowing Regular Expressions in Configuration +............................................. + +gitolite has a mechanism to prevent unsafe configuration variable +values, which prevent characters like ``|`` commonly used in regular +expressions. If you do not need the safety feature of gitolite and +need to use regular expressions in your configuration (e.g. for +``multimailhook.refFilter*`` variables), set +`UNSAFE_PATT +<http://gitolite.com/gitolite/git-config.html#unsafe-patt>`__ to a +less restrictive value. + +Troubleshooting +--------------- + +Warning: this will disable ``git-multimail`` during the debug, and +could confuse your users. Don't run on a production server. + +To debug configuration issues with ``git-multimail``, you can add the +``--stdout`` option when calling ``git_multimail.py`` like this:: + + #!/bin/sh + exec /path/to/git-multimail/git-multimail/git_multimail.py --stdout "$@" + +and try pushing from a test repository. You should see the source of +the email that would have been sent in the output of ``git push``. diff --git a/contrib/hooks/multimail/doc/troubleshooting.rst b/contrib/hooks/multimail/doc/troubleshooting.rst new file mode 100644 index 000000000000..651b509ee66c --- /dev/null +++ b/contrib/hooks/multimail/doc/troubleshooting.rst @@ -0,0 +1,78 @@ +Troubleshooting issues with git-multimail: a FAQ +================================================ + +How to check that git-multimail is properly set up? +--------------------------------------------------- + +Since version 1.4.0, git-multimail allows a simple self-checking of +its configuration: run it with the environment variable +``GIT_MULTIMAIL_CHECK_SETUP`` set to a non-empty string. You should +get something like this:: + + $ GIT_MULTIMAIL_CHECK_SETUP=true /home/moy/dev/git-multimail/git-multimail/git_multimail.py + Environment values: + administrator : 'the administrator of this repository' + charset : 'utf-8' + emailprefix : '[git-multimail] ' + fqdn : 'anie' + projectdesc : 'UNNAMED PROJECT' + pusher : 'moy' + repo_path : '/home/moy/dev/git-multimail' + repo_shortname : 'git-multimail' + + Now, checking that git-multimail's standard input is properly set ... + Please type some text and then press Return + foo + You have just entered: + foo + git-multimail seems properly set up. + +If you forgot to set an important variable, you may get instead:: + + $ GIT_MULTIMAIL_CHECK_SETUP=true /home/moy/dev/git-multimail/git-multimail/git_multimail.py + No email recipients configured! + +Do not set ``$GIT_MULTIMAIL_CHECK_SETUP`` other than for testing your +configuration: it would disable the hook completely. + +Git is not using the right address in the From/To/Reply-To field +---------------------------------------------------------------- + +First, make sure that git-multimail actually uses what you think it is +using. A lot happens to your email (especially when posting to a +mailing-list) between the time `git_multimail.py` sends it and the +time it reaches your inbox. + +A simple test (to do on a test repository, do not use in production as +it would disable email sending): change your post-receive hook to call +`git_multimail.py` with the `--stdout` option, and try to push to the +repository. You should see something like:: + + Counting objects: 3, done. + Writing objects: 100% (3/3), 263 bytes | 0 bytes/s, done. + Total 3 (delta 0), reused 0 (delta 0) + remote: Sending notification emails to: foo.bar@example.com + remote: =========================================================================== + remote: Date: Mon, 25 Apr 2016 18:39:59 +0200 + remote: To: foo.bar@example.com + remote: Subject: [git] branch master updated: foo + remote: MIME-Version: 1.0 + remote: Content-Type: text/plain; charset=utf-8 + remote: Content-Transfer-Encoding: 8bit + remote: Message-ID: <20160425163959.2311.20498@anie> + remote: From: Auth Or <Foo.Bar@example.com> + remote: Reply-To: Auth Or <Foo.Bar@example.com> + remote: X-Git-Host: example + ... + remote: -- + remote: To stop receiving notification emails like this one, please contact + remote: the administrator of this repository. + remote: =========================================================================== + To /path/to/repo + 6278f04..e173f20 master -> master + +Note: this does not include the sender (Return-Path: header), as it is +not part of the message content but passed to the mailer. Some mailer +show the ``Sender:`` field instead of the ``From:`` field (for +example, Zimbra Webmail shows ``From: <sender-field> on behalf of +<from-field>``). diff --git a/contrib/hooks/multimail/git_multimail.py b/contrib/hooks/multimail/git_multimail.py new file mode 100755 index 000000000000..8823399e7522 --- /dev/null +++ b/contrib/hooks/multimail/git_multimail.py @@ -0,0 +1,4346 @@ +#! /usr/bin/env python + +__version__ = '1.5.0' + +# Copyright (c) 2015-2016 Matthieu Moy and others +# Copyright (c) 2012-2014 Michael Haggerty and others +# Derived from contrib/hooks/post-receive-email, which is +# Copyright (c) 2007 Andy Parkins +# and also includes contributions by other authors. +# +# This file is part of git-multimail. +# +# git-multimail is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License version +# 2 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +# <http://www.gnu.org/licenses/>. + +"""Generate notification emails for pushes to a git repository. + +This hook sends emails describing changes introduced by pushes to a +git repository. For each reference that was changed, it emits one +ReferenceChange email summarizing how the reference was changed, +followed by one Revision email for each new commit that was introduced +by the reference change. + +Each commit is announced in exactly one Revision email. If the same +commit is merged into another branch in the same or a later push, then +the ReferenceChange email will list the commit's SHA1 and its one-line +summary, but no new Revision email will be generated. + +This script is designed to be used as a "post-receive" hook in a git +repository (see githooks(5)). It can also be used as an "update" +script, but this usage is not completely reliable and is deprecated. + +To help with debugging, this script accepts a --stdout option, which +causes the emails to be written to standard output rather than sent +using sendmail. + +See the accompanying README file for the complete documentation. + +""" + +import sys +import os +import re +import bisect +import socket +import subprocess +import shlex +import optparse +import logging +import smtplib +try: + import ssl +except ImportError: + # Python < 2.6 do not have ssl, but that's OK if we don't use it. + pass +import time + +import uuid +import base64 + +PYTHON3 = sys.version_info >= (3, 0) + +if sys.version_info <= (2, 5): + def all(iterable): + for element in iterable: + if not element: + return False + return True + + +def is_ascii(s): + return all(ord(c) < 128 and ord(c) > 0 for c in s) + + +if PYTHON3: + def is_string(s): + return isinstance(s, str) + + def str_to_bytes(s): + return s.encode(ENCODING) + + def bytes_to_str(s, errors='strict'): + return s.decode(ENCODING, errors) + + unicode = str + + def write_str(f, msg): + # Try outputing with the default encoding. If it fails, + # try UTF-8. + try: + f.buffer.write(msg.encode(sys.getdefaultencoding())) + except UnicodeEncodeError: + f.buffer.write(msg.encode(ENCODING)) + + def read_line(f): + # Try reading with the default encoding. If it fails, + # try UTF-8. + out = f.buffer.readline() + try: + return out.decode(sys.getdefaultencoding()) + except UnicodeEncodeError: + return out.decode(ENCODING) + + import html + + def html_escape(s): + return html.escape(s) + +else: + def is_string(s): + try: + return isinstance(s, basestring) + except NameError: # Silence Pyflakes warning + raise + + def str_to_bytes(s): + return s + + def bytes_to_str(s, errors='strict'): + return s + + def write_str(f, msg): + f.write(msg) + + def read_line(f): + return f.readline() + + def next(it): + return it.next() + + import cgi + + def html_escape(s): + return cgi.escape(s, True) + +try: + from email.charset import Charset + from email.utils import make_msgid + from email.utils import getaddresses + from email.utils import formataddr + from email.utils import formatdate + from email.header import Header +except ImportError: + # Prior to Python 2.5, the email module used different names: + from email.Charset import Charset + from email.Utils import make_msgid + from email.Utils import getaddresses + from email.Utils import formataddr + from email.Utils import formatdate + from email.Header import Header + + +DEBUG = False + +ZEROS = '0' * 40 +LOGBEGIN = '- Log -----------------------------------------------------------------\n' +LOGEND = '-----------------------------------------------------------------------\n' + +ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender']) + +# It is assumed in many places that the encoding is uniformly UTF-8, +# so changing these constants is unsupported. But define them here +# anyway, to make it easier to find (at least most of) the places +# where the encoding is important. +(ENCODING, CHARSET) = ('UTF-8', 'utf-8') + + +REF_CREATED_SUBJECT_TEMPLATE = ( + '%(emailprefix)s%(refname_type)s %(short_refname)s created' + ' (now %(newrev_short)s)' + ) +REF_UPDATED_SUBJECT_TEMPLATE = ( + '%(emailprefix)s%(refname_type)s %(short_refname)s updated' + ' (%(oldrev_short)s -> %(newrev_short)s)' + ) +REF_DELETED_SUBJECT_TEMPLATE = ( + '%(emailprefix)s%(refname_type)s %(short_refname)s deleted' + ' (was %(oldrev_short)s)' + ) + +COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = ( + '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s' + ) + +REFCHANGE_HEADER_TEMPLATE = """\ +Date: %(send_date)s +To: %(recipients)s +Subject: %(subject)s +MIME-Version: 1.0 +Content-Type: text/%(contenttype)s; charset=%(charset)s +Content-Transfer-Encoding: 8bit +Message-ID: %(msgid)s +From: %(fromaddr)s +Reply-To: %(reply_to)s +Thread-Index: %(thread_index)s +X-Git-Host: %(fqdn)s +X-Git-Repo: %(repo_shortname)s +X-Git-Refname: %(refname)s +X-Git-Reftype: %(refname_type)s +X-Git-Oldrev: %(oldrev)s +X-Git-Newrev: %(newrev)s +X-Git-NotificationType: ref_changed +X-Git-Multimail-Version: %(multimail_version)s +Auto-Submitted: auto-generated +""" + +REFCHANGE_INTRO_TEMPLATE = """\ +This is an automated email from the git hooks/post-receive script. + +%(pusher)s pushed a change to %(refname_type)s %(short_refname)s +in repository %(repo_shortname)s. + +""" + + +FOOTER_TEMPLATE = """\ + +-- \n\ +To stop receiving notification emails like this one, please contact +%(administrator)s. +""" + + +REWIND_ONLY_TEMPLATE = """\ +This update removed existing revisions from the reference, leaving the +reference pointing at a previous point in the repository history. + + * -- * -- N %(refname)s (%(newrev_short)s) + \\ + O -- O -- O (%(oldrev_short)s) + +Any revisions marked "omit" are not gone; other references still +refer to them. Any revisions marked "discard" are gone forever. +""" + + +NON_FF_TEMPLATE = """\ +This update added new revisions after undoing existing revisions. +That is to say, some revisions that were in the old version of the +%(refname_type)s are not in the new version. This situation occurs +when a user --force pushes a change and generates a repository +containing something like this: + + * -- * -- B -- O -- O -- O (%(oldrev_short)s) + \\ + N -- N -- N %(refname)s (%(newrev_short)s) + +You should already have received notification emails for all of the O +revisions, and so the following emails describe only the N revisions +from the common base, B. + +Any revisions marked "omit" are not gone; other references still +refer to them. Any revisions marked "discard" are gone forever. +""" + + +NO_NEW_REVISIONS_TEMPLATE = """\ +No new revisions were added by this update. +""" + + +DISCARDED_REVISIONS_TEMPLATE = """\ +This change permanently discards the following revisions: +""" + + +NO_DISCARDED_REVISIONS_TEMPLATE = """\ +The revisions that were on this %(refname_type)s are still contained in +other references; therefore, this change does not discard any commits +from the repository. +""" + + +NEW_REVISIONS_TEMPLATE = """\ +The %(tot)s revisions listed above as "new" are entirely new to this +repository and will be described in separate emails. The revisions +listed as "add" were already present in the repository and have only +been added to this reference. + +""" + + +TAG_CREATED_TEMPLATE = """\ + at %(newrev_short)-8s (%(newrev_type)s) +""" + + +TAG_UPDATED_TEMPLATE = """\ +*** WARNING: tag %(short_refname)s was modified! *** + + from %(oldrev_short)-8s (%(oldrev_type)s) + to %(newrev_short)-8s (%(newrev_type)s) +""" + + +TAG_DELETED_TEMPLATE = """\ +*** WARNING: tag %(short_refname)s was deleted! *** + +""" + + +# The template used in summary tables. It looks best if this uses the +# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE. +BRIEF_SUMMARY_TEMPLATE = """\ +%(action)8s %(rev_short)-8s %(text)s +""" + + +NON_COMMIT_UPDATE_TEMPLATE = """\ +This is an unusual reference change because the reference did not +refer to a commit either before or after the change. We do not know +how to provide full information about this reference change. +""" + + +REVISION_HEADER_TEMPLATE = """\ +Date: %(send_date)s +To: %(recipients)s +Cc: %(cc_recipients)s +Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s +MIME-Version: 1.0 +Content-Type: text/%(contenttype)s; charset=%(charset)s +Content-Transfer-Encoding: 8bit +From: %(fromaddr)s +Reply-To: %(reply_to)s +In-Reply-To: %(reply_to_msgid)s +References: %(reply_to_msgid)s +Thread-Index: %(thread_index)s +X-Git-Host: %(fqdn)s +X-Git-Repo: %(repo_shortname)s +X-Git-Refname: %(refname)s +X-Git-Reftype: %(refname_type)s +X-Git-Rev: %(rev)s +X-Git-NotificationType: diff +X-Git-Multimail-Version: %(multimail_version)s +Auto-Submitted: auto-generated +""" + +REVISION_INTRO_TEMPLATE = """\ +This is an automated email from the git hooks/post-receive script. + +%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s +in repository %(repo_shortname)s. + +""" + +LINK_TEXT_TEMPLATE = """\ +View the commit online: +%(browse_url)s + +""" + +LINK_HTML_TEMPLATE = """\ +<p><a href="%(browse_url)s">View the commit online</a>.</p> +""" + + +REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE + + +# Combined, meaning refchange+revision email (for single-commit additions) +COMBINED_HEADER_TEMPLATE = """\ +Date: %(send_date)s +To: %(recipients)s +Subject: %(subject)s +MIME-Version: 1.0 +Content-Type: text/%(contenttype)s; charset=%(charset)s +Content-Transfer-Encoding: 8bit +Message-ID: %(msgid)s +From: %(fromaddr)s +Reply-To: %(reply_to)s +X-Git-Host: %(fqdn)s +X-Git-Repo: %(repo_shortname)s +X-Git-Refname: %(refname)s +X-Git-Reftype: %(refname_type)s +X-Git-Oldrev: %(oldrev)s +X-Git-Newrev: %(newrev)s +X-Git-Rev: %(rev)s +X-Git-NotificationType: ref_changed_plus_diff +X-Git-Multimail-Version: %(multimail_version)s +Auto-Submitted: auto-generated +""" + +COMBINED_INTRO_TEMPLATE = """\ +This is an automated email from the git hooks/post-receive script. + +%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s +in repository %(repo_shortname)s. + +""" + +COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE + + +class CommandError(Exception): + def __init__(self, cmd, retcode): + self.cmd = cmd + self.retcode = retcode + Exception.__init__( + self, + 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,) + ) + + +class ConfigurationException(Exception): + pass + + +# The "git" program (this could be changed to include a full path): +GIT_EXECUTABLE = 'git' + + +# How "git" should be invoked (including global arguments), as a list +# of words. This variable is usually initialized automatically by +# read_git_output() via choose_git_command(), but if a value is set +# here then it will be used unconditionally. +GIT_CMD = None + + +def choose_git_command(): + """Decide how to invoke git, and record the choice in GIT_CMD.""" + + global GIT_CMD + + if GIT_CMD is None: + try: + # Check to see whether the "-c" option is accepted (it was + # only added in Git 1.7.2). We don't actually use the + # output of "git --version", though if we needed more + # specific version information this would be the place to + # do it. + cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version'] + read_output(cmd) + GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] + except CommandError: + GIT_CMD = [GIT_EXECUTABLE] + + +def read_git_output(args, input=None, keepends=False, **kw): + """Read the output of a Git command.""" + + if GIT_CMD is None: + choose_git_command() + + return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw) + + +def read_output(cmd, input=None, keepends=False, **kw): + if input: + stdin = subprocess.PIPE + input = str_to_bytes(input) + else: + stdin = None + errors = 'strict' + if 'errors' in kw: + errors = kw['errors'] + del kw['errors'] + p = subprocess.Popen( + tuple(str_to_bytes(w) for w in cmd), + stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw + ) + (out, err) = p.communicate(input) + out = bytes_to_str(out, errors=errors) + retcode = p.wait() + if retcode: + raise CommandError(cmd, retcode) + if not keepends: + out = out.rstrip('\n\r') + return out + + +def read_git_lines(args, keepends=False, **kw): + """Return the lines output by Git command. + + Return as single lines, with newlines stripped off.""" + + return read_git_output(args, keepends=True, **kw).splitlines(keepends) + + +def git_rev_list_ish(cmd, spec, args=None, **kw): + """Common functionality for invoking a 'git rev-list'-like command. + + Parameters: + * cmd is the Git command to run, e.g., 'rev-list' or 'log'. + * spec is a list of revision arguments to pass to the named + command. If None, this function returns an empty list. + * args is a list of extra arguments passed to the named command. + * All other keyword arguments (if any) are passed to the + underlying read_git_lines() function. + + Return the output of the Git command in the form of a list, one + entry per output line. + """ + if spec is None: + return [] + if args is None: + args = [] + args = [cmd, '--stdin'] + args + spec_stdin = ''.join(s + '\n' for s in spec) + return read_git_lines(args, input=spec_stdin, **kw) + + +def git_rev_list(spec, **kw): + """Run 'git rev-list' with the given list of revision arguments. + + See git_rev_list_ish() for parameter and return value + documentation. + """ + return git_rev_list_ish('rev-list', spec, **kw) + + +def git_log(spec, **kw): + """Run 'git log' with the given list of revision arguments. + + See git_rev_list_ish() for parameter and return value + documentation. + """ + return git_rev_list_ish('log', spec, **kw) + + +def header_encode(text, header_name=None): + """Encode and line-wrap the value of an email header field.""" + + # Convert to unicode, if required. + if not isinstance(text, unicode): + text = unicode(text, 'utf-8') + + if is_ascii(text): + charset = 'ascii' + else: + charset = 'utf-8' + + return Header(text, header_name=header_name, charset=Charset(charset)).encode() + + +def addr_header_encode(text, header_name=None): + """Encode and line-wrap the value of an email header field containing + email addresses.""" + + # Convert to unicode, if required. + if not isinstance(text, unicode): + text = unicode(text, 'utf-8') + + text = ', '.join( + formataddr((header_encode(name), emailaddr)) + for name, emailaddr in getaddresses([text]) + ) + + if is_ascii(text): + charset = 'ascii' + else: + charset = 'utf-8' + + return Header(text, header_name=header_name, charset=Charset(charset)).encode() + + +class Config(object): + def __init__(self, section, git_config=None): + """Represent a section of the git configuration. + + If git_config is specified, it is passed to "git config" in + the GIT_CONFIG environment variable, meaning that "git config" + will read the specified path rather than the Git default + config paths.""" + + self.section = section + if git_config: + self.env = os.environ.copy() + self.env['GIT_CONFIG'] = git_config + else: + self.env = None + + @staticmethod + def _split(s): + """Split NUL-terminated values.""" + + words = s.split('\0') + assert words[-1] == '' + return words[:-1] + + @staticmethod + def add_config_parameters(c): + """Add configuration parameters to Git. + + c is either an str or a list of str, each element being of the + form 'var=val' or 'var', with the same syntax and meaning as + the argument of 'git -c var=val'. + """ + if isinstance(c, str): + c = (c,) + parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '') + if parameters: + parameters += ' ' + # git expects GIT_CONFIG_PARAMETERS to be of the form + # "'name1=value1' 'name2=value2' 'name3=value3'" + # including everything inside the double quotes (but not the double + # quotes themselves). Spacing is critical. Also, if a value contains + # a literal single quote that quote must be represented using the + # four character sequence: '\'' + parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c) + os.environ['GIT_CONFIG_PARAMETERS'] = parameters + + def get(self, name, default=None): + try: + values = self._split(read_git_output( + ['config', '--get', '--null', '%s.%s' % (self.section, name)], + env=self.env, keepends=True, + )) + assert len(values) == 1 + return values[0] + except CommandError: + return default + + def get_bool(self, name, default=None): + try: + value = read_git_output( + ['config', '--get', '--bool', '%s.%s' % (self.section, name)], + env=self.env, + ) + except CommandError: + return default + return value == 'true' + + def get_all(self, name, default=None): + """Read a (possibly multivalued) setting from the configuration. + + Return the result as a list of values, or default if the name + is unset.""" + + try: + return self._split(read_git_output( + ['config', '--get-all', '--null', '%s.%s' % (self.section, name)], + env=self.env, keepends=True, + )) + except CommandError: + t, e, traceback = sys.exc_info() + if e.retcode == 1: + # "the section or key is invalid"; i.e., there is no + # value for the specified key. + return default + else: + raise + + def set(self, name, value): + read_git_output( + ['config', '%s.%s' % (self.section, name), value], + env=self.env, + ) + + def add(self, name, value): + read_git_output( + ['config', '--add', '%s.%s' % (self.section, name), value], + env=self.env, + ) + + def __contains__(self, name): + return self.get_all(name, default=None) is not None + + # We don't use this method anymore internally, but keep it here in + # case somebody is calling it from their own code: + def has_key(self, name): + return name in self + + def unset_all(self, name): + try: + read_git_output( + ['config', '--unset-all', '%s.%s' % (self.section, name)], + env=self.env, + ) + except CommandError: + t, e, traceback = sys.exc_info() + if e.retcode == 5: + # The name doesn't exist, which is what we wanted anyway... + pass + else: + raise + + def set_recipients(self, name, value): + self.unset_all(name) + for pair in getaddresses([value]): + self.add(name, formataddr(pair)) + + +def generate_summaries(*log_args): + """Generate a brief summary for each revision requested. + + log_args are strings that will be passed directly to "git log" as + revision selectors. Iterate over (sha1_short, subject) for each + commit specified by log_args (subject is the first line of the + commit message as a string without EOLs).""" + + cmd = [ + 'log', '--abbrev', '--format=%h %s', + ] + list(log_args) + ['--'] + for line in read_git_lines(cmd): + yield tuple(line.split(' ', 1)) + + +def limit_lines(lines, max_lines): + for (index, line) in enumerate(lines): + if index < max_lines: + yield line + + if index >= max_lines: + yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,) + + +def limit_linelength(lines, max_linelength): + for line in lines: + # Don't forget that lines always include a trailing newline. + if len(line) > max_linelength + 1: + line = line[:max_linelength - 7] + ' [...]\n' + yield line + + +class CommitSet(object): + """A (constant) set of object names. + + The set should be initialized with full SHA1 object names. The + __contains__() method returns True iff its argument is an + abbreviation of any the names in the set.""" + + def __init__(self, names): + self._names = sorted(names) + + def __len__(self): + return len(self._names) + + def __contains__(self, sha1_abbrev): + """Return True iff this set contains sha1_abbrev (which might be abbreviated).""" + + i = bisect.bisect_left(self._names, sha1_abbrev) + return i < len(self) and self._names[i].startswith(sha1_abbrev) + + +class GitObject(object): + def __init__(self, sha1, type=None): + if sha1 == ZEROS: + self.sha1 = self.type = self.commit_sha1 = None + else: + self.sha1 = sha1 + self.type = type or read_git_output(['cat-file', '-t', self.sha1]) + + if self.type == 'commit': + self.commit_sha1 = self.sha1 + elif self.type == 'tag': + try: + self.commit_sha1 = read_git_output( + ['rev-parse', '--verify', '%s^0' % (self.sha1,)] + ) + except CommandError: + # Cannot deref tag to determine commit_sha1 + self.commit_sha1 = None + else: + self.commit_sha1 = None + + self.short = read_git_output(['rev-parse', '--short', sha1]) + + def get_summary(self): + """Return (sha1_short, subject) for this commit.""" + + if not self.sha1: + raise ValueError('Empty commit has no summary') + + return next(iter(generate_summaries('--no-walk', self.sha1))) + + def __eq__(self, other): + return isinstance(other, GitObject) and self.sha1 == other.sha1 + + def __ne__(self, other): + return not self == other + + def __hash__(self): + return hash(self.sha1) + + def __nonzero__(self): + return bool(self.sha1) + + def __bool__(self): + """Python 2 backward compatibility""" + return self.__nonzero__() + + def __str__(self): + return self.sha1 or ZEROS + + +class Change(object): + """A Change that has been made to the Git repository. + + Abstract class from which both Revisions and ReferenceChanges are + derived. A Change knows how to generate a notification email + describing itself.""" + + def __init__(self, environment): + self.environment = environment + self._values = None + self._contains_html_diff = False + + def _contains_diff(self): + # We do contain a diff, should it be rendered in HTML? + if self.environment.commit_email_format == "html": + self._contains_html_diff = True + + def _compute_values(self): + """Return a dictionary {keyword: expansion} for this Change. + + Derived classes overload this method to add more entries to + the return value. This method is used internally by + get_values(). The return value should always be a new + dictionary.""" + + values = self.environment.get_values() + fromaddr = self.environment.get_fromaddr(change=self) + if fromaddr is not None: + values['fromaddr'] = fromaddr + values['multimail_version'] = get_version() + return values + + # Aliases usable in template strings. Tuple of pairs (destination, + # source). + VALUES_ALIAS = ( + ("id", "newrev"), + ) + + def get_values(self, **extra_values): + """Return a dictionary {keyword: expansion} for this Change. + + Return a dictionary mapping keywords to the values that they + should be expanded to for this Change (used when interpolating + template strings). If any keyword arguments are supplied, add + those to the return value as well. The return value is always + a new dictionary.""" + + if self._values is None: + self._values = self._compute_values() + + values = self._values.copy() + if extra_values: + values.update(extra_values) + + for alias, val in self.VALUES_ALIAS: + values[alias] = values[val] + return values + + def expand(self, template, **extra_values): + """Expand template. + + Expand the template (which should be a string) using string + interpolation of the values for this Change. If any keyword + arguments are provided, also include those in the keywords + available for interpolation.""" + + return template % self.get_values(**extra_values) + + def expand_lines(self, template, html_escape_val=False, **extra_values): + """Break template into lines and expand each line.""" + + values = self.get_values(**extra_values) + if html_escape_val: + for k in values: + if is_string(values[k]): + values[k] = html_escape(values[k]) + for line in template.splitlines(True): + yield line % values + + def expand_header_lines(self, template, **extra_values): + """Break template into lines and expand each line as an RFC 2822 header. + + Encode values and split up lines that are too long. Silently + skip lines that contain references to unknown variables.""" + + values = self.get_values(**extra_values) + if self._contains_html_diff: + self._content_type = 'html' + else: + self._content_type = 'plain' + values['contenttype'] = self._content_type + + for line in template.splitlines(): + (name, value) = line.split(': ', 1) + + try: + value = value % values + except KeyError: + t, e, traceback = sys.exc_info() + if DEBUG: + self.environment.log_warning( + 'Warning: unknown variable %r in the following line; line skipped:\n' + ' %s\n' + % (e.args[0], line,) + ) + else: + if name.lower() in ADDR_HEADERS: + value = addr_header_encode(value, name) + else: + value = header_encode(value, name) + for splitline in ('%s: %s\n' % (name, value)).splitlines(True): + yield splitline + + def generate_email_header(self): + """Generate the RFC 2822 email headers for this Change, a line at a time. + + The output should not include the trailing blank line.""" + + raise NotImplementedError() + + def generate_browse_link(self, base_url): + """Generate a link to an online repository browser.""" + return iter(()) + + def generate_email_intro(self, html_escape_val=False): + """Generate the email intro for this Change, a line at a time. + + The output will be used as the standard boilerplate at the top + of the email body.""" + + raise NotImplementedError() + + def generate_email_body(self, push): + """Generate the main part of the email body, a line at a time. + + The text in the body might be truncated after a specified + number of lines (see multimailhook.emailmaxlines).""" + + raise NotImplementedError() + + def generate_email_footer(self, html_escape_val): + """Generate the footer of the email, a line at a time. + + The footer is always included, irrespective of + multimailhook.emailmaxlines.""" + + raise NotImplementedError() + + def _wrap_for_html(self, lines): + """Wrap the lines in HTML <pre> tag when using HTML format. + + Escape special HTML characters and add <pre> and </pre> tags around + the given lines if we should be generating HTML as indicated by + self._contains_html_diff being set to true. + """ + if self._contains_html_diff: + yield "<pre style='margin:0'>\n" + + for line in lines: + yield html_escape(line) + + yield '</pre>\n' + else: + for line in lines: + yield line + + def generate_email(self, push, body_filter=None, extra_header_values={}): + """Generate an email describing this change. + + Iterate over the lines (including the header lines) of an + email describing this change. If body_filter is not None, + then use it to filter the lines that are intended for the + email body. + + The extra_header_values field is received as a dict and not as + **kwargs, to allow passing other keyword arguments in the + future (e.g. passing extra values to generate_email_intro()""" + + for line in self.generate_email_header(**extra_header_values): + yield line + yield '\n' + html_escape_val = (self.environment.html_in_intro and + self._contains_html_diff) + intro = self.generate_email_intro(html_escape_val) + if not self.environment.html_in_intro: + intro = self._wrap_for_html(intro) + for line in intro: + yield line + + if self.environment.commitBrowseURL: + for line in self.generate_browse_link(self.environment.commitBrowseURL): + yield line + + body = self.generate_email_body(push) + if body_filter is not None: + body = body_filter(body) + + diff_started = False + if self._contains_html_diff: + # "white-space: pre" is the default, but we need to + # specify it again in case the message is viewed in a + # webmail which wraps it in an element setting white-space + # to something else (Zimbra does this and sets + # white-space: pre-line). + yield '<pre style="white-space: pre; background: #F8F8F8">' + for line in body: + if self._contains_html_diff: + # This is very, very naive. It would be much better to really + # parse the diff, i.e. look at how many lines do we have in + # the hunk headers instead of blindly highlighting everything + # that looks like it might be part of a diff. + bgcolor = '' + fgcolor = '' + if line.startswith('--- a/'): + diff_started = True + bgcolor = 'e0e0ff' + elif line.startswith('diff ') or line.startswith('index '): + diff_started = True + fgcolor = '808080' + elif diff_started: + if line.startswith('+++ '): + bgcolor = 'e0e0ff' + elif line.startswith('@@'): + bgcolor = 'e0e0e0' + elif line.startswith('+'): + bgcolor = 'e0ffe0' + elif line.startswith('-'): + bgcolor = 'ffe0e0' + elif line.startswith('commit '): + fgcolor = '808000' + elif line.startswith(' '): + fgcolor = '404040' + + # Chop the trailing LF, we don't want it inside <pre>. + line = html_escape(line[:-1]) + + if bgcolor or fgcolor: + style = 'display:block; white-space:pre;' + if bgcolor: + style += 'background:#' + bgcolor + ';' + if fgcolor: + style += 'color:#' + fgcolor + ';' + # Use a <span style='display:block> to color the + # whole line. The newline must be inside the span + # to display properly both in Firefox and in + # text-based browser. + line = "<span style='%s'>%s\n</span>" % (style, line) + else: + line = line + '\n' + + yield line + if self._contains_html_diff: + yield '</pre>' + html_escape_val = (self.environment.html_in_footer and + self._contains_html_diff) + footer = self.generate_email_footer(html_escape_val) + if not self.environment.html_in_footer: + footer = self._wrap_for_html(footer) + for line in footer: + yield line + + def get_specific_fromaddr(self): + """For kinds of Changes which specify it, return the kind-specific + From address to use.""" + return None + + +class Revision(Change): + """A Change consisting of a single git commit.""" + + CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$') + + def __init__(self, reference_change, rev, num, tot): + Change.__init__(self, reference_change.environment) + self.reference_change = reference_change + self.rev = rev + self.change_type = self.reference_change.change_type + self.refname = self.reference_change.refname + self.num = num + self.tot = tot + self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1]) + self.recipients = self.environment.get_revision_recipients(self) + + # -s is short for --no-patch, but -s works on older git's (e.g. 1.7) + self.parents = read_git_lines(['show', '-s', '--format=%P', + self.rev.sha1])[0].split() + + self.cc_recipients = '' + if self.environment.get_scancommitforcc(): + self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients()) + if self.cc_recipients: + self.environment.log_msg( + 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1)) + + def _cc_recipients(self): + cc_recipients = [] + message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1]) + lines = message.strip().split('\n') + for line in lines: + m = re.match(self.CC_RE, line) + if m: + cc_recipients.append(m.group('to')) + + return cc_recipients + + def _compute_values(self): + values = Change._compute_values(self) + + oneline = read_git_output( + ['log', '--format=%s', '--no-walk', self.rev.sha1] + ) + + max_subject_length = self.environment.get_max_subject_length() + if max_subject_length > 0 and len(oneline) > max_subject_length: + oneline = oneline[:max_subject_length - 6] + ' [...]' + + values['rev'] = self.rev.sha1 + values['parents'] = ' '.join(self.parents) + values['rev_short'] = self.rev.short + values['change_type'] = self.change_type + values['refname'] = self.refname + values['newrev'] = self.rev.sha1 + values['short_refname'] = self.reference_change.short_refname + values['refname_type'] = self.reference_change.refname_type + values['reply_to_msgid'] = self.reference_change.msgid + values['thread_index'] = self.reference_change.thread_index + values['num'] = self.num + values['tot'] = self.tot + values['recipients'] = self.recipients + if self.cc_recipients: + values['cc_recipients'] = self.cc_recipients + values['oneline'] = oneline + values['author'] = self.author + + reply_to = self.environment.get_reply_to_commit(self) + if reply_to: + values['reply_to'] = reply_to + + return values + + def generate_email_header(self, **extra_values): + for line in self.expand_header_lines( + REVISION_HEADER_TEMPLATE, **extra_values + ): + yield line + + def generate_browse_link(self, base_url): + if '%(' not in base_url: + base_url += '%(id)s' + url = "".join(self.expand_lines(base_url)) + if self._content_type == 'html': + for line in self.expand_lines(LINK_HTML_TEMPLATE, + html_escape_val=True, + browse_url=url): + yield line + elif self._content_type == 'plain': + for line in self.expand_lines(LINK_TEXT_TEMPLATE, + html_escape_val=False, + browse_url=url): + yield line + else: + raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.") + + def generate_email_intro(self, html_escape_val=False): + for line in self.expand_lines(REVISION_INTRO_TEMPLATE, + html_escape_val=html_escape_val): + yield line + + def generate_email_body(self, push): + """Show this revision.""" + + for line in read_git_lines( + ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1], + keepends=True, + errors='replace'): + if line.startswith('Date: ') and self.environment.date_substitute: + yield self.environment.date_substitute + line[len('Date: '):] + else: + yield line + + def generate_email_footer(self, html_escape_val): + return self.expand_lines(REVISION_FOOTER_TEMPLATE, + html_escape_val=html_escape_val) + + def generate_email(self, push, body_filter=None, extra_header_values={}): + self._contains_diff() + return Change.generate_email(self, push, body_filter, extra_header_values) + + def get_specific_fromaddr(self): + return self.environment.from_commit + + +class ReferenceChange(Change): + """A Change to a Git reference. + + An abstract class representing a create, update, or delete of a + Git reference. Derived classes handle specific types of reference + (e.g., tags vs. branches). These classes generate the main + reference change email summarizing the reference change and + whether it caused any any commits to be added or removed. + + ReferenceChange objects are usually created using the static + create() method, which has the logic to decide which derived class + to instantiate.""" + + REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$') + + @staticmethod + def create(environment, oldrev, newrev, refname): + """Return a ReferenceChange object representing the change. + + Return an object that represents the type of change that is being + made. oldrev and newrev should be SHA1s or ZEROS.""" + + old = GitObject(oldrev) + new = GitObject(newrev) + rev = new or old + + # The revision type tells us what type the commit is, combined with + # the location of the ref we can decide between + # - working branch + # - tracking branch + # - unannotated tag + # - annotated tag + m = ReferenceChange.REF_RE.match(refname) + if m: + area = m.group('area') + short_refname = m.group('shortname') + else: + area = '' + short_refname = refname + + if rev.type == 'tag': + # Annotated tag: + klass = AnnotatedTagChange + elif rev.type == 'commit': + if area == 'tags': + # Non-annotated tag: + klass = NonAnnotatedTagChange + elif area == 'heads': + # Branch: + klass = BranchChange + elif area == 'remotes': + # Tracking branch: + environment.log_warning( + '*** Push-update of tracking branch %r\n' + '*** - incomplete email generated.' + % (refname,) + ) + klass = OtherReferenceChange + else: + # Some other reference namespace: + environment.log_warning( + '*** Push-update of strange reference %r\n' + '*** - incomplete email generated.' + % (refname,) + ) + klass = OtherReferenceChange + else: + # Anything else (is there anything else?) + environment.log_warning( + '*** Unknown type of update to %r (%s)\n' + '*** - incomplete email generated.' + % (refname, rev.type,) + ) + klass = OtherReferenceChange + + return klass( + environment, + refname=refname, short_refname=short_refname, + old=old, new=new, rev=rev, + ) + + @staticmethod + def make_thread_index(): + """Return a string appropriate for the Thread-Index header, + needed by MS Outlook to get threading right. + + The format is (base64-encoded): + - 1 byte must be 1 + - 5 bytes encode a date (hardcoded here) + - 16 bytes for a globally unique identifier + + FIXME: Unfortunately, even with the Thread-Index field, MS + Outlook doesn't seem to do the threading reliably (see + https://github.com/git-multimail/git-multimail/pull/194). + """ + thread_index = b'\x01\x00\x00\x12\x34\x56' + uuid.uuid4().bytes + return base64.standard_b64encode(thread_index).decode('ascii') + + def __init__(self, environment, refname, short_refname, old, new, rev): + Change.__init__(self, environment) + self.change_type = { + (False, True): 'create', + (True, True): 'update', + (True, False): 'delete', + }[bool(old), bool(new)] + self.refname = refname + self.short_refname = short_refname + self.old = old + self.new = new + self.rev = rev + self.msgid = make_msgid() + self.thread_index = self.make_thread_index() + self.diffopts = environment.diffopts + self.graphopts = environment.graphopts + self.logopts = environment.logopts + self.commitlogopts = environment.commitlogopts + self.showgraph = environment.refchange_showgraph + self.showlog = environment.refchange_showlog + + self.header_template = REFCHANGE_HEADER_TEMPLATE + self.intro_template = REFCHANGE_INTRO_TEMPLATE + self.footer_template = FOOTER_TEMPLATE + + def _compute_values(self): + values = Change._compute_values(self) + + values['change_type'] = self.change_type + values['refname_type'] = self.refname_type + values['refname'] = self.refname + values['short_refname'] = self.short_refname + values['msgid'] = self.msgid + values['thread_index'] = self.thread_index + values['recipients'] = self.recipients + values['oldrev'] = str(self.old) + values['oldrev_short'] = self.old.short + values['newrev'] = str(self.new) + values['newrev_short'] = self.new.short + + if self.old: + values['oldrev_type'] = self.old.type + if self.new: + values['newrev_type'] = self.new.type + + reply_to = self.environment.get_reply_to_refchange(self) + if reply_to: + values['reply_to'] = reply_to + + return values + + def send_single_combined_email(self, known_added_sha1s): + """Determine if a combined refchange/revision email should be sent + + If there is only a single new (non-merge) commit added by a + change, it is useful to combine the ReferenceChange and + Revision emails into one. In such a case, return the single + revision; otherwise, return None. + + This method is overridden in BranchChange.""" + + return None + + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): + """Generate an email describing this change AND specified revision. + + Iterate over the lines (including the header lines) of an + email describing this change. If body_filter is not None, + then use it to filter the lines that are intended for the + email body. + + The extra_header_values field is received as a dict and not as + **kwargs, to allow passing other keyword arguments in the + future (e.g. passing extra values to generate_email_intro() + + This method is overridden in BranchChange.""" + + raise NotImplementedError + + def get_subject(self): + template = { + 'create': REF_CREATED_SUBJECT_TEMPLATE, + 'update': REF_UPDATED_SUBJECT_TEMPLATE, + 'delete': REF_DELETED_SUBJECT_TEMPLATE, + }[self.change_type] + return self.expand(template) + + def generate_email_header(self, **extra_values): + if 'subject' not in extra_values: + extra_values['subject'] = self.get_subject() + + for line in self.expand_header_lines( + self.header_template, **extra_values + ): + yield line + + def generate_email_intro(self, html_escape_val=False): + for line in self.expand_lines(self.intro_template, + html_escape_val=html_escape_val): + yield line + + def generate_email_body(self, push): + """Call the appropriate body-generation routine. + + Call one of generate_create_summary() / + generate_update_summary() / generate_delete_summary().""" + + change_summary = { + 'create': self.generate_create_summary, + 'delete': self.generate_delete_summary, + 'update': self.generate_update_summary, + }[self.change_type](push) + for line in change_summary: + yield line + + for line in self.generate_revision_change_summary(push): + yield line + + def generate_email_footer(self, html_escape_val): + return self.expand_lines(self.footer_template, + html_escape_val=html_escape_val) + + def generate_revision_change_graph(self, push): + if self.showgraph: + args = ['--graph'] + self.graphopts + for newold in ('new', 'old'): + has_newold = False + spec = push.get_commits_spec(newold, self) + for line in git_log(spec, args=args, keepends=True): + if not has_newold: + has_newold = True + yield '\n' + yield 'Graph of %s commits:\n\n' % ( + {'new': 'new', 'old': 'discarded'}[newold],) + yield ' ' + line + if has_newold: + yield '\n' + + def generate_revision_change_log(self, new_commits_list): + if self.showlog: + yield '\n' + yield 'Detailed log of new commits:\n\n' + for line in read_git_lines( + ['log', '--no-walk'] + + self.logopts + + new_commits_list + + ['--'], + keepends=True, + ): + yield line + + def generate_new_revision_summary(self, tot, new_commits_list, push): + for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot): + yield line + for line in self.generate_revision_change_graph(push): + yield line + for line in self.generate_revision_change_log(new_commits_list): + yield line + + def generate_revision_change_summary(self, push): + """Generate a summary of the revisions added/removed by this change.""" + + if self.new.commit_sha1 and not self.old.commit_sha1: + # A new reference was created. List the new revisions + # brought by the new reference (i.e., those revisions that + # were not in the repository before this reference + # change). + sha1s = list(push.get_new_commits(self)) + sha1s.reverse() + tot = len(sha1s) + new_revisions = [ + Revision(self, GitObject(sha1), num=i + 1, tot=tot) + for (i, sha1) in enumerate(sha1s) + ] + + if new_revisions: + yield self.expand('This %(refname_type)s includes the following new commits:\n') + yield '\n' + for r in new_revisions: + (sha1, subject) = r.rev.get_summary() + yield r.expand( + BRIEF_SUMMARY_TEMPLATE, action='new', text=subject, + ) + yield '\n' + for line in self.generate_new_revision_summary( + tot, [r.rev.sha1 for r in new_revisions], push): + yield line + else: + for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): + yield line + + elif self.new.commit_sha1 and self.old.commit_sha1: + # A reference was changed to point at a different commit. + # List the revisions that were removed and/or added *from + # that reference* by this reference change, along with a + # diff between the trees for its old and new values. + + # List of the revisions that were added to the branch by + # this update. Note this list can include revisions that + # have already had notification emails; we want such + # revisions in the summary even though we will not send + # new notification emails for them. + adds = list(generate_summaries( + '--topo-order', '--reverse', '%s..%s' + % (self.old.commit_sha1, self.new.commit_sha1,) + )) + + # List of the revisions that were removed from the branch + # by this update. This will be empty except for + # non-fast-forward updates. + discards = list(generate_summaries( + '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,) + )) + + if adds: + new_commits_list = push.get_new_commits(self) + else: + new_commits_list = [] + new_commits = CommitSet(new_commits_list) + + if discards: + discarded_commits = CommitSet(push.get_discarded_commits(self)) + else: + discarded_commits = CommitSet([]) + + if discards and adds: + for (sha1, subject) in discards: + if sha1 in discarded_commits: + action = 'discard' + else: + action = 'omit' + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action=action, + rev_short=sha1, text=subject, + ) + for (sha1, subject) in adds: + if sha1 in new_commits: + action = 'new' + else: + action = 'add' + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action=action, + rev_short=sha1, text=subject, + ) + yield '\n' + for line in self.expand_lines(NON_FF_TEMPLATE): + yield line + + elif discards: + for (sha1, subject) in discards: + if sha1 in discarded_commits: + action = 'discard' + else: + action = 'omit' + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action=action, + rev_short=sha1, text=subject, + ) + yield '\n' + for line in self.expand_lines(REWIND_ONLY_TEMPLATE): + yield line + + elif adds: + (sha1, subject) = self.old.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='from', + rev_short=sha1, text=subject, + ) + for (sha1, subject) in adds: + if sha1 in new_commits: + action = 'new' + else: + action = 'add' + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action=action, + rev_short=sha1, text=subject, + ) + + yield '\n' + + if new_commits: + for line in self.generate_new_revision_summary( + len(new_commits), new_commits_list, push): + yield line + else: + for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE): + yield line + for line in self.generate_revision_change_graph(push): + yield line + + # The diffstat is shown from the old revision to the new + # revision. This is to show the truth of what happened in + # this change. There's no point showing the stat from the + # base to the new revision because the base is effectively a + # random revision at this point - the user will be interested + # in what this revision changed - including the undoing of + # previous revisions in the case of non-fast-forward updates. + yield '\n' + yield 'Summary of changes:\n' + for line in read_git_lines( + ['diff-tree'] + + self.diffopts + + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)], + keepends=True, + ): + yield line + + elif self.old.commit_sha1 and not self.new.commit_sha1: + # A reference was deleted. List the revisions that were + # removed from the repository by this reference change. + + sha1s = list(push.get_discarded_commits(self)) + tot = len(sha1s) + discarded_revisions = [ + Revision(self, GitObject(sha1), num=i + 1, tot=tot) + for (i, sha1) in enumerate(sha1s) + ] + + if discarded_revisions: + for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE): + yield line + yield '\n' + for r in discarded_revisions: + (sha1, subject) = r.rev.get_summary() + yield r.expand( + BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject, + ) + for line in self.generate_revision_change_graph(push): + yield line + else: + for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE): + yield line + + elif not self.old.commit_sha1 and not self.new.commit_sha1: + for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE): + yield line + + def generate_create_summary(self, push): + """Called for the creation of a reference.""" + + # This is a new reference and so oldrev is not valid + (sha1, subject) = self.new.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='at', + rev_short=sha1, text=subject, + ) + yield '\n' + + def generate_update_summary(self, push): + """Called for the change of a pre-existing branch.""" + + return iter([]) + + def generate_delete_summary(self, push): + """Called for the deletion of any type of reference.""" + + (sha1, subject) = self.old.get_summary() + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='was', + rev_short=sha1, text=subject, + ) + yield '\n' + + def get_specific_fromaddr(self): + return self.environment.from_refchange + + +class BranchChange(ReferenceChange): + refname_type = 'branch' + + def __init__(self, environment, refname, short_refname, old, new, rev): + ReferenceChange.__init__( + self, environment, + refname=refname, short_refname=short_refname, + old=old, new=new, rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + self._single_revision = None + + def send_single_combined_email(self, known_added_sha1s): + if not self.environment.combine_when_single_commit: + return None + + # In the sadly-all-too-frequent usecase of people pushing only + # one of their commits at a time to a repository, users feel + # the reference change summary emails are noise rather than + # important signal. This is because, in this particular + # usecase, there is a reference change summary email for each + # new commit, and all these summaries do is point out that + # there is one new commit (which can readily be inferred by + # the existence of the individual revision email that is also + # sent). In such cases, our users prefer there to be a combined + # reference change summary/new revision email. + # + # So, if the change is an update and it doesn't discard any + # commits, and it adds exactly one non-merge commit (gerrit + # forces a workflow where every commit is individually merged + # and the git-multimail hook fired off for just this one + # change), then we send a combined refchange/revision email. + try: + # If this change is a reference update that doesn't discard + # any commits... + if self.change_type != 'update': + return None + + if read_git_lines( + ['merge-base', self.old.sha1, self.new.sha1] + ) != [self.old.sha1]: + return None + + # Check if this update introduced exactly one non-merge + # commit: + + def split_line(line): + """Split line into (sha1, [parent,...]).""" + + words = line.split() + return (words[0], words[1:]) + + # Get the new commits introduced by the push as a list of + # (sha1, [parent,...]) + new_commits = [ + split_line(line) + for line in read_git_lines( + [ + 'log', '-3', '--format=%H %P', + '%s..%s' % (self.old.sha1, self.new.sha1), + ] + ) + ] + + if not new_commits: + return None + + # If the newest commit is a merge, save it for a later check + # but otherwise ignore it + merge = None + tot = len(new_commits) + if len(new_commits[0][1]) > 1: + merge = new_commits[0][0] + del new_commits[0] + + # Our primary check: we can't combine if more than one commit + # is introduced. We also currently only combine if the new + # commit is a non-merge commit, though it may make sense to + # combine if it is a merge as well. + if not ( + len(new_commits) == 1 and + len(new_commits[0][1]) == 1 and + new_commits[0][0] in known_added_sha1s + ): + return None + + # We do not want to combine revision and refchange emails if + # those go to separate locations. + rev = Revision(self, GitObject(new_commits[0][0]), 1, tot) + if rev.recipients != self.recipients: + return None + + # We ignored the newest commit if it was just a merge of the one + # commit being introduced. But we don't want to ignore that + # merge commit it it involved conflict resolutions. Check that. + if merge and merge != read_git_output(['diff-tree', '--cc', merge]): + return None + + # We can combine the refchange and one new revision emails + # into one. Return the Revision that a combined email should + # be sent about. + return rev + except CommandError: + # Cannot determine number of commits in old..new or new..old; + # don't combine reference/revision emails: + return None + + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}): + values = revision.get_values() + if extra_header_values: + values.update(extra_header_values) + if 'subject' not in extra_header_values: + values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values) + + self._single_revision = revision + self._contains_diff() + self.header_template = COMBINED_HEADER_TEMPLATE + self.intro_template = COMBINED_INTRO_TEMPLATE + self.footer_template = COMBINED_FOOTER_TEMPLATE + + def revision_gen_link(base_url): + # revision is used only to generate the body, and + # _content_type is set while generating headers. Get it + # from the BranchChange object. + revision._content_type = self._content_type + return revision.generate_browse_link(base_url) + self.generate_browse_link = revision_gen_link + for line in self.generate_email(push, body_filter, values): + yield line + + def generate_email_body(self, push): + '''Call the appropriate body generation routine. + + If this is a combined refchange/revision email, the special logic + for handling this combined email comes from this function. For + other cases, we just use the normal handling.''' + + # If self._single_revision isn't set; don't override + if not self._single_revision: + for line in super(BranchChange, self).generate_email_body(push): + yield line + return + + # This is a combined refchange/revision email; we first provide + # some info from the refchange portion, and then call the revision + # generate_email_body function to handle the revision portion. + adds = list(generate_summaries( + '--topo-order', '--reverse', '%s..%s' + % (self.old.commit_sha1, self.new.commit_sha1,) + )) + + yield self.expand("The following commit(s) were added to %(refname)s by this push:\n") + for (sha1, subject) in adds: + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='new', + rev_short=sha1, text=subject, + ) + + yield self._single_revision.rev.short + " is described below\n" + yield '\n' + + for line in self._single_revision.generate_email_body(push): + yield line + + +class AnnotatedTagChange(ReferenceChange): + refname_type = 'annotated tag' + + def __init__(self, environment, refname, short_refname, old, new, rev): + ReferenceChange.__init__( + self, environment, + refname=refname, short_refname=short_refname, + old=old, new=new, rev=rev, + ) + self.recipients = environment.get_announce_recipients(self) + self.show_shortlog = environment.announce_show_shortlog + + ANNOTATED_TAG_FORMAT = ( + '%(*objectname)\n' + '%(*objecttype)\n' + '%(taggername)\n' + '%(taggerdate)' + ) + + def describe_tag(self, push): + """Describe the new value of an annotated tag.""" + + # Use git for-each-ref to pull out the individual fields from + # the tag + [tagobject, tagtype, tagger, tagged] = read_git_lines( + ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname], + ) + + yield self.expand( + BRIEF_SUMMARY_TEMPLATE, action='tagging', + rev_short=tagobject, text='(%s)' % (tagtype,), + ) + if tagtype == 'commit': + # If the tagged object is a commit, then we assume this is a + # release, and so we calculate which tag this tag is + # replacing + try: + prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)]) + except CommandError: + prevtag = None + if prevtag: + yield ' replaces %s\n' % (prevtag,) + else: + prevtag = None + yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),) + + yield ' by %s\n' % (tagger,) + yield ' on %s\n' % (tagged,) + yield '\n' + + # Show the content of the tag message; this might contain a + # change log or release notes so is worth displaying. + yield LOGBEGIN + contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True)) + contents = contents[contents.index('\n') + 1:] + if contents and contents[-1][-1:] != '\n': + contents.append('\n') + for line in contents: + yield line + + if self.show_shortlog and tagtype == 'commit': + # Only commit tags make sense to have rev-list operations + # performed on them + yield '\n' + if prevtag: + # Show changes since the previous release + revlist = read_git_output( + ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)], + keepends=True, + ) + else: + # No previous tag, show all the changes since time + # began + revlist = read_git_output( + ['rev-list', '--pretty=short', '%s' % (self.new,)], + keepends=True, + ) + for line in read_git_lines(['shortlog'], input=revlist, keepends=True): + yield line + + yield LOGEND + yield '\n' + + def generate_create_summary(self, push): + """Called for the creation of an annotated tag.""" + + for line in self.expand_lines(TAG_CREATED_TEMPLATE): + yield line + + for line in self.describe_tag(push): + yield line + + def generate_update_summary(self, push): + """Called for the update of an annotated tag. + + This is probably a rare event and may not even be allowed.""" + + for line in self.expand_lines(TAG_UPDATED_TEMPLATE): + yield line + + for line in self.describe_tag(push): + yield line + + def generate_delete_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_DELETED_TEMPLATE): + yield line + + yield self.expand(' tag was %(oldrev_short)s\n') + yield '\n' + + +class NonAnnotatedTagChange(ReferenceChange): + refname_type = 'tag' + + def __init__(self, environment, refname, short_refname, old, new, rev): + ReferenceChange.__init__( + self, environment, + refname=refname, short_refname=short_refname, + old=old, new=new, rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + + def generate_create_summary(self, push): + """Called for the creation of an annotated tag.""" + + for line in self.expand_lines(TAG_CREATED_TEMPLATE): + yield line + + def generate_update_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_UPDATED_TEMPLATE): + yield line + + def generate_delete_summary(self, push): + """Called when a non-annotated reference is updated.""" + + for line in self.expand_lines(TAG_DELETED_TEMPLATE): + yield line + + for line in ReferenceChange.generate_delete_summary(self, push): + yield line + + +class OtherReferenceChange(ReferenceChange): + refname_type = 'reference' + + def __init__(self, environment, refname, short_refname, old, new, rev): + # We use the full refname as short_refname, because otherwise + # the full name of the reference would not be obvious from the + # text of the email. + ReferenceChange.__init__( + self, environment, + refname=refname, short_refname=refname, + old=old, new=new, rev=rev, + ) + self.recipients = environment.get_refchange_recipients(self) + + +class Mailer(object): + """An object that can send emails.""" + + def __init__(self, environment): + self.environment = environment + + def close(self): + pass + + def send(self, lines, to_addrs): + """Send an email consisting of lines. + + lines must be an iterable over the lines constituting the + header and body of the email. to_addrs is a list of recipient + addresses (can be needed even if lines already contains a + "To:" field). It can be either a string (comma-separated list + of email addresses) or a Python list of individual email + addresses. + + """ + + raise NotImplementedError() + + +class SendMailer(Mailer): + """Send emails using 'sendmail -oi -t'.""" + + SENDMAIL_CANDIDATES = [ + '/usr/sbin/sendmail', + '/usr/lib/sendmail', + ] + + @staticmethod + def find_sendmail(): + for path in SendMailer.SENDMAIL_CANDIDATES: + if os.access(path, os.X_OK): + return path + else: + raise ConfigurationException( + 'No sendmail executable found. ' + 'Try setting multimailhook.sendmailCommand.' + ) + + def __init__(self, environment, command=None, envelopesender=None): + """Construct a SendMailer instance. + + command should be the command and arguments used to invoke + sendmail, as a list of strings. If an envelopesender is + provided, it will also be passed to the command, via '-f + envelopesender'.""" + super(SendMailer, self).__init__(environment) + if command: + self.command = command[:] + else: + self.command = [self.find_sendmail(), '-oi', '-t'] + + if envelopesender: + self.command.extend(['-f', envelopesender]) + + def send(self, lines, to_addrs): + try: + p = subprocess.Popen(self.command, stdin=subprocess.PIPE) + except OSError: + self.environment.get_logger().error( + '*** Cannot execute command: %s\n' % ' '.join(self.command) + + '*** %s\n' % sys.exc_info()[1] + + '*** Try setting multimailhook.mailer to "smtp"\n' + + '*** to send emails without using the sendmail command.\n' + ) + sys.exit(1) + try: + lines = (str_to_bytes(line) for line in lines) + p.stdin.writelines(lines) + except Exception: + self.environment.get_logger().error( + '*** Error while generating commit email\n' + '*** - mail sending aborted.\n' + ) + if hasattr(p, 'terminate'): + # subprocess.terminate() is not available in Python 2.4 + p.terminate() + else: + import signal + os.kill(p.pid, signal.SIGTERM) + raise + else: + p.stdin.close() + retcode = p.wait() + if retcode: + raise CommandError(self.command, retcode) + + +class SMTPMailer(Mailer): + """Send emails using Python's smtplib.""" + + def __init__(self, environment, + envelopesender, smtpserver, + smtpservertimeout=10.0, smtpserverdebuglevel=0, + smtpencryption='none', + smtpuser='', smtppass='', + smtpcacerts='' + ): + super(SMTPMailer, self).__init__(environment) + if not envelopesender: + self.environment.get_logger().error( + 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n' + 'please set either multimailhook.envelopeSender or user.email\n' + ) + sys.exit(1) + if smtpencryption == 'ssl' and not (smtpuser and smtppass): + raise ConfigurationException( + 'Cannot use SMTPMailer with security option ssl ' + 'without options username and password.' + ) + self.envelopesender = envelopesender + self.smtpserver = smtpserver + self.smtpservertimeout = smtpservertimeout + self.smtpserverdebuglevel = smtpserverdebuglevel + self.security = smtpencryption + self.username = smtpuser + self.password = smtppass + self.smtpcacerts = smtpcacerts + self.loggedin = False + try: + def call(klass, server, timeout): + try: + return klass(server, timeout=timeout) + except TypeError: + # Old Python versions do not have timeout= argument. + return klass(server) + if self.security == 'none': + self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) + elif self.security == 'ssl': + if self.smtpcacerts: + raise smtplib.SMTPException( + "Checking certificate is not supported for ssl, prefer starttls" + ) + self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout) + elif self.security == 'tls': + if 'ssl' not in sys.modules: + self.environment.get_logger().error( + '*** Your Python version does not have the ssl library installed\n' + '*** smtpEncryption=tls is not available.\n' + '*** Either upgrade Python to 2.6 or later\n' + ' or use git_multimail.py version 1.2.\n') + if ':' not in self.smtpserver: + self.smtpserver += ':587' # default port for TLS + self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout) + # start: ehlo + starttls + # equivalent to + # self.smtp.ehlo() + # self.smtp.starttls() + # with acces to the ssl layer + self.smtp.ehlo() + if not self.smtp.has_extn("starttls"): + raise smtplib.SMTPException("STARTTLS extension not supported by server") + resp, reply = self.smtp.docmd("STARTTLS") + if resp != 220: + raise smtplib.SMTPException("Wrong answer to the STARTTLS command") + if self.smtpcacerts: + self.smtp.sock = ssl.wrap_socket( + self.smtp.sock, + ca_certs=self.smtpcacerts, + cert_reqs=ssl.CERT_REQUIRED + ) + else: + self.smtp.sock = ssl.wrap_socket( + self.smtp.sock, + cert_reqs=ssl.CERT_NONE + ) + self.environment.get_logger().error( + '*** Warning, the server certificat is not verified (smtp) ***\n' + '*** set the option smtpCACerts ***\n' + ) + if not hasattr(self.smtp.sock, "read"): + # using httplib.FakeSocket with Python 2.5.x or earlier + self.smtp.sock.read = self.smtp.sock.recv + self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock) + self.smtp.helo_resp = None + self.smtp.ehlo_resp = None + self.smtp.esmtp_features = {} + self.smtp.does_esmtp = 0 + # end: ehlo + starttls + self.smtp.ehlo() + else: + sys.stdout.write('*** Error: Control reached an invalid option. ***') + sys.exit(1) + if self.smtpserverdebuglevel > 0: + sys.stdout.write( + "*** Setting debug on for SMTP server connection (%s) ***\n" + % self.smtpserverdebuglevel) + self.smtp.set_debuglevel(self.smtpserverdebuglevel) + except Exception: + self.environment.get_logger().error( + '*** Error establishing SMTP connection to %s ***\n' + '*** %s\n' + % (self.smtpserver, sys.exc_info()[1])) + sys.exit(1) + + def close(self): + if hasattr(self, 'smtp'): + self.smtp.quit() + del self.smtp + + def __del__(self): + self.close() + + def send(self, lines, to_addrs): + try: + if self.username or self.password: + if not self.loggedin: + self.smtp.login(self.username, self.password) + self.loggedin = True + msg = ''.join(lines) + # turn comma-separated list into Python list if needed. + if is_string(to_addrs): + to_addrs = [email for (name, email) in getaddresses([to_addrs])] + self.smtp.sendmail(self.envelopesender, to_addrs, msg) + except socket.timeout: + self.environment.get_logger().error( + '*** Error sending email ***\n' + '*** SMTP server timed out (timeout is %s)\n' + % self.smtpservertimeout) + except smtplib.SMTPResponseException: + err = sys.exc_info()[1] + self.environment.get_logger().error( + '*** Error sending email ***\n' + '*** Error %d: %s\n' + % (err.smtp_code, bytes_to_str(err.smtp_error))) + try: + smtp = self.smtp + # delete the field before quit() so that in case of + # error, self.smtp is deleted anyway. + del self.smtp + smtp.quit() + except: + self.environment.get_logger().error( + '*** Error closing the SMTP connection ***\n' + '*** Exiting anyway ... ***\n' + '*** %s\n' % sys.exc_info()[1]) + sys.exit(1) + + +class OutputMailer(Mailer): + """Write emails to an output stream, bracketed by lines of '=' characters. + + This is intended for debugging purposes.""" + + SEPARATOR = '=' * 75 + '\n' + + def __init__(self, f, environment=None): + super(OutputMailer, self).__init__(environment=environment) + self.f = f + + def send(self, lines, to_addrs): + write_str(self.f, self.SEPARATOR) + for line in lines: + write_str(self.f, line) + write_str(self.f, self.SEPARATOR) + + +def get_git_dir(): + """Determine GIT_DIR. + + Determine GIT_DIR either from the GIT_DIR environment variable or + from the working directory, using Git's usual rules.""" + + try: + return read_git_output(['rev-parse', '--git-dir']) + except CommandError: + sys.stderr.write('fatal: git_multimail: not in a git directory\n') + sys.exit(1) + + +class Environment(object): + """Describes the environment in which the push is occurring. + + An Environment object encapsulates information about the local + environment. For example, it knows how to determine: + + * the name of the repository to which the push occurred + + * what user did the push + + * what users want to be informed about various types of changes. + + An Environment object is expected to have the following methods: + + get_repo_shortname() + + Return a short name for the repository, for display + purposes. + + get_repo_path() + + Return the absolute path to the Git repository. + + get_emailprefix() + + Return a string that will be prefixed to every email's + subject. + + get_pusher() + + Return the username of the person who pushed the changes. + This value is used in the email body to indicate who + pushed the change. + + get_pusher_email() (may return None) + + Return the email address of the person who pushed the + changes. The value should be a single RFC 2822 email + address as a string; e.g., "Joe User <user@example.com>" + if available, otherwise "user@example.com". If set, the + value is used as the Reply-To address for refchange + emails. If it is impossible to determine the pusher's + email, this attribute should be set to None (in which case + no Reply-To header will be output). + + get_sender() + + Return the address to be used as the 'From' email address + in the email envelope. + + get_fromaddr(change=None) + + Return the 'From' email address used in the email 'From:' + headers. If the change is known when this function is + called, it is passed in as the 'change' parameter. (May + be a full RFC 2822 email address like 'Joe User + <user@example.com>'.) + + get_administrator() + + Return the name and/or email of the repository + administrator. This value is used in the footer as the + person to whom requests to be removed from the + notification list should be sent. Ideally, it should + include a valid email address. + + get_reply_to_refchange() + get_reply_to_commit() + + Return the address to use in the email "Reply-To" header, + as a string. These can be an RFC 2822 email address, or + None to omit the "Reply-To" header. + get_reply_to_refchange() is used for refchange emails; + get_reply_to_commit() is used for individual commit + emails. + + get_ref_filter_regex() + + Return a tuple -- a compiled regex, and a boolean indicating + whether the regex picks refs to include (if False, the regex + matches on refs to exclude). + + get_default_ref_ignore_regex() + + Return a regex that should be ignored for both what emails + to send and when computing what commits are considered new + to the repository. Default is "^refs/notes/". + + get_max_subject_length() + + Return an int giving the maximal length for the subject + (git log --oneline). + + They should also define the following attributes: + + announce_show_shortlog (bool) + + True iff announce emails should include a shortlog. + + commit_email_format (string) + + If "html", generate commit emails in HTML instead of plain text + used by default. + + html_in_intro (bool) + html_in_footer (bool) + + When generating HTML emails, the introduction (respectively, + the footer) will be HTML-escaped iff html_in_intro (respectively, + the footer) is true. When false, only the values used to expand + the template are escaped. + + refchange_showgraph (bool) + + True iff refchanges emails should include a detailed graph. + + refchange_showlog (bool) + + True iff refchanges emails should include a detailed log. + + diffopts (list of strings) + + The options that should be passed to 'git diff' for the + summary email. The value should be a list of strings + representing words to be passed to the command. + + graphopts (list of strings) + + Analogous to diffopts, but contains options passed to + 'git log --graph' when generating the detailed graph for + a set of commits (see refchange_showgraph) + + logopts (list of strings) + + Analogous to diffopts, but contains options passed to + 'git log' when generating the detailed log for a set of + commits (see refchange_showlog) + + commitlogopts (list of strings) + + The options that should be passed to 'git log' for each + commit mail. The value should be a list of strings + representing words to be passed to the command. + + date_substitute (string) + + String to be used in substitution for 'Date:' at start of + line in the output of 'git log'. + + quiet (bool) + On success do not write to stderr + + stdout (bool) + Write email to stdout rather than emailing. Useful for debugging + + combine_when_single_commit (bool) + + True if a combined email should be produced when a single + new commit is pushed to a branch, False otherwise. + + from_refchange, from_commit (strings) + + Addresses to use for the From: field for refchange emails + and commit emails respectively. Set from + multimailhook.fromRefchange and multimailhook.fromCommit + by ConfigEnvironmentMixin. + + log_file, error_log_file, debug_log_file (string) + + Name of a file to which logs should be sent. + + verbose (int) + + How verbose the system should be. + - 0 (default): show info, errors, ... + - 1 : show basic debug info + """ + + REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$') + + def __init__(self, osenv=None): + self.osenv = osenv or os.environ + self.announce_show_shortlog = False + self.commit_email_format = "text" + self.html_in_intro = False + self.html_in_footer = False + self.commitBrowseURL = None + self.maxcommitemails = 500 + self.excludemergerevisions = False + self.diffopts = ['--stat', '--summary', '--find-copies-harder'] + self.graphopts = ['--oneline', '--decorate'] + self.logopts = [] + self.refchange_showgraph = False + self.refchange_showlog = False + self.commitlogopts = ['-C', '--stat', '-p', '--cc'] + self.date_substitute = 'AuthorDate: ' + self.quiet = False + self.stdout = False + self.combine_when_single_commit = True + self.logger = None + + self.COMPUTED_KEYS = [ + 'administrator', + 'charset', + 'emailprefix', + 'pusher', + 'pusher_email', + 'repo_path', + 'repo_shortname', + 'sender', + ] + + self._values = None + + def get_logger(self): + """Get (possibly creates) the logger associated to this environment.""" + if self.logger is None: + self.logger = Logger(self) + return self.logger + + def get_repo_shortname(self): + """Use the last part of the repo path, with ".git" stripped off if present.""" + + basename = os.path.basename(os.path.abspath(self.get_repo_path())) + m = self.REPO_NAME_RE.match(basename) + if m: + return m.group('name') + else: + return basename + + def get_pusher(self): + raise NotImplementedError() + + def get_pusher_email(self): + return None + + def get_fromaddr(self, change=None): + config = Config('user') + fromname = config.get('name', default='') + fromemail = config.get('email', default='') + if fromemail: + return formataddr([fromname, fromemail]) + return self.get_sender() + + def get_administrator(self): + return 'the administrator of this repository' + + def get_emailprefix(self): + return '' + + def get_repo_path(self): + if read_git_output(['rev-parse', '--is-bare-repository']) == 'true': + path = get_git_dir() + else: + path = read_git_output(['rev-parse', '--show-toplevel']) + return os.path.abspath(path) + + def get_charset(self): + return CHARSET + + def get_values(self): + """Return a dictionary {keyword: expansion} for this Environment. + + This method is called by Change._compute_values(). The keys + in the returned dictionary are available to be used in any of + the templates. The dictionary is created by calling + self.get_NAME() for each of the attributes named in + COMPUTED_KEYS and recording those that do not return None. + The return value is always a new dictionary.""" + + if self._values is None: + values = {'': ''} # %()s expands to the empty string. + + for key in self.COMPUTED_KEYS: + value = getattr(self, 'get_%s' % (key,))() + if value is not None: + values[key] = value + + self._values = values + + return self._values.copy() + + def get_refchange_recipients(self, refchange): + """Return the recipients for notifications about refchange. + + Return the list of email addresses to which notifications + about the specified ReferenceChange should be sent.""" + + raise NotImplementedError() + + def get_announce_recipients(self, annotated_tag_change): + """Return the recipients for notifications about annotated_tag_change. + + Return the list of email addresses to which notifications + about the specified AnnotatedTagChange should be sent.""" + + raise NotImplementedError() + + def get_reply_to_refchange(self, refchange): + return self.get_pusher_email() + + def get_revision_recipients(self, revision): + """Return the recipients for messages about revision. + + Return the list of email addresses to which notifications + about the specified Revision should be sent. This method + could be overridden, for example, to take into account the + contents of the revision when deciding whom to notify about + it. For example, there could be a scheme for users to express + interest in particular files or subdirectories, and only + receive notification emails for revisions that affecting those + files.""" + + raise NotImplementedError() + + def get_reply_to_commit(self, revision): + return revision.author + + def get_default_ref_ignore_regex(self): + # The commit messages of git notes are essentially meaningless + # and "filenames" in git notes commits are an implementational + # detail that might surprise users at first. As such, we + # would need a completely different method for handling emails + # of git notes in order for them to be of benefit for users, + # which we simply do not have right now. + return "^refs/notes/" + + def get_max_subject_length(self): + """Return the maximal subject line (git log --oneline) length. + Longer subject lines will be truncated.""" + raise NotImplementedError() + + def filter_body(self, lines): + """Filter the lines intended for an email body. + + lines is an iterable over the lines that would go into the + email body. Filter it (e.g., limit the number of lines, the + line length, character set, etc.), returning another iterable. + See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin + for classes implementing this functionality.""" + + return lines + + def log_msg(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().info(msg) + + def log_warning(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().warning(msg) + + def log_error(self, msg): + """Write the string msg on a log file or on stderr. + + Sends the text to stderr by default, override to change the behavior.""" + self.get_logger().error(msg) + + def check(self): + pass + + +class ConfigEnvironmentMixin(Environment): + """A mixin that sets self.config to its constructor's config argument. + + This class's constructor consumes the "config" argument. + + Mixins that need to inspect the config should inherit from this + class (1) to make sure that "config" is still in the constructor + arguments with its own constructor runs and/or (2) to be sure that + self.config is set after construction.""" + + def __init__(self, config, **kw): + super(ConfigEnvironmentMixin, self).__init__(**kw) + self.config = config + + +class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin): + """An Environment that reads most of its information from "git config".""" + + @staticmethod + def forbid_field_values(name, value, forbidden): + for forbidden_val in forbidden: + if value is not None and value.lower() == forbidden: + raise ConfigurationException( + '"%s" is not an allowed setting for %s' % (value, name) + ) + + def __init__(self, config, **kw): + super(ConfigOptionsEnvironmentMixin, self).__init__( + config=config, **kw + ) + + for var, cfg in ( + ('announce_show_shortlog', 'announceshortlog'), + ('refchange_showgraph', 'refchangeShowGraph'), + ('refchange_showlog', 'refchangeshowlog'), + ('quiet', 'quiet'), + ('stdout', 'stdout'), + ): + val = config.get_bool(cfg) + if val is not None: + setattr(self, var, val) + + commit_email_format = config.get('commitEmailFormat') + if commit_email_format is not None: + if commit_email_format != "html" and commit_email_format != "text": + self.log_warning( + '*** Unknown value for multimailhook.commitEmailFormat: %s\n' % + commit_email_format + + '*** Expected either "text" or "html". Ignoring.\n' + ) + else: + self.commit_email_format = commit_email_format + + html_in_intro = config.get_bool('htmlInIntro') + if html_in_intro is not None: + self.html_in_intro = html_in_intro + + html_in_footer = config.get_bool('htmlInFooter') + if html_in_footer is not None: + self.html_in_footer = html_in_footer + + self.commitBrowseURL = config.get('commitBrowseURL') + + self.excludemergerevisions = config.get('excludeMergeRevisions') + + maxcommitemails = config.get('maxcommitemails') + if maxcommitemails is not None: + try: + self.maxcommitemails = int(maxcommitemails) + except ValueError: + self.log_warning( + '*** Malformed value for multimailhook.maxCommitEmails: %s\n' + % maxcommitemails + + '*** Expected a number. Ignoring.\n' + ) + + diffopts = config.get('diffopts') + if diffopts is not None: + self.diffopts = shlex.split(diffopts) + + graphopts = config.get('graphOpts') + if graphopts is not None: + self.graphopts = shlex.split(graphopts) + + logopts = config.get('logopts') + if logopts is not None: + self.logopts = shlex.split(logopts) + + commitlogopts = config.get('commitlogopts') + if commitlogopts is not None: + self.commitlogopts = shlex.split(commitlogopts) + + date_substitute = config.get('dateSubstitute') + if date_substitute == 'none': + self.date_substitute = None + elif date_substitute is not None: + self.date_substitute = date_substitute + + reply_to = config.get('replyTo') + self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to) + self.forbid_field_values('replyToRefchange', + self.__reply_to_refchange, + ['author']) + self.__reply_to_commit = config.get('replyToCommit', default=reply_to) + + self.from_refchange = config.get('fromRefchange') + self.forbid_field_values('fromRefchange', + self.from_refchange, + ['author', 'none']) + self.from_commit = config.get('fromCommit') + self.forbid_field_values('fromCommit', + self.from_commit, + ['none']) + + combine = config.get_bool('combineWhenSingleCommit') + if combine is not None: + self.combine_when_single_commit = combine + + self.log_file = config.get('logFile', default=None) + self.error_log_file = config.get('errorLogFile', default=None) + self.debug_log_file = config.get('debugLogFile', default=None) + if config.get_bool('Verbose', default=False): + self.verbose = 1 + else: + self.verbose = 0 + + def get_administrator(self): + return ( + self.config.get('administrator') or + self.get_sender() or + super(ConfigOptionsEnvironmentMixin, self).get_administrator() + ) + + def get_repo_shortname(self): + return ( + self.config.get('reponame') or + super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname() + ) + + def get_emailprefix(self): + emailprefix = self.config.get('emailprefix') + if emailprefix is not None: + emailprefix = emailprefix.strip() + if emailprefix: + emailprefix += ' ' + else: + emailprefix = '[%(repo_shortname)s] ' + short_name = self.get_repo_shortname() + try: + return emailprefix % {'repo_shortname': short_name} + except: + self.get_logger().error( + '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix + + '*** %s\n' % sys.exc_info()[1] + + "*** Only the '%(repo_shortname)s' placeholder is allowed\n" + ) + raise ConfigurationException( + '"%s" is not an allowed setting for emailPrefix' % emailprefix + ) + + def get_sender(self): + return self.config.get('envelopesender') + + def process_addr(self, addr, change): + if addr.lower() == 'author': + if hasattr(change, 'author'): + return change.author + else: + return None + elif addr.lower() == 'pusher': + return self.get_pusher_email() + elif addr.lower() == 'none': + return None + else: + return addr + + def get_fromaddr(self, change=None): + fromaddr = self.config.get('from') + if change: + specific_fromaddr = change.get_specific_fromaddr() + if specific_fromaddr: + fromaddr = specific_fromaddr + if fromaddr: + fromaddr = self.process_addr(fromaddr, change) + if fromaddr: + return fromaddr + return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change) + + def get_reply_to_refchange(self, refchange): + if self.__reply_to_refchange is None: + return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange) + else: + return self.process_addr(self.__reply_to_refchange, refchange) + + def get_reply_to_commit(self, revision): + if self.__reply_to_commit is None: + return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision) + else: + return self.process_addr(self.__reply_to_commit, revision) + + def get_scancommitforcc(self): + return self.config.get('scancommitforcc') + + +class FilterLinesEnvironmentMixin(Environment): + """Handle encoding and maximum line length of body lines. + + email_max_line_length (int or None) + + The maximum length of any single line in the email body. + Longer lines are truncated at that length with ' [...]' + appended. + + strict_utf8 (bool) + + If this field is set to True, then the email body text is + expected to be UTF-8. Any invalid characters are + converted to U+FFFD, the Unicode replacement character + (encoded as UTF-8, of course). + + """ + + def __init__(self, strict_utf8=True, + email_max_line_length=500, max_subject_length=500, + **kw): + super(FilterLinesEnvironmentMixin, self).__init__(**kw) + self.__strict_utf8 = strict_utf8 + self.__email_max_line_length = email_max_line_length + self.__max_subject_length = max_subject_length + + def filter_body(self, lines): + lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines) + if self.__strict_utf8: + if not PYTHON3: + lines = (line.decode(ENCODING, 'replace') for line in lines) + # Limit the line length in Unicode-space to avoid + # splitting characters: + if self.__email_max_line_length > 0: + lines = limit_linelength(lines, self.__email_max_line_length) + if not PYTHON3: + lines = (line.encode(ENCODING, 'replace') for line in lines) + elif self.__email_max_line_length: + lines = limit_linelength(lines, self.__email_max_line_length) + + return lines + + def get_max_subject_length(self): + return self.__max_subject_length + + +class ConfigFilterLinesEnvironmentMixin( + ConfigEnvironmentMixin, + FilterLinesEnvironmentMixin, + ): + """Handle encoding and maximum line length based on config.""" + + def __init__(self, config, **kw): + strict_utf8 = config.get_bool('emailstrictutf8', default=None) + if strict_utf8 is not None: + kw['strict_utf8'] = strict_utf8 + + email_max_line_length = config.get('emailmaxlinelength') + if email_max_line_length is not None: + kw['email_max_line_length'] = int(email_max_line_length) + + max_subject_length = config.get('subjectMaxLength', default=email_max_line_length) + if max_subject_length is not None: + kw['max_subject_length'] = int(max_subject_length) + + super(ConfigFilterLinesEnvironmentMixin, self).__init__( + config=config, **kw + ) + + +class MaxlinesEnvironmentMixin(Environment): + """Limit the email body to a specified number of lines.""" + + def __init__(self, emailmaxlines, **kw): + super(MaxlinesEnvironmentMixin, self).__init__(**kw) + self.__emailmaxlines = emailmaxlines + + def filter_body(self, lines): + lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines) + if self.__emailmaxlines > 0: + lines = limit_lines(lines, self.__emailmaxlines) + return lines + + +class ConfigMaxlinesEnvironmentMixin( + ConfigEnvironmentMixin, + MaxlinesEnvironmentMixin, + ): + """Limit the email body to the number of lines specified in config.""" + + def __init__(self, config, **kw): + emailmaxlines = int(config.get('emailmaxlines', default='0')) + super(ConfigMaxlinesEnvironmentMixin, self).__init__( + config=config, + emailmaxlines=emailmaxlines, + **kw + ) + + +class FQDNEnvironmentMixin(Environment): + """A mixin that sets the host's FQDN to its constructor argument.""" + + def __init__(self, fqdn, **kw): + super(FQDNEnvironmentMixin, self).__init__(**kw) + self.COMPUTED_KEYS += ['fqdn'] + self.__fqdn = fqdn + + def get_fqdn(self): + """Return the fully-qualified domain name for this host. + + Return None if it is unavailable or unwanted.""" + + return self.__fqdn + + +class ConfigFQDNEnvironmentMixin( + ConfigEnvironmentMixin, + FQDNEnvironmentMixin, + ): + """Read the FQDN from the config.""" + + def __init__(self, config, **kw): + fqdn = config.get('fqdn') + super(ConfigFQDNEnvironmentMixin, self).__init__( + config=config, + fqdn=fqdn, + **kw + ) + + +class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin): + """Get the FQDN by calling socket.getfqdn().""" + + def __init__(self, **kw): + super(ComputeFQDNEnvironmentMixin, self).__init__( + fqdn=socket.getfqdn(), + **kw + ) + + +class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin): + """Deduce pusher_email from pusher by appending an emaildomain.""" + + def __init__(self, **kw): + super(PusherDomainEnvironmentMixin, self).__init__(**kw) + self.__emaildomain = self.config.get('emaildomain') + + def get_pusher_email(self): + if self.__emaildomain: + # Derive the pusher's full email address in the default way: + return '%s@%s' % (self.get_pusher(), self.__emaildomain) + else: + return super(PusherDomainEnvironmentMixin, self).get_pusher_email() + + +class StaticRecipientsEnvironmentMixin(Environment): + """Set recipients statically based on constructor parameters.""" + + def __init__( + self, + refchange_recipients, announce_recipients, revision_recipients, scancommitforcc, + **kw + ): + super(StaticRecipientsEnvironmentMixin, self).__init__(**kw) + + # The recipients for various types of notification emails, as + # RFC 2822 email addresses separated by commas (or the empty + # string if no recipients are configured). Although there is + # a mechanism to choose the recipient lists based on on the + # actual *contents* of the change being reported, we only + # choose based on the *type* of the change. Therefore we can + # compute them once and for all: + self.__refchange_recipients = refchange_recipients + self.__announce_recipients = announce_recipients + self.__revision_recipients = revision_recipients + + def check(self): + if not (self.get_refchange_recipients(None) or + self.get_announce_recipients(None) or + self.get_revision_recipients(None) or + self.get_scancommitforcc()): + raise ConfigurationException('No email recipients configured!') + super(StaticRecipientsEnvironmentMixin, self).check() + + def get_refchange_recipients(self, refchange): + if self.__refchange_recipients is None: + return super(StaticRecipientsEnvironmentMixin, + self).get_refchange_recipients(refchange) + return self.__refchange_recipients + + def get_announce_recipients(self, annotated_tag_change): + if self.__announce_recipients is None: + return super(StaticRecipientsEnvironmentMixin, + self).get_refchange_recipients(annotated_tag_change) + return self.__announce_recipients + + def get_revision_recipients(self, revision): + if self.__revision_recipients is None: + return super(StaticRecipientsEnvironmentMixin, + self).get_refchange_recipients(revision) + return self.__revision_recipients + + +class CLIRecipientsEnvironmentMixin(Environment): + """Mixin storing recipients information coming from the + command-line.""" + + def __init__(self, cli_recipients=None, **kw): + super(CLIRecipientsEnvironmentMixin, self).__init__(**kw) + self.__cli_recipients = cli_recipients + + def get_refchange_recipients(self, refchange): + if self.__cli_recipients is None: + return super(CLIRecipientsEnvironmentMixin, + self).get_refchange_recipients(refchange) + return self.__cli_recipients + + def get_announce_recipients(self, annotated_tag_change): + if self.__cli_recipients is None: + return super(CLIRecipientsEnvironmentMixin, + self).get_announce_recipients(annotated_tag_change) + return self.__cli_recipients + + def get_revision_recipients(self, revision): + if self.__cli_recipients is None: + return super(CLIRecipientsEnvironmentMixin, + self).get_revision_recipients(revision) + return self.__cli_recipients + + +class ConfigRecipientsEnvironmentMixin( + ConfigEnvironmentMixin, + StaticRecipientsEnvironmentMixin + ): + """Determine recipients statically based on config.""" + + def __init__(self, config, **kw): + super(ConfigRecipientsEnvironmentMixin, self).__init__( + config=config, + refchange_recipients=self._get_recipients( + config, 'refchangelist', 'mailinglist', + ), + announce_recipients=self._get_recipients( + config, 'announcelist', 'refchangelist', 'mailinglist', + ), + revision_recipients=self._get_recipients( + config, 'commitlist', 'mailinglist', + ), + scancommitforcc=config.get('scancommitforcc'), + **kw + ) + + def _get_recipients(self, config, *names): + """Return the recipients for a particular type of message. + + Return the list of email addresses to which a particular type + of notification email should be sent, by looking at the config + value for "multimailhook.$name" for each of names. Use the + value from the first name that is configured. The return + value is a (possibly empty) string containing RFC 2822 email + addresses separated by commas. If no configuration could be + found, raise a ConfigurationException.""" + + for name in names: + lines = config.get_all(name) + if lines is not None: + lines = [line.strip() for line in lines] + # Single "none" is a special value equivalen to empty string. + if lines == ['none']: + lines = [''] + return ', '.join(lines) + else: + return '' + + +class StaticRefFilterEnvironmentMixin(Environment): + """Set branch filter statically based on constructor parameters.""" + + def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex, + ref_filter_do_send_regex, ref_filter_dont_send_regex, + **kw): + super(StaticRefFilterEnvironmentMixin, self).__init__(**kw) + + if ref_filter_incl_regex and ref_filter_excl_regex: + raise ConfigurationException( + "Cannot specify both a ref inclusion and exclusion regex.") + self.__is_inclusion_filter = bool(ref_filter_incl_regex) + default_exclude = self.get_default_ref_ignore_regex() + if ref_filter_incl_regex: + ref_filter_regex = ref_filter_incl_regex + elif ref_filter_excl_regex: + ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude + else: + ref_filter_regex = default_exclude + try: + self.__compiled_regex = re.compile(ref_filter_regex) + except Exception: + raise ConfigurationException( + 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1])) + + if ref_filter_do_send_regex and ref_filter_dont_send_regex: + raise ConfigurationException( + "Cannot specify both a ref doSend and dontSend regex.") + self.__is_do_send_filter = bool(ref_filter_do_send_regex) + if ref_filter_do_send_regex: + ref_filter_send_regex = ref_filter_do_send_regex + elif ref_filter_dont_send_regex: + ref_filter_send_regex = ref_filter_dont_send_regex + else: + ref_filter_send_regex = '.*' + self.__is_do_send_filter = True + try: + self.__send_compiled_regex = re.compile(ref_filter_send_regex) + except Exception: + raise ConfigurationException( + 'Invalid Ref Filter Regex "%s": %s' % + (ref_filter_send_regex, sys.exc_info()[1])) + + def get_ref_filter_regex(self, send_filter=False): + if send_filter: + return self.__send_compiled_regex, self.__is_do_send_filter + else: + return self.__compiled_regex, self.__is_inclusion_filter + + +class ConfigRefFilterEnvironmentMixin( + ConfigEnvironmentMixin, + StaticRefFilterEnvironmentMixin + ): + """Determine branch filtering statically based on config.""" + + def _get_regex(self, config, key): + """Get a list of whitespace-separated regex. The refFilter* config + variables are multivalued (hence the use of get_all), and we + allow each entry to be a whitespace-separated list (hence the + split on each line). The whole thing is glued into a single regex.""" + values = config.get_all(key) + if values is None: + return values + items = [] + for line in values: + for i in line.split(): + items.append(i) + if items == []: + return None + return '|'.join(items) + + def __init__(self, config, **kw): + super(ConfigRefFilterEnvironmentMixin, self).__init__( + config=config, + ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'), + ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'), + ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'), + ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'), + **kw + ) + + +class ProjectdescEnvironmentMixin(Environment): + """Make a "projectdesc" value available for templates. + + By default, it is set to the first line of $GIT_DIR/description + (if that file is present and appears to be set meaningfully).""" + + def __init__(self, **kw): + super(ProjectdescEnvironmentMixin, self).__init__(**kw) + self.COMPUTED_KEYS += ['projectdesc'] + + def get_projectdesc(self): + """Return a one-line descripition of the project.""" + + git_dir = get_git_dir() + try: + projectdesc = open(os.path.join(git_dir, 'description')).readline().strip() + if projectdesc and not projectdesc.startswith('Unnamed repository'): + return projectdesc + except IOError: + pass + + return 'UNNAMED PROJECT' + + +class GenericEnvironmentMixin(Environment): + def get_pusher(self): + return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user')) + + +class GitoliteEnvironmentHighPrecMixin(Environment): + def get_pusher(self): + return self.osenv.get('GL_USER', 'unknown user') + + +class GitoliteEnvironmentLowPrecMixin( + ConfigEnvironmentMixin, + Environment): + + def get_repo_shortname(self): + # The gitolite environment variable $GL_REPO is a pretty good + # repo_shortname (though it's probably not as good as a value + # the user might have explicitly put in his config). + return ( + self.osenv.get('GL_REPO', None) or + super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname() + ) + + @staticmethod + def _compile_regex(re_template): + return ( + re.compile(re_template % x) + for x in ( + r'BEGIN\s+USER\s+EMAILS', + r'([^\s]+)\s+(.*)', + r'END\s+USER\s+EMAILS', + )) + + def get_fromaddr(self, change=None): + GL_USER = self.osenv.get('GL_USER') + if GL_USER is not None: + # Find the path to gitolite.conf. Note that gitolite v3 + # did away with the GL_ADMINDIR and GL_CONF environment + # variables (they are now hard-coded). + GL_ADMINDIR = self.osenv.get( + 'GL_ADMINDIR', + os.path.expanduser(os.path.join('~', '.gitolite'))) + GL_CONF = self.osenv.get( + 'GL_CONF', + os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf')) + + mailaddress_map = self.config.get('MailaddressMap') + # If relative, consider relative to GL_CONF: + if mailaddress_map: + mailaddress_map = os.path.join(os.path.dirname(GL_CONF), + mailaddress_map) + if os.path.isfile(mailaddress_map): + f = open(mailaddress_map, 'rU') + try: + # Leading '#' is optional + re_begin, re_user, re_end = self._compile_regex( + r'^(?:\s*#)?\s*%s\s*$') + for l in f: + l = l.rstrip('\n') + if re_begin.match(l) or re_end.match(l): + continue # Ignore these lines + m = re_user.match(l) + if m: + if m.group(1) == GL_USER: + return m.group(2) + else: + continue # Not this user, but not an error + raise ConfigurationException( + "Syntax error in mail address map.\n" + "Check file {}.\n" + "Line: {}".format(mailaddress_map, l)) + + finally: + f.close() + + if os.path.isfile(GL_CONF): + f = open(GL_CONF, 'rU') + try: + in_user_emails_section = False + re_begin, re_user, re_end = self._compile_regex( + r'^\s*#\s*%s\s*$') + for l in f: + l = l.rstrip('\n') + if not in_user_emails_section: + if re_begin.match(l): + in_user_emails_section = True + continue + if re_end.match(l): + break + m = re_user.match(l) + if m and m.group(1) == GL_USER: + return m.group(2) + finally: + f.close() + return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change) + + +class IncrementalDateTime(object): + """Simple wrapper to give incremental date/times. + + Each call will result in a date/time a second later than the + previous call. This can be used to falsify email headers, to + increase the likelihood that email clients sort the emails + correctly.""" + + def __init__(self): + self.time = time.time() + self.next = self.__next__ # Python 2 backward compatibility + + def __next__(self): + formatted = formatdate(self.time, True) + self.time += 1 + return formatted + + +class StashEnvironmentHighPrecMixin(Environment): + def __init__(self, user=None, repo=None, **kw): + super(StashEnvironmentHighPrecMixin, + self).__init__(user=user, repo=repo, **kw) + self.__user = user + self.__repo = repo + + def get_pusher(self): + return re.match(r'(.*?)\s*<', self.__user).group(1) + + def get_pusher_email(self): + return self.__user + + +class StashEnvironmentLowPrecMixin(Environment): + def __init__(self, user=None, repo=None, **kw): + super(StashEnvironmentLowPrecMixin, self).__init__(**kw) + self.__repo = repo + self.__user = user + + def get_repo_shortname(self): + return self.__repo + + def get_fromaddr(self, change=None): + return self.__user + + +class GerritEnvironmentHighPrecMixin(Environment): + def __init__(self, project=None, submitter=None, update_method=None, **kw): + super(GerritEnvironmentHighPrecMixin, + self).__init__(submitter=submitter, project=project, **kw) + self.__project = project + self.__submitter = submitter + self.__update_method = update_method + "Make an 'update_method' value available for templates." + self.COMPUTED_KEYS += ['update_method'] + + def get_pusher(self): + if self.__submitter: + if self.__submitter.find('<') != -1: + # Submitter has a configured email, we transformed + # __submitter into an RFC 2822 string already. + return re.match(r'(.*?)\s*<', self.__submitter).group(1) + else: + # Submitter has no configured email, it's just his name. + return self.__submitter + else: + # If we arrive here, this means someone pushed "Submit" from + # the gerrit web UI for the CR (or used one of the programmatic + # APIs to do the same, such as gerrit review) and the + # merge/push was done by the Gerrit user. It was technically + # triggered by someone else, but sadly we have no way of + # determining who that someone else is at this point. + return 'Gerrit' # 'unknown user'? + + def get_pusher_email(self): + if self.__submitter: + return self.__submitter + else: + return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email() + + def get_default_ref_ignore_regex(self): + default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex() + return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/' + + def get_revision_recipients(self, revision): + # Merge commits created by Gerrit when users hit "Submit this patchset" + # in the Web UI (or do equivalently with REST APIs or the gerrit review + # command) are not something users want to see an individual email for. + # Filter them out. + committer = read_git_output(['log', '--no-walk', '--format=%cN', + revision.rev.sha1]) + if committer == 'Gerrit Code Review': + return [] + else: + return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision) + + def get_update_method(self): + return self.__update_method + + +class GerritEnvironmentLowPrecMixin(Environment): + def __init__(self, project=None, submitter=None, **kw): + super(GerritEnvironmentLowPrecMixin, self).__init__(**kw) + self.__project = project + self.__submitter = submitter + + def get_repo_shortname(self): + return self.__project + + def get_fromaddr(self, change=None): + if self.__submitter and self.__submitter.find('<') != -1: + return self.__submitter + else: + return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change) + + +class Push(object): + """Represent an entire push (i.e., a group of ReferenceChanges). + + It is easy to figure out what commits were added to a *branch* by + a Reference change: + + git rev-list change.old..change.new + + or removed from a *branch*: + + git rev-list change.new..change.old + + But it is not quite so trivial to determine which entirely new + commits were added to the *repository* by a push and which old + commits were discarded by a push. A big part of the job of this + class is to figure out these things, and to make sure that new + commits are only detailed once even if they were added to multiple + references. + + The first step is to determine the "other" references--those + unaffected by the current push. They are computed by listing all + references then removing any affected by this push. The results + are stored in Push._other_ref_sha1s. + + The commits contained in the repository before this push were + + git rev-list other1 other2 other3 ... change1.old change2.old ... + + Where "changeN.old" is the old value of one of the references + affected by this push. + + The commits contained in the repository after this push are + + git rev-list other1 other2 other3 ... change1.new change2.new ... + + The commits added by this push are the difference between these + two sets, which can be written + + git rev-list \ + ^other1 ^other2 ... \ + ^change1.old ^change2.old ... \ + change1.new change2.new ... + + The commits removed by this push can be computed by + + git rev-list \ + ^other1 ^other2 ... \ + ^change1.new ^change2.new ... \ + change1.old change2.old ... + + The last point is that it is possible that other pushes are + occurring simultaneously to this one, so reference values can + change at any time. It is impossible to eliminate all race + conditions, but we reduce the window of time during which problems + can occur by translating reference names to SHA1s as soon as + possible and working with SHA1s thereafter (because SHA1s are + immutable).""" + + # A map {(changeclass, changetype): integer} specifying the order + # that reference changes will be processed if multiple reference + # changes are included in a single push. The order is significant + # mostly because new commit notifications are threaded together + # with the first reference change that includes the commit. The + # following order thus causes commits to be grouped with branch + # changes (as opposed to tag changes) if possible. + SORT_ORDER = dict( + (value, i) for (i, value) in enumerate([ + (BranchChange, 'update'), + (BranchChange, 'create'), + (AnnotatedTagChange, 'update'), + (AnnotatedTagChange, 'create'), + (NonAnnotatedTagChange, 'update'), + (NonAnnotatedTagChange, 'create'), + (BranchChange, 'delete'), + (AnnotatedTagChange, 'delete'), + (NonAnnotatedTagChange, 'delete'), + (OtherReferenceChange, 'update'), + (OtherReferenceChange, 'create'), + (OtherReferenceChange, 'delete'), + ]) + ) + + def __init__(self, environment, changes, ignore_other_refs=False): + self.changes = sorted(changes, key=self._sort_key) + self.__other_ref_sha1s = None + self.__cached_commits_spec = {} + self.environment = environment + + if ignore_other_refs: + self.__other_ref_sha1s = set() + + @classmethod + def _sort_key(klass, change): + return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,) + + @property + def _other_ref_sha1s(self): + """The GitObjects referred to by references unaffected by this push. + """ + if self.__other_ref_sha1s is None: + # The refnames being changed by this push: + updated_refs = set( + change.refname + for change in self.changes + ) + + # The SHA-1s of commits referred to by all references in this + # repository *except* updated_refs: + sha1s = set() + fmt = ( + '%(objectname) %(objecttype) %(refname)\n' + '%(*objectname) %(*objecttype) %(refname)' + ) + ref_filter_regex, is_inclusion_filter = \ + self.environment.get_ref_filter_regex() + for line in read_git_lines( + ['for-each-ref', '--format=%s' % (fmt,)]): + (sha1, type, name) = line.split(' ', 2) + if (sha1 and type == 'commit' and + name not in updated_refs and + include_ref(name, ref_filter_regex, is_inclusion_filter)): + sha1s.add(sha1) + + self.__other_ref_sha1s = sha1s + + return self.__other_ref_sha1s + + def _get_commits_spec_incl(self, new_or_old, reference_change=None): + """Get new or old SHA-1 from one or each of the changed refs. + + Return a list of SHA-1 commit identifier strings suitable as + arguments to 'git rev-list' (or 'git log' or ...). The + returned identifiers are either the old or new values from one + or all of the changed references, depending on the values of + new_or_old and reference_change. + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned SHA-1 identifiers are the new values from + each changed reference. If 'old', the SHA-1 identifiers are + the old values from each changed reference. + + If reference_change is specified and not None, only the new or + old reference from the specified reference is included in the + return value. + + This function returns None if there are no matching revisions + (e.g., because a branch was deleted and new_or_old is 'new'). + """ + + if not reference_change: + incl_spec = sorted( + getattr(change, new_or_old).sha1 + for change in self.changes + if getattr(change, new_or_old) + ) + if not incl_spec: + incl_spec = None + elif not getattr(reference_change, new_or_old).commit_sha1: + incl_spec = None + else: + incl_spec = [getattr(reference_change, new_or_old).commit_sha1] + return incl_spec + + def _get_commits_spec_excl(self, new_or_old): + """Get exclusion revisions for determining new or discarded commits. + + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that will exclude all + commits that, depending on the value of new_or_old, were + either previously in the repository (useful for determining + which commits are new to the repository) or currently in the + repository (useful for determining which commits were + discarded from the repository). + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the commits to be excluded are those that were in the + repository before the push. If 'old', the commits to be + excluded are those that are currently in the repository. """ + + old_or_new = {'old': 'new', 'new': 'old'}[new_or_old] + excl_revs = self._other_ref_sha1s.union( + getattr(change, old_or_new).sha1 + for change in self.changes + if getattr(change, old_or_new).type in ['commit', 'tag'] + ) + return ['^' + sha1 for sha1 in sorted(excl_revs)] + + def get_commits_spec(self, new_or_old, reference_change=None): + """Get rev-list arguments for added or discarded commits. + + Return a list of strings suitable as arguments to 'git + rev-list' (or 'git log' or ...) that select those commits + that, depending on the value of new_or_old, are either new to + the repository or were discarded from the repository. + + new_or_old is either the string 'new' or the string 'old'. If + 'new', the returned list is used to select commits that are + new to the repository. If 'old', the returned value is used + to select the commits that have been discarded from the + repository. + + If reference_change is specified and not None, the new or + discarded commits are limited to those that are reachable from + the new or old value of the specified reference. + + This function returns None if there are no added (or discarded) + revisions. + """ + key = (new_or_old, reference_change) + if key not in self.__cached_commits_spec: + ret = self._get_commits_spec_incl(new_or_old, reference_change) + if ret is not None: + ret.extend(self._get_commits_spec_excl(new_or_old)) + self.__cached_commits_spec[key] = ret + return self.__cached_commits_spec[key] + + def get_new_commits(self, reference_change=None): + """Return a list of commits added by this push. + + Return a list of the object names of commits that were added + by the part of this push represented by reference_change. If + reference_change is None, then return a list of *all* commits + added by this push.""" + + spec = self.get_commits_spec('new', reference_change) + return git_rev_list(spec) + + def get_discarded_commits(self, reference_change): + """Return a list of commits discarded by this push. + + Return a list of the object names of commits that were + entirely discarded from the repository by the part of this + push represented by reference_change.""" + + spec = self.get_commits_spec('old', reference_change) + return git_rev_list(spec) + + def send_emails(self, mailer, body_filter=None): + """Use send all of the notification emails needed for this push. + + Use send all of the notification emails (including reference + change emails and commit emails) needed for this push. Send + the emails using mailer. If body_filter is not None, then use + it to filter the lines that are intended for the email + body.""" + + # The sha1s of commits that were introduced by this push. + # They will be removed from this set as they are processed, to + # guarantee that one (and only one) email is generated for + # each new commit. + unhandled_sha1s = set(self.get_new_commits()) + send_date = IncrementalDateTime() + for change in self.changes: + sha1s = [] + for sha1 in reversed(list(self.get_new_commits(change))): + if sha1 in unhandled_sha1s: + sha1s.append(sha1) + unhandled_sha1s.remove(sha1) + + # Check if we've got anyone to send to + if not change.recipients: + change.environment.log_warning( + '*** no recipients configured so no email will be sent\n' + '*** for %r update %s->%s' + % (change.refname, change.old.sha1, change.new.sha1,) + ) + else: + if not change.environment.quiet: + change.environment.log_msg( + 'Sending notification emails to: %s' % (change.recipients,)) + extra_values = {'send_date': next(send_date)} + + rev = change.send_single_combined_email(sha1s) + if rev: + mailer.send( + change.generate_combined_email(self, rev, body_filter, extra_values), + rev.recipients, + ) + # This change is now fully handled; no need to handle + # individual revisions any further. + continue + else: + mailer.send( + change.generate_email(self, body_filter, extra_values), + change.recipients, + ) + + max_emails = change.environment.maxcommitemails + if max_emails and len(sha1s) > max_emails: + change.environment.log_warning( + '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) + + '*** Try setting multimailhook.maxCommitEmails to a greater value\n' + + '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails + ) + return + + for (num, sha1) in enumerate(sha1s): + rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s)) + if len(rev.parents) > 1 and change.environment.excludemergerevisions: + # skipping a merge commit + continue + if not rev.recipients and rev.cc_recipients: + change.environment.log_msg('*** Replacing Cc: with To:') + rev.recipients = rev.cc_recipients + rev.cc_recipients = None + if rev.recipients: + extra_values = {'send_date': next(send_date)} + mailer.send( + rev.generate_email(self, body_filter, extra_values), + rev.recipients, + ) + + # Consistency check: + if unhandled_sha1s: + change.environment.log_error( + 'ERROR: No emails were sent for the following new commits:\n' + ' %s' + % ('\n '.join(sorted(unhandled_sha1s)),) + ) + + +def include_ref(refname, ref_filter_regex, is_inclusion_filter): + does_match = bool(ref_filter_regex.search(refname)) + if is_inclusion_filter: + return does_match + else: # exclusion filter -- we include the ref if the regex doesn't match + return not does_match + + +def run_as_post_receive_hook(environment, mailer): + environment.check() + send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) + ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) + changes = [] + while True: + line = read_line(sys.stdin) + if line == '': + break + (oldrev, newrev, refname) = line.strip().split(' ', 2) + environment.get_logger().debug( + "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" % + (oldrev, newrev, refname)) + + if not include_ref(refname, ref_filter_regex, is_inclusion_filter): + continue + if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): + continue + changes.append( + ReferenceChange.create(environment, oldrev, newrev, refname) + ) + if not changes: + mailer.close() + return + push = Push(environment, changes) + try: + push.send_emails(mailer, body_filter=environment.filter_body) + finally: + mailer.close() + + +def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False): + environment.check() + send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True) + ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False) + if not include_ref(refname, ref_filter_regex, is_inclusion_filter): + return + if not include_ref(refname, send_filter_regex, send_is_inclusion_filter): + return + changes = [ + ReferenceChange.create( + environment, + read_git_output(['rev-parse', '--verify', oldrev]), + read_git_output(['rev-parse', '--verify', newrev]), + refname, + ), + ] + if not changes: + mailer.close() + return + push = Push(environment, changes, force_send) + try: + push.send_emails(mailer, body_filter=environment.filter_body) + finally: + mailer.close() + + +def check_ref_filter(environment): + send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True) + ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False) + + def inc_exc_lusion(b): + if b: + return 'inclusion' + else: + return 'exclusion' + + if send_filter_regex: + sys.stdout.write("DoSend/DontSend filter regex (" + + (inc_exc_lusion(send_is_inclusion)) + + '): ' + send_filter_regex.pattern + + '\n') + if send_filter_regex: + sys.stdout.write("Include/Exclude filter regex (" + + (inc_exc_lusion(ref_is_inclusion)) + + '): ' + ref_filter_regex.pattern + + '\n') + sys.stdout.write(os.linesep) + + sys.stdout.write( + "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n" + "or refFilterExclusionRegex. No emails will be sent for commits included\n" + "in these refs.\n" + "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n" + "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n" + "refFilterExclusionRegex. Emails will be sent for commits included in these\n" + "refs only when the commit reaches a ref which isn't excluded.\n" + "Refs marked as DO-SEND are not excluded by any filter. Emails will\n" + "be sent normally for commits included in these refs.\n") + + sys.stdout.write(os.linesep) + + for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']): + sys.stdout.write(refname) + if not include_ref(refname, ref_filter_regex, ref_is_inclusion): + sys.stdout.write(' EXCLUDE') + elif not include_ref(refname, send_filter_regex, send_is_inclusion): + sys.stdout.write(' DONT-SEND') + else: + sys.stdout.write(' DO-SEND') + + sys.stdout.write(os.linesep) + + +def show_env(environment, out): + out.write('Environment values:\n') + for (k, v) in sorted(environment.get_values().items()): + if k: # Don't show the {'' : ''} pair. + out.write(' %s : %r\n' % (k, v)) + out.write('\n') + # Flush to avoid interleaving with further log output + out.flush() + + +def check_setup(environment): + environment.check() + show_env(environment, sys.stdout) + sys.stdout.write("Now, checking that git-multimail's standard input " + "is properly set ..." + os.linesep) + sys.stdout.write("Please type some text and then press Return" + os.linesep) + stdin = sys.stdin.readline() + sys.stdout.write("You have just entered:" + os.linesep) + sys.stdout.write(stdin) + sys.stdout.write("git-multimail seems properly set up." + os.linesep) + + +def choose_mailer(config, environment): + mailer = config.get('mailer', default='sendmail') + + if mailer == 'smtp': + smtpserver = config.get('smtpserver', default='localhost') + smtpservertimeout = float(config.get('smtpservertimeout', default=10.0)) + smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0)) + smtpencryption = config.get('smtpencryption', default='none') + smtpuser = config.get('smtpuser', default='') + smtppass = config.get('smtppass', default='') + smtpcacerts = config.get('smtpcacerts', default='') + mailer = SMTPMailer( + environment, + envelopesender=(environment.get_sender() or environment.get_fromaddr()), + smtpserver=smtpserver, smtpservertimeout=smtpservertimeout, + smtpserverdebuglevel=smtpserverdebuglevel, + smtpencryption=smtpencryption, + smtpuser=smtpuser, + smtppass=smtppass, + smtpcacerts=smtpcacerts + ) + elif mailer == 'sendmail': + command = config.get('sendmailcommand') + if command: + command = shlex.split(command) + mailer = SendMailer(environment, + command=command, envelopesender=environment.get_sender()) + else: + environment.log_error( + 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer + + 'please use one of "smtp" or "sendmail".' + ) + sys.exit(1) + return mailer + + +KNOWN_ENVIRONMENTS = { + 'generic': {'highprec': GenericEnvironmentMixin}, + 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin, + 'lowprec': GitoliteEnvironmentLowPrecMixin}, + 'stash': {'highprec': StashEnvironmentHighPrecMixin, + 'lowprec': StashEnvironmentLowPrecMixin}, + 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin, + 'lowprec': GerritEnvironmentLowPrecMixin}, + } + + +def choose_environment(config, osenv=None, env=None, recipients=None, + hook_info=None): + env_name = choose_environment_name(config, env, osenv) + environment_klass = build_environment_klass(env_name) + env = build_environment(environment_klass, env_name, config, + osenv, recipients, hook_info) + return env + + +def choose_environment_name(config, env, osenv): + if not osenv: + osenv = os.environ + + if not env: + env = config.get('environment') + + if not env: + if 'GL_USER' in osenv and 'GL_REPO' in osenv: + env = 'gitolite' + else: + env = 'generic' + return env + + +COMMON_ENVIRONMENT_MIXINS = [ + ConfigRecipientsEnvironmentMixin, + CLIRecipientsEnvironmentMixin, + ConfigRefFilterEnvironmentMixin, + ProjectdescEnvironmentMixin, + ConfigMaxlinesEnvironmentMixin, + ComputeFQDNEnvironmentMixin, + ConfigFilterLinesEnvironmentMixin, + PusherDomainEnvironmentMixin, + ConfigOptionsEnvironmentMixin, + ] + + +def build_environment_klass(env_name): + if 'class' in KNOWN_ENVIRONMENTS[env_name]: + return KNOWN_ENVIRONMENTS[env_name]['class'] + + environment_mixins = [] + known_env = KNOWN_ENVIRONMENTS[env_name] + if 'highprec' in known_env: + high_prec_mixin = known_env['highprec'] + environment_mixins.append(high_prec_mixin) + environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS + if 'lowprec' in known_env: + low_prec_mixin = known_env['lowprec'] + environment_mixins.append(low_prec_mixin) + environment_mixins.append(Environment) + klass_name = env_name.capitalize() + 'Environment' + environment_klass = type( + klass_name, + tuple(environment_mixins), + {}, + ) + KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass + return environment_klass + + +GerritEnvironment = build_environment_klass('gerrit') +StashEnvironment = build_environment_klass('stash') +GitoliteEnvironment = build_environment_klass('gitolite') +GenericEnvironment = build_environment_klass('generic') + + +def build_environment(environment_klass, env, config, + osenv, recipients, hook_info): + environment_kw = { + 'osenv': osenv, + 'config': config, + } + + if env == 'stash': + environment_kw['user'] = hook_info['stash_user'] + environment_kw['repo'] = hook_info['stash_repo'] + elif env == 'gerrit': + environment_kw['project'] = hook_info['project'] + environment_kw['submitter'] = hook_info['submitter'] + environment_kw['update_method'] = hook_info['update_method'] + + environment_kw['cli_recipients'] = recipients + + return environment_klass(**environment_kw) + + +def get_version(): + oldcwd = os.getcwd() + try: + try: + os.chdir(os.path.dirname(os.path.realpath(__file__))) + git_version = read_git_output(['describe', '--tags', 'HEAD']) + if git_version == __version__: + return git_version + else: + return '%s (%s)' % (__version__, git_version) + except: + pass + finally: + os.chdir(oldcwd) + return __version__ + + +def compute_gerrit_options(options, args, required_gerrit_options, + raw_refname): + if None in required_gerrit_options: + raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, " + "and --project; or none of them.") + + if options.environment not in (None, 'gerrit'): + raise SystemExit("Non-gerrit environments incompatible with --oldrev, " + "--newrev, --refname, and --project") + options.environment = 'gerrit' + + if args: + raise SystemExit("Error: Positional parameters not allowed with " + "--oldrev, --newrev, and --refname.") + + # Gerrit oddly omits 'refs/heads/' in the refname when calling + # ref-updated hook; put it back. + git_dir = get_git_dir() + if (not os.path.exists(os.path.join(git_dir, raw_refname)) and + os.path.exists(os.path.join(git_dir, 'refs', 'heads', + raw_refname))): + options.refname = 'refs/heads/' + options.refname + + # New revisions can appear in a gerrit repository either due to someone + # pushing directly (in which case options.submitter will be set), or they + # can press "Submit this patchset" in the web UI for some CR (in which + # case options.submitter will not be set and gerrit will not have provided + # us the information about who pressed the button). + # + # Note for the nit-picky: I'm lumping in REST API calls and the ssh + # gerrit review command in with "Submit this patchset" button, since they + # have the same effect. + if options.submitter: + update_method = 'pushed' + # The submitter argument is almost an RFC 2822 email address; change it + # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is + options.submitter = options.submitter.replace('(', '<').replace(')', '>') + else: + update_method = 'submitted' + # Gerrit knew who submitted this patchset, but threw that information + # away when it invoked this hook. However, *IF* Gerrit created a + # merge to bring the patchset in (project 'Submit Type' is either + # "Always Merge", or is "Merge if Necessary" and happens to be + # necessary for this particular CR), then it will have the committer + # of that merge be 'Gerrit Code Review' and the author will be the + # person who requested the submission of the CR. Since this is fairly + # likely for most gerrit installations (of a reasonable size), it's + # worth the extra effort to try to determine the actual submitter. + rev_info = read_git_lines(['log', '--no-walk', '--merges', + '--format=%cN%n%aN <%aE>', options.newrev]) + if rev_info and rev_info[0] == 'Gerrit Code Review': + options.submitter = rev_info[1] + + # We pass back refname, oldrev, newrev as args because then the + # gerrit ref-updated hook is much like the git update hook + return (options, + [options.refname, options.oldrev, options.newrev], + {'project': options.project, 'submitter': options.submitter, + 'update_method': update_method}) + + +def check_hook_specific_args(options, args): + raw_refname = options.refname + # Convert each string option unicode for Python3. + if PYTHON3: + opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname', + 'project', 'submitter', 'stash_user', 'stash_repo'] + for opt in opts: + if not hasattr(options, opt): + continue + obj = getattr(options, opt) + if obj: + enc = obj.encode('utf-8', 'surrogateescape') + dec = enc.decode('utf-8', 'replace') + setattr(options, opt, dec) + + # First check for stash arguments + if (options.stash_user is None) != (options.stash_repo is None): + raise SystemExit("Error: Specify both of --stash-user and " + "--stash-repo or neither.") + if options.stash_user: + options.environment = 'stash' + return options, args, {'stash_user': options.stash_user, + 'stash_repo': options.stash_repo} + + # Finally, check for gerrit specific arguments + required_gerrit_options = (options.oldrev, options.newrev, options.refname, + options.project) + if required_gerrit_options != (None,) * 4: + return compute_gerrit_options(options, args, required_gerrit_options, + raw_refname) + + # No special options in use, just return what we started with + return options, args, {} + + +class Logger(object): + def parse_verbose(self, verbose): + if verbose > 0: + return logging.DEBUG + else: + return logging.INFO + + def create_log_file(self, environment, name, path, verbosity): + log_file = logging.getLogger(name) + file_handler = logging.FileHandler(path) + log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") + file_handler.setFormatter(log_fmt) + log_file.addHandler(file_handler) + log_file.setLevel(verbosity) + return log_file + + def __init__(self, environment): + self.environment = environment + self.loggers = [] + stderr_log = logging.getLogger('git_multimail.stderr') + + class EncodedStderr(object): + def write(self, x): + write_str(sys.stderr, x) + + def flush(self): + sys.stderr.flush() + + stderr_handler = logging.StreamHandler(EncodedStderr()) + stderr_log.addHandler(stderr_handler) + stderr_log.setLevel(self.parse_verbose(environment.verbose)) + self.loggers.append(stderr_log) + + if environment.debug_log_file is not None: + debug_log_file = self.create_log_file( + environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG) + self.loggers.append(debug_log_file) + + if environment.log_file is not None: + log_file = self.create_log_file( + environment, 'git_multimail.file', environment.log_file, logging.INFO) + self.loggers.append(log_file) + + if environment.error_log_file is not None: + error_log_file = self.create_log_file( + environment, 'git_multimail.error', environment.error_log_file, logging.ERROR) + self.loggers.append(error_log_file) + + def info(self, msg, *args, **kwargs): + for l in self.loggers: + l.info(msg, *args, **kwargs) + + def debug(self, msg, *args, **kwargs): + for l in self.loggers: + l.debug(msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + for l in self.loggers: + l.warning(msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + for l in self.loggers: + l.error(msg, *args, **kwargs) + + +def main(args): + parser = optparse.OptionParser( + description=__doc__, + usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV', + ) + + parser.add_option( + '--environment', '--env', action='store', type='choice', + choices=list(KNOWN_ENVIRONMENTS.keys()), default=None, + help=( + 'Choose type of environment is in use. Default is taken from ' + 'multimailhook.environment if set; otherwise "generic".' + ), + ) + parser.add_option( + '--stdout', action='store_true', default=False, + help='Output emails to stdout rather than sending them.', + ) + parser.add_option( + '--recipients', action='store', default=None, + help='Set list of email recipients for all types of emails.', + ) + parser.add_option( + '--show-env', action='store_true', default=False, + help=( + 'Write to stderr the values determined for the environment ' + '(intended for debugging purposes), then proceed normally.' + ), + ) + parser.add_option( + '--force-send', action='store_true', default=False, + help=( + 'Force sending refchange email when using as an update hook. ' + 'This is useful to work around the unreliable new commits ' + 'detection in this mode.' + ), + ) + parser.add_option( + '-c', metavar="<name>=<value>", action='append', + help=( + 'Pass a configuration parameter through to git. The value given ' + 'will override values from configuration files. See the -c option ' + 'of git(1) for more details. (Only works with git >= 1.7.3)' + ), + ) + parser.add_option( + '--version', '-v', action='store_true', default=False, + help=( + "Display git-multimail's version" + ), + ) + + parser.add_option( + '--python-version', action='store_true', default=False, + help=( + "Display the version of Python used by git-multimail" + ), + ) + + parser.add_option( + '--check-ref-filter', action='store_true', default=False, + help=( + 'List refs and show information on how git-multimail ' + 'will process them.' + ) + ) + + # The following options permit this script to be run as a gerrit + # ref-updated hook. See e.g. + # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt + # We suppress help for these items, since these are specific to gerrit, + # and we don't want users directly using them any way other than how the + # gerrit ref-updated hook is called. + parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP) + parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP) + parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP) + parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP) + parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP) + + # The following allow this to be run as a stash asynchronous post-receive + # hook (almost identical to a git post-receive hook but triggered also for + # merges of pull requests from the UI). We suppress help for these items, + # since these are specific to stash. + parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP) + parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP) + + (options, args) = parser.parse_args(args) + (options, args, hook_info) = check_hook_specific_args(options, args) + + if options.version: + sys.stdout.write('git-multimail version ' + get_version() + '\n') + return + + if options.python_version: + sys.stdout.write('Python version ' + sys.version + '\n') + return + + if options.c: + Config.add_config_parameters(options.c) + + config = Config('multimailhook') + + environment = None + try: + environment = choose_environment( + config, osenv=os.environ, + env=options.environment, + recipients=options.recipients, + hook_info=hook_info, + ) + + if options.show_env: + show_env(environment, sys.stderr) + + if options.stdout or environment.stdout: + mailer = OutputMailer(sys.stdout, environment) + else: + mailer = choose_mailer(config, environment) + + must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP') + if must_check_setup == '': + must_check_setup = False + if options.check_ref_filter: + check_ref_filter(environment) + elif must_check_setup: + check_setup(environment) + # Dual mode: if arguments were specified on the command line, run + # like an update hook; otherwise, run as a post-receive hook. + elif args: + if len(args) != 3: + parser.error('Need zero or three non-option arguments') + (refname, oldrev, newrev) = args + environment.get_logger().debug( + "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" % + (refname, oldrev, newrev, options.force_send)) + run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send) + else: + run_as_post_receive_hook(environment, mailer) + except ConfigurationException: + sys.exit(sys.exc_info()[1]) + except SystemExit: + raise + except Exception: + t, e, tb = sys.exc_info() + import traceback + sys.stderr.write('\n') # Avoid mixing message with previous output + msg = ( + 'Exception \'' + t.__name__ + + '\' raised. Please report this as a bug to\n' + 'https://github.com/git-multimail/git-multimail/issues\n' + 'with the information below:\n\n' + 'git-multimail version ' + get_version() + '\n' + 'Python version ' + sys.version + '\n' + + traceback.format_exc()) + try: + environment.get_logger().error(msg) + except: + sys.stderr.write(msg) + sys.exit(1) + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/contrib/hooks/multimail/migrate-mailhook-config b/contrib/hooks/multimail/migrate-mailhook-config new file mode 100755 index 000000000000..241ba22fa3c8 --- /dev/null +++ b/contrib/hooks/multimail/migrate-mailhook-config @@ -0,0 +1,274 @@ +#! /usr/bin/env python + +"""Migrate a post-receive-email configuration to be usable with git_multimail.py. + +See README.migrate-from-post-receive-email for more information. + +""" + +import sys +import optparse + +from git_multimail import CommandError +from git_multimail import Config +from git_multimail import read_output + + +OLD_NAMES = [ + 'mailinglist', + 'announcelist', + 'envelopesender', + 'emailprefix', + 'showrev', + 'emailmaxlines', + 'diffopts', + 'scancommitforcc', + ] + +NEW_NAMES = [ + 'environment', + 'reponame', + 'mailinglist', + 'refchangelist', + 'commitlist', + 'announcelist', + 'announceshortlog', + 'envelopesender', + 'administrator', + 'emailprefix', + 'emailmaxlines', + 'diffopts', + 'emaildomain', + 'scancommitforcc', + ] + + +INFO = """\ + +SUCCESS! + +Your post-receive-email configuration has been converted to +git-multimail format. Please see README and +README.migrate-from-post-receive-email to learn about other +git-multimail configuration possibilities. + +For example, git-multimail has the following new options with no +equivalent in post-receive-email. You might want to read about them +to see if they would be useful in your situation: + +""" + + +def _check_old_config_exists(old): + """Check that at least one old configuration value is set.""" + + for name in OLD_NAMES: + if name in old: + return True + + return False + + +def _check_new_config_clear(new): + """Check that none of the new configuration names are set.""" + + retval = True + for name in NEW_NAMES: + if name in new: + if retval: + sys.stderr.write('INFO: The following configuration values already exist:\n\n') + sys.stderr.write(' "%s.%s"\n' % (new.section, name)) + retval = False + + return retval + + +def erase_values(config, names): + for name in names: + if name in config: + try: + sys.stderr.write('...unsetting "%s.%s"\n' % (config.section, name)) + config.unset_all(name) + except CommandError: + sys.stderr.write( + '\nWARNING: could not unset "%s.%s". ' + 'Perhaps it is not set at the --local level?\n\n' + % (config.section, name) + ) + + +def is_section_empty(section, local): + """Return True iff the specified configuration section is empty. + + Iff local is True, use the --local option when invoking 'git + config'.""" + + if local: + local_option = ['--local'] + else: + local_option = [] + + try: + read_output( + ['git', 'config'] + + local_option + + ['--get-regexp', '^%s\.' % (section,)] + ) + except CommandError: + t, e, traceback = sys.exc_info() + if e.retcode == 1: + # This means that no settings were found. + return True + else: + raise + else: + return False + + +def remove_section_if_empty(section): + """If the specified configuration section is empty, delete it.""" + + try: + empty = is_section_empty(section, local=True) + except CommandError: + # Older versions of git do not support the --local option, so + # if the first attempt fails, try without --local. + try: + empty = is_section_empty(section, local=False) + except CommandError: + sys.stderr.write( + '\nINFO: If configuration section "%s.*" is empty, you might want ' + 'to delete it.\n\n' + % (section,) + ) + return + + if empty: + sys.stderr.write('...removing section "%s.*"\n' % (section,)) + read_output(['git', 'config', '--remove-section', section]) + else: + sys.stderr.write( + '\nINFO: Configuration section "%s.*" still has contents. ' + 'It will not be deleted.\n\n' + % (section,) + ) + + +def migrate_config(strict=False, retain=False, overwrite=False): + old = Config('hooks') + new = Config('multimailhook') + if not _check_old_config_exists(old): + sys.exit( + 'Your repository has no post-receive-email configuration. ' + 'Nothing to do.' + ) + if not _check_new_config_clear(new): + if overwrite: + sys.stderr.write('\nWARNING: Erasing the above values...\n\n') + erase_values(new, NEW_NAMES) + else: + sys.exit( + '\nERROR: Refusing to overwrite existing values. Use the --overwrite\n' + 'option to continue anyway.' + ) + + name = 'showrev' + if name in old: + msg = 'git-multimail does not support "%s.%s"' % (old.section, name,) + if strict: + sys.exit( + 'ERROR: %s.\n' + 'Please unset that value then try again, or run without --strict.' + % (msg,) + ) + else: + sys.stderr.write('\nWARNING: %s (ignoring).\n\n' % (msg,)) + + for name in ['mailinglist', 'announcelist']: + if name in old: + sys.stderr.write( + '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) + ) + old_recipients = old.get_all(name, default=None) + old_recipients = ', '.join(o.strip() for o in old_recipients) + new.set_recipients(name, old_recipients) + + if strict: + sys.stderr.write( + '...setting "%s.commitlist" to the empty string\n' % (new.section,) + ) + new.set_recipients('commitlist', '') + sys.stderr.write( + '...setting "%s.announceshortlog" to "true"\n' % (new.section,) + ) + new.set('announceshortlog', 'true') + + for name in ['envelopesender', 'emailmaxlines', 'diffopts', 'scancommitforcc']: + if name in old: + sys.stderr.write( + '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) + ) + new.set(name, old.get(name)) + + name = 'emailprefix' + if name in old: + sys.stderr.write( + '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name) + ) + new.set(name, old.get(name)) + elif strict: + sys.stderr.write( + '...setting "%s.%s" to "[SCM]" to preserve old subject lines\n' + % (new.section, name) + ) + new.set(name, '[SCM]') + + if not retain: + erase_values(old, OLD_NAMES) + remove_section_if_empty(old.section) + + sys.stderr.write(INFO) + for name in NEW_NAMES: + if name not in OLD_NAMES: + sys.stderr.write(' "%s.%s"\n' % (new.section, name,)) + sys.stderr.write('\n') + + +def main(args): + parser = optparse.OptionParser( + description=__doc__, + usage='%prog [OPTIONS]', + ) + + parser.add_option( + '--strict', action='store_true', default=False, + help=( + 'Slavishly configure git-multimail as closely as possible to ' + 'the post-receive-email configuration. Default is to turn ' + 'on some new features that have no equivalent in post-receive-email.' + ), + ) + parser.add_option( + '--retain', action='store_true', default=False, + help=( + 'Retain the post-receive-email configuration values. ' + 'Default is to delete them after the new values are set.' + ), + ) + parser.add_option( + '--overwrite', action='store_true', default=False, + help=( + 'Overwrite any existing git-multimail configuration settings. ' + 'Default is to abort if such settings already exist.' + ), + ) + + (options, args) = parser.parse_args(args) + + if args: + parser.error('Unexpected arguments: %s' % (' '.join(args),)) + + migrate_config(strict=options.strict, retain=options.retain, overwrite=options.overwrite) + + +main(sys.argv[1:]) diff --git a/contrib/hooks/multimail/post-receive.example b/contrib/hooks/multimail/post-receive.example new file mode 100755 index 000000000000..b9bb11834e1f --- /dev/null +++ b/contrib/hooks/multimail/post-receive.example @@ -0,0 +1,101 @@ +#! /usr/bin/env python + +"""Example post-receive hook based on git-multimail. + +The simplest way to use git-multimail is to use the script +git_multimail.py directly as a post-receive hook, and to configure it +using Git's configuration files and command-line parameters. You can +also write your own Python wrapper for more advanced configurability, +using git_multimail.py as a Python module. + +This script is a simple example of such a post-receive hook. It is +intended to be customized before use; see the comments in the script +to help you get started. + +Using git-multimail as a Python module as done here provides more +flexibility. It has the following advantages: + +* The tool's behavior can be customized using arbitrary Python code, + without having to edit git_multimail.py. + +* Configuration settings can be read from other sources; for example, + user names and email addresses could be read from LDAP or from a + database. Or the settings can even be hardcoded in the importing + Python script, if this is preferred. + +This script is a very basic example of how to use git_multimail.py as +a module. The comments below explain some of the points at which the +script's behavior could be changed or customized. + +""" + +import sys + +# If necessary, add the path to the directory containing +# git_multimail.py to the Python path as follows. (This is not +# necessary if git_multimail.py is in the same directory as this +# script): + +#LIBDIR = 'path/to/directory/containing/module' +#sys.path.insert(0, LIBDIR) + +import git_multimail + +# It is possible to modify the output templates here; e.g.: + +#git_multimail.FOOTER_TEMPLATE = """\ +# +#-- \n\ +#This email was generated by the wonderful git-multimail tool. +#""" + + +# Specify which "git config" section contains the configuration for +# git-multimail: +config = git_multimail.Config('multimailhook') + +# Set some Git configuration variables. Equivalent to passing var=val +# to "git -c var=val" each time git is called, or to adding the +# configuration in .git/config (must come before instanciating the +# environment) : +#git_multimail.Config.add_config_parameters('multimailhook.commitEmailFormat=html') +#git_multimail.Config.add_config_parameters(('user.name=foo', 'user.email=foo@example.com')) + +# Select the type of environment: +try: + environment = git_multimail.GenericEnvironment(config=config) + #environment = git_multimail.GitoliteEnvironment(config=config) +except git_multimail.ConfigurationException: + sys.stderr.write('*** %s\n' % sys.exc_info()[1]) + sys.exit(1) + + +# Choose the method of sending emails based on the git config: +mailer = git_multimail.choose_mailer(config, environment) + +# Alternatively, you may hardcode the mailer using code like one of +# the following: + +# Use "/usr/sbin/sendmail -oi -t" to send emails. The envelopesender +# argument is optional: +#mailer = git_multimail.SendMailer( +# command=['/usr/sbin/sendmail', '-oi', '-t'], +# envelopesender='git-repo@example.com', +# ) + +# Use Python's smtplib to send emails. Both arguments are required. +#mailer = git_multimail.SMTPMailer( +# environment=environment, +# envelopesender='git-repo@example.com', +# # The smtpserver argument can also include a port number; e.g., +# # smtpserver='mail.example.com:25' +# smtpserver='mail.example.com', +# ) + +# OutputMailer is intended only for testing; it writes the emails to +# the specified file stream. +#mailer = git_multimail.OutputMailer(sys.stdout) + + +# Read changes from stdin and send notification emails: +git_multimail.run_as_post_receive_hook(environment, mailer) |