From d60a425fbe5d67732d4e2b5275d496c47c36b474 Mon Sep 17 00:00:00 2001 From: Guillaume Sauthier Date: Mon, 5 Nov 2012 11:30:06 -0500 Subject: [PATCH] HTML email notification methods and hook (pull request #51) --- docs/04_releases.mkd | 1 + groovy/sendmail-html.groovy | 544 ++++++++++++++++++ src/com/gitblit/GitBlit.java | 31 + tests/com/gitblit/tests/GroovyScriptTest.java | 28 + 4 files changed, 604 insertions(+) create mode 100644 groovy/sendmail-html.groovy diff --git a/docs/04_releases.mkd b/docs/04_releases.mkd index 2d5d7e55..f0470e5c 100644 --- a/docs/04_releases.mkd +++ b/docs/04_releases.mkd @@ -50,6 +50,7 @@ In order to fork a repository, the user account must have the *fork* permission - Added support for X-Forwarded-Context for Apache subdomain proxy configurations (issue 135) - Delete branch feature (issue 121, Github/ajermakovics) - Added line links to blob view (issue 130) +- Added HTML sendmail hook script and Gitblit.sendHtmlMail method (github/sauthieg) - Added RedmineUserService (github/mallowlabs) - Support for committer verification. Requires use of *--no-ff* when merging branches or pull requests. See setup page for details. diff --git a/groovy/sendmail-html.groovy b/groovy/sendmail-html.groovy new file mode 100644 index 00000000..fb89a146 --- /dev/null +++ b/groovy/sendmail-html.groovy @@ -0,0 +1,544 @@ +/* + * Copyright 2012 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.gitblit.GitBlit +import com.gitblit.Keys +import com.gitblit.models.RepositoryModel +import com.gitblit.models.TeamModel +import com.gitblit.models.UserModel +import com.gitblit.utils.JGitUtils +import java.text.SimpleDateFormat + +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.diff.RawTextComparator; +import org.eclipse.jgit.diff.DiffEntry.ChangeType; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.IndexDiff; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.Config +import org.eclipse.jgit.patch.FileHeader; +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand +import org.eclipse.jgit.transport.ReceiveCommand.Result +import org.eclipse.jgit.treewalk.FileTreeIterator; +import org.eclipse.jgit.treewalk.EmptyTreeIterator; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.util.io.DisabledOutputStream; +import org.slf4j.Logger +import groovy.xml.MarkupBuilder + +import java.io.IOException; +import java.security.MessageDigest + + +/** + * Sample Gitblit Post-Receive Hook: sendmail-html + * + * The Post-Receive hook is executed AFTER the pushed commits have been applied + * to the Git repository. This is the appropriate point to trigger an + * integration build or to send a notification. + * + * This script is only executed when pushing to *Gitblit*, not to other Git + * tooling you may be using. + * + * If this script is specified in *groovy.postReceiveScripts* of gitblit.properties + * or web.xml then it will be executed by any repository when it receives a + * push. If you choose to share your script then you may have to consider + * tailoring control-flow based on repository access restrictions. + * + * Scripts may also be specified per-repository in the repository settings page. + * Shared scripts will be excluded from this list of available scripts. + * + * This script is dynamically reloaded and it is executed within it's own + * exception handler so it will not crash another script nor crash Gitblit. + * + * If you want this hook script to fail and abort all subsequent scripts in the + * chain, "return false" at the appropriate failure points. + * + * Bound Variables: + * gitblit Gitblit Server com.gitblit.GitBlit + * repository Gitblit Repository com.gitblit.models.RepositoryModel + * user Gitblit User com.gitblit.models.UserModel + * commands JGit commands Collection + * url Base url for Gitblit java.lang.String + * logger Logs messages to Gitblit org.slf4j.Logger + * clientLogger Logs messages to Git client com.gitblit.utils.ClientLogger + * + * Accessing Gitblit Custom Fields: + * def myCustomField = repository.customFields.myCustomField + * + */ + +com.gitblit.models.UserModel userModel = user + +// Indicate we have started the script +logger.info("sendmail hook triggered by ${user.username} for ${repository.name}") + +/* + * Primitive email notification. + * This requires the mail settings to be properly configured in Gitblit. + */ + +Repository r = gitblit.getRepository(repository.name) + +// reuse existing repository config settings, if available +Config config = r.getConfig() +def mailinglist = config.getString('hooks', null, 'mailinglist') +def emailprefix = config.getString('hooks', null, 'emailprefix') + +// set default values +def toAddresses = [] +if (emailprefix == null) { + emailprefix = '[Gitblit]' +} + +if (mailinglist != null) { + def addrs = mailinglist.split(/(,|\s)/) + toAddresses.addAll(addrs) +} + +// add all mailing lists defined in gitblit.properties or web.xml +toAddresses.addAll(gitblit.getStrings(Keys.mail.mailingLists)) + +// add all team mailing lists +def teams = gitblit.getRepositoryTeams(repository) +for (team in teams) { + TeamModel model = gitblit.getTeamModel(team) + if (model.mailingLists) { + toAddresses.addAll(model.mailingLists) + } +} + +// add all mailing lists for the repository +toAddresses.addAll(repository.mailingLists) + +// define the summary and commit urls +def repo = repository.name +def summaryUrl = url + "/summary?r=$repo" +def baseCommitUrl = url + "/commit?r=$repo&h=" +def baseBlobDiffUrl = url + "/blobdiff/?r=$repo&h=" +def baseCommitDiffUrl = url + "/commitdiff/?r=$repo&h=" + +if (gitblit.getBoolean(Keys.web.mountParameters, true)) { + repo = repo.replace('/', gitblit.getString(Keys.web.forwardSlashCharacter, '/')).replace('/', '%2F') + summaryUrl = url + "/summary/$repo" + baseCommitUrl = url + "/commit/$repo/" + baseBlobDiffUrl = url + "/blobdiff/$repo/" + baseCommitDiffUrl = url + "/commitdiff/$repo/" +} + +class HtmlMailWriter { + Repository repository + def url + def baseCommitUrl + def baseCommitDiffUrl + def baseBlobDiffUrl + def mountParameters + def commitCount = 0 + def commands + def writer = new StringWriter(); + def builder = new MarkupBuilder(writer) + + def writeStyle() { + builder.style(type:"text/css", ''' + th, td { + padding: 2px; + } + thead { + text-align: left; + font-weight: bold; + } + thead tr { + border-bottom: 1px dotted #000; + } + a { + text-decoration: none; + } + .commits-table { + border-collapse: collapse; + font-family: sans-serif; + width: 100%; + } + .label-commit { + border-radius:4px; + background-color: #3A87AD; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .label-add { + border-radius:4px; + background-color: green; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .label-delete { + border-radius:4px; + background-color: grey; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .label-rename { + border-radius:4px; + background-color: blue; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .label-modify { + border-radius:4px; + background-color: orange; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .label-copy { + border-radius:4px; + background-color: teal; + padding: 2px 4px; + color: white; + vertical-align: baseline; + font-weight: bold; + font-family: monospace; + } + .gravatar-column { + width: 5%; + } + .author-column { + width: 10%; + } + .commit-column { + width: 5%; + } + .status-column { + width: 10%; + padding-bottom: 5px; + padding-top: 5px; + } + ''') + } + + def writeBranchTitle(type, name, action, number) { + builder.h2 { + mkp.yield "$type " + span(style:"font-family: monospace;", name ) + mkp.yield " $action ($number commits)" + } + } + + def writeBranchDeletedTitle(type, name) { + builder.h2 { + mkp.yield "$type " + span(style:"font-family: monospace;", name ) + mkp.yield " deleted" + } + } + + def commitUrl(RevCommit commit) { + "${baseCommitUrl}$commit.id.name" + } + + def commitDiffUrl(RevCommit commit) { + "${baseCommitDiffUrl}$commit.id.name" + } + + def encoded(String path) { + path.replace('/', '!') + } + + def blobDiffUrl(objectId, path) { + if (mountParameters) { + // REST style + "${baseBlobDiffUrl}${objectId.name()}/${encoded(path)}" + } else { + "${baseBlobDiffUrl}${objectId.name()}&f=${path}" + } + + } + + def writeCommitTable(commits) { + // Write commits table + builder.table('class':"commits-table") { + thead { + tr { + th(colspan:2, "Author") + th( "Commit" ) + th( "Message" ) + } + } + tbody() { + + // Write all the commits + for (commit in commits) { + writeCommit(commit) + + // Write detail on that particular commit + tr { + td (colspan:3) + td { writeStatusTable(commit) } + } + } + } + } + } + + def writeCommit(commit) { + def abbreviated = repository.newObjectReader().abbreviate(commit.id, 6).name() + def author = commit.authorIdent.name + def email = commit.authorIdent.emailAddress + def message = commit.shortMessage + builder.tr { + td('class':"gravatar-column") { + img(src:gravatarUrl(email)) + } + td('class':"author-column") { p(author) } + td('class':"commit-column") { + a(href:commitUrl(commit)) { + span('class':"label-commit", abbreviated ) + } + } + td { + mkp.yield message + a(href:commitDiffUrl(commit), " [commitdiff]" ) + } + } + } + + def writeStatusLabel(style, label) { + builder.span('class' : style, label ) + } + + def writeAddStatusLine(ObjectId id, FileHeader header) { + builder.td('class':"status-column") { + a(href:blobDiffUrl(id, header.newPath)) { writeStatusLabel("label-add", "add") } + } + builder.td { + span(style:'font-family: monospace;', header.newPath) + } + } + + def writeCopyStatusLine(ObjectId id, FileHeader header) { + builder.td('class':"status-column") { + a(href:blobDiffUrl(id, header.newPath)) { writeStatusLabel("label-copy", "copy") } + } + builder.td() { + span(style : "font-family: monospace; ", header.oldPath + " copied to " + header.newPath) + } + } + + def writeDeleteStatusLine(ObjectId id, FileHeader header) { + builder.td('class':"status-column") { + a(href:blobDiffUrl(id, header.oldPath)) { writeStatusLabel("label-delete", "delete") } + } + builder.td() { + span(style : "font-family: monospace; ", header.oldPath) + } + } + + def writeModifyStatusLine(ObjectId id, FileHeader header) { + builder.td('class':"status-column") { + a(href:blobDiffUrl(id, header.oldPath)) { writeStatusLabel("label-modify", "modify") } + } + builder.td() { + span(style : "font-family: monospace; ", header.oldPath) + } + } + + def writeRenameStatusLine(ObjectId id, FileHeader header) { + builder.td('class':"status-column") { + a(href:blobDiffUrl(id, header.newPath)) { writeStatusLabel("label-rename", "rename") } + } + builder.td() { + span(style : "font-family: monospace; ", header.olPath + " -> " + header.newPath) + } + } + + def writeStatusLine(ObjectId id, FileHeader header) { + builder.tr { + switch (header.changeType) { + case ChangeType.ADD: + writeAddStatusLine(id, header) + break; + case ChangeType.COPY: + writeCopyStatusLine(id, header) + break; + case ChangeType.DELETE: + writeDeleteStatusLine(id, header) + break; + case ChangeType.MODIFY: + writeModifyStatusLine(id, header) + break; + case ChangeType.RENAME: + writeRenameStatusLine(id, header) + break; + } + } + } + + def writeStatusTable(RevCommit commit) { + DiffFormatter formatter = new DiffFormatter(DisabledOutputStream.INSTANCE) + formatter.setRepository(repository) + formatter.setDetectRenames(true) + formatter.setDiffComparator(RawTextComparator.DEFAULT); + + def diffs + RevWalk rw = new RevWalk(repository); + if (commit.parentCount > 0) { + RevCommit parent = commit.parents[0] + diffs = formatter.scan(parent.tree, commit.tree) + } else { + diffs = formatter.scan(new EmptyTreeIterator(), + new CanonicalTreeParser(null, rw.objectReader, commit.tree)) + } + // Write status table + builder.table('class':"commits-table") { + tbody() { + for (DiffEntry entry in diffs) { + FileHeader header = formatter.toFileHeader(entry) + writeStatusLine(commit.id, header) + } + } + } + } + + + def md5(text) { + + def digest = MessageDigest.getInstance("MD5") + + //Quick MD5 of text + def hash = new BigInteger(1, digest.digest(text.getBytes())) + .toString(16) + .padLeft(32, "0") + hash.toString() + } + + def gravatarUrl(email) { + def cleaned = email.trim().toLowerCase() + "http://www.gravatar.com/avatar/${md5(cleaned)}?s=30" + } + + def writeNavbar() { + builder.div('class':"navbar navbar-fixed-top") { + div('class':"navbar-inner") { + div('class':"container") { + a('class':"brand", href:"${url}", title:"GitBlit") { + img(src:"${url}/gitblt_25_white.png", + width:"79", + height:"25", + 'class':"logo") + } + } + } + } + } + + def write() { + builder.html { + head { + link(rel:"stylesheet", href:"${url}/bootstrap/css/bootstrap.css") + link(rel:"stylesheet", href:"${url}/gitblit.css") + writeStyle() + } + body { + + writeNavbar() + + for (command in commands) { + def ref = command.refName + def refType = 'Branch' + if (ref.startsWith('refs/heads/')) { + ref = command.refName.substring('refs/heads/'.length()) + } else if (ref.startsWith('refs/tags/')) { + ref = command.refName.substring('refs/tags/'.length()) + refType = 'Tag' + } + + switch (command.type) { + case ReceiveCommand.Type.CREATE: + def commits = JGitUtils.getRevLog(repository, command.oldId.name, command.newId.name).reverse() + commitCount += commits.size() + // new branch + // Write header + writeBranchTitle(refType, ref, "created", commits.size()) + writeCommitTable(commits) + break + case ReceiveCommand.Type.UPDATE: + def commits = JGitUtils.getRevLog(repository, command.oldId.name, command.newId.name).reverse() + commitCount += commits.size() + // fast-forward branch commits table + // Write header + writeBranchTitle(refType, ref, "updated", commits.size()) + writeCommitTable(commits) + break + case ReceiveCommand.Type.UPDATE_NONFASTFORWARD: + def commits = JGitUtils.getRevLog(repository, command.oldId.name, command.newId.name).reverse() + commitCount += commits.size() + // non-fast-forward branch commits table + // Write header + writeBranchTitle(refType, ref, "updated [NON fast-forward]", commits.size()) + writeCommitTable(commits) + break + case ReceiveCommand.Type.DELETE: + // deleted branch/tag + writeBranchDeletedTitle(refType, ref) + break + default: + break + } + } + } + } + writer.toString() + } + +} + +def mailWriter = new HtmlMailWriter() +mailWriter.repository = r +mailWriter.baseCommitUrl = baseCommitUrl +mailWriter.baseBlobDiffUrl = baseBlobDiffUrl +mailWriter.baseCommitDiffUrl = baseCommitDiffUrl +mailWriter.commands = commands +mailWriter.url = url +mailWriter.mountParameters = gitblit.getBoolean(Keys.web.mountParameters, true) + +def content = mailWriter.write() + +// close the repository reference +r.close() + +// tell Gitblit to send the message (Gitblit filters duplicate addresses) +def repositoryName = repository.name.substring(0, repository.name.length() - 4) +gitblit.sendHtmlMail("${emailprefix}[$repositoryName] ${userModel.displayName} pushed ${mailWriter.commitCount} commits", + content, + toAddresses) diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java index 0d37b44f..32f4c474 100644 --- a/src/com/gitblit/GitBlit.java +++ b/src/com/gitblit/GitBlit.java @@ -2657,6 +2657,37 @@ public class GitBlit implements ServletContextListener { } } + /** + * Notify users by email of something. + * + * @param subject + * @param message + * @param toAddresses + */ + public void sendHtmlMail(String subject, String message, Collection toAddresses) { + this.sendHtmlMail(subject, message, toAddresses.toArray(new String[0])); + } + + /** + * Notify users by email of something. + * + * @param subject + * @param message + * @param toAddresses + */ + public void sendHtmlMail(String subject, String message, String... toAddresses) { + try { + Message mail = mailExecutor.createMessage(toAddresses); + if (mail != null) { + mail.setSubject(subject); + mail.setContent(message, "text/html"); + mailExecutor.queue(mail); + } + } catch (MessagingException e) { + logger.error("Messaging error", e); + } + } + /** * Returns the descriptions/comments of the Gitblit config settings. * diff --git a/tests/com/gitblit/tests/GroovyScriptTest.java b/tests/com/gitblit/tests/GroovyScriptTest.java index 3d3621df..47d20a4c 100644 --- a/tests/com/gitblit/tests/GroovyScriptTest.java +++ b/tests/com/gitblit/tests/GroovyScriptTest.java @@ -69,6 +69,31 @@ public class GroovyScriptTest { } } + @Test + public void testSendHtmlMail() throws Exception { + MockGitblit gitblit = new MockGitblit(); + MockLogger logger = new MockLogger(); + MockClientLogger clientLogger = new MockClientLogger(); + List commands = new ArrayList(); + commands.add(new ReceiveCommand(ObjectId + .fromString("c18877690322dfc6ae3e37bb7f7085a24e94e887"), ObjectId + .fromString("3fa7c46d11b11d61f1cbadc6888be5d0eae21969"), "refs/heads/master")); + commands.add(new ReceiveCommand(ObjectId + .fromString("c18877690322dfc6ae3e37bb7f7085a24e94e887"), ObjectId + .fromString("3fa7c46d11b11d61f1cbadc6888be5d0eae21969"), "refs/heads/master2")); + + RepositoryModel repository = GitBlit.self().getRepositoryModel("helloworld.git"); + repository.mailingLists.add("list@helloworld.git"); + + test("sendmail-html.groovy", gitblit, logger, clientLogger, commands, repository); + assertEquals(1, logger.messages.size()); + assertEquals(1, gitblit.messages.size()); + MockMail m = gitblit.messages.get(0); + assertEquals(5, m.toAddresses.size()); + assertTrue(m.message.contains("BIT")); + assertTrue(m.message.contains("")); + } + @Test public void testSendMail() throws Exception { MockGitblit gitblit = new MockGitblit(); @@ -296,6 +321,9 @@ public class GroovyScriptTest { public void sendMail(String subject, String message, Collection toAddresses) { messages.add(new MockMail(subject, message, toAddresses)); } + public void sendHtmlMail(String subject, String message, Collection toAddresses) { + messages.add(new MockMail(subject, message, toAddresses)); + } } class MockLogger { -- 2.39.5