diff --git a/README.md b/README.md index 8b93b51..1c211e1 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,17 @@ - Repository search (Code and Issues) - Wiki - Issues +- Fork / Pull request +- Mail notification - Activity timeline - User management (for Administrators) - Group (like Organization in Github) Following features are not implemented, but we will make them in the future release! -- Fork and pull request - Network graph - Statics - Watch / Star -- Notification If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/takezoe/gitbucket/wiki). @@ -37,7 +37,7 @@ Release Notes -------- -### 1.5 - COMMING SOON! +### 1.5 - 4 Sep 2013 - Fork and pull request. - LDAP authentication. - Mail notification. @@ -45,6 +45,7 @@ - Add the branch tab in the repository viewer. - Encoding auto detection for the file content in the repository viewer. - Add favicon, header logo and icons for the timeline. +- Specify data directory via environment variable GITBUCKET_HOME. - Fixed some bugs. ### 1.4 - 31 Jul 2013 diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index 8d6b21c..11c0d8b 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -1,7 +1,7 @@ package app import _root_.util.Directory._ -import _root_.util.{StringUtil, FileUtil, Validations} +import _root_.util.{FileUtil, Validations} import org.scalatra._ import org.scalatra.json._ import org.json4s._ @@ -22,9 +22,8 @@ implicit val jsonFormats = DefaultFormats -// before() { -// contentType = "text/html" -// } + // Don't set content type via Accept header. + override def format(implicit request: HttpServletRequest) = "" override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { val httpRequest = request.asInstanceOf[HttpServletRequest] diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index fab8b75..adee1e8 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -4,7 +4,7 @@ import service._ import IssuesService._ -import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAuthenticator} +import util.{CollaboratorsAuthenticator, ReferrerAuthenticator, ReadableUsersAuthenticator, Notifier} import org.scalatra.Ok class IssuesController extends IssuesControllerBase @@ -112,6 +112,11 @@ // record activity recordCreateIssueActivity(owner, name, userName, issueId, form.title) + // notifications + Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ + Notifier.msgIssue(s"${baseUrl}/${owner}/${name}/issues/${issueId}") + } + redirect(s"/${owner}/${name}/issues/${issueId}") }) @@ -129,21 +134,15 @@ post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => handleComment(form.issueId, Some(form.content), repository)() map { case (issue, id) => - if(issue.isPullRequest){ - redirect(s"/${repository.owner}/${repository.name}/pull/${form.issueId}#comment-${id}") - } else { - redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") - } + redirect(s"/${repository.owner}/${repository.name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") } getOrElse NotFound }) post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => handleComment(form.issueId, form.content, repository)() map { case (issue, id) => - if(issue.isPullRequest){ - redirect(s"/${repository.owner}/${repository.name}/pull/${form.issueId}#comment-${id}") - } else { - redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") - } + redirect(s"/${repository.owner}/${repository.name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") } getOrElse NotFound }) @@ -282,9 +281,10 @@ val (action, recordActivity) = getAction(issue) .collect { - case "close" if(issue.isPullRequest) => true -> (Some("close") -> Some(recordClosePullRequestActivity _)) - case "close" if(!issue.isPullRequest) => true -> (Some("close") -> Some(recordCloseIssueActivity _)) - case "reopen" => false -> (Some("reopen") -> Some(recordReopenIssueActivity _)) + case "close" => true -> (Some("close") -> + Some(if(issue.isPullRequest) recordClosePullRequestActivity _ else recordCloseIssueActivity _)) + case "reopen" => false -> (Some("reopen") -> + Some(recordReopenIssueActivity _)) } .map { case (closed, t) => updateClosed(owner, name, issueId, closed) @@ -300,15 +300,29 @@ } // record activity - content foreach { content => - if(issue.isPullRequest) - recordCommentPullRequestActivity(owner, name, userName, issueId, content) - else - recordCommentIssueActivity(owner, name, userName, issueId, content) + content foreach { + (if(issue.isPullRequest) recordCommentPullRequestActivity _ else recordCommentIssueActivity _) + (owner, name, userName, issueId, _) } recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - (issue, commentId) + // notifications + Notifier() match { + case f => + content foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgComment(s"${baseUrl}/${owner}/${name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${issueId}#comment-${commentId}") + } + } + action foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgStatus(s"${baseUrl}/${owner}/${name}/issues/${issueId}") + } + } + } + + issue -> commentId } } diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala index 0cd143f..3a287f3 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -1,6 +1,6 @@ package app -import util.{LockUtil, CollaboratorsAuthenticator, JGitUtil, ReferrerAuthenticator} +import util.{LockUtil, CollaboratorsAuthenticator, JGitUtil, ReferrerAuthenticator, Notifier} import util.Directory._ import util.Implicits._ import service._ @@ -100,7 +100,7 @@ getPullRequest(repository.owner, repository.name, issueId).map { case (issue, pullreq) => val remote = getRepositoryDir(repository.owner, repository.name) val tmpdir = new java.io.File(getTemporaryDir(repository.owner, repository.name), s"merge-${issueId}") - val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).call + val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(pullreq.branch).call try { // mark issue as merged and close. @@ -155,6 +155,11 @@ } } + // notifications + Notifier().toNotify(repository, issueId, "merge"){ + Notifier.msgStatus(s"${baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}") + } + redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") } finally { @@ -179,7 +184,7 @@ FileUtils.deleteDirectory(tmpdir) } - val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).call + val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(branch).call try { git.checkout.setName(branch).call @@ -303,8 +308,14 @@ .call } + // record activity recordPullRequestActivity(repository.owner, repository.name, loginUserName, issueId, form.title) + // notifications + Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ + Notifier.msgPullRequest(s"${baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}") + } + redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") }) diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index 40c71eb..6d9d2ad 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -47,7 +47,7 @@ if(getValue(props, Notification, false)){ Some(Smtp( getValue(props, SmtpHost, ""), - getOptionValue(props, SmtpPort, Some(25)), + getOptionValue(props, SmtpPort, Some(DefaultSmtpPort)), getOptionValue(props, SmtpUser, None), getOptionValue(props, SmtpPassword, None), getOptionValue[Boolean](props, SmtpSsl, None))) @@ -99,6 +99,7 @@ password: Option[String], ssl: Option[Boolean]) + val DefaultSmtpPort = 25 val DefaultLdapPort = 389 private val AllowAccountRegistration = "allow_account_registration" diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index 4cda144..dd6dd6e 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -113,6 +113,7 @@ private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) override def contextInitialized(event: ServletContextEvent): Unit = { + event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${Directory.DatabaseHome}") super.contextInitialized(event) logger.debug("H2 started") diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 94c432b..eac94c4 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -24,21 +24,9 @@ override def init(config: ServletConfig): Unit = { setReceivePackFactory(new GitBucketReceivePackFactory()) - - // TODO are there any other ways...? - super.init(new ServletConfig(){ - def getInitParameter(name: String): String = name match { - case "base-path" => Directory.RepositoryHome - case "export-all" => "true" - case name => config.getInitParameter(name) - } - def getInitParameterNames(): java.util.Enumeration[String] = { - config.getInitParameterNames - } - - def getServletContext(): ServletContext = config.getServletContext - def getServletName(): String = config.getServletName - }); + config.getServletContext.setInitParameter("base-path", Directory.RepositoryHome) + config.getServletContext.setInitParameter("export-all", "true") + super.init(config) } } diff --git a/src/main/scala/servlet/TransactionFilter.scala b/src/main/scala/servlet/TransactionFilter.scala index 5a26734..0e96273 100644 --- a/src/main/scala/servlet/TransactionFilter.scala +++ b/src/main/scala/servlet/TransactionFilter.scala @@ -3,7 +3,6 @@ import javax.servlet._ import org.slf4j.LoggerFactory import javax.servlet.http.HttpServletRequest -import scala.slick.session.Database /** * Controls the transaction with the open session in view pattern. @@ -21,15 +20,19 @@ // assets don't need transaction chain.doFilter(req, res) } else { - val context = req.getServletContext - Database.forURL(context.getInitParameter("db.url"), - context.getInitParameter("db.user"), - context.getInitParameter("db.password")) withTransaction { + Database(req.getServletContext) withTransaction { logger.debug("TODO begin transaction") chain.doFilter(req, res) logger.debug("TODO end transaction") } } } - -} \ No newline at end of file + +} + +object Database { + def apply(context: ServletContext): scala.slick.session.Database = + scala.slick.session.Database.forURL(context.getInitParameter("db.url"), + context.getInitParameter("db.user"), + context.getInitParameter("db.password")) +} diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index 754d737..73cee55 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -7,11 +7,16 @@ */ object Directory { - val GitBucketHome = new File(System.getProperty("user.home"), "gitbucket").getAbsolutePath + val GitBucketHome = (scala.util.Properties.envOrNone("GITBUCKET_HOME") match { + case Some(env) => new File(env) + case None => new File(System.getProperty("user.home"), "gitbucket") + }).getAbsolutePath val GitBucketConf = new File(GitBucketHome, "gitbucket.conf") val RepositoryHome = s"${GitBucketHome}/repositories" + + val DatabaseHome = s"${GitBucketHome}/data" /** * Repository names of the specified user. diff --git a/src/main/scala/util/Notifier.scala b/src/main/scala/util/Notifier.scala index dab8eb3..4fc522f 100644 --- a/src/main/scala/util/Notifier.scala +++ b/src/main/scala/util/Notifier.scala @@ -1,37 +1,104 @@ package util -import org.apache.commons.mail.{DefaultAuthenticator, SimpleEmail} +import scala.concurrent._ +import ExecutionContext.Implicits.global +import org.apache.commons.mail.{DefaultAuthenticator, HtmlEmail} +import org.slf4j.LoggerFactory -import service.SystemSettingsService.{SystemSettings, Smtp} +import app.Context +import service.{AccountService, RepositoryService, IssuesService, SystemSettingsService} +import servlet.Database +import SystemSettingsService.Smtp -trait Notifier { +trait Notifier extends RepositoryService with AccountService with IssuesService { + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context): Unit + + protected def recipients(issue: model.Issue)(notify: String => Unit)(implicit context: Context) = + ( + // individual repository's owner + issue.userName :: + // collaborators + getCollaborators(issue.userName, issue.repositoryName) ::: + // participants + issue.openedUserName :: + getComments(issue.userName, issue.repositoryName, issue.issueId).map(_.commentedUserName) + ) + .distinct + .withFilter ( _ != context.loginAccount.get.userName ) // the operation in person is excluded + .foreach ( getAccountByUserName(_) filterNot (_.isGroupAccount) foreach (x => notify(x.mailAddress)) ) } object Notifier { - def apply(settings: SystemSettings) = { - new Mailer(settings.smtp.get) + // TODO We want to be able to switch to mock. + def apply(): Notifier = new SystemSettingsService {}.loadSystemSettings match { + case settings if settings.notification => new Mailer(settings.smtp.get) + case _ => new MockMailer } + def msgIssue(url: String) = (content: String) => s""" + |${content}
+ |--
+ |View it on GitBucket + """.stripMargin + + def msgPullRequest(url: String) = (content: String) => s""" + |${content}
+ |View, comment on, or merge it at:
+ |${url} + """.stripMargin + + def msgComment(url: String) = (content: String) => s""" + |${content}
+ |--
+ |View it on GitBucket + """.stripMargin + + def msgStatus(url: String) = (content: String) => s""" + |${content} #${url split('/') last} + """.stripMargin } -class Mailer(val smtp: Smtp) extends Notifier { - def notifyTo(issue: model.Issue) = { - val email = new SimpleEmail - email.setHostName(smtp.host) - email.setSmtpPort(smtp.port.get) - smtp.user.foreach { user => - email.setAuthenticator(new DefaultAuthenticator(user, smtp.password.getOrElse(""))) - } - smtp.ssl.foreach { ssl => - email.setSSLOnConnect(ssl) - } - email.setFrom("TODO address", "TODO name") - email.addTo("TODO") - email.setSubject(s"[${issue.repositoryName}] ${issue.title} (#${issue.issueId})") - email.setMsg("TODO") +class Mailer(private val smtp: Smtp) extends Notifier { + private val logger = LoggerFactory.getLogger(classOf[Mailer]) - email.send + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context) = { + val f = future { + val email = new HtmlEmail + email.setHostName(smtp.host) + email.setSmtpPort(smtp.port.get) + smtp.user.foreach { user => + email.setAuthenticator(new DefaultAuthenticator(user, smtp.password.getOrElse(""))) + } + smtp.ssl.foreach { ssl => + email.setSSLOnConnect(ssl) + } + email.setFrom("notifications@gitbucket.com", context.loginAccount.get.userName) + email.setHtmlMsg(msg(view.Markdown.toHtml(content, r, false, true))) + + // TODO Can we use the Database Session in other than Transaction Filter? + Database(context.request.getServletContext) withSession { + getIssue(r.owner, r.name, issueId.toString) foreach { issue => + email.setSubject(s"[${r.name}] ${issue.title} (#${issueId})") + recipients(issue) { + email.getToAddresses.clear + email.addTo(_).send + } + } + } + "Notifications Successful." + } + f onSuccess { + case s => logger.debug(s) + } + f onFailure { + case t => logger.error("Notifications Failed.", t) + } } } -class MockMailer extends Notifier \ No newline at end of file +class MockMailer extends Notifier { + def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + (msg: String => String)(implicit context: Context): Unit = {} +} \ No newline at end of file diff --git a/src/main/twirl/settings/options.scala.html b/src/main/twirl/settings/options.scala.html index d386ac3..e41deda 100644 --- a/src/main/twirl/settings/options.scala.html +++ b/src/main/twirl/settings/options.scala.html @@ -20,6 +20,7 @@ } +