diff --git a/README.md b/README.md index fb22665..f36a135 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ Release Notes -------- +### 2.1 - xx Sep 2014 +- Upgrade to Slick 2.0 from 1.9 +- Base part of the plug-in system is merged +- Many bug fix and improvements + ### 2.0 - 31 May 2014 - Modern Github UI - Preview in AceEditor diff --git a/build.xml b/build.xml index 0babeba..1af360d 100644 --- a/build.xml +++ b/build.xml @@ -50,8 +50,8 @@ - + diff --git a/project/build.scala b/project/build.scala index 0b79bb0..bd092ff 100644 --- a/project/build.scala +++ b/project/build.scala @@ -39,7 +39,9 @@ "org.apache.httpcomponents" % "httpclient" % "4.3", "org.apache.sshd" % "apache-sshd" % "0.11.0", "com.typesafe.slick" %% "slick" % "2.0.2", + "org.mozilla" % "rhino" % "1.7R4", "com.novell.ldap" % "jldap" % "2009-10-07", + "org.quartz-scheduler" % "quartz" % "2.2.1", "com.h2database" % "h2" % "1.3.173", "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", diff --git a/src/main/resources/update/1_0.sql b/src/main/resources/update/1_0.sql index 418c3e3..5067ada 100644 --- a/src/main/resources/update/1_0.sql +++ b/src/main/resources/update/1_0.sql @@ -1,135 +1,135 @@ -CREATE TABLE ACCOUNT( - USER_NAME VARCHAR(100) NOT NULL, - MAIL_ADDRESS VARCHAR(100) NOT NULL, - PASSWORD VARCHAR(40) NOT NULL, - ADMINISTRATOR BOOLEAN NOT NULL, - URL VARCHAR(200), - REGISTERED_DATE TIMESTAMP NOT NULL, - UPDATED_DATE TIMESTAMP NOT NULL, - LAST_LOGIN_DATE TIMESTAMP -); - -CREATE TABLE REPOSITORY( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - PRIVATE BOOLEAN NOT NULL, - DESCRIPTION TEXT, - DEFAULT_BRANCH VARCHAR(100), - REGISTERED_DATE TIMESTAMP NOT NULL, - UPDATED_DATE TIMESTAMP NOT NULL, - LAST_ACTIVITY_DATE TIMESTAMP NOT NULL -); - -CREATE TABLE COLLABORATOR( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - COLLABORATOR_NAME VARCHAR(100) NOT NULL -); - -CREATE TABLE ISSUE( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - ISSUE_ID INT NOT NULL, - OPENED_USER_NAME VARCHAR(100) NOT NULL, - MILESTONE_ID INT, - ASSIGNED_USER_NAME VARCHAR(100), - TITLE TEXT NOT NULL, - CONTENT TEXT, - CLOSED BOOLEAN NOT NULL, - REGISTERED_DATE TIMESTAMP NOT NULL, - UPDATED_DATE TIMESTAMP NOT NULL -); - -CREATE TABLE ISSUE_ID( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - ISSUE_ID INT NOT NULL -); - -CREATE TABLE ISSUE_COMMENT( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - ISSUE_ID INT NOT NULL, - COMMENT_ID INT AUTO_INCREMENT, - ACTION VARCHAR(10), - COMMENTED_USER_NAME VARCHAR(100) NOT NULL, - CONTENT TEXT NOT NULL, - REGISTERED_DATE TIMESTAMP NOT NULL, - UPDATED_DATE TIMESTAMP NOT NULL -); - -CREATE TABLE LABEL( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - LABEL_ID INT AUTO_INCREMENT, - LABEL_NAME VARCHAR(100) NOT NULL, - COLOR CHAR(6) NOT NULL -); - -CREATE TABLE ISSUE_LABEL( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - ISSUE_ID INT NOT NULL, - LABEL_ID INT NOT NULL -); - -CREATE TABLE MILESTONE( - USER_NAME VARCHAR(100) NOT NULL, - REPOSITORY_NAME VARCHAR(100) NOT NULL, - MILESTONE_ID INT AUTO_INCREMENT, - TITLE VARCHAR(100) NOT NULL, - DESCRIPTION TEXT, - DUE_DATE TIMESTAMP, - CLOSED_DATE TIMESTAMP -); - -ALTER TABLE ACCOUNT ADD CONSTRAINT IDX_ACCOUNT_PK PRIMARY KEY (USER_NAME); -ALTER TABLE ACCOUNT ADD CONSTRAINT IDX_ACCOUNT_1 UNIQUE (MAIL_ADDRESS); - -ALTER TABLE REPOSITORY ADD CONSTRAINT IDX_REPOSITORY_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); -ALTER TABLE REPOSITORY ADD CONSTRAINT IDX_REPOSITORY_FK0 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); - -ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); -ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); -ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_FK1 FOREIGN KEY (COLLABORATOR_NAME) REFERENCES ACCOUNT (USER_NAME); - -ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_PK PRIMARY KEY (ISSUE_ID, USER_NAME, REPOSITORY_NAME); -ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); -ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK1 FOREIGN KEY (OPENED_USER_NAME) REFERENCES ACCOUNT (USER_NAME); -ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK2 FOREIGN KEY (MILESTONE_ID) REFERENCES MILESTONE (MILESTONE_ID); - -ALTER TABLE ISSUE_ID ADD CONSTRAINT IDX_ISSUE_ID_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); -ALTER TABLE ISSUE_ID ADD CONSTRAINT IDX_ISSUE_ID_FK1 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); - -ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_PK PRIMARY KEY (COMMENT_ID); -ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_1 UNIQUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID, COMMENT_ID); -ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); - -ALTER TABLE LABEL ADD CONSTRAINT IDX_LABEL_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, LABEL_ID); -ALTER TABLE LABEL ADD CONSTRAINT IDX_LABEL_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); - -ALTER TABLE ISSUE_LABEL ADD CONSTRAINT IDX_ISSUE_LABEL_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID, LABEL_ID); -ALTER TABLE ISSUE_LABEL ADD CONSTRAINT IDX_ISSUE_LABEL_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); - -ALTER TABLE MILESTONE ADD CONSTRAINT IDX_MILESTONE_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, MILESTONE_ID); -ALTER TABLE MILESTONE ADD CONSTRAINT IDX_MILESTONE_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); - -INSERT INTO ACCOUNT ( - USER_NAME, - MAIL_ADDRESS, - PASSWORD, - ADMINISTRATOR, - URL, - REGISTERED_DATE, - UPDATED_DATE, - LAST_LOGIN_DATE -) VALUES ( - 'root', - 'root@localhost', - 'dc76e9f0c0006e8f919e0c515c66dbba3982f785', - true, - 'https://github.com/takezoe/gitbucket', - SYSDATE, - SYSDATE, - NULL -); +CREATE TABLE ACCOUNT( + USER_NAME VARCHAR(100) NOT NULL, + MAIL_ADDRESS VARCHAR(100) NOT NULL, + PASSWORD VARCHAR(40) NOT NULL, + ADMINISTRATOR BOOLEAN NOT NULL, + URL VARCHAR(200), + REGISTERED_DATE TIMESTAMP NOT NULL, + UPDATED_DATE TIMESTAMP NOT NULL, + LAST_LOGIN_DATE TIMESTAMP +); + +CREATE TABLE REPOSITORY( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + PRIVATE BOOLEAN NOT NULL, + DESCRIPTION TEXT, + DEFAULT_BRANCH VARCHAR(100), + REGISTERED_DATE TIMESTAMP NOT NULL, + UPDATED_DATE TIMESTAMP NOT NULL, + LAST_ACTIVITY_DATE TIMESTAMP NOT NULL +); + +CREATE TABLE COLLABORATOR( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + COLLABORATOR_NAME VARCHAR(100) NOT NULL +); + +CREATE TABLE ISSUE( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + ISSUE_ID INT NOT NULL, + OPENED_USER_NAME VARCHAR(100) NOT NULL, + MILESTONE_ID INT, + ASSIGNED_USER_NAME VARCHAR(100), + TITLE TEXT NOT NULL, + CONTENT TEXT, + CLOSED BOOLEAN NOT NULL, + REGISTERED_DATE TIMESTAMP NOT NULL, + UPDATED_DATE TIMESTAMP NOT NULL +); + +CREATE TABLE ISSUE_ID( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + ISSUE_ID INT NOT NULL +); + +CREATE TABLE ISSUE_COMMENT( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + ISSUE_ID INT NOT NULL, + COMMENT_ID INT AUTO_INCREMENT, + ACTION VARCHAR(10), + COMMENTED_USER_NAME VARCHAR(100) NOT NULL, + CONTENT TEXT NOT NULL, + REGISTERED_DATE TIMESTAMP NOT NULL, + UPDATED_DATE TIMESTAMP NOT NULL +); + +CREATE TABLE LABEL( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + LABEL_ID INT AUTO_INCREMENT, + LABEL_NAME VARCHAR(100) NOT NULL, + COLOR CHAR(6) NOT NULL +); + +CREATE TABLE ISSUE_LABEL( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + ISSUE_ID INT NOT NULL, + LABEL_ID INT NOT NULL +); + +CREATE TABLE MILESTONE( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + MILESTONE_ID INT AUTO_INCREMENT, + TITLE VARCHAR(100) NOT NULL, + DESCRIPTION TEXT, + DUE_DATE TIMESTAMP, + CLOSED_DATE TIMESTAMP +); + +ALTER TABLE ACCOUNT ADD CONSTRAINT IDX_ACCOUNT_PK PRIMARY KEY (USER_NAME); +ALTER TABLE ACCOUNT ADD CONSTRAINT IDX_ACCOUNT_1 UNIQUE (MAIL_ADDRESS); + +ALTER TABLE REPOSITORY ADD CONSTRAINT IDX_REPOSITORY_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE REPOSITORY ADD CONSTRAINT IDX_REPOSITORY_FK0 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); + +ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE COLLABORATOR ADD CONSTRAINT IDX_COLLABORATOR_FK1 FOREIGN KEY (COLLABORATOR_NAME) REFERENCES ACCOUNT (USER_NAME); + +ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_PK PRIMARY KEY (ISSUE_ID, USER_NAME, REPOSITORY_NAME); +ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK1 FOREIGN KEY (OPENED_USER_NAME) REFERENCES ACCOUNT (USER_NAME); +ALTER TABLE ISSUE ADD CONSTRAINT IDX_ISSUE_FK2 FOREIGN KEY (MILESTONE_ID) REFERENCES MILESTONE (MILESTONE_ID); + +ALTER TABLE ISSUE_ID ADD CONSTRAINT IDX_ISSUE_ID_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME); +ALTER TABLE ISSUE_ID ADD CONSTRAINT IDX_ISSUE_ID_FK1 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); + +ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_PK PRIMARY KEY (COMMENT_ID); +ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_1 UNIQUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID, COMMENT_ID); +ALTER TABLE ISSUE_COMMENT ADD CONSTRAINT IDX_ISSUE_COMMENT_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); + +ALTER TABLE LABEL ADD CONSTRAINT IDX_LABEL_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, LABEL_ID); +ALTER TABLE LABEL ADD CONSTRAINT IDX_LABEL_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); + +ALTER TABLE ISSUE_LABEL ADD CONSTRAINT IDX_ISSUE_LABEL_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID, LABEL_ID); +ALTER TABLE ISSUE_LABEL ADD CONSTRAINT IDX_ISSUE_LABEL_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); + +ALTER TABLE MILESTONE ADD CONSTRAINT IDX_MILESTONE_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, MILESTONE_ID); +ALTER TABLE MILESTONE ADD CONSTRAINT IDX_MILESTONE_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); + +INSERT INTO ACCOUNT ( + USER_NAME, + MAIL_ADDRESS, + PASSWORD, + ADMINISTRATOR, + URL, + REGISTERED_DATE, + UPDATED_DATE, + LAST_LOGIN_DATE +) VALUES ( + 'root', + 'root@localhost', + 'dc76e9f0c0006e8f919e0c515c66dbba3982f785', + true, + 'https://github.com/takezoe/gitbucket', + SYSDATE, + SYSDATE, + NULL +); diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 90fe415..25dbf5b 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -1,4 +1,4 @@ -import _root_.servlet.{BasicAuthenticationFilter, TransactionFilter} +import _root_.servlet.{PluginActionInvokeFilter, BasicAuthenticationFilter, TransactionFilter} import app._ //import jp.sf.amateras.scalatra.forms.ValidationJavaScriptProvider import org.scalatra._ @@ -10,6 +10,8 @@ // Register TransactionFilter and BasicAuthenticationFilter at first context.addFilter("transactionFilter", new TransactionFilter) context.getFilterRegistration("transactionFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*") + context.addFilter("pluginActionInvokeFilter", new PluginActionInvokeFilter) + context.getFilterRegistration("pluginActionInvokeFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*") context.addFilter("basicAuthenticationFilter", new BasicAuthenticationFilter) context.getFilterRegistration("basicAuthenticationFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/git/*") diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index 1db5882..a908d76 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -292,7 +292,7 @@ * Create new repository. */ post("/new", newRepositoryForm)(usersOnly { form => - LockUtil.lock(s"${form.owner}/${form.name}/create"){ + LockUtil.lock(s"${form.owner}/${form.name}"){ if(getRepository(form.owner, form.name, context.baseUrl).isEmpty){ val ownerAccount = getAccountByUserName(form.owner).get val loginAccount = context.loginAccount.get @@ -355,7 +355,7 @@ val loginAccount = context.loginAccount.get val loginUserName = loginAccount.userName - LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ + LockUtil.lock(s"${loginUserName}/${repository.name}"){ if(repository.owner == loginUserName || getRepository(loginAccount.userName, repository.name, baseUrl).isDefined){ // redirect to the repository if repository already exists redirect(s"/${loginUserName}/${repository.name}") diff --git a/src/main/scala/app/DashboardController.scala b/src/main/scala/app/DashboardController.scala index 8fe73bf..bd788c9 100644 --- a/src/main/scala/app/DashboardController.scala +++ b/src/main/scala/app/DashboardController.scala @@ -1,109 +1,109 @@ -package app - -import service._ -import util.{UsersAuthenticator, Keys} -import util.Implicits._ - -class DashboardController extends DashboardControllerBase - with IssuesService with PullRequestService with RepositoryService with AccountService - with UsersAuthenticator - -trait DashboardControllerBase extends ControllerBase { - self: IssuesService with PullRequestService 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") - }) - - get("/dashboard/pulls")(usersOnly { - searchPullRequests("created_by", None) - }) - - get("/dashboard/pulls/owned")(usersOnly { - searchPullRequests("created_by", None) - }) - - get("/dashboard/pulls/public")(usersOnly { - searchPullRequests("not_created_by", None) - }) - - get("/dashboard/pulls/for/:owner/:repository")(usersOnly { - searchPullRequests("all", Some(params("owner") + "/" + params("repository"))) - }) - - private def searchIssues(filter: String) = { - import IssuesService._ - - // condition - val condition = session.putAndGet(Keys.Session.DashboardIssues, - if(request.hasQueryString) IssueSearchCondition(request) - else session.getAs[IssueSearchCondition](Keys.Session.DashboardIssues).getOrElse(IssueSearchCondition()) - ) - - val userName = context.loginAccount.get.userName - val repositories = getUserRepositories(userName, context.baseUrl).map(repo => repo.owner -> repo.name) - val filterUser = Map(filter -> userName) - val page = IssueSearchCondition.page(request) - // - dashboard.html.issues( - issues.html.listparts( - searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, repositories: _*), - page, - countIssue(condition.copy(state = "open"), filterUser, false, repositories: _*), - countIssue(condition.copy(state = "closed"), filterUser, false, repositories: _*), - condition), - countIssue(condition, Map.empty, false, repositories: _*), - countIssue(condition, Map("assigned" -> userName), false, repositories: _*), - countIssue(condition, Map("created_by" -> userName), false, repositories: _*), - countIssueGroupByRepository(condition, filterUser, false, repositories: _*), - condition, - filter) - - } - - private def searchPullRequests(filter: String, repository: Option[String]) = { - import IssuesService._ - import PullRequestService._ - - // condition - val condition = session.putAndGet(Keys.Session.DashboardPulls, { - if(request.hasQueryString) IssueSearchCondition(request) - else session.getAs[IssueSearchCondition](Keys.Session.DashboardPulls).getOrElse(IssueSearchCondition()) - }.copy(repo = repository)) - - val userName = context.loginAccount.get.userName - val repositories = getUserRepositories(userName, context.baseUrl).map(repo => repo.owner -> repo.name) - val filterUser = Map(filter -> userName) - val page = IssueSearchCondition.page(request) - - val counts = countIssueGroupByRepository( - IssueSearchCondition().copy(state = condition.state), Map.empty, true, repositories: _*) - - dashboard.html.pulls( - pulls.html.listparts( - searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, repositories: _*), - page, - countIssue(condition.copy(state = "open"), filterUser, true, repositories: _*), - countIssue(condition.copy(state = "closed"), filterUser, true, repositories: _*), - condition, - None, - false), - getPullRequestCountGroupByUser(condition.state == "closed", userName, None), - getRepositoryNamesOfUser(userName).map { RepoName => - (userName, RepoName, counts.collectFirst { case (_, RepoName, count) => count }.getOrElse(0)) - }.sortBy(_._3).reverse, - condition, - filter) - - } - - -} +package app + +import service._ +import util.{UsersAuthenticator, Keys} +import util.Implicits._ + +class DashboardController extends DashboardControllerBase + with IssuesService with PullRequestService with RepositoryService with AccountService + with UsersAuthenticator + +trait DashboardControllerBase extends ControllerBase { + self: IssuesService with PullRequestService 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") + }) + + get("/dashboard/pulls")(usersOnly { + searchPullRequests("created_by", None) + }) + + get("/dashboard/pulls/owned")(usersOnly { + searchPullRequests("created_by", None) + }) + + get("/dashboard/pulls/public")(usersOnly { + searchPullRequests("not_created_by", None) + }) + + get("/dashboard/pulls/for/:owner/:repository")(usersOnly { + searchPullRequests("all", Some(params("owner") + "/" + params("repository"))) + }) + + private def searchIssues(filter: String) = { + import IssuesService._ + + // condition + val condition = session.putAndGet(Keys.Session.DashboardIssues, + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](Keys.Session.DashboardIssues).getOrElse(IssueSearchCondition()) + ) + + val userName = context.loginAccount.get.userName + val repositories = getUserRepositories(userName, context.baseUrl).map(repo => repo.owner -> repo.name) + val filterUser = Map(filter -> userName) + val page = IssueSearchCondition.page(request) + // + dashboard.html.issues( + issues.html.listparts( + searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, repositories: _*), + page, + countIssue(condition.copy(state = "open"), filterUser, false, repositories: _*), + countIssue(condition.copy(state = "closed"), filterUser, false, repositories: _*), + condition), + countIssue(condition, Map.empty, false, repositories: _*), + countIssue(condition, Map("assigned" -> userName), false, repositories: _*), + countIssue(condition, Map("created_by" -> userName), false, repositories: _*), + countIssueGroupByRepository(condition, filterUser, false, repositories: _*), + condition, + filter) + + } + + private def searchPullRequests(filter: String, repository: Option[String]) = { + import IssuesService._ + import PullRequestService._ + + // condition + val condition = session.putAndGet(Keys.Session.DashboardPulls, { + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](Keys.Session.DashboardPulls).getOrElse(IssueSearchCondition()) + }.copy(repo = repository)) + + val userName = context.loginAccount.get.userName + val repositories = getUserRepositories(userName, context.baseUrl).map(repo => repo.owner -> repo.name) + val filterUser = Map(filter -> userName) + val page = IssueSearchCondition.page(request) + + val counts = countIssueGroupByRepository( + IssueSearchCondition().copy(state = condition.state), Map.empty, true, repositories: _*) + + dashboard.html.pulls( + pulls.html.listparts( + searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, repositories: _*), + page, + countIssue(condition.copy(state = "open"), filterUser, true, repositories: _*), + countIssue(condition.copy(state = "closed"), filterUser, true, repositories: _*), + condition, + None, + false), + getPullRequestCountGroupByUser(condition.state == "closed", None, None), + getRepositoryNamesOfUser(userName).map { RepoName => + (userName, RepoName, counts.collectFirst { case (_, RepoName, count) => count }.getOrElse(0)) + }.sortBy(_._3).reverse, + condition, + filter) + + } + + +} diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index c3d1928..2925e8c 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -22,8 +22,8 @@ val loginAccount = context.loginAccount html.index(getRecentActivities(), - getVisibleRepositories(loginAccount, context.baseUrl), - loginAccount.map{ account => getUserRepositories(account.userName, context.baseUrl) }.getOrElse(Nil) + getVisibleRepositories(loginAccount, context.baseUrl, withoutPhysicalInfo = true), + loginAccount.map{ account => getUserRepositories(account.userName, context.baseUrl, withoutPhysicalInfo = true) }.getOrElse(Nil) ) } diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index 661c1e0..4f28466 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -1,403 +1,403 @@ -package app - -import jp.sf.amateras.scalatra.forms._ - -import service._ -import IssuesService._ -import util._ -import util.Implicits._ -import util.ControlUtil._ -import org.scalatra.Ok -import model.Issue - -class IssuesController extends IssuesControllerBase - with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator - -trait IssuesControllerBase extends ControllerBase { - self: IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator => - - case class IssueCreateForm(title: String, content: Option[String], - assignedUserName: Option[String], milestoneId: Option[Int], labelNames: Option[String]) - case class IssueEditForm(title: String, content: Option[String]) - case class CommentForm(issueId: Int, content: String) - case class IssueStateForm(issueId: Int, content: Option[String]) - - val issueCreateForm = mapping( - "title" -> trim(label("Title", text(required))), - "content" -> trim(optional(text())), - "assignedUserName" -> trim(optional(text())), - "milestoneId" -> trim(optional(number())), - "labelNames" -> trim(optional(text())) - )(IssueCreateForm.apply) - - val issueEditForm = mapping( - "title" -> trim(label("Title", text(required))), - "content" -> trim(optional(text())) - )(IssueEditForm.apply) - - val commentForm = mapping( - "issueId" -> label("Issue Id", number()), - "content" -> trim(label("Comment", text(required))) - )(CommentForm.apply) - - val issueStateForm = mapping( - "issueId" -> label("Issue Id", number()), - "content" -> trim(optional(text())) - )(IssueStateForm.apply) - - get("/:owner/:repository/issues")(referrersOnly { - searchIssues("all", _) - }) - - get("/:owner/:repository/issues/assigned/:userName")(referrersOnly { - searchIssues("assigned", _) - }) - - get("/:owner/:repository/issues/created_by/:userName")(referrersOnly { - searchIssues("created_by", _) - }) - - get("/:owner/:repository/issues/:id")(referrersOnly { repository => - defining(repository.owner, repository.name, params("id")){ case (owner, name, issueId) => - getIssue(owner, name, issueId) map { - issues.html.issue( - _, - getComments(owner, name, issueId.toInt), - getIssueLabels(owner, name, issueId.toInt), - (getCollaborators(owner, name) ::: (if(getAccountByUserName(owner).get.isGroupAccount) Nil else List(owner))).sorted, - getMilestonesWithIssueCount(owner, name), - getLabels(owner, name), - hasWritePermission(owner, name, context.loginAccount), - repository) - } getOrElse NotFound - } - }) - - get("/:owner/:repository/issues/new")(readableUsersOnly { repository => - defining(repository.owner, repository.name){ case (owner, name) => - issues.html.create( - (getCollaborators(owner, name) ::: (if(getAccountByUserName(owner).get.isGroupAccount) Nil else List(owner))).sorted, - getMilestones(owner, name), - getLabels(owner, name), - hasWritePermission(owner, name, context.loginAccount), - repository) - } - }) - - post("/:owner/:repository/issues/new", issueCreateForm)(readableUsersOnly { (form, repository) => - defining(repository.owner, repository.name){ case (owner, name) => - val writable = hasWritePermission(owner, name, context.loginAccount) - val userName = context.loginAccount.get.userName - - // insert issue - val issueId = createIssue(owner, name, userName, form.title, form.content, - if(writable) form.assignedUserName else None, - if(writable) form.milestoneId else None) - - // insert labels - if(writable){ - form.labelNames.map { value => - val labels = getLabels(owner, name) - value.split(",").foreach { labelName => - labels.find(_.labelName == labelName).map { label => - registerIssueLabel(owner, name, issueId, label.labelId) - } - } - } - } - - // record activity - recordCreateIssueActivity(owner, name, userName, issueId, form.title) - - // extract references and create refer comment - getIssue(owner, name, issueId.toString).foreach { issue => - createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) - } - - // notifications - Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ - Notifier.msgIssue(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") - } - - redirect(s"/${owner}/${name}/issues/${issueId}") - } - }) - - ajaxPost("/:owner/:repository/issues/edit/:id", issueEditForm)(readableUsersOnly { (form, repository) => - defining(repository.owner, repository.name){ case (owner, name) => - getIssue(owner, name, params("id")).map { issue => - if(isEditable(owner, name, issue.openedUserName)){ - // update issue - updateIssue(owner, name, issue.issueId, form.title, form.content) - // extract references and create refer comment - createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) - - redirect(s"/${owner}/${name}/issues/_data/${issue.issueId}") - } else Unauthorized - } getOrElse NotFound - } - }) - - post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, Some(form.content), repository)() map { case (issue, 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) => - redirect(s"/${repository.owner}/${repository.name}/${ - if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") - } getOrElse NotFound - }) - - ajaxPost("/:owner/:repository/issue_comments/edit/:id", commentForm)(readableUsersOnly { (form, repository) => - defining(repository.owner, repository.name){ case (owner, name) => - getComment(owner, name, params("id")).map { comment => - if(isEditable(owner, name, comment.commentedUserName)){ - updateComment(comment.commentId, form.content) - redirect(s"/${owner}/${name}/issue_comments/_data/${comment.commentId}") - } else Unauthorized - } getOrElse NotFound - } - }) - - ajaxPost("/:owner/:repository/issue_comments/delete/:id")(readableUsersOnly { repository => - defining(repository.owner, repository.name){ case (owner, name) => - getComment(owner, name, params("id")).map { comment => - if(isEditable(owner, name, comment.commentedUserName)){ - Ok(deleteComment(comment.commentId)) - } else Unauthorized - } getOrElse NotFound - } - }) - - ajaxGet("/:owner/:repository/issues/_data/:id")(readableUsersOnly { repository => - getIssue(repository.owner, repository.name, params("id")) map { x => - if(isEditable(x.userName, x.repositoryName, x.openedUserName)){ - params.get("dataType") collect { - case t if t == "html" => issues.html.editissue( - x.title, x.content, x.issueId, x.userName, x.repositoryName) - } getOrElse { - contentType = formats("json") - org.json4s.jackson.Serialization.write( - Map("title" -> x.title, - "content" -> view.Markdown.toHtml(x.content getOrElse "No description given.", - repository, false, true) - )) - } - } else Unauthorized - } getOrElse NotFound - }) - - ajaxGet("/:owner/:repository/issue_comments/_data/:id")(readableUsersOnly { repository => - getComment(repository.owner, repository.name, params("id")) map { x => - if(isEditable(x.userName, x.repositoryName, x.commentedUserName)){ - params.get("dataType") collect { - case t if t == "html" => issues.html.editcomment( - x.content, x.commentId, x.userName, x.repositoryName) - } getOrElse { - contentType = formats("json") - org.json4s.jackson.Serialization.write( - Map("content" -> view.Markdown.toHtml(x.content, - repository, false, true) - )) - } - } else Unauthorized - } getOrElse NotFound - }) - - ajaxPost("/:owner/:repository/issues/:id/label/new")(collaboratorsOnly { repository => - defining(params("id").toInt){ issueId => - registerIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) - issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) - } - }) - - ajaxPost("/:owner/:repository/issues/:id/label/delete")(collaboratorsOnly { repository => - defining(params("id").toInt){ issueId => - deleteIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) - issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) - } - }) - - ajaxPost("/:owner/:repository/issues/:id/assign")(collaboratorsOnly { repository => - updateAssignedUserName(repository.owner, repository.name, params("id").toInt, assignedUserName("assignedUserName")) - Ok("updated") - }) - - ajaxPost("/:owner/:repository/issues/:id/milestone")(collaboratorsOnly { repository => - updateMilestoneId(repository.owner, repository.name, params("id").toInt, milestoneId("milestoneId")) - milestoneId("milestoneId").map { milestoneId => - getMilestonesWithIssueCount(repository.owner, repository.name) - .find(_._1.milestoneId == milestoneId).map { case (_, openCount, closeCount) => - issues.milestones.html.progress(openCount + closeCount, closeCount, false) - } getOrElse NotFound - } getOrElse Ok() - }) - - post("/:owner/:repository/issues/batchedit/state")(collaboratorsOnly { repository => - defining(params.get("value")){ action => - executeBatch(repository) { - handleComment(_, None, repository)( _ => action) - } - } - }) - - post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => - params("value").toIntOpt.map{ labelId => - executeBatch(repository) { issueId => - getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { - registerIssueLabel(repository.owner, repository.name, issueId, labelId) - } - } - } getOrElse NotFound - }) - - post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => - defining(assignedUserName("value")){ value => - executeBatch(repository) { - updateAssignedUserName(repository.owner, repository.name, _, value) - } - } - }) - - post("/:owner/:repository/issues/batchedit/milestone")(collaboratorsOnly { repository => - defining(milestoneId("value")){ value => - executeBatch(repository) { - updateMilestoneId(repository.owner, repository.name, _, value) - } - } - }) - - get("/:owner/:repository/_attached/:file")(referrersOnly { repository => - (Directory.getAttachedDir(repository.owner, repository.name) match { - case dir if(dir.exists && dir.isDirectory) => - dir.listFiles.find(_.getName.startsWith(params("file") + ".")).map { file => - contentType = FileUtil.getMimeType(file.getName) - file - } - case _ => None - }) getOrElse NotFound - }) - - val assignedUserName = (key: String) => params.get(key) filter (_.trim != "") - val milestoneId: String => Option[Int] = (key: String) => params.get(key).flatMap(_.toIntOpt) - - private def isEditable(owner: String, repository: String, author: String)(implicit context: app.Context): Boolean = - hasWritePermission(owner, repository, context.loginAccount) || author == context.loginAccount.get.userName - - private def executeBatch(repository: RepositoryService.RepositoryInfo)(execute: Int => Unit) = { - params("checked").split(',') map(_.toInt) foreach execute - redirect(s"/${repository.owner}/${repository.name}/issues") - } - - private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = { - StringUtil.extractIssueId(message).foreach { issueId => - if(getIssue(owner, repository, issueId).isDefined){ - createComment(owner, repository, context.loginAccount.get.userName, issueId.toInt, - fromIssue.issueId + ":" + fromIssue.title, "refer") - } - } - } - - /** - * @see [[https://github.com/takezoe/gitbucket/wiki/CommentAction]] - */ - private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) - (getAction: model.Issue => Option[String] = - p1 => params.get("action").filter(_ => isEditable(p1.userName, p1.repositoryName, p1.openedUserName))) = { - - defining(repository.owner, repository.name){ case (owner, name) => - val userName = context.loginAccount.get.userName - - getIssue(owner, name, issueId.toString) map { issue => - val (action, recordActivity) = - getAction(issue) - .collect { - 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) - t - } - .getOrElse(None -> None) - - val commentId = content - .map ( _ -> action.map( _ + "_comment" ).getOrElse("comment") ) - .getOrElse ( action.get.capitalize -> action.get ) - match { - case (content, action) => createComment(owner, name, userName, issueId, content, action) - } - - // record activity - content foreach { - (if(issue.isPullRequest) recordCommentPullRequestActivity _ else recordCommentIssueActivity _) - (owner, name, userName, issueId, _) - } - recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - - // extract references and create refer comment - content.map { content => - createReferComment(owner, name, issue, content) - } - - // notifications - Notifier() match { - case f => - content foreach { - f.toNotify(repository, issueId, _){ - Notifier.msgComment(s"${context.baseUrl}/${owner}/${name}/${ - if(issue.isPullRequest) "pull" else "issues"}/${issueId}#comment-${commentId}") - } - } - action foreach { - f.toNotify(repository, issueId, _){ - Notifier.msgStatus(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") - } - } - } - - issue -> commentId - } - } - } - - private def searchIssues(filter: String, repository: RepositoryService.RepositoryInfo) = { - defining(repository.owner, repository.name){ case (owner, repoName) => - val filterUser = Map(filter -> params.getOrElse("userName", "")) - val page = IssueSearchCondition.page(request) - val sessionKey = Keys.Session.Issues(owner, repoName) - - // retrieve search condition - val condition = session.putAndGet(sessionKey, - if(request.hasQueryString) IssueSearchCondition(request) - else session.getAs[IssueSearchCondition](sessionKey).getOrElse(IssueSearchCondition()) - ) - - issues.html.list( - searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, owner -> repoName), - page, - (getCollaborators(owner, repoName) :+ owner).sorted, - getMilestones(owner, repoName), - getLabels(owner, repoName), - countIssue(condition.copy(state = "open"), filterUser, false, owner -> repoName), - countIssue(condition.copy(state = "closed"), filterUser, false, owner -> repoName), - countIssue(condition, Map.empty, false, owner -> repoName), - context.loginAccount.map(x => countIssue(condition, Map("assigned" -> x.userName), false, owner -> repoName)), - context.loginAccount.map(x => countIssue(condition, Map("created_by" -> x.userName), false, owner -> repoName)), - countIssueGroupByLabels(owner, repoName, condition, filterUser), - condition, - filter, - repository, - hasWritePermission(owner, repoName, context.loginAccount)) - } - } - -} +package app + +import jp.sf.amateras.scalatra.forms._ + +import service._ +import IssuesService._ +import util._ +import util.Implicits._ +import util.ControlUtil._ +import org.scalatra.Ok +import model.Issue + +class IssuesController extends IssuesControllerBase + with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator + +trait IssuesControllerBase extends ControllerBase { + self: IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator => + + case class IssueCreateForm(title: String, content: Option[String], + assignedUserName: Option[String], milestoneId: Option[Int], labelNames: Option[String]) + case class IssueEditForm(title: String, content: Option[String]) + case class CommentForm(issueId: Int, content: String) + case class IssueStateForm(issueId: Int, content: Option[String]) + + val issueCreateForm = mapping( + "title" -> trim(label("Title", text(required))), + "content" -> trim(optional(text())), + "assignedUserName" -> trim(optional(text())), + "milestoneId" -> trim(optional(number())), + "labelNames" -> trim(optional(text())) + )(IssueCreateForm.apply) + + val issueEditForm = mapping( + "title" -> trim(label("Title", text(required))), + "content" -> trim(optional(text())) + )(IssueEditForm.apply) + + val commentForm = mapping( + "issueId" -> label("Issue Id", number()), + "content" -> trim(label("Comment", text(required))) + )(CommentForm.apply) + + val issueStateForm = mapping( + "issueId" -> label("Issue Id", number()), + "content" -> trim(optional(text())) + )(IssueStateForm.apply) + + get("/:owner/:repository/issues")(referrersOnly { + searchIssues("all", _) + }) + + get("/:owner/:repository/issues/assigned/:userName")(referrersOnly { + searchIssues("assigned", _) + }) + + get("/:owner/:repository/issues/created_by/:userName")(referrersOnly { + searchIssues("created_by", _) + }) + + get("/:owner/:repository/issues/:id")(referrersOnly { repository => + defining(repository.owner, repository.name, params("id")){ case (owner, name, issueId) => + getIssue(owner, name, issueId) map { + issues.html.issue( + _, + getComments(owner, name, issueId.toInt), + getIssueLabels(owner, name, issueId.toInt), + (getCollaborators(owner, name) ::: (if(getAccountByUserName(owner).get.isGroupAccount) Nil else List(owner))).sorted, + getMilestonesWithIssueCount(owner, name), + getLabels(owner, name), + hasWritePermission(owner, name, context.loginAccount), + repository) + } getOrElse NotFound + } + }) + + get("/:owner/:repository/issues/new")(readableUsersOnly { repository => + defining(repository.owner, repository.name){ case (owner, name) => + issues.html.create( + (getCollaborators(owner, name) ::: (if(getAccountByUserName(owner).get.isGroupAccount) Nil else List(owner))).sorted, + getMilestones(owner, name), + getLabels(owner, name), + hasWritePermission(owner, name, context.loginAccount), + repository) + } + }) + + post("/:owner/:repository/issues/new", issueCreateForm)(readableUsersOnly { (form, repository) => + defining(repository.owner, repository.name){ case (owner, name) => + val writable = hasWritePermission(owner, name, context.loginAccount) + val userName = context.loginAccount.get.userName + + // insert issue + val issueId = createIssue(owner, name, userName, form.title, form.content, + if(writable) form.assignedUserName else None, + if(writable) form.milestoneId else None) + + // insert labels + if(writable){ + form.labelNames.map { value => + val labels = getLabels(owner, name) + value.split(",").foreach { labelName => + labels.find(_.labelName == labelName).map { label => + registerIssueLabel(owner, name, issueId, label.labelId) + } + } + } + } + + // record activity + recordCreateIssueActivity(owner, name, userName, issueId, form.title) + + // extract references and create refer comment + getIssue(owner, name, issueId.toString).foreach { issue => + createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) + } + + // notifications + Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ + Notifier.msgIssue(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") + } + + redirect(s"/${owner}/${name}/issues/${issueId}") + } + }) + + ajaxPost("/:owner/:repository/issues/edit/:id", issueEditForm)(readableUsersOnly { (form, repository) => + defining(repository.owner, repository.name){ case (owner, name) => + getIssue(owner, name, params("id")).map { issue => + if(isEditable(owner, name, issue.openedUserName)){ + // update issue + updateIssue(owner, name, issue.issueId, form.title, form.content) + // extract references and create refer comment + createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) + + redirect(s"/${owner}/${name}/issues/_data/${issue.issueId}") + } else Unauthorized + } getOrElse NotFound + } + }) + + post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => + handleComment(form.issueId, Some(form.content), repository)() map { case (issue, 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) => + redirect(s"/${repository.owner}/${repository.name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${form.issueId}#comment-${id}") + } getOrElse NotFound + }) + + ajaxPost("/:owner/:repository/issue_comments/edit/:id", commentForm)(readableUsersOnly { (form, repository) => + defining(repository.owner, repository.name){ case (owner, name) => + getComment(owner, name, params("id")).map { comment => + if(isEditable(owner, name, comment.commentedUserName)){ + updateComment(comment.commentId, form.content) + redirect(s"/${owner}/${name}/issue_comments/_data/${comment.commentId}") + } else Unauthorized + } getOrElse NotFound + } + }) + + ajaxPost("/:owner/:repository/issue_comments/delete/:id")(readableUsersOnly { repository => + defining(repository.owner, repository.name){ case (owner, name) => + getComment(owner, name, params("id")).map { comment => + if(isEditable(owner, name, comment.commentedUserName)){ + Ok(deleteComment(comment.commentId)) + } else Unauthorized + } getOrElse NotFound + } + }) + + ajaxGet("/:owner/:repository/issues/_data/:id")(readableUsersOnly { repository => + getIssue(repository.owner, repository.name, params("id")) map { x => + if(isEditable(x.userName, x.repositoryName, x.openedUserName)){ + params.get("dataType") collect { + case t if t == "html" => issues.html.editissue( + x.title, x.content, x.issueId, x.userName, x.repositoryName) + } getOrElse { + contentType = formats("json") + org.json4s.jackson.Serialization.write( + Map("title" -> x.title, + "content" -> view.Markdown.toHtml(x.content getOrElse "No description given.", + repository, false, true) + )) + } + } else Unauthorized + } getOrElse NotFound + }) + + ajaxGet("/:owner/:repository/issue_comments/_data/:id")(readableUsersOnly { repository => + getComment(repository.owner, repository.name, params("id")) map { x => + if(isEditable(x.userName, x.repositoryName, x.commentedUserName)){ + params.get("dataType") collect { + case t if t == "html" => issues.html.editcomment( + x.content, x.commentId, x.userName, x.repositoryName) + } getOrElse { + contentType = formats("json") + org.json4s.jackson.Serialization.write( + Map("content" -> view.Markdown.toHtml(x.content, + repository, false, true) + )) + } + } else Unauthorized + } getOrElse NotFound + }) + + ajaxPost("/:owner/:repository/issues/:id/label/new")(collaboratorsOnly { repository => + defining(params("id").toInt){ issueId => + registerIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) + issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + } + }) + + ajaxPost("/:owner/:repository/issues/:id/label/delete")(collaboratorsOnly { repository => + defining(params("id").toInt){ issueId => + deleteIssueLabel(repository.owner, repository.name, issueId, params("labelId").toInt) + issues.html.labellist(getIssueLabels(repository.owner, repository.name, issueId)) + } + }) + + ajaxPost("/:owner/:repository/issues/:id/assign")(collaboratorsOnly { repository => + updateAssignedUserName(repository.owner, repository.name, params("id").toInt, assignedUserName("assignedUserName")) + Ok("updated") + }) + + ajaxPost("/:owner/:repository/issues/:id/milestone")(collaboratorsOnly { repository => + updateMilestoneId(repository.owner, repository.name, params("id").toInt, milestoneId("milestoneId")) + milestoneId("milestoneId").map { milestoneId => + getMilestonesWithIssueCount(repository.owner, repository.name) + .find(_._1.milestoneId == milestoneId).map { case (_, openCount, closeCount) => + issues.milestones.html.progress(openCount + closeCount, closeCount, false) + } getOrElse NotFound + } getOrElse Ok() + }) + + post("/:owner/:repository/issues/batchedit/state")(collaboratorsOnly { repository => + defining(params.get("value")){ action => + executeBatch(repository) { + handleComment(_, None, repository)( _ => action) + } + } + }) + + post("/:owner/:repository/issues/batchedit/label")(collaboratorsOnly { repository => + params("value").toIntOpt.map{ labelId => + executeBatch(repository) { issueId => + getIssueLabel(repository.owner, repository.name, issueId, labelId) getOrElse { + registerIssueLabel(repository.owner, repository.name, issueId, labelId) + } + } + } getOrElse NotFound + }) + + post("/:owner/:repository/issues/batchedit/assign")(collaboratorsOnly { repository => + defining(assignedUserName("value")){ value => + executeBatch(repository) { + updateAssignedUserName(repository.owner, repository.name, _, value) + } + } + }) + + post("/:owner/:repository/issues/batchedit/milestone")(collaboratorsOnly { repository => + defining(milestoneId("value")){ value => + executeBatch(repository) { + updateMilestoneId(repository.owner, repository.name, _, value) + } + } + }) + + get("/:owner/:repository/_attached/:file")(referrersOnly { repository => + (Directory.getAttachedDir(repository.owner, repository.name) match { + case dir if(dir.exists && dir.isDirectory) => + dir.listFiles.find(_.getName.startsWith(params("file") + ".")).map { file => + contentType = FileUtil.getMimeType(file.getName) + file + } + case _ => None + }) getOrElse NotFound + }) + + val assignedUserName = (key: String) => params.get(key) filter (_.trim != "") + val milestoneId: String => Option[Int] = (key: String) => params.get(key).flatMap(_.toIntOpt) + + private def isEditable(owner: String, repository: String, author: String)(implicit context: app.Context): Boolean = + hasWritePermission(owner, repository, context.loginAccount) || author == context.loginAccount.get.userName + + private def executeBatch(repository: RepositoryService.RepositoryInfo)(execute: Int => Unit) = { + params("checked").split(',') map(_.toInt) foreach execute + redirect(s"/${repository.owner}/${repository.name}/issues") + } + + private def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String) = { + StringUtil.extractIssueId(message).foreach { issueId => + if(getIssue(owner, repository, issueId).isDefined){ + createComment(owner, repository, context.loginAccount.get.userName, issueId.toInt, + fromIssue.issueId + ":" + fromIssue.title, "refer") + } + } + } + + /** + * @see [[https://github.com/takezoe/gitbucket/wiki/CommentAction]] + */ + private def handleComment(issueId: Int, content: Option[String], repository: RepositoryService.RepositoryInfo) + (getAction: model.Issue => Option[String] = + p1 => params.get("action").filter(_ => isEditable(p1.userName, p1.repositoryName, p1.openedUserName))) = { + + defining(repository.owner, repository.name){ case (owner, name) => + val userName = context.loginAccount.get.userName + + getIssue(owner, name, issueId.toString) map { issue => + val (action, recordActivity) = + getAction(issue) + .collect { + 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) + t + } + .getOrElse(None -> None) + + val commentId = content + .map ( _ -> action.map( _ + "_comment" ).getOrElse("comment") ) + .getOrElse ( action.get.capitalize -> action.get ) + match { + case (content, action) => createComment(owner, name, userName, issueId, content, action) + } + + // record activity + content foreach { + (if(issue.isPullRequest) recordCommentPullRequestActivity _ else recordCommentIssueActivity _) + (owner, name, userName, issueId, _) + } + recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) + + // extract references and create refer comment + content.map { content => + createReferComment(owner, name, issue, content) + } + + // notifications + Notifier() match { + case f => + content foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgComment(s"${context.baseUrl}/${owner}/${name}/${ + if(issue.isPullRequest) "pull" else "issues"}/${issueId}#comment-${commentId}") + } + } + action foreach { + f.toNotify(repository, issueId, _){ + Notifier.msgStatus(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") + } + } + } + + issue -> commentId + } + } + } + + private def searchIssues(filter: String, repository: RepositoryService.RepositoryInfo) = { + defining(repository.owner, repository.name){ case (owner, repoName) => + val filterUser = Map(filter -> params.getOrElse("userName", "")) + val page = IssueSearchCondition.page(request) + val sessionKey = Keys.Session.Issues(owner, repoName) + + // retrieve search condition + val condition = session.putAndGet(sessionKey, + if(request.hasQueryString) IssueSearchCondition(request) + else session.getAs[IssueSearchCondition](sessionKey).getOrElse(IssueSearchCondition()) + ) + + issues.html.list( + searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, owner -> repoName), + page, + (getCollaborators(owner, repoName) :+ owner).sorted, + getMilestones(owner, repoName), + getLabels(owner, repoName), + countIssue(condition.copy(state = "open"), filterUser, false, owner -> repoName), + countIssue(condition.copy(state = "closed"), filterUser, false, owner -> repoName), + countIssue(condition, Map.empty, false, owner -> repoName), + context.loginAccount.map(x => countIssue(condition, Map("assigned" -> x.userName), false, owner -> repoName)), + context.loginAccount.map(x => countIssue(condition, Map("created_by" -> x.userName), false, owner -> repoName)), + countIssueGroupByLabels(owner, repoName, condition, filterUser), + condition, + filter, + repository, + hasWritePermission(owner, repoName, context.loginAccount)) + } + } + +} diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala index 0f08602..41d35bd 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -13,7 +13,6 @@ import service.IssuesService._ import service.PullRequestService._ import util.JGitUtil.DiffInfo -import service.RepositoryService.RepositoryTreeNode import util.JGitUtil.CommitInfo import org.slf4j.LoggerFactory import org.eclipse.jgit.merge.MergeStrategy @@ -124,7 +123,7 @@ params("id").toIntOpt.flatMap { issueId => val owner = repository.owner val name = repository.name - LockUtil.lock(s"${owner}/${name}/merge"){ + LockUtil.lock(s"${owner}/${name}"){ getPullRequest(owner, name, issueId).map { case (issue, pullreq) => using(Git.open(getRepositoryDir(owner, name))) { git => // mark issue as merged and close. @@ -367,7 +366,7 @@ */ private def checkConflict(userName: String, repositoryName: String, branch: String, requestUserName: String, requestRepositoryName: String, requestBranch: String): Boolean = { - LockUtil.lock(s"${userName}/${repositoryName}/merge-check"){ + LockUtil.lock(s"${userName}/${repositoryName}"){ using(Git.open(getRepositoryDir(requestUserName, requestRepositoryName))) { git => val remoteRefName = s"refs/heads/${branch}" val tmpRefName = s"refs/merge-check/${userName}/${branch}" @@ -403,7 +402,7 @@ private def checkConflictInPullRequest(userName: String, repositoryName: String, branch: String, requestUserName: String, requestRepositoryName: String, requestBranch: String, issueId: Int): Boolean = { - LockUtil.lock(s"${userName}/${repositoryName}/merge") { + LockUtil.lock(s"${userName}/${repositoryName}") { using(Git.open(getRepositoryDir(userName, repositoryName))) { git => // merge val merger = MergeStrategy.RECURSIVE.newMerger(git.getRepository, true) @@ -466,7 +465,7 @@ pulls.html.list( searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, owner -> repoName), - getPullRequestCountGroupByUser(condition.state == "closed", owner, Some(repoName)), + getPullRequestCountGroupByUser(condition.state == "closed", Some(owner), Some(repoName)), userName, page, countIssue(condition.copy(state = "open" ), filterUser, true, owner -> repoName), diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index 41450ea..81ba552 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -4,12 +4,14 @@ import util.Directory._ import util.ControlUtil._ import util.Implicits._ -import util.{UsersAuthenticator, OwnerAuthenticator} +import util.{LockUtil, UsersAuthenticator, OwnerAuthenticator} import util.JGitUtil.CommitInfo import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils import org.scalatra.i18n.Messages import service.WebHookService.WebHookPayload +import util.JGitUtil.CommitInfo +import util.ControlUtil._ import org.eclipse.jgit.api.Git class RepositorySettingsController extends RepositorySettingsControllerBase @@ -186,15 +188,17 @@ post("/:owner/:repository/settings/transfer", transferForm)(ownerOnly { (form, repository) => // Change repository owner if(repository.owner != form.newOwner){ - // Update database - renameRepository(repository.owner, repository.name, form.newOwner, repository.name) - // Move git repository - defining(getRepositoryDir(repository.owner, repository.name)){ dir => - FileUtils.moveDirectory(dir, getRepositoryDir(form.newOwner, repository.name)) - } - // Move wiki repository - defining(getWikiRepositoryDir(repository.owner, repository.name)){ dir => - FileUtils.moveDirectory(dir, getWikiRepositoryDir(form.newOwner, repository.name)) + LockUtil.lock(s"${repository.owner}/${repository.name}"){ + // Update database + renameRepository(repository.owner, repository.name, form.newOwner, repository.name) + // Move git repository + defining(getRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getRepositoryDir(form.newOwner, repository.name)) + } + // Move wiki repository + defining(getWikiRepositoryDir(repository.owner, repository.name)){ dir => + FileUtils.moveDirectory(dir, getWikiRepositoryDir(form.newOwner, repository.name)) + } } } redirect(s"/${form.newOwner}/${repository.name}") @@ -204,12 +208,13 @@ * Delete the repository. */ post("/:owner/:repository/settings/delete")(ownerOnly { repository => - deleteRepository(repository.owner, repository.name) + LockUtil.lock(s"${repository.owner}/${repository.name}"){ + deleteRepository(repository.owner, repository.name) - FileUtils.deleteDirectory(getRepositoryDir(repository.owner, repository.name)) - FileUtils.deleteDirectory(getWikiRepositoryDir(repository.owner, repository.name)) - FileUtils.deleteDirectory(getTemporaryDir(repository.owner, repository.name)) - + FileUtils.deleteDirectory(getRepositoryDir(repository.owner, repository.name)) + FileUtils.deleteDirectory(getWikiRepositoryDir(repository.owner, repository.name)) + FileUtils.deleteDirectory(getTemporaryDir(repository.owner, repository.name)) + } redirect(s"/${repository.owner}") }) diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index 93036eb..da30699 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -3,8 +3,13 @@ import service.{AccountService, SystemSettingsService} import SystemSettingsService._ import util.AdminAuthenticator +import util.Directory._ +import util.ControlUtil._ import jp.sf.amateras.scalatra.forms._ import ssh.SshServer +import org.apache.commons.io.FileUtils +import java.io.FileInputStream +import plugin.{Plugin, PluginSystem} class SystemSettingsController extends SystemSettingsControllerBase with AccountService with AdminAuthenticator @@ -47,6 +52,11 @@ } else Nil } + private val pluginForm = mapping( + "pluginId" -> list(trim(label("", text()))) + )(PluginForm.apply) + + case class PluginForm(pluginIds: List[String]) get("/admin/system")(adminOnly { admin.html.system(flash.get("info")) @@ -71,4 +81,104 @@ redirect("/admin/system") }) +// TODO Enable commented code to enable plug-in system +// get("/admin/plugins")(adminOnly { +// val installedPlugins = plugin.PluginSystem.plugins +// val updatablePlugins = getAvailablePlugins(installedPlugins).filter(_.status == "updatable") +// admin.plugins.html.installed(installedPlugins, updatablePlugins) +// }) +// +// post("/admin/plugins/_update", pluginForm)(adminOnly { form => +// deletePlugins(form.pluginIds) +// installPlugins(form.pluginIds) +// redirect("/admin/plugins") +// }) +// +// post("/admin/plugins/_delete", pluginForm)(adminOnly { form => +// deletePlugins(form.pluginIds) +// redirect("/admin/plugins") +// }) +// +// get("/admin/plugins/available")(adminOnly { +// val installedPlugins = plugin.PluginSystem.plugins +// val availablePlugins = getAvailablePlugins(installedPlugins).filter(_.status == "available") +// admin.plugins.html.available(availablePlugins) +// }) +// +// post("/admin/plugins/_install", pluginForm)(adminOnly { form => +// installPlugins(form.pluginIds) +// redirect("/admin/plugins") +// }) + +// get("/admin/plugins/console")(adminOnly { +// admin.plugins.html.console() +// }) +// +// post("/admin/plugins/console")(adminOnly { +// val script = request.getParameter("script") +// val result = plugin.JavaScriptPlugin.evaluateJavaScript(script) +// Ok(result) +// }) + + // TODO Move these methods to PluginSystem or Service? + private def deletePlugins(pluginIds: List[String]): Unit = { + pluginIds.foreach { pluginId => + plugin.PluginSystem.uninstall(pluginId) + val dir = new java.io.File(PluginHome, pluginId) + if(dir.exists && dir.isDirectory){ + FileUtils.deleteQuietly(dir) + PluginSystem.uninstall(pluginId) + } + } + } + + private def installPlugins(pluginIds: List[String]): Unit = { + val dir = getPluginCacheDir() + val installedPlugins = plugin.PluginSystem.plugins + getAvailablePlugins(installedPlugins).filter(x => pluginIds.contains(x.id)).foreach { plugin => + val pluginDir = new java.io.File(PluginHome, plugin.id) + if(!pluginDir.exists){ + FileUtils.copyDirectory(new java.io.File(dir, plugin.repository + "/" + plugin.id), pluginDir) + } + PluginSystem.installPlugin(plugin.id) + } + } + + private def getAvailablePlugins(installedPlugins: List[Plugin]): List[SystemSettingsControllerBase.AvailablePlugin] = { + val repositoryRoot = getPluginCacheDir() + + if(repositoryRoot.exists && repositoryRoot.isDirectory){ + PluginSystem.repositories.flatMap { repo => + val repoDir = new java.io.File(repositoryRoot, repo.id) + if(repoDir.exists && repoDir.isDirectory){ + repoDir.listFiles.filter(d => d.isDirectory && !d.getName.startsWith(".")).map { plugin => + val propertyFile = new java.io.File(plugin, "plugin.properties") + val properties = new java.util.Properties() + if(propertyFile.exists && propertyFile.isFile){ + using(new FileInputStream(propertyFile)){ in => + properties.load(in) + } + } + SystemSettingsControllerBase.AvailablePlugin( + repository = repo.id, + id = properties.getProperty("id"), + version = properties.getProperty("version"), + author = properties.getProperty("author"), + url = properties.getProperty("url"), + description = properties.getProperty("description"), + status = installedPlugins.find(_.id == properties.getProperty("id")) match { + case Some(x) if(PluginSystem.isUpdatable(x.version, properties.getProperty("version")))=> "updatable" + case Some(x) => "installed" + case None => "available" + }) + } + } else Nil + } + } else Nil + } +} + +object SystemSettingsControllerBase { + case class AvailablePlugin(repository: String, id: String, version: String, + author: String, url: String, description: String, status: String) } diff --git a/src/main/scala/model/Account.scala b/src/main/scala/model/Account.scala index 7880f3e..ca7aff5 100644 --- a/src/main/scala/model/Account.scala +++ b/src/main/scala/model/Account.scala @@ -23,6 +23,7 @@ } case class Account( + userName: String, fullName: String, mailAddress: String, diff --git a/src/main/scala/plugin/JavaScriptPlugin.scala b/src/main/scala/plugin/JavaScriptPlugin.scala new file mode 100644 index 0000000..1a8d0e5 --- /dev/null +++ b/src/main/scala/plugin/JavaScriptPlugin.scala @@ -0,0 +1,88 @@ +package plugin + +import org.mozilla.javascript.{Context => JsContext} +import org.mozilla.javascript.{Function => JsFunction} +import scala.collection.mutable.ListBuffer +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} + +class JavaScriptPlugin(val id: String, val version: String, + val author: String, val url: String, val description: String) extends Plugin { + + private val repositoryMenuList = ListBuffer[RepositoryMenu]() + private val globalMenuList = ListBuffer[GlobalMenu]() + private val repositoryActionList = ListBuffer[Action]() + private val globalActionList = ListBuffer[Action]() + + def repositoryMenus : List[RepositoryMenu] = repositoryMenuList.toList + def globalMenus : List[GlobalMenu] = globalMenuList.toList + def repositoryActions : List[Action] = repositoryActionList.toList + def globalActions : List[Action] = globalActionList.toList + + def addRepositoryMenu(label: String, name: String, url: String, icon: String, condition: JsFunction): Unit = { + repositoryMenuList += RepositoryMenu(label, name, url, icon, (context) => { + val context = JsContext.enter() + try { + condition.call(context, condition, condition, Array(context)).asInstanceOf[Boolean] + } finally { + JsContext.exit() + } + }) + } + + def addGlobalMenu(label: String, url: String, icon: String, condition: JsFunction): Unit = { + globalMenuList += GlobalMenu(label, url, icon, (context) => { + val context = JsContext.enter() + try { + condition.call(context, condition, condition, Array(context)).asInstanceOf[Boolean] + } finally { + JsContext.exit() + } + }) + } + + def addGlobalAction(path: String, function: JsFunction): Unit = { + globalActionList += Action(path, (request, response) => { + val context = JsContext.enter() + try { + function.call(context, function, function, Array(request, response)) + } finally { + JsContext.exit() + } + }) + } + + def addRepositoryAction(path: String, function: JsFunction): Unit = { + repositoryActionList += Action(path, (request, response) => { + val context = JsContext.enter() + try { + function.call(context, function, function, Array(request, response)) + } finally { + JsContext.exit() + } + }) + } + +} + +object JavaScriptPlugin { + + def define(id: String, version: String, author: String, url: String, description: String) + = new JavaScriptPlugin(id, version, author, url, description) + + def evaluateJavaScript(script: String, vars: Map[String, Any] = Map.empty): Any = { + val context = JsContext.enter() + try { + val scope = context.initStandardObjects() + scope.put("PluginSystem", scope, PluginSystem) + scope.put("JavaScriptPlugin", scope, this) + vars.foreach { case (key, value) => + scope.put(key, scope, value) + } + val result = context.evaluateString(scope, script, "", 1, null) + result + } finally { + JsContext.exit + } + } + +} \ No newline at end of file diff --git a/src/main/scala/plugin/Plugin.scala b/src/main/scala/plugin/Plugin.scala new file mode 100644 index 0000000..59961fe --- /dev/null +++ b/src/main/scala/plugin/Plugin.scala @@ -0,0 +1,16 @@ +package plugin + +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} + +trait Plugin { + val id: String + val version: String + val author: String + val url: String + val description: String + + def repositoryMenus : List[RepositoryMenu] + def globalMenus : List[GlobalMenu] + def repositoryActions : List[Action] + def globalActions : List[Action] +} diff --git a/src/main/scala/plugin/PluginSystem.scala b/src/main/scala/plugin/PluginSystem.scala new file mode 100644 index 0000000..e40f860 --- /dev/null +++ b/src/main/scala/plugin/PluginSystem.scala @@ -0,0 +1,123 @@ +package plugin + +import app.Context +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import util.Directory._ +import util.ControlUtil._ +import org.apache.commons.io.FileUtils +import util.JGitUtil +import org.eclipse.jgit.api.Git + +/** + * Provides extension points to plug-ins. + */ +object PluginSystem { + + private val logger = LoggerFactory.getLogger(PluginSystem.getClass) + + private val initialized = new AtomicBoolean(false) + private val pluginsMap = scala.collection.mutable.Map[String, Plugin]() + private val repositoriesList = scala.collection.mutable.ListBuffer[PluginRepository]() + + def install(plugin: Plugin): Unit = { + pluginsMap.put(plugin.id, plugin) + } + + def plugins: List[Plugin] = pluginsMap.values.toList + + def uninstall(id: String): Unit = { + pluginsMap.remove(id) + } + + def repositories: List[PluginRepository] = repositoriesList.toList + + /** + * Initializes the plugin system. Load scripts from GITBUCKET_HOME/plugins. + */ + def init(): Unit = { + if(initialized.compareAndSet(false, true)){ + // Load installed plugins + val pluginDir = new java.io.File(PluginHome) + if(pluginDir.exists && pluginDir.isDirectory){ + pluginDir.listFiles.filter(f => f.isDirectory && !f.getName.startsWith(".")).foreach { dir => + installPlugin(dir.getName) + } + } + // Add default plugin repositories + repositoriesList += PluginRepository("central", "https://github.com/takezoe/gitbucket_plugins.git") + } + } + + // TODO Method name seems to not so good. + def installPlugin(id: String): Unit = { + val pluginDir = new java.io.File(PluginHome) + val javaScriptFile = new java.io.File(pluginDir, id + "/plugin.js") + + if(javaScriptFile.exists && javaScriptFile.isFile){ + val properties = new java.util.Properties() + using(new java.io.FileInputStream(new java.io.File(pluginDir, id + "/plugin.properties"))){ in => + properties.load(in) + } + + val script = FileUtils.readFileToString(javaScriptFile, "UTF-8") + try { + JavaScriptPlugin.evaluateJavaScript(script, Map( + "id" -> properties.getProperty("id"), + "version" -> properties.getProperty("version"), + "author" -> properties.getProperty("author"), + "url" -> properties.getProperty("url"), + "description" -> properties.getProperty("description") + )) + } catch { + case e: Exception => logger.warn(s"Error in plugin loading for ${javaScriptFile.getAbsolutePath}", e) + } + } + } + + def repositoryMenus : List[RepositoryMenu] = pluginsMap.values.flatMap(_.repositoryMenus).toList + def globalMenus : List[GlobalMenu] = pluginsMap.values.flatMap(_.globalMenus).toList + def repositoryActions : List[Action] = pluginsMap.values.flatMap(_.repositoryActions).toList + def globalActions : List[Action] = pluginsMap.values.flatMap(_.globalActions).toList + + // Case classes to hold plug-ins information internally in GitBucket + case class PluginRepository(id: String, url: String) + case class GlobalMenu(label: String, url: String, icon: String, condition: Context => Boolean) + case class RepositoryMenu(label: String, name: String, url: String, icon: String, condition: Context => Boolean) + case class Action(path: String, function: (HttpServletRequest, HttpServletResponse) => Any) + + /** + * Checks whether the plugin is updatable. + */ + def isUpdatable(oldVersion: String, newVersion: String): Boolean = { + if(oldVersion == newVersion){ + false + } else { + val dim1 = oldVersion.split("\\.").map(_.toInt) + val dim2 = newVersion.split("\\.").map(_.toInt) + dim1.zip(dim2).foreach { case (a, b) => + if(a < b){ + return true + } else if(a > b){ + return false + } + } + return false + } + } + + // TODO This is a test +// addGlobalMenu("Google", "http://www.google.co.jp/", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAEvwAABL8BkeKJvAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIgSURBVEiJtdZNiI1hFAfw36ORhSFFPgYLszOKJAsWRLGzks1gYyFZKFs7C7K2Y2XDRiwmq9kIJWQjJR9Tk48xRtTIRwjH4p473nm99yLNqdNTz/mf//+555x7ektEmEmbNaPs6OkUKKX0YBmWp6/IE8bwIs8xjEfEt0aiiJBl6sEuXMRLfEf8pX/PnIvJ0TPFWxE4+w+Ef/Kzbd5qDx5l8H8tkku7LG17gH7sxWatevdhEUoXsjda5RnDTZzH6jagtMe0lHIa23AJw3iOiSRZlmJ9mfcyfTzFl2AldmI3rkbEkbrAYKrX7S1eVRyWVnxhQ87eiLjQ+o2/mtyve+PuYy3W4+EfsP2/TVGKTHRI+Iz9Fdx8XOmAnZjGWRMYqoF/4ESW4hpOYk1iZ2WsLjDUTeBYBfgeuyux2XiNT5hXud+DD5W8Y90EtifoSfultfjx7MVtrKzcr8No5m7vJtCLx1hQJ8/4IZzClpyoy5ibsYUYQW81Z9o2jYgPeKr15+poEXE9+1XF9WIkOaasaV2P4k4pZUdDbEm+VEQcjIgtEfGxlLIVd/Gs6TX1MhzQquU3HK1t23f4IsuS94fxNXMO/MbXIDBg+tidw5yMbcCmylSdqWEH/kagYLKWeAt9Fcxi3KhhJuXq6SqQBMO15NDalvswmLWux4cbuToIbMS9BpJOfg8bm7imtmmTlVJWaa3hpnU9nufziBjtyDHTny0/AaA7Qnb4AM4aAAAAAElFTkSuQmCC") +// { context => context.loginAccount.isDefined } +// +// addRepositoryMenu("Board", "board", "/board", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAEvwAABL8BkeKJvAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIgSURBVEiJtdZNiI1hFAfw36ORhSFFPgYLszOKJAsWRLGzks1gYyFZKFs7C7K2Y2XDRiwmq9kIJWQjJR9Tk48xRtTIRwjH4p473nm99yLNqdNTz/mf//+555x7ektEmEmbNaPs6OkUKKX0YBmWp6/IE8bwIs8xjEfEt0aiiJBl6sEuXMRLfEf8pX/PnIvJ0TPFWxE4+w+Ef/Kzbd5qDx5l8H8tkku7LG17gH7sxWatevdhEUoXsjda5RnDTZzH6jagtMe0lHIa23AJw3iOiSRZlmJ9mfcyfTzFl2AldmI3rkbEkbrAYKrX7S1eVRyWVnxhQ87eiLjQ+o2/mtyve+PuYy3W4+EfsP2/TVGKTHRI+Iz9Fdx8XOmAnZjGWRMYqoF/4ESW4hpOYk1iZ2WsLjDUTeBYBfgeuyux2XiNT5hXud+DD5W8Y90EtifoSfultfjx7MVtrKzcr8No5m7vJtCLx1hQJ8/4IZzClpyoy5ibsYUYQW81Z9o2jYgPeKr15+poEXE9+1XF9WIkOaasaV2P4k4pZUdDbEm+VEQcjIgtEfGxlLIVd/Gs6TX1MhzQquU3HK1t23f4IsuS94fxNXMO/MbXIDBg+tidw5yMbcCmylSdqWEH/kagYLKWeAt9Fcxi3KhhJuXq6SqQBMO15NDalvswmLWux4cbuToIbMS9BpJOfg8bm7imtmmTlVJWaa3hpnU9nufziBjtyDHTny0/AaA7Qnb4AM4aAAAAAElFTkSuQmCC") +// { context => true} +// +// addGlobalAction("/hello"){ (request, response) => +// "Hello World!" +// } + +} + + diff --git a/src/main/scala/plugin/PluginUpdateJob.scala b/src/main/scala/plugin/PluginUpdateJob.scala new file mode 100644 index 0000000..36a70e8 --- /dev/null +++ b/src/main/scala/plugin/PluginUpdateJob.scala @@ -0,0 +1,67 @@ +package plugin + +import util.Directory._ +import org.eclipse.jgit.api.Git +import org.slf4j.LoggerFactory +import org.quartz.{Scheduler, JobExecutionContext, Job} +import org.quartz.JobBuilder._ +import org.quartz.TriggerBuilder._ +import org.quartz.SimpleScheduleBuilder._ + +class PluginUpdateJob extends Job { + + private val logger = LoggerFactory.getLogger(classOf[PluginUpdateJob]) + private var failedCount = 0 + + /** + * Clone or pull all plugin repositories + * + * TODO Support plugin repository access through the proxy server + */ + override def execute(context: JobExecutionContext): Unit = { + try { + if(failedCount > 3){ + logger.error("Skip plugin information updating because failed count is over limit") + } else { + logger.info("Start plugin information updating") + PluginSystem.repositories.foreach { repository => + logger.info(s"Updating ${repository.id}: ${repository.url}...") + val dir = getPluginCacheDir() + val repo = new java.io.File(dir, repository.id) + if(repo.exists){ + // pull if the repository is already cloned + Git.open(repo).pull().call() + } else { + // clone if the repository is not exist + Git.cloneRepository().setURI(repository.url).setDirectory(repo).call() + } + } + logger.info("End plugin information updating") + } + } catch { + case e: Exception => { + failedCount = failedCount + 1 + logger.error("Failed to update plugin information", e) + } + } + } +} + +object PluginUpdateJob { + + def schedule(scheduler: Scheduler): Unit = { +// TODO Enable commented code to enable plug-in system +// val job = newJob(classOf[PluginUpdateJob]) +// .withIdentity("pluginUpdateJob") +// .build() +// +// val trigger = newTrigger() +// .withIdentity("pluginUpdateTrigger") +// .startNow() +// .withSchedule(simpleSchedule().withIntervalInHours(24).repeatForever()) +// .build() +// +// scheduler.scheduleJob(job, trigger) + } + +} \ No newline at end of file diff --git a/src/main/scala/plugin/ScalaPlugin.scala b/src/main/scala/plugin/ScalaPlugin.scala new file mode 100644 index 0000000..c0bb728 --- /dev/null +++ b/src/main/scala/plugin/ScalaPlugin.scala @@ -0,0 +1,38 @@ +package plugin + +import app.Context +import scala.collection.mutable.ListBuffer +import plugin.PluginSystem.{Action, GlobalMenu, RepositoryMenu} +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} + +// TODO This is a sample implementation for Scala based plug-ins. +class ScalaPlugin(val id: String, val version: String, + val author: String, val url: String, val description: String) extends Plugin { + + private val repositoryMenuList = ListBuffer[RepositoryMenu]() + private val globalMenuList = ListBuffer[GlobalMenu]() + private val repositoryActionList = ListBuffer[Action]() + private val globalActionList = ListBuffer[Action]() + + def repositoryMenus : List[RepositoryMenu] = repositoryMenuList.toList + def globalMenus : List[GlobalMenu] = globalMenuList.toList + def repositoryActions : List[Action] = repositoryActionList.toList + def globalActions : List[Action] = globalActionList.toList + + def addRepositoryMenu(label: String, name: String, url: String, icon: String)(condition: (Context) => Boolean): Unit = { + repositoryMenuList += RepositoryMenu(label, name, url, icon, condition) + } + + def addGlobalMenu(label: String, url: String, icon: String)(condition: (Context) => Boolean): Unit = { + globalMenuList += GlobalMenu(label, url, icon, condition) + } + + def addGlobalAction(path: String)(function: (HttpServletRequest, HttpServletResponse) => Any): Unit = { + globalActionList += Action(path, function) + } + + def addRepositoryAction(path: String)(function: (HttpServletRequest, HttpServletResponse) => Any): Unit = { + repositoryActionList += Action(path, function) + } + +} diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 46ac85e..70c907e 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -51,6 +51,7 @@ repos: (String, String)*)(implicit s: Session): Int = // TODO check SQL Query(searchIssueQuery(repos, condition, filterUser, onlyPullRequest).length).first + /** * Returns the Map which contains issue count for each labels. * diff --git a/src/main/scala/service/PullRequestService.scala b/src/main/scala/service/PullRequestService.scala index beb79b9..e40ca3c 100644 --- a/src/main/scala/service/PullRequestService.scala +++ b/src/main/scala/service/PullRequestService.scala @@ -20,13 +20,13 @@ .map(pr => pr.commitIdTo -> pr.commitIdFrom) .update((commitIdTo, commitIdFrom)) - def getPullRequestCountGroupByUser(closed: Boolean, owner: String, repository: Option[String]) + def getPullRequestCountGroupByUser(closed: Boolean, owner: Option[String], repository: Option[String]) (implicit s: Session): List[PullRequestCount] = PullRequests .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) } .filter { case (t1, t2) => (t2.closed is closed.bind) && - (t1.userName is owner.bind) && + (t1.userName is owner.get.bind, owner.isDefined) && (t1.repositoryName is repository.get.bind, repository.isDefined) } .groupBy { case (t1, t2) => t2.openedUserName } diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index cdc72fe..c7e0afb 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -156,13 +156,18 @@ } } - def getUserRepositories(userName: String, baseUrl: String)(implicit s: Session): List[RepositoryInfo] = { + def getUserRepositories(userName: String, baseUrl: String, withoutPhysicalInfo: Boolean = false) + (implicit s: Session): List[RepositoryInfo] = { Repositories.filter { t1 => (t1.userName is userName.bind) || (Collaborators.filter { t2 => t2.byRepository(t1.userName, t1.repositoryName) && (t2.collaboratorName is userName.bind)} exists) }.sortBy(_.lastActivityDate desc).list.map{ repository => new RepositoryInfo( - JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + if(withoutPhysicalInfo){ + new JGitUtil.RepositoryInfo(repository.userName, repository.repositoryName, baseUrl) + } else { + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl) + }, repository, getForkedCount( repository.originUserName.getOrElse(repository.userName), @@ -179,9 +184,12 @@ * @param loginAccount the logged in account * @param baseUrl the base url of this application * @param repositoryUserName the repository owner (if None then returns all repositories which are visible for logged in user) + * @param withoutPhysicalInfo if true then the result does not include physical repository information such as commit count, + * branches and tags * @return the repository information which is sorted in descending order of lastActivityDate. */ - def getVisibleRepositories(loginAccount: Option[Account], baseUrl: String, repositoryUserName: Option[String] = None) + def getVisibleRepositories(loginAccount: Option[Account], baseUrl: String, repositoryUserName: Option[String] = None, + withoutPhysicalInfo: Boolean = false) (implicit s: Session): List[RepositoryInfo] = { (loginAccount match { // for Administrators @@ -197,7 +205,11 @@ repositoryUserName.map { userName => t.userName is userName.bind } getOrElse LiteralColumn(true) }.sortBy(_.lastActivityDate desc).list.map{ repository => new RepositoryInfo( - JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + if(withoutPhysicalInfo){ + new JGitUtil.RepositoryInfo(repository.userName, repository.repositoryName, baseUrl) + } else { + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl) + }, repository, getForkedCount( repository.originUserName.getOrElse(repository.userName), diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index 801ff68..e79faa5 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -1,193 +1,209 @@ -package servlet - -import java.io.File -import java.sql.{DriverManager, Connection} -import org.apache.commons.io.FileUtils -import javax.servlet.{ServletContext, ServletContextListener, ServletContextEvent} -import org.apache.commons.io.IOUtils -import org.slf4j.LoggerFactory -import util.Directory._ -import util.ControlUtil._ -import org.eclipse.jgit.api.Git -import util.Directory - -object AutoUpdate { - - /** - * Version of GitBucket - * - * @param majorVersion the major version - * @param minorVersion the minor version - */ - case class Version(majorVersion: Int, minorVersion: Int){ - - private val logger = LoggerFactory.getLogger(classOf[servlet.AutoUpdate.Version]) - - /** - * Execute update/MAJOR_MINOR.sql to update schema to this version. - * If corresponding SQL file does not exist, this method do nothing. - */ - def update(conn: Connection): Unit = { - val sqlPath = s"update/${majorVersion}_${minorVersion}.sql" - - using(Thread.currentThread.getContextClassLoader.getResourceAsStream(sqlPath)){ in => - if(in != null){ - val sql = IOUtils.toString(in, "UTF-8") - using(conn.createStatement()){ stmt => - logger.debug(sqlPath + "=" + sql) - stmt.executeUpdate(sql) - } - } - } - } - - /** - * MAJOR.MINOR - */ - val versionString = s"${majorVersion}.${minorVersion}" - } - - /** - * The history of versions. A head of this sequence is the current BitBucket version. - */ - val versions = Seq( - new Version(2, 0){ - override def update(conn: Connection): Unit = { - import eu.medsea.mimeutil.{MimeUtil2, MimeType} - - val mimeUtil = new MimeUtil2() - mimeUtil.registerMimeDetector("eu.medsea.mimeutil.detector.MagicMimeMimeDetector") - - super.update(conn) - using(conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY")){ rs => - while(rs.next){ - defining(Directory.getAttachedDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME"))){ dir => - if(dir.exists && dir.isDirectory){ - dir.listFiles.foreach { file => - if(file.getName.indexOf('.') < 0){ - val mimeType = MimeUtil2.getMostSpecificMimeType(mimeUtil.getMimeTypes(file, new MimeType("application/octet-stream"))).toString - if(mimeType.startsWith("image/")){ - file.renameTo(new File(file.getParent, file.getName + "." + mimeType.split("/")(1))) - } - } - } - } - } - } - } - } - }, - Version(1, 13), - Version(1, 12), - Version(1, 11), - Version(1, 10), - Version(1, 9), - Version(1, 8), - Version(1, 7), - Version(1, 6), - Version(1, 5), - Version(1, 4), - new Version(1, 3){ - override def update(conn: Connection): Unit = { - super.update(conn) - // Fix wiki repository configuration - using(conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY")){ rs => - while(rs.next){ - using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => - defining(git.getRepository.getConfig){ config => - if(!config.getBoolean("http", "receivepack", false)){ - config.setBoolean("http", null, "receivepack", true) - config.save - } - } - } - } - } - } - }, - Version(1, 2), - Version(1, 1), - Version(1, 0), - Version(0, 0) - ) - - /** - * The head version of BitBucket. - */ - val headVersion = versions.head - - /** - * The version file (GITBUCKET_HOME/version). - */ - lazy val versionFile = new File(GitBucketHome, "version") - - /** - * Returns the current version from the version file. - */ - def getCurrentVersion(): Version = { - if(versionFile.exists){ - FileUtils.readFileToString(versionFile, "UTF-8").trim.split("\\.") match { - case Array(majorVersion, minorVersion) => { - versions.find { v => - v.majorVersion == majorVersion.toInt && v.minorVersion == minorVersion.toInt - }.getOrElse(Version(0, 0)) - } - case _ => Version(0, 0) - } - } else Version(0, 0) - } - -} - -/** - * Update database schema automatically in the context initializing. - */ -class AutoUpdateListener extends ServletContextListener { - import AutoUpdate._ - private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) - - override def contextInitialized(event: ServletContextEvent): Unit = { - val datadir = event.getServletContext.getInitParameter("gitbucket.home") - if(datadir != null){ - System.setProperty("gitbucket.home", datadir) - } - org.h2.Driver.load() - event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${DatabaseHome};MVCC=true") - - logger.debug("Start schema update") - defining(getConnection(event.getServletContext)){ conn => - try { - defining(getCurrentVersion()){ currentVersion => - if(currentVersion == headVersion){ - logger.debug("No update") - } else if(!versions.contains(currentVersion)){ - logger.warn(s"Skip migration because ${currentVersion.versionString} is illegal version.") - } else { - versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn)) - FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") - conn.commit() - logger.debug(s"Updated from ${currentVersion.versionString} to ${headVersion.versionString}") - } - } - } catch { - case ex: Throwable => { - logger.error("Failed to schema update", ex) - ex.printStackTrace() - conn.rollback() - } - } - } - logger.debug("End schema update") - } - - def contextDestroyed(sce: ServletContextEvent): Unit = { - // Nothing to do. - } - - private def getConnection(servletContext: ServletContext): Connection = - DriverManager.getConnection( - servletContext.getInitParameter("db.url"), - servletContext.getInitParameter("db.user"), - servletContext.getInitParameter("db.password")) - -} +package servlet + +import java.io.File +import java.sql.{DriverManager, Connection} +import org.apache.commons.io.FileUtils +import javax.servlet.{ServletContext, ServletContextListener, ServletContextEvent} +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import util.Directory._ +import util.ControlUtil._ +import org.eclipse.jgit.api.Git +import util.Directory +import plugin.PluginUpdateJob + +object AutoUpdate { + + /** + * Version of GitBucket + * + * @param majorVersion the major version + * @param minorVersion the minor version + */ + case class Version(majorVersion: Int, minorVersion: Int){ + + private val logger = LoggerFactory.getLogger(classOf[servlet.AutoUpdate.Version]) + + /** + * Execute update/MAJOR_MINOR.sql to update schema to this version. + * If corresponding SQL file does not exist, this method do nothing. + */ + def update(conn: Connection): Unit = { + val sqlPath = s"update/${majorVersion}_${minorVersion}.sql" + + using(Thread.currentThread.getContextClassLoader.getResourceAsStream(sqlPath)){ in => + if(in != null){ + val sql = IOUtils.toString(in, "UTF-8") + using(conn.createStatement()){ stmt => + logger.debug(sqlPath + "=" + sql) + stmt.executeUpdate(sql) + } + } + } + } + + /** + * MAJOR.MINOR + */ + val versionString = s"${majorVersion}.${minorVersion}" + } + + /** + * The history of versions. A head of this sequence is the current BitBucket version. + */ + val versions = Seq( + new Version(2, 0){ + override def update(conn: Connection): Unit = { + import eu.medsea.mimeutil.{MimeUtil2, MimeType} + + val mimeUtil = new MimeUtil2() + mimeUtil.registerMimeDetector("eu.medsea.mimeutil.detector.MagicMimeMimeDetector") + + super.update(conn) + using(conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY")){ rs => + while(rs.next){ + defining(Directory.getAttachedDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME"))){ dir => + if(dir.exists && dir.isDirectory){ + dir.listFiles.foreach { file => + if(file.getName.indexOf('.') < 0){ + val mimeType = MimeUtil2.getMostSpecificMimeType(mimeUtil.getMimeTypes(file, new MimeType("application/octet-stream"))).toString + if(mimeType.startsWith("image/")){ + file.renameTo(new File(file.getParent, file.getName + "." + mimeType.split("/")(1))) + } + } + } + } + } + } + } + } + }, + Version(1, 13), + Version(1, 12), + Version(1, 11), + Version(1, 10), + Version(1, 9), + Version(1, 8), + Version(1, 7), + Version(1, 6), + Version(1, 5), + Version(1, 4), + new Version(1, 3){ + override def update(conn: Connection): Unit = { + super.update(conn) + // Fix wiki repository configuration + using(conn.createStatement.executeQuery("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY")){ rs => + while(rs.next){ + using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => + defining(git.getRepository.getConfig){ config => + if(!config.getBoolean("http", "receivepack", false)){ + config.setBoolean("http", null, "receivepack", true) + config.save + } + } + } + } + } + } + }, + Version(1, 2), + Version(1, 1), + Version(1, 0), + Version(0, 0) + ) + + /** + * The head version of BitBucket. + */ + val headVersion = versions.head + + /** + * The version file (GITBUCKET_HOME/version). + */ + lazy val versionFile = new File(GitBucketHome, "version") + + /** + * Returns the current version from the version file. + */ + def getCurrentVersion(): Version = { + if(versionFile.exists){ + FileUtils.readFileToString(versionFile, "UTF-8").trim.split("\\.") match { + case Array(majorVersion, minorVersion) => { + versions.find { v => + v.majorVersion == majorVersion.toInt && v.minorVersion == minorVersion.toInt + }.getOrElse(Version(0, 0)) + } + case _ => Version(0, 0) + } + } else Version(0, 0) + } + +} + +/** + * Update database schema automatically in the context initializing. + */ +class AutoUpdateListener extends ServletContextListener { + import org.quartz.impl.StdSchedulerFactory + import org.quartz.JobBuilder._ + import org.quartz.TriggerBuilder._ + import org.quartz.SimpleScheduleBuilder._ + import AutoUpdate._ + + private val logger = LoggerFactory.getLogger(classOf[AutoUpdateListener]) + private val scheduler = StdSchedulerFactory.getDefaultScheduler + + override def contextInitialized(event: ServletContextEvent): Unit = { + val datadir = event.getServletContext.getInitParameter("gitbucket.home") + if(datadir != null){ + System.setProperty("gitbucket.home", datadir) + } + org.h2.Driver.load() + event.getServletContext.setInitParameter("db.url", s"jdbc:h2:${DatabaseHome};MVCC=true") + + logger.debug("Start schema update") + defining(getConnection(event.getServletContext)){ conn => + try { + defining(getCurrentVersion()){ currentVersion => + if(currentVersion == headVersion){ + logger.debug("No update") + } else if(!versions.contains(currentVersion)){ + logger.warn(s"Skip migration because ${currentVersion.versionString} is illegal version.") + } else { + versions.takeWhile(_ != currentVersion).reverse.foreach(_.update(conn)) + FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") + conn.commit() + logger.debug(s"Updated from ${currentVersion.versionString} to ${headVersion.versionString}") + } + } + } catch { + case ex: Throwable => { + logger.error("Failed to schema update", ex) + ex.printStackTrace() + conn.rollback() + } + } + } + logger.debug("End schema update") + + logger.debug("Starting plugin system...") + plugin.PluginSystem.init() + + scheduler.start() + PluginUpdateJob.schedule(scheduler) + logger.debug("PluginUpdateJob is started.") + + logger.debug("Plugin system is initialized.") + } + + def contextDestroyed(sce: ServletContextEvent): Unit = { + scheduler.shutdown() + } + + private def getConnection(servletContext: ServletContext): Connection = + DriverManager.getConnection( + servletContext.getInitParameter("db.url"), + servletContext.getInitParameter("db.user"), + servletContext.getInitParameter("db.password")) + +} diff --git a/src/main/scala/servlet/PluginActionInvokeFilter.scala b/src/main/scala/servlet/PluginActionInvokeFilter.scala new file mode 100644 index 0000000..6dbbe78 --- /dev/null +++ b/src/main/scala/servlet/PluginActionInvokeFilter.scala @@ -0,0 +1,81 @@ +package servlet + +import javax.servlet._ +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} +import org.apache.commons.io.IOUtils +import twirl.api.Html +import service.{AccountService, RepositoryService, SystemSettingsService} +import model.Account +import util.{JGitUtil, Keys} + +class PluginActionInvokeFilter extends Filter with SystemSettingsService with RepositoryService with AccountService { + + def init(config: FilterConfig) = {} + + def destroy(): Unit = {} + + def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = { + (req, res) match { + case (request: HttpServletRequest, response: HttpServletResponse) => { + Database(req.getServletContext) withTransaction { implicit session => + val path = req.asInstanceOf[HttpServletRequest].getRequestURI + if(!processGlobalAction(path, request, response) && !processRepositoryAction(path, request, response)){ + chain.doFilter(req, res) + } + } + } + } + } + + private def processGlobalAction(path: String, request: HttpServletRequest, response: HttpServletResponse): Boolean = { + plugin.PluginSystem.globalActions.find(_.path == path).map { action => + val result = action.function(request, response) + result match { + case x: String => { + response.setContentType("text/html; charset=UTF-8") + val loginAccount = request.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + implicit val context = app.Context(loadSystemSettings(), Option(loginAccount), request) + val html = _root_.html.main("GitBucket", None)(Html(x)) + IOUtils.write(html.toString.getBytes("UTF-8"), response.getOutputStream) + } + case x => { + // TODO returns as JSON? + response.setContentType("application/json; charset=UTF-8") + + } + } + true + } getOrElse false + } + + private def processRepositoryAction(path: String, request: HttpServletRequest, response: HttpServletResponse) + (implicit session: model.simple.Session): Boolean = { + val elements = path.split("/") + if(elements.length > 3){ + val owner = elements(1) + val name = elements(2) + val remain = elements.drop(3).mkString("/", "/", "") + getRepository(owner, name, "").flatMap { repository => // TODO fill baseUrl + plugin.PluginSystem.repositoryActions.find(_.path == remain).map { action => + val result = action.function(request, response) + result match { + case x: String => { + response.setContentType("text/html; charset=UTF-8") + val loginAccount = request.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + implicit val context = app.Context(loadSystemSettings(), Option(loginAccount), request) + val html = _root_.html.main("GitBucket", None)(_root_.html.menu("", repository)(Html(x))) // TODO specify active side menu + IOUtils.write(html.toString.getBytes("UTF-8"), response.getOutputStream) + } + case x => { + // TODO returns as JSON? + response.setContentType("application/json; charset=UTF-8") + + } + } + true + } + } getOrElse false + } else false + } + +} diff --git a/src/main/scala/servlet/TransactionFilter.scala b/src/main/scala/servlet/TransactionFilter.scala index 6773a59..b4712e9 100644 --- a/src/main/scala/servlet/TransactionFilter.scala +++ b/src/main/scala/servlet/TransactionFilter.scala @@ -33,6 +33,7 @@ } object Database { + def apply(context: ServletContext): slick.jdbc.JdbcBackend.Database = slick.jdbc.JdbcBackend.Database.forURL(context.getInitParameter("db.url"), context.getInitParameter("db.user"), diff --git a/src/main/scala/util/Directory.scala b/src/main/scala/util/Directory.scala index 1d15f1a..920b22a 100644 --- a/src/main/scala/util/Directory.scala +++ b/src/main/scala/util/Directory.scala @@ -34,6 +34,10 @@ val DatabaseHome = s"${GitBucketHome}/data" + val PluginHome = s"${GitBucketHome}/plugins" + + val TemporaryHome = s"${GitBucketHome}/tmp" + /** * Substance directory of the repository. */ @@ -55,13 +59,18 @@ * Root of temporary directories for the upload file. */ def getTemporaryDir(sessionId: String): File = - new File(s"${GitBucketHome}/tmp/_upload/${sessionId}") + new File(s"${TemporaryHome}/_upload/${sessionId}") /** * Root of temporary directories for the specified repository. */ def getTemporaryDir(owner: String, repository: String): File = - new File(s"${GitBucketHome}/tmp/${owner}/${repository}") + new File(s"${TemporaryHome}/${owner}/${repository}") + + /** + * Root of plugin cache directory. Plugin repositories are cloned into this directory. + */ + def getPluginCacheDir(): File = new File(s"${TemporaryHome}/_plugins") /** * Temporary directory which is used to create an archive to download repository contents. diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index 48c4ad7..c68bdbf 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -35,7 +35,11 @@ * @param branchList the list of branch names * @param tags the list of tags */ - case class RepositoryInfo(owner: String, name: String, url: String, commitCount: Int, branchList: List[String], tags: List[TagInfo]) + case class RepositoryInfo(owner: String, name: String, url: String, commitCount: Int, branchList: List[String], tags: List[TagInfo]){ + def this(owner: String, name: String, baseUrl: String) = { + this(owner, name, s"${baseUrl}/git/${owner}/${name}.git", 0, Nil, Nil) + } + } /** * The file data for the file list of the repository viewer. diff --git a/src/main/scala/util/LDAPUtil.scala b/src/main/scala/util/LDAPUtil.scala index 131a133..d107b96 100644 --- a/src/main/scala/util/LDAPUtil.scala +++ b/src/main/scala/util/LDAPUtil.scala @@ -47,11 +47,11 @@ keystore = ldapSettings.keystore.getOrElse(""), error = "User LDAP Authentication Failed." ){ conn => - findMailAddress(conn, userDN, ldapSettings.mailAttribute) match { + findMailAddress(conn, userDN, ldapSettings.userNameAttribute, userName, ldapSettings.mailAttribute) match { case Some(mailAddress) => Right(LDAPUserInfo( userName = getUserNameFromMailAddress(userName), fullName = ldapSettings.fullNameAttribute.flatMap { fullNameAttribute => - findFullName(conn, userDN, fullNameAttribute) + findFullName(conn, userDN, ldapSettings.userNameAttribute, userName, fullNameAttribute) }.getOrElse(userName), mailAddress = mailAddress)) case None => Left("Can't find mail address.") @@ -130,15 +130,15 @@ } } - private def findMailAddress(conn: LDAPConnection, userDN: String, mailAttribute: String): Option[String] = - defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, null, Array[String](mailAttribute), false)){ results => + private def findMailAddress(conn: LDAPConnection, userDN: String, userNameAttribute: String, userName: String, mailAttribute: String): Option[String] = + defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, userNameAttribute + "=" + userName, Array[String](mailAttribute), false)){ results => if(results.hasMore) { Option(results.next.getAttribute(mailAttribute)).map(_.getStringValue) } else None } - private def findFullName(conn: LDAPConnection, userDN: String, nameAttribute: String): Option[String] = - defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, null, Array[String](nameAttribute), false)){ results => + private def findFullName(conn: LDAPConnection, userDN: String, userNameAttribute: String, userName: String, nameAttribute: String): Option[String] = + defining(conn.search(userDN, LDAPConnection.SCOPE_BASE, userNameAttribute + "=" + userName, Array[String](nameAttribute), false)){ results => if(results.hasMore) { Option(results.next.getAttribute(nameAttribute)).map(_.getStringValue) } else None diff --git a/src/main/twirl/admin/menu.scala.html b/src/main/twirl/admin/menu.scala.html index 09bc2de..25cda19 100644 --- a/src/main/twirl/admin/menu.scala.html +++ b/src/main/twirl/admin/menu.scala.html @@ -11,6 +11,9 @@ System Settings + + Plugins +
  • H2 Console
  • diff --git a/src/main/twirl/admin/plugins/available.scala.html b/src/main/twirl/admin/plugins/available.scala.html new file mode 100644 index 0000000..fcf37a0 --- /dev/null +++ b/src/main/twirl/admin/plugins/available.scala.html @@ -0,0 +1,37 @@ +@(plugins: List[app.SystemSettingsControllerBase.AvailablePlugin])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Plugins"){ + @admin.html.menu("plugins"){ + @tab("available") +
    + + + + + + + + @plugins.zipWithIndex.map { case (plugin, i) => + + + + + + + } +
    IDVersionProviderDescription
    + + @plugin.id + @plugin.version@plugin.author@plugin.description
    + +
    + } +} + diff --git a/src/main/twirl/admin/plugins/console.scala.html b/src/main/twirl/admin/plugins/console.scala.html new file mode 100644 index 0000000..3c4158e --- /dev/null +++ b/src/main/twirl/admin/plugins/console.scala.html @@ -0,0 +1,37 @@ +@()(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("JavaScript Console"){ + @admin.html.menu("plugins"){ + @tab("console") +
    +
    +
    JavaScript Console
    +
    +
    +
    +
    +
    + +
    +
    + } +} + + \ No newline at end of file diff --git a/src/main/twirl/admin/plugins/installed.scala.html b/src/main/twirl/admin/plugins/installed.scala.html new file mode 100644 index 0000000..f85c149 --- /dev/null +++ b/src/main/twirl/admin/plugins/installed.scala.html @@ -0,0 +1,47 @@ +@(plugins: List[plugin.Plugin], + updatablePlugins: List[app.SystemSettingsControllerBase.AvailablePlugin])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Plugins"){ + @admin.html.menu("plugins"){ + @tab("installed") +
    + + + + + + + + @plugins.zipWithIndex.map { case (plugin, i) => + + + + + + + } +
    IDVersionProviderDescription
    + + @plugin.id + + @plugin.version + @updatablePlugins.find(_.id == plugin.id).map { x => + (@x.version is available) + } + @plugin.author@plugin.description
    + + +
    + } +} + diff --git a/src/main/twirl/admin/plugins/tab.scala.html b/src/main/twirl/admin/plugins/tab.scala.html new file mode 100644 index 0000000..2e9d1ac --- /dev/null +++ b/src/main/twirl/admin/plugins/tab.scala.html @@ -0,0 +1,9 @@ +@(active: String)(implicit context: app.Context) +@import context._ + diff --git a/src/main/twirl/admin/users/list.scala.html b/src/main/twirl/admin/users/list.scala.html index 942f6ee..3b2029c 100644 --- a/src/main/twirl/admin/users/list.scala.html +++ b/src/main/twirl/admin/users/list.scala.html @@ -1,71 +1,71 @@ -@(users: List[model.Account], members: Map[String, List[String]], includeRemoved: Boolean)(implicit context: app.Context) -@import context._ -@import view.helpers._ -@html.main("Manage Users"){ - @admin.html.menu("users"){ - - - - @users.map { account => - - - - } -
    -
    - @if(account.isGroupAccount){ - Edit - } else { - Edit - } -
    -
    - @avatar(account.userName, 20) - @account.userName - @if(account.isGroupAccount){ - (Group) - } else { - @if(account.isAdmin){ - (Administrator) - } else { - (Normal) - } - } - @if(account.isGroupAccount){ - @members(account.userName).map { userName => - @avatar(userName, 20, tooltip = true) - } - } -
    -
    -
    - @if(!account.isGroupAccount){ - @account.mailAddress - } - @account.url.map { url => - @url - } -
    -
    - Registered: @datetime(account.registeredDate) - Updated: @datetime(account.updatedDate) - @if(!account.isGroupAccount){ - Last Login: @account.lastLoginDate.map(datetime) - } -
    -
    - } -} - \ No newline at end of file diff --git a/src/main/twirl/admin/users/user.scala.html b/src/main/twirl/admin/users/user.scala.html index 3a2430c..fb022c0 100644 --- a/src/main/twirl/admin/users/user.scala.html +++ b/src/main/twirl/admin/users/user.scala.html @@ -1,80 +1,80 @@ -@(account: Option[model.Account])(implicit context: app.Context) -@import context._ -@html.main(if(account.isEmpty) "New User" else "Update User"){ - @admin.html.menu("users"){ -
    -
    -
    -
    - -
    - -
    - - @if(account.isDefined){ - - } -
    - @if(account.map(_.password.nonEmpty).getOrElse(true)){ -
    - -
    - -
    - -
    - } -
    - -
    - -
    - -
    -
    - -
    - -
    - -
    -
    - - - -
    -
    - -
    - -
    - -
    -
    -
    -
    - - @helper.html.uploadavatar(account) -
    -
    -
    -
    - - Cancel -
    -
    - } -} +@(account: Option[model.Account])(implicit context: app.Context) +@import context._ +@html.main(if(account.isEmpty) "New User" else "Update User"){ + @admin.html.menu("users"){ +
    +
    +
    +
    + +
    + +
    + + @if(account.isDefined){ + + } +
    + @if(account.map(_.password.nonEmpty).getOrElse(true)){ +
    + +
    + +
    + +
    + } +
    + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + + + +
    +
    + +
    + +
    + +
    +
    +
    +
    + + @helper.html.uploadavatar(account) +
    +
    +
    +
    + + Cancel +
    +
    + } +} diff --git a/src/main/twirl/helper/activities.scala.html b/src/main/twirl/helper/activities.scala.html index 6e7f324..980eaf5 100644 --- a/src/main/twirl/helper/activities.scala.html +++ b/src/main/twirl/helper/activities.scala.html @@ -1,98 +1,98 @@ -@(activities: List[model.Activity])(implicit context: app.Context) -@import context._ -@import view.helpers._ - -@if(activities.isEmpty){ - No activity -} else { - @activities.map { activity => -
    - @(activity.activityType match { - case "open_issue" => detailActivity(activity, "activity-issue.png") - case "comment_issue" => detailActivity(activity, "activity-comment.png") - case "close_issue" => detailActivity(activity, "activity-issue-close.png") - case "reopen_issue" => detailActivity(activity, "activity-issue-reopen.png") - case "open_pullreq" => detailActivity(activity, "activity-merge.png") - case "merge_pullreq" => detailActivity(activity, "activity-merge.png") - case "create_repository" => simpleActivity(activity, "activity-create-repository.png") - case "create_branch" => simpleActivity(activity, "activity-branch.png") - case "delete_branch" => simpleActivity(activity, "activity-delete.png") - case "create_tag" => simpleActivity(activity, "activity-tag.png") - case "delete_tag" => simpleActivity(activity, "activity-delete.png") - case "fork" => simpleActivity(activity, "activity-fork.png") - case "push" => customActivity(activity, "activity-commit.png"){ -
    - {activity.additionalInfo.get.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => - if(i == 3){ -
    ...
    - } else { - if(commit.nonEmpty){ -
    - {commit.substring(0, 7)} - {commit.substring(41)} -
    - } - } - }} -
    - } - case "create_wiki" => customActivity(activity, "activity-wiki.png"){ - - } - case "edit_wiki" => customActivity(activity, "activity-wiki.png"){ - activity.additionalInfo.get.split(":") match { - case Array(pageName, commitId) => - - case Array(pageName) => -
    - Edited {pageName}. -
    - } - } - }) -
    - } -} - -@detailActivity(activity: model.Activity, image: String) = { -
    -
    -
    @datetime(activity.activityDate)
    -
    - @avatar(activity.activityUserName, 16) - @activityMessage(activity.message) -
    - @activity.additionalInfo.map { additionalInfo => -
    @additionalInfo
    - } -
    -} - -@customActivity(activity: model.Activity, image: String)(additionalInfo: Any) = { -
    -
    -
    @datetime(activity.activityDate)
    -
    - @avatar(activity.activityUserName, 16) - @activityMessage(activity.message) -
    - @additionalInfo -
    -} - -@simpleActivity(activity: model.Activity, image: String) = { -
    -
    -
    - @avatar(activity.activityUserName, 16) - @activityMessage(activity.message) - @datetime(activity.activityDate) -
    -
    -} - +@(activities: List[model.Activity])(implicit context: app.Context) +@import context._ +@import view.helpers._ + +@if(activities.isEmpty){ + No activity +} else { + @activities.map { activity => +
    + @(activity.activityType match { + case "open_issue" => detailActivity(activity, "activity-issue.png") + case "comment_issue" => detailActivity(activity, "activity-comment.png") + case "close_issue" => detailActivity(activity, "activity-issue-close.png") + case "reopen_issue" => detailActivity(activity, "activity-issue-reopen.png") + case "open_pullreq" => detailActivity(activity, "activity-merge.png") + case "merge_pullreq" => detailActivity(activity, "activity-merge.png") + case "create_repository" => simpleActivity(activity, "activity-create-repository.png") + case "create_branch" => simpleActivity(activity, "activity-branch.png") + case "delete_branch" => simpleActivity(activity, "activity-delete.png") + case "create_tag" => simpleActivity(activity, "activity-tag.png") + case "delete_tag" => simpleActivity(activity, "activity-delete.png") + case "fork" => simpleActivity(activity, "activity-fork.png") + case "push" => customActivity(activity, "activity-commit.png"){ +
    + {activity.additionalInfo.get.split("\n").reverse.take(4).zipWithIndex.map{ case (commit, i) => + if(i == 3){ +
    ...
    + } else { + if(commit.nonEmpty){ +
    + {commit.substring(0, 7)} + {commit.substring(41)} +
    + } + } + }} +
    + } + case "create_wiki" => customActivity(activity, "activity-wiki.png"){ + + } + case "edit_wiki" => customActivity(activity, "activity-wiki.png"){ + activity.additionalInfo.get.split(":") match { + case Array(pageName, commitId) => + + case Array(pageName) => +
    + Edited {pageName}. +
    + } + } + }) +
    + } +} + +@detailActivity(activity: model.Activity, image: String) = { +
    +
    +
    @datetime(activity.activityDate)
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) +
    + @activity.additionalInfo.map { additionalInfo => +
    @additionalInfo
    + } +
    +} + +@customActivity(activity: model.Activity, image: String)(additionalInfo: Any) = { +
    +
    +
    @datetime(activity.activityDate)
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) +
    + @additionalInfo +
    +} + +@simpleActivity(activity: model.Activity, image: String) = { +
    +
    +
    + @avatar(activity.activityUserName, 16) + @activityMessage(activity.message) + @datetime(activity.activityDate) +
    +
    +} + diff --git a/src/main/twirl/helper/diff.scala.html b/src/main/twirl/helper/diff.scala.html index 551e3fa..e1ab2d7 100644 --- a/src/main/twirl/helper/diff.scala.html +++ b/src/main/twirl/helper/diff.scala.html @@ -1,105 +1,105 @@ -@(diffs: Seq[util.JGitUtil.DiffInfo], - repository: service.RepositoryService.RepositoryInfo, - newCommitId: Option[String], - oldCommitId: Option[String], - showIndex: Boolean)(implicit context: app.Context) -@import context._ -@import view.helpers._ -@import org.eclipse.jgit.diff.DiffEntry.ChangeType -@if(showIndex){ -
    -
    - -
    - Showing @diffs.size changed @plural(diffs.size, "file") -
    - -} -@diffs.zipWithIndex.map { case (diff, i) => - - - - - - - - -
    - @if(diff.changeType == ChangeType.COPY || diff.changeType == ChangeType.RENAME){ - @diff.oldPath -> @diff.newPath - @if(newCommitId.isDefined){ - - } - } - @if(diff.changeType == ChangeType.ADD || diff.changeType == ChangeType.MODIFY){ - @diff.newPath - @if(newCommitId.isDefined){ - - } - } - @if(diff.changeType == ChangeType.DELETE){ - @diff.oldPath - @if(oldCommitId.isDefined){ - - } - } -
    - @if(diff.newContent != None || diff.oldContent != None){ -
    - - - } else { - Not supported - } -
    -} - - - - + + + \ No newline at end of file diff --git a/src/main/twirl/helper/preview.scala.html b/src/main/twirl/helper/preview.scala.html index 185cccf..b6f37c7 100644 --- a/src/main/twirl/helper/preview.scala.html +++ b/src/main/twirl/helper/preview.scala.html @@ -25,8 +25,8 @@ - - + + +@(collaborators: List[String], + milestones: List[model.Milestone], + labels: List[model.Label], + hasWritePermission: Boolean, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main(s"New Issue - ${repository.owner}/${repository.name}", Some(repository)){ + @html.menu("issues", repository){ + @tab("", true, repository) +
    +
    +
    +
    @avatar(loginAccount.get.userName, 48)
    +
    +
    + + +
    + No one is assigned + @if(hasWritePermission){ + + @helper.html.dropdown() { +
  • Clear assignee
  • + @collaborators.map { collaborator => +
  • @avatar(collaborator, 20) @collaborator
  • + } + } + } +
    + No milestone + @if(hasWritePermission){ + + @helper.html.dropdown() { +
  • No milestone
  • + @milestones.filter(_.closedDate.isEmpty).map { milestone => +
  • + + @milestone.title +
    + @milestone.dueDate.map { dueDate => + @if(isPast(dueDate)){ + Due in @date(dueDate) + } else { + Due in @date(dueDate) + } + }.getOrElse { + No due date + } +
    +
    +
  • + } + } + } +
    +
    +
    + @helper.html.preview(repository, "", false, true, "width: 565px; height: 200px; max-height: 250px;", elastic = true) +
    +
    +
    + +
    +
    +
    + @if(hasWritePermission){ + Add Labels +
    +
    + + +
    +
    + } +
    +
    +
    + } +} + diff --git a/src/main/twirl/issues/issuedetail.scala.html b/src/main/twirl/issues/issuedetail.scala.html index 4eae1ee..ac9101e 100644 --- a/src/main/twirl/issues/issuedetail.scala.html +++ b/src/main/twirl/issues/issuedetail.scala.html @@ -116,7 +116,7 @@ .append($this.find('img.avatar').clone(false)).append(' ') .append($('').attr('href', '@path/' + userName).text(userName)) .append(' is assigned'); - $('a.assign[data-name=' + userName + '] i').attr('class', 'icon-ok'); + $('a.assign[data-name=' + jqSelectorEscape(userName) + '] i').attr('class', 'icon-ok'); } }); }); diff --git a/src/main/twirl/main.scala.html b/src/main/twirl/main.scala.html index b3ab4e0..d401121 100644 --- a/src/main/twirl/main.scala.html +++ b/src/main/twirl/main.scala.html @@ -9,26 +9,26 @@ - - + + - - - + + + - - + + - - - - - - + + + + + +