diff --git a/src/main/resources/update/1_4.sql b/src/main/resources/update/1_4.sql index a68ca43..2d3c492 100644 --- a/src/main/resources/update/1_4.sql +++ b/src/main/resources/update/1_4.sql @@ -8,3 +8,17 @@ ALTER TABLE GROUP_MEMBER ADD CONSTRAINT IDX_GROUP_MEMBER_FK1 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); ALTER TABLE ACCOUNT ADD COLUMN GROUP_ACCOUNT BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE OR REPLACE VIEW ISSUE_OUTLINE_VIEW AS + SELECT + A.USER_NAME, + A.REPOSITORY_NAME, + A.ISSUE_ID, + NVL(B.COMMENT_COUNT, 0) AS COMMENT_COUNT + FROM ISSUE A + LEFT OUTER JOIN ( + SELECT USER_NAME, REPOSITORY_NAME, ISSUE_ID, COUNT(COMMENT_ID) AS COMMENT_COUNT FROM ISSUE_COMMENT + WHERE ACTION IN ('comment', 'close_comment', 'reopen_comment') + GROUP BY USER_NAME, REPOSITORY_NAME, ISSUE_ID + ) B + ON (A.USER_NAME = B.USER_NAME AND A.REPOSITORY_NAME = B.REPOSITORY_NAME AND A.ISSUE_ID = B.ISSUE_ID); diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 60a8dbb..874bbbd 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -8,6 +8,7 @@ context.mount(new SearchController, "/") context.mount(new FileUploadController, "/upload") context.mount(new SignInController, "/*") + context.mount(new DashboardController, "/*") context.mount(new UserManagementController, "/*") context.mount(new SystemSettingsController, "/*") context.mount(new CreateRepositoryController, "/*") diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index bff5994..c359ff2 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.{FileUtil, Validations} +import _root_.util.{StringUtil, FileUtil, Validations} import org.scalatra._ import org.scalatra.json._ import org.json4s._ @@ -10,7 +10,7 @@ import model.Account import scala.Some import service.AccountService -import javax.servlet.http.{HttpSession, HttpServletRequest} +import javax.servlet.http.{HttpServletResponse, HttpSession, HttpServletRequest} import java.text.SimpleDateFormat import javax.servlet.{FilterChain, ServletResponse, ServletRequest} @@ -23,16 +23,28 @@ implicit val jsonFormats = DefaultFormats override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { - val httpRequest = request.asInstanceOf[HttpServletRequest] - val path = httpRequest.getRequestURI.substring(request.getServletContext.getContextPath.length) + val httpRequest = request.asInstanceOf[HttpServletRequest] + val httpResponse = response.asInstanceOf[HttpServletResponse] + val context = request.getServletContext.getContextPath + val path = httpRequest.getRequestURI.substring(context.length) if(path.startsWith("/console/")){ - Option(httpRequest.getSession.getAttribute("LOGIN_ACCOUNT").asInstanceOf[Account]).collect { - case account if(account.isAdmin) => chain.doFilter(request, response) + val account = httpRequest.getSession.getAttribute("LOGIN_ACCOUNT").asInstanceOf[Account] + if(account == null){ + // Redirect to login form + httpResponse.sendRedirect(context + "/signin?" + path) + } else if(account.isAdmin){ + // H2 Console (administrators only) + chain.doFilter(request, response) + } else { + // Redirect to dashboard + httpResponse.sendRedirect(context + "/") } } else if(path.startsWith("/git/")){ + // Git repository chain.doFilter(request, response) } else { + // Scalatra actions super.doFilter(request, response, chain) } } diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index deb5493..3446d16 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -193,4 +193,4 @@ } } -} \ No newline at end of file +} diff --git a/src/main/scala/app/DashboardController.scala b/src/main/scala/app/DashboardController.scala new file mode 100644 index 0000000..02e1668 --- /dev/null +++ b/src/main/scala/app/DashboardController.scala @@ -0,0 +1,49 @@ +package app + +import service._ +import util.UsersAuthenticator + +class DashboardController extends DashboardControllerBase + with IssuesService with RepositoryService with AccountService + with UsersAuthenticator + +trait DashboardControllerBase extends ControllerBase { + self: IssuesService with RepositoryService with UsersAuthenticator => + + get("/dashboard/issues/repos")(usersOnly { + searchIssues("all") + }) + + get("/dashboard/issues/assigned")(usersOnly { + searchIssues("assigned") + }) + + get("/dashboard/issues/created_by")(usersOnly { + searchIssues("created_by") + }) + + private def searchIssues(filter: String) = { + import IssuesService._ + + // condition + val sessionKey = "dashboard/issues" + val condition = if(request.getQueryString == null) + session.get(sessionKey).getOrElse(IssueSearchCondition()).asInstanceOf[IssueSearchCondition] + else IssueSearchCondition(request) + + session.put(sessionKey, condition) + + val repositories = getAccessibleRepositories(context.loginAccount, baseUrl) + // + dashboard.html.issues( + issues.html.listparts(Nil, 0, 0, 0, condition), + 0, + 0, + 0, + repositories, + condition, + filter) + + } + +} \ No newline at end of file diff --git a/src/main/scala/model/Issue.scala b/src/main/scala/model/Issue.scala index d134b8e..769b059 100644 --- a/src/main/scala/model/Issue.scala +++ b/src/main/scala/model/Issue.scala @@ -7,6 +7,11 @@ def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository) } +object IssueOutline extends Table[(String, String, Int, Int)]("ISSUE_OUTLINE_VIEW") with IssueTemplate { + def commentCount = column[Int]("COMMENT_COUNT") + def * = userName ~ repositoryName ~ issueId ~ commentCount +} + object Issues extends Table[Issue]("ISSUE") with IssueTemplate with MilestoneTemplate { def openedUserName = column[String]("OPENED_USER_NAME") def assignedUserName = column[String]("ASSIGNED_USER_NAME") diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index c0f90e0..ac1ca2b 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -7,7 +7,7 @@ import model._ import util.Implicits._ -import util.StringUtil +import util.StringUtil._ trait IssuesService { import IssuesService._ @@ -99,47 +99,38 @@ def searchIssue(owner: String, repository: String, condition: IssueSearchCondition, filter: String, userName: Option[String], offset: Int, limit: Int): List[(Issue, List[Label], Int)] = { - // get issues and comment count - val issues = searchIssueQuery(owner, repository, condition, filter, userName) - .leftJoin(Query(IssueComments) - .filter { t => - (t.byRepository(owner, repository)) && - (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + // get issues and comment count and labels + searchIssueQuery(owner, repository, condition, filter, userName) + .innerJoin(IssueOutline).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } + .leftJoin (IssueLabels) .on { case ((t1, t2), t3) => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) } + .leftJoin (Labels) .on { case (((t1, t2), t3), t4) => t3.byLabel(t4.userName, t4.repositoryName, t4.labelId) } + .map { case (((t1, t2), t3), t4) => + (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?) } - .groupBy { _.issueId } - .map { case (issueId, t) => issueId ~ t.length }).on((t1, t2) => t1.issueId is t2._1) - .sortBy { case (t1, t2) => - (condition.sort match { - case "created" => t1.registeredDate - case "comments" => t2._2 - case "updated" => t1.updatedDate - }) match { - case sort => condition.direction match { - case "asc" => sort asc - case "desc" => sort desc + .sortBy(_._4) // labelName + .sortBy { case (t1, commentCount, _,_,_) => + (condition.sort match { + case "created" => t1.registeredDate + case "comments" => commentCount + case "updated" => t1.updatedDate + }) match { + case sort => condition.direction match { + case "asc" => sort asc + case "desc" => sort desc + } } } - } - .map { case (t1, t2) => (t1, t2._2.ifNull(0)) } - .drop(offset).take(limit) - .list - - // get labels - val labels = Query(IssueLabels) - .innerJoin(Labels).on { (t1, t2) => - t1.byLabel(t2.userName, t2.repositoryName, t2.labelId) - } - .filter { case (t1, t2) => - (t1.byRepository(owner, repository)) && - (t1.issueId inSetBind (issues.map(_._1.issueId))) - } - .sortBy { case (t1, t2) => t1.issueId ~ t2.labelName } - .map { case (t1, t2) => (t1.issueId, t2) } - .list - - issues.map { case (issue, commentCount) => - (issue, labels.collect { case (issueId, labels) if(issueId == issue.issueId) => labels }, commentCount) - } + .drop(offset).take(limit) + .list + .splitWith(_._1.issueId == _._1.issueId) + .map { issues => issues.head match { + case (issue, commentCount, _,_,_) => + (issue, + issues.flatMap { t => t._3.map ( + Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get) + )} toList, + commentCount) + }} toList } /** @@ -247,42 +238,47 @@ */ def searchIssuesByKeyword(owner: String, repository: String, query: String): List[(Issue, Int, String)] = { import scala.slick.driver.H2Driver.likeEncode - val keywords = StringUtil.splitWords(query.toLowerCase) + val keywords = splitWords(query.toLowerCase) // Search Issue - val issues = Query(Issues).filter { t => - keywords.map { keyword => - (t.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || - (t.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) - } .reduceLeft(_ && _) - }.map { t => (t, 0, t.content.?) } + val issues = Issues + .innerJoin(IssueOutline).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .filter { case (t1, t2) => + keywords.map { keyword => + (t1.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || + (t1.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) + } .reduceLeft(_ && _) + } + .map { case (t1, t2) => + (t1, 0, t1.content.?, t2.commentCount) + } // Search IssueComment - val comments = Query(IssueComments).innerJoin(Issues).on { case (t1, t2) => - t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) - }.filter { case (t1, t2) => - keywords.map { query => - t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') - }.reduceLeft(_ && _) - }.map { case (t1, t2) => (t2, t1.commentId, t1.content.?) } + val comments = IssueComments + .innerJoin(Issues).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .innerJoin(IssueOutline).on { case ((t1, t2), t3) => + t2.byIssue(t3.userName, t3.repositoryName, t3.issueId) + } + .filter { case ((t1, t2), t3) => + keywords.map { query => + t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') + }.reduceLeft(_ && _) + } + .map { case ((t1, t2), t3) => + (t2, t1.commentId, t1.content.?, t3.commentCount) + } - def getCommentCount(issue: Issue): Int = { - Query(IssueComments) - .filter { t => - t.byIssue(issue.userName, issue.repositoryName, issue.issueId) && - (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) - } - .map(_.issueId) - .list.length - } - - issues.union(comments).sortBy { case (issue, commentId, _) => + issues.union(comments).sortBy { case (issue, commentId, _, _) => issue.issueId ~ commentId - }.list.splitWith { case ((issue1, _, _), (issue2, _, _)) => + }.list.splitWith { case ((issue1, _, _, _), (issue2, _, _, _)) => issue1.issueId == issue2.issueId - }.map { result => - val (issue, _, content) = result.head - (issue, getCommentCount(issue) , content.getOrElse("")) + }.map { _.head match { + case (issue, _, content, commentCount) => (issue, commentCount, content.getOrElse("")) + } }.toList } @@ -290,13 +286,13 @@ object IssuesService { import javax.servlet.http.HttpServletRequest - import util.StringUtil._ val IssueLimit = 30 case class IssueSearchCondition( labels: Set[String] = Set.empty, milestoneId: Option[Option[Int]] = None, + repo: Option[String] = None, state: String = "open", sort: String = "created", direction: String = "desc"){ @@ -308,6 +304,7 @@ case Some(x) => x.toString case None => "none" })}, + repo.map("for=" + urlEncode(_)), Some("state=" + urlEncode(state)), Some("sort=" + urlEncode(sort)), Some("direction=" + urlEncode(direction))).flatten.mkString("&") @@ -328,6 +325,7 @@ case "none" => None case x => Some(x.toInt) }), + param(request, "for"), param(request, "state", Seq("open", "closed")).getOrElse("open"), param(request, "sort", Seq("created", "comments", "updated")).getOrElse("created"), param(request, "direction", Seq("asc", "desc")).getOrElse("desc")) diff --git a/src/main/scala/service/RepositorySearchService.scala b/src/main/scala/service/RepositorySearchService.scala index 669a36b..4807421 100644 --- a/src/main/scala/service/RepositorySearchService.scala +++ b/src/main/scala/service/RepositorySearchService.scala @@ -29,20 +29,24 @@ def countFiles(owner: String, repository: String, query: String): Int = JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => - searchRepositoryFiles(git, query).length + if(JGitUtil.isEmpty(git)) 0 else searchRepositoryFiles(git, query).length } def searchFiles(owner: String, repository: String, query: String): List[FileSearchResult] = JGitUtil.withGit(getRepositoryDir(owner, repository)){ git => - val files = searchRepositoryFiles(git, query) - val commits = JGitUtil.getLatestCommitFromPaths(git, files.toList.map(_._1), "HEAD") - files.map { case (path, text) => - val (highlightText, lineNumber) = getHighlightText(text, query) - FileSearchResult( - path, - commits(path).getCommitterIdent.getWhen, - highlightText, - lineNumber) + if(JGitUtil.isEmpty(git)){ + Nil + } else { + val files = searchRepositoryFiles(git, query) + val commits = JGitUtil.getLatestCommitFromPaths(git, files.toList.map(_._1), "HEAD") + files.map { case (path, text) => + val (highlightText, lineNumber) = getHighlightText(text, query) + FileSearchResult( + path, + commits(path).getCommitterIdent.getWhen, + highlightText, + lineNumber) + } } } @@ -118,4 +122,4 @@ highlightText: String, highlightLineNumber: Int) -} \ No newline at end of file +} diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index f2f87d1..49f30aa 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -53,14 +53,11 @@ */ def getWikiPage(owner: String, repository: String, pageName: String): Option[WikiPageInfo] = { JGitUtil.withGit(Directory.getWikiRepositoryDir(owner, repository)){ git => - try { + if(!JGitUtil.isEmpty(git)){ JGitUtil.getFileList(git, "master", ".").find(_.name == pageName + ".md").map { file => WikiPageInfo(file.name, new String(git.getRepository.open(file.id).getBytes, "UTF-8"), file.committer, file.time) } - } catch { - // TODO no commit, but it should not judge by exception. - case e: NullPointerException => None - } + } else None } } @@ -69,7 +66,7 @@ */ def getFileContent(owner: String, repository: String, path: String): Option[Array[Byte]] = { JGitUtil.withGit(Directory.getWikiRepositoryDir(owner, repository)){ git => - try { + if(!JGitUtil.isEmpty(git)){ val index = path.lastIndexOf('/') val parentPath = if(index < 0) "." else path.substring(0, index) val fileName = if(index < 0) path else path.substring(index + 1) @@ -77,10 +74,7 @@ JGitUtil.getFileList(git, "master", parentPath).find(_.name == fileName).map { file => git.getRepository.open(file.id).getBytes } - } catch { - // TODO no commit, but it should not judge by exception. - case e: NullPointerException => None - } + } else None } } diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index aaba45a..2c44e85 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -537,6 +537,8 @@ } } + def isEmpty(git: Git): Boolean = git.getRepository.resolve(Constants.HEAD) == null + private def setReceivePack(repository: org.eclipse.jgit.lib.Repository): Unit = { val config = repository.getConfig config.setBoolean("http", null, "receivepack", true) @@ -553,4 +555,4 @@ }.find(_._1 != null) } -} \ No newline at end of file +} diff --git a/src/main/twirl/dashboard/issues.scala.html b/src/main/twirl/dashboard/issues.scala.html new file mode 100644 index 0000000..b1839ab --- /dev/null +++ b/src/main/twirl/dashboard/issues.scala.html @@ -0,0 +1,48 @@ +@(listparts: twirl.api.Html, + allCount: Int, + assignedCount: Int, + createdByCount: Int, + repositories: List[service.RepositoryService.RepositoryInfo], + condition: service.IssuesService.IssueSearchCondition, + filter: String)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Your Issues"){ +@dashboard.html.tab("issues") +
+
+ +
+ +
+ @listparts +
+} diff --git a/src/main/twirl/dashboard/tab.scala.html b/src/main/twirl/dashboard/tab.scala.html new file mode 100644 index 0000000..1ffc620 --- /dev/null +++ b/src/main/twirl/dashboard/tab.scala.html @@ -0,0 +1,8 @@ +@(active: String = "")(implicit context: app.Context) +@import context._ + diff --git a/src/main/twirl/index.scala.html b/src/main/twirl/index.scala.html index e2feac5..c15f057 100644 --- a/src/main/twirl/index.scala.html +++ b/src/main/twirl/index.scala.html @@ -5,9 +5,9 @@ @import context._ @import view.helpers._ @main("GitBucket"){ +@dashboard.html.tab()
-

News Feed

@helper.html.activities(activities)
diff --git a/src/main/twirl/issues/list.scala.html b/src/main/twirl/issues/list.scala.html index b028b65..d9ce5fc 100644 --- a/src/main/twirl/issues/list.scala.html +++ b/src/main/twirl/issues/list.scala.html @@ -131,158 +131,8 @@ @_root_.issues.labels.html.edit(None, repository) }
-
- @if(condition.labels.nonEmpty || condition.milestoneId.isDefined){ - - Clear milestone and label filters - - } -
- @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), service.IssuesService.IssueLimit, 7, condition.toURL) -
- -
- - -
- - @if(issues.isEmpty){ - - - - } else { - @if(hasWritePermission){ - - - - } - } - @issues.map { case (issue, labels, commentCount) => - - - - } -
- No issues to show. - @if(condition.labels.nonEmpty || condition.milestoneId.isDefined){ - Clear active filters. - } else { - Create a new issue. - } -
-
- -
- @helper.html.dropdown("Label") { - @labels.map { label => -
  • - - -   - @label.labelName - -
  • - } - } - @helper.html.dropdown("Assignee") { -
  • Clear assignee
  • - @collaborators.map { collaborator => -
  • @avatar(collaborator, 20) @collaborator
  • - } - } - @helper.html.dropdown("Milestone") { -
  • Clear this milestone
  • - @milestones.map { milestone => -
  • - - @milestone.title -
    - @milestone.dueDate.map { dueDate => - @if(isPast(dueDate)){ - Due in @date(dueDate) - } else { - Due in @date(dueDate) - } - }.getOrElse { - No due date - } -
    -
    -
  • - } - } -
    - @if(hasWritePermission){ - -
    -
    - @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), service.IssuesService.IssueLimit, 10, condition.toURL) -
    -
    + @***** show issue list *****@ + @listparts(issues, page, openCount, closedCount, condition, collaborators, milestones, labels, Some(repository), hasWritePermission)
    @if(hasWritePermission){
    diff --git a/src/main/twirl/issues/listparts.scala.html b/src/main/twirl/issues/listparts.scala.html new file mode 100644 index 0000000..3541dba --- /dev/null +++ b/src/main/twirl/issues/listparts.scala.html @@ -0,0 +1,176 @@ +@(issues: List[(model.Issue, List[model.Label], Int)], + page: Int, + openCount: Int, + closedCount: Int, + condition: service.IssuesService.IssueSearchCondition, + collaborators: List[String] = Nil, + milestones: List[model.Milestone] = Nil, + labels: List[model.Label] = Nil, + repository: Option[service.RepositoryService.RepositoryInfo] = None, + hasWritePermission: Boolean = false)(implicit context: app.Context) +@import context._ +@import view.helpers._ + +
    + @if(condition.labels.nonEmpty || condition.milestoneId.isDefined){ + + Clear milestone and label filters + + } + @if(condition.repo.isDefined){ + + Clear filter on @condition.repo + + } +
    + @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), service.IssuesService.IssueLimit, 7, condition.toURL) +
    + +
    + + +
    + + @if(issues.isEmpty){ + + + + } else { + @if(hasWritePermission){ + + + + } + } + @issues.map { case (issue, labels, commentCount) => + + + + } +
    + No issues to show. + @if(condition.labels.nonEmpty || condition.milestoneId.isDefined){ + Clear active filters. + } else { + @if(repository.isDefined){ + Create a new issue. + } + } +
    +
    + +
    + @helper.html.dropdown("Label") { + @labels.map { label => +
  • + + +   + @label.labelName + +
  • + } + } + @helper.html.dropdown("Assignee") { +
  • Clear assignee
  • + @collaborators.map { collaborator => +
  • @avatar(collaborator, 20) @collaborator
  • + } + } + @helper.html.dropdown("Milestone") { +
  • Clear this milestone
  • + @milestones.map { milestone => +
  • + + @milestone.title +
    + @milestone.dueDate.map { dueDate => + @if(isPast(dueDate)){ + Due in @date(dueDate) + } else { + Due in @date(dueDate) + } + }.getOrElse { + No due date + } +
    +
    +
  • + } + } +
    + @if(hasWritePermission){ + +
    +
    + @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), service.IssuesService.IssueLimit, 10, condition.toURL) +
    +
    + diff --git a/src/main/twirl/newrepo.scala.html b/src/main/twirl/newrepo.scala.html index 7cfcd01..f7575be 100644 --- a/src/main/twirl/newrepo.scala.html +++ b/src/main/twirl/newrepo.scala.html @@ -17,7 +17,7 @@
  • @avatar(groupName, 20) @groupName
  • } - + /