diff --git a/README.md b/README.md index e56432c..2ab7d07 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,20 @@ - Public / Private Git repository (http access only) - Repository viewer (some advanced features are not implemented) +- Repository search (Code and Issues) - Wiki - Issues - Activity timeline - User management (for Administrators) +- Group (like Organization in Github) Following features are not implemented, but we will make them in the future release! - Fork and pull request -- Search - Network graph - Statics - Watch / Star -- Team management (like Organization in Github) +- Notification If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/takezoe/gitbucket/wiki). @@ -36,6 +37,16 @@ Release Notes -------- +### 1.4 - 31 Jul 2013 +- Group management. +- Repository search for code and issues. +- Display user related issues on the dashboard. +- Display participants avatar of issues on the issue page. +- Performance improvement for repository viewer. +- Alert by milestone due date. +- H2 database administration console. +- Fixed some bugs. + ### 1.3 - 18 Jul 2013 - Batch updating for issues. - Display assigned user on issue list. diff --git a/etc/gitbucket.erd b/etc/gitbucket.erd new file mode 100644 index 0000000..74f5fb4 --- /dev/null +++ b/etc/gitbucket.erd @@ -0,0 +1,1745 @@ + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 33 + 18 + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 723 + 138 + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 1182 + 339 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1301 + 836 + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 684 + 858 + + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 293 + 478 + + + + + + + + 2 + + + + + + + ISSUE_FK_1 + + + + + + + + 2 + + + + + + + + + + + 2 + + + + + + -1 + -1 + 875 + 677 + + + + + + + + 2 + + + + + + + MILESTONE_FK_1 + + + + + + + + + MILESTONE + Milestone + + + + USER_NAME + User Name + + VARCHAR + 文字列 + true + 12 + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + MILESTONE_ID + Milestone ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + TITLE + Title + + VARCHAR + 文字列 + true + 12 + + 100 + true + false + + false + + + + DESCRIPTION + Description + + TEXT + 文字列 + true + 2005 + + + false + false + + false + + + + DUE_DATE + Due Date + + TIMESTAMP + 日時 + false + 93 + + 10 + false + false + + false + + + + CLOSED_DATE + Closed Date + + 10 + false + false + + false + + + + + + 255 + 255 + 206 + + + + ISSUE_FK_2 + + + + + + + + 2 + + + + + + + ISSUE_FK_2 + + + + USER_NAME + User Name + + VARCHAR + 文字列 + true + 12 + + 100 + true + true + + false + + + + OPENED_USER_NAME + Opened User Name + + 100 + true + false + + false + + + + + + + + + + + 2 + + + + + + + ISSUE_FK_2 + + + + + ASSIGNED_USER_NAME + Assinged User Name + + 100 + false + false + + false + + + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 18 + 776 + + + + + + + + + 2 + + + + + + + ISSUE_COMMENT_FK_2 + + + + + COMMENTED_USER_NAME + Commented User Name + + 100 + true + false + + false + + + + + + + + + + ISSUE_COMMENT + Issue Comment + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + COMMENT_ID + Comment ID + + 10 + true + true + + true + + + + ACTION + Action + + VARCHAR + 文字列 + true + 12 + + 20 + true + false + Expand to VARCHAR(20) from VARCHAR(10) in 1.3 + false + + + + + CONTENT + Content + + TEXT + 文字列 + true + 2005 + + + true + false + + false + + + + REGISTERED_DATE + Registered Date + + TIMESTAMP + 日時 + false + 93 + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_COMMENT_FK_1 + + + + + + + ISSUE + Issue + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + + MILESTONE_ID + Milestone ID + + 10 + false + false + + false + + + + + TITLE + Title + + + true + false + + false + + + + CONTENT + Content + + + true + false + + false + + + + REGISTERED_DATE + Registered Date + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + ISSUE_LABEL_FK_2 + + + + + + + ISSUE_LABEL + Issue Label + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + ISSUE_ID + Issue ID + + 10 + true + true + + false + + + + LABEL_ID + Label ID + + 10 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_LABEL_FK_1 + + + + + + LABEL + Label + + + + USER_NAME + User Name + + 100 + false + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + true + + false + + + + LABEL_ID + Label ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + LABEL_NAME + Label Name + + 100 + true + false + + false + + + + COLOR + Color + + CHAR + 文字 + true + 1 + + 6 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + LABEL_FK_1 + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 481 + 361 + + + + + + + + ISSUE_ID + Issue ID + + + + USER_NAME + User Name + + 100 + false + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + false + + false + + + + ISSUE_ID + Issue ID + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ISSUE_ID_FK_1 + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1199 + 25 + + + + + + + + + 2 + + + + + + + ACTIVITY_FK_2 + + + + + ACTIVITY_USER_NAME + Activity User Name + + 100 + true + false + + false + + + + + + + + + + ACTIVITY + Activity + Since 1.2 + + + ACTIVITY_ID + Activity ID + + INT + 整数 + false + 4 + + 10 + true + true + + true + + + + USER_NAME + User Name + + 100 + true + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + false + + false + + + + + ACTIVITY_TYPE + Activity Type + + 100 + true + false + + false + + + + MESSAGE + Message + + + true + false + + false + + + + ADDITIONAL_INFO + Additional Information + + + false + false + + false + + + + ACTIVITY_DATE + Activity Date + + 10 + true + false + + false + + + + + + 255 + 255 + 206 + + + + + ACTIVITY_FK_1 + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 1451 + 577 + + + + + + + + COMMIT_LOG + Commit Log + Since 1.2 + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + COMMIT_ID + Commit ID + + 40 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + COMMIT_LOG_FK_1 + + + + + + REPOSITORY + Repository + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + REPOSITORY_TYPE + Repository Type + + 10 + true + false + 0:Public 1:Private + false + 0 + + + DESCRIPTION + Description + + TEXT + 文字列 + true + 2005 + + + false + false + + false + + + + DEFAULT_BRANCH + Default Branch + + VARCHAR + 文字列 + true + 12 + + 100 + false + false + + false + + + + REGISTERED_DATE + Registered Date + + TIMESTAMP + 日時 + false + 93 + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + LAST_ACTIVITY_DATE + Last Activity Date + + 10 + true + false + + false + + + + + + IDX_PROJECT_1 + + UNIQUE + + + PROJECT_NAME + USER_ID + + + + + 255 + 255 + 206 + + + + PROJECT_ACCOUNT_FK_1 + + + + + REPOSITORY_NAME + Repository Name + + 100 + true + true + + false + + + + + + + + + + + COLLABORATORS + Collaborators + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + + COLLABORATOR_NAME + Collaborator Name + + 100 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + PROJECT_ACCOUNT_FK_2 + + + + + + + + + + + + + + + + + 2 + + + + + + + + + + 2 + + + + + + -1 + -1 + 432 + 240 + + + + + + + + + 2 + + + + + + + GROUP_MEMBER_FK_2 + + + + + + + GROUP_MEMBER + Group Member + Since 1.4 + + + GROUP_NAME + Group Name + + 100 + true + true + + false + + + + USER_NAME + User Name + + 100 + true + true + + false + + + + + + 255 + 255 + 206 + + + + + GROUP_MEMBER_FK_1 + + + + + + + + + + + + ACCOUNT + Account + + + + + MAIL_ADDRESS + Mail Address + + VARCHAR + 文字列 + true + 12 + + 100 + true + false + + false + + + + PASSWORD + Password + + 40 + true + false + + false + + + + ADMINISTRATOR + Administrator + + BOOLEAN + 真偽値 + false + 16 + + 10 + true + false + + false + 0 + + + URL + URL + + 200 + false + false + + false + + + + REGISTERED_DATE + Registered Date + + 10 + true + false + + false + + + + UPDATED_DATE + Updated Date + + 10 + true + false + + false + + + + LAST_LOGIN_DATE + Last Login Date + + 10 + false + false + + false + + + + IMAGE + Image + + 100 + false + false + Since 1.3 + false + + + + GROUP_ACCOUNT + Group Account + + BOOLEAN + 真偽値 + false + 16 + + 10 + true + false + Since 1.4 + false + FALSE + + + + + IDX_ACCOUNT_1 + + UNIQUE + + + MAIL_ADDRESS + + + + + 255 + 255 + 206 + + + + + + + + + + + + + + + + + + + + 2 + + + + + + -1 + -1 + 410 + 860 + + + + + + ISSUE_OUTLINE_VIEW + Issue Outline View + Since 1.4 + + + USER_NAME + User Name + + 100 + false + false + + false + + + + REPOSITORY_NAME + Repository Name + + 100 + false + false + + false + + + + ISSUE_ID + Issue ID + + INT + 整数 + false + 4 + + 10 + false + false + + false + + + + COMMENT_COUNT + Comment Count + + 10 + false + false + + false + + + + + + 210 + 232 + 249 + + + + + + H2 + false + + sun.jdbc.odbc.JdbcOdbc + + + + + + false + + \ No newline at end of file diff --git a/etc/icons.svg b/etc/icons.svg new file mode 100644 index 0000000..785ffa6 --- /dev/null +++ b/etc/icons.svg @@ -0,0 +1,222 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gitbucket.erd b/gitbucket.erd deleted file mode 100644 index 7b63794..0000000 --- a/gitbucket.erd +++ /dev/null @@ -1,1200 +0,0 @@ - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 37 - 36 - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 751 - 47 - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 882 - 239 - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 940 - 615 - - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 420 - 758 - - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 307 - 356 - - - - - - - - 2 - - - - - - - ISSUE_FK_1 - - - - - - - - 2 - - - - - - - - - - - 2 - - - - - - -1 - -1 - 641 - 569 - - - - - - - - 2 - - - - - - - MILESTONE_FK_1 - - - - - - - - - MILESTONE - Milestone - - - - USER_NAME - User Name - - VARCHAR - 文字列 - true - 12 - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - MILESTONE_ID - Milestone ID - - INT - 整数 - false - 4 - - 10 - true - true - - true - - - - MILESTONE_NAME - Milestone Name - - 100 - true - false - - false - - - - - - 255 - 255 - 206 - - - - ISSUE_FK_2 - - - - - - - - 2 - - - - - - - ISSUE_FK_2 - - - - USER_NAME - User Name - - VARCHAR - 文字列 - true - 12 - - 100 - true - true - - false - - - - OPENED_USER_NAME - Opened User Name - - 100 - true - false - - false - - - - - - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 26 - 660 - - - - - - - - - 2 - - - - - - - ISSUE_COMMENT_FK_2 - - - - - COMMENTED_USER_NAME - Commented User Name - - 100 - true - false - - false - - - - - - - - - - ISSUE_COMMENT - Issue Comment - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - COMMENT_ID - Comment ID - - 10 - true - true - - true - - - - - CONTENT - Content - - TEXT - 文字列 - true - 2005 - - - true - false - - false - - - - REGISTERED_DATE - Registered Date - - TIMESTAMP - 日時 - false - 93 - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_COMMENT_FK_1 - - - - - - - ISSUE - Issue - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - - MILESTONE_ID - Milestone ID - - 10 - false - false - - false - - - - TITLE - Title - - - true - false - - false - - - - CONTENT - Content - - - true - false - - false - - - - REGISTERED_DATE - Registered Date - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - ISSUE_LABEL_FK_2 - - - - - - - ISSUE_LABEL - Issue Label - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - ISSUE_ID - Issue ID - - 10 - true - true - - false - - - - LABEL_ID - Label ID - - 10 - true - true - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_LABEL_FK_1 - - - - - - LABEL - Label - - - - USER_NAME - User Name - - 100 - false - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - false - true - - false - - - - LABEL_ID - Label ID - - INT - 整数 - false - 4 - - 10 - true - true - - true - - - - LABEL_NAME - Label Name - - 100 - true - false - - false - - - - COLOR - Color - - CHAR - 文字 - true - 1 - - 6 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - LABEL_FK_1 - - - - - - - - 2 - - - - - - - - - - 2 - - - - - - -1 - -1 - 388 - 166 - - - - - - - - ISSUE_ID - Issue ID - - - - USER_NAME - User Name - - 100 - false - false - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - false - false - - false - - - - ISSUE_ID - Issue ID - - 10 - true - false - - false - - - - - - 255 - 255 - 206 - - - - - ISSUE_ID_FK_1 - - - - - - - - REPOSITORY - Repository - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - REPOSITORY_TYPE - Repository Type - - 10 - true - false - 0:Public 1:Private - false - 0 - - - DESCRIPTION - Description - - TEXT - 文字列 - true - 2005 - - - false - false - - false - - - - DEFAULT_BRANCH - Default Branch - - VARCHAR - 文字列 - true - 12 - - 100 - false - false - - false - - - - REGISTERED_DATE - Registered Date - - TIMESTAMP - 日時 - false - 93 - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - LAST_ACTIVITY_DATE - Last Activity Date - - 10 - true - false - - false - - - - - - IDX_PROJECT_1 - - UNIQUE - - - PROJECT_NAME - USER_ID - - - - - 255 - 255 - 206 - - - - PROJECT_ACCOUNT_FK_1 - - - - - REPOSITORY_NAME - Repository Name - - 100 - true - true - - false - - - - - - - - - - - COLLABORATORS - Collaborators - - - - USER_NAME - User Name - - 100 - true - true - - false - - - - - COLLABORATOR_NAME - Collaborator Name - - 100 - true - true - - false - - - - - - 255 - 255 - 206 - - - - - PROJECT_ACCOUNT_FK_2 - - - - - - - - - - - - - ACCOUNT - Account - - - - - MAIL_ADDRESS - Mail Address - - VARCHAR - 文字列 - true - 12 - - 100 - true - false - - false - - - - PASSWORD - Password - - 20 - true - false - - false - - - - USER_TYPE - User Type - - INT - 整数 - false - 4 - - 10 - true - false - 0:Normal 1:Administrator - false - 0 - - - URL - URL - - 200 - false - false - - false - - - - REGISTERED_DATE - Registered Date - - 10 - true - false - - false - - - - UPDATED_DATE - Updated Date - - 10 - true - false - - false - - - - LAST_LOGIN_DATE - Last Login Date - - 10 - false - false - - false - - - - - - IDX_ACCOUNT_1 - - UNIQUE - - - MAIL_ADDRESS - - - - - 255 - 255 - 206 - - - - - - - - - - - - - - H2 - false - - sun.jdbc.odbc.JdbcOdbc - - - - - - false - - \ No newline at end of file diff --git a/lib/scalatra-forms_2.10-0.0.1.jar b/lib/scalatra-forms_2.10-0.0.1.jar deleted file mode 100644 index baea441..0000000 --- a/lib/scalatra-forms_2.10-0.0.1.jar +++ /dev/null Binary files differ diff --git a/project/build.scala b/project/build.scala index 774c0dd..cc8251e 100644 --- a/project/build.scala +++ b/project/build.scala @@ -2,6 +2,7 @@ import Keys._ import org.scalatra.sbt._ import org.scalatra.sbt.PluginKeys._ +import sbt.ScalaVersion import twirl.sbt.TwirlPlugin._ import com.typesafe.sbteclipse.plugin.EclipsePlugin.EclipseKeys @@ -20,7 +21,10 @@ name := Name, version := Version, scalaVersion := ScalaVersion, - resolvers += Classpaths.typesafeReleases, + resolvers ++= Seq( + Classpaths.typesafeReleases, + "amateras-repo" at "http://amateras.sourceforge.jp/mvn/" + ), libraryDependencies ++= Seq( "org.eclipse.jgit" % "org.eclipse.jgit.http.server" % "3.0.0.201306101825-r", "org.apache.commons" % "commons-io" % "1.3.2", @@ -28,6 +32,7 @@ "org.scalatra" %% "scalatra-specs2" % ScalatraVersion % "test", "org.scalatra" %% "scalatra-json" % ScalatraVersion, "org.json4s" %% "json4s-jackson" % "3.2.4", + "jp.sf.amateras" %% "scalatra-forms" % "0.0.1", "commons-io" % "commons-io" % "2.4", "org.pegdown" % "pegdown" % "1.3.0", "org.apache.commons" % "commons-compress" % "1.5", diff --git a/project/plugins.sbt b/project/plugins.sbt index 3249832..e75b1d5 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,6 @@ addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.1.2") -addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.2.0") +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.1") addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.2.0") diff --git a/src/main/resources/update/1_4.sql b/src/main/resources/update/1_4.sql index a68ca43..2d3c492 100644 --- a/src/main/resources/update/1_4.sql +++ b/src/main/resources/update/1_4.sql @@ -8,3 +8,17 @@ ALTER TABLE GROUP_MEMBER ADD CONSTRAINT IDX_GROUP_MEMBER_FK1 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); ALTER TABLE ACCOUNT ADD COLUMN GROUP_ACCOUNT BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE OR REPLACE VIEW ISSUE_OUTLINE_VIEW AS + SELECT + A.USER_NAME, + A.REPOSITORY_NAME, + A.ISSUE_ID, + NVL(B.COMMENT_COUNT, 0) AS COMMENT_COUNT + FROM ISSUE A + LEFT OUTER JOIN ( + SELECT USER_NAME, REPOSITORY_NAME, ISSUE_ID, COUNT(COMMENT_ID) AS COMMENT_COUNT FROM ISSUE_COMMENT + WHERE ACTION IN ('comment', 'close_comment', 'reopen_comment') + GROUP BY USER_NAME, REPOSITORY_NAME, ISSUE_ID + ) B + ON (A.USER_NAME = B.USER_NAME AND A.REPOSITORY_NAME = B.REPOSITORY_NAME AND A.ISSUE_ID = B.ISSUE_ID); diff --git a/src/main/resources/update/1_5.sql b/src/main/resources/update/1_5.sql new file mode 100644 index 0000000..35131fc --- /dev/null +++ b/src/main/resources/update/1_5.sql @@ -0,0 +1,22 @@ +ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_USER_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN ORIGIN_REPOSITORY_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN PARENT_USER_NAME VARCHAR(100); +ALTER TABLE REPOSITORY ADD COLUMN PARENT_REPOSITORY_NAME VARCHAR(100); + +CREATE TABLE PULL_REQUEST( + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + ISSUE_ID INT NOT NULL, + BRANCH VARCHAR(100) NOT NULL, + REQUEST_USER_NAME VARCHAR(100) NOT NULL, + REQUEST_REPOSITORY_NAME VARCHAR(100) NOT NULL, + REQUEST_BRANCH VARCHAR(100) NOT NULL, + COMMIT_ID_FROM VARCHAR(40) NOT NULL, + COMMIT_ID_TO VARCHAR(40) NOT NULL +); + +ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_PK PRIMARY KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID); +ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_FK0 FOREIGN KEY (USER_NAME, REPOSITORY_NAME, ISSUE_ID) REFERENCES ISSUE (USER_NAME, REPOSITORY_NAME, ISSUE_ID); +ALTER TABLE PULL_REQUEST ADD CONSTRAINT IDX_PULL_REQUEST_FK1 FOREIGN KEY (REQUEST_USER_NAME, REQUEST_REPOSITORY_NAME) REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME); + +ALTER TABLE ISSUE ADD COLUMN PULL_REQUEST BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 1233a28..874bbbd 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -18,6 +18,7 @@ context.mount(new LabelsController, "/*") context.mount(new MilestonesController, "/*") context.mount(new IssuesController, "/*") + context.mount(new PullRequestsController, "/*") context.mount(new RepositorySettingsController, "/*") val dir = new java.io.File(_root_.util.Directory.GitBucketHome) diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index acf066f..48b649b 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -58,7 +58,7 @@ case _ => _root_.account.html.repositories(account, if(account.isGroupAccount) Nil else getGroupsByUserName(userName), - getVisibleRepositories(userName, baseUrl, context.loginAccount.map(_.userName))) + getVisibleRepositories(context.loginAccount, baseUrl, Some(userName))) } } getOrElse NotFound } diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index bff5994..c359ff2 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -1,7 +1,7 @@ package app import _root_.util.Directory._ -import _root_.util.{FileUtil, Validations} +import _root_.util.{StringUtil, FileUtil, Validations} import org.scalatra._ import org.scalatra.json._ import org.json4s._ @@ -10,7 +10,7 @@ import model.Account import scala.Some import service.AccountService -import javax.servlet.http.{HttpSession, HttpServletRequest} +import javax.servlet.http.{HttpServletResponse, HttpSession, HttpServletRequest} import java.text.SimpleDateFormat import javax.servlet.{FilterChain, ServletResponse, ServletRequest} @@ -23,16 +23,28 @@ implicit val jsonFormats = DefaultFormats override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { - val httpRequest = request.asInstanceOf[HttpServletRequest] - val path = httpRequest.getRequestURI.substring(request.getServletContext.getContextPath.length) + val httpRequest = request.asInstanceOf[HttpServletRequest] + val httpResponse = response.asInstanceOf[HttpServletResponse] + val context = request.getServletContext.getContextPath + val path = httpRequest.getRequestURI.substring(context.length) if(path.startsWith("/console/")){ - Option(httpRequest.getSession.getAttribute("LOGIN_ACCOUNT").asInstanceOf[Account]).collect { - case account if(account.isAdmin) => chain.doFilter(request, response) + val account = httpRequest.getSession.getAttribute("LOGIN_ACCOUNT").asInstanceOf[Account] + if(account == null){ + // Redirect to login form + httpResponse.sendRedirect(context + "/signin?" + path) + } else if(account.isAdmin){ + // H2 Console (administrators only) + chain.doFilter(request, response) + } else { + // Redirect to dashboard + httpResponse.sendRedirect(context + "/") } } else if(path.startsWith("/git/")){ + // Git repository chain.doFilter(request, response) } else { + // Scalatra actions super.doFilter(request, response, chain) } } diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala index a622c88..3446d16 100644 --- a/src/main/scala/app/CreateRepositoryController.scala +++ b/src/main/scala/app/CreateRepositoryController.scala @@ -1,27 +1,30 @@ package app import util.Directory._ -import util.{JGitUtil, UsersAuthenticator} +import util.{LockUtil, JGitUtil, UsersAuthenticator, ReferrerAuthenticator} import service._ import java.io.File import org.eclipse.jgit.api.Git import org.apache.commons.io._ import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.lib.PersonIdent class CreateRepositoryController extends CreateRepositoryControllerBase with RepositoryService with AccountService with WikiService with LabelsService with ActivityService - with UsersAuthenticator + with UsersAuthenticator with ReferrerAuthenticator /** * Creates new repository. */ trait CreateRepositoryControllerBase extends ControllerBase { self: RepositoryService with AccountService with WikiService with LabelsService with ActivityService - with UsersAuthenticator => + with UsersAuthenticator with ReferrerAuthenticator => case class RepositoryCreationForm(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) - val form = mapping( + case class ForkRepositoryForm(owner: String, name: String) + + val newForm = mapping( "owner" -> trim(label("Owner" , text(required, maxlength(40), identifier, existsAccount))), "name" -> trim(label("Repository name", text(required, maxlength(40), identifier, unique))), "description" -> trim(label("Description" , optional(text()))), @@ -29,6 +32,11 @@ "createReadme" -> trim(label("Create README" , boolean())) )(RepositoryCreationForm.apply) + val forkForm = mapping( + "owner" -> trim(label("Repository owner", text(required))), + "name" -> trim(label("Repository name", text(required))) + )(ForkRepositoryForm.apply) + /** * Show the new repository form. */ @@ -39,77 +47,142 @@ /** * Create new repository. */ - post("/new", form)(usersOnly { form => - val ownerAccount = getAccountByUserName(form.owner).get - val loginAccount = context.loginAccount.get - val loginUserName = loginAccount.userName + post("/new", newForm)(usersOnly { form => + LockUtil.lock(s"${form.owner}/${form.name}/create"){ + if(getRepository(form.owner, form.name, baseUrl).isEmpty){ + val ownerAccount = getAccountByUserName(form.owner).get + val loginAccount = context.loginAccount.get + val loginUserName = loginAccount.userName - // Insert to the database at first - createRepository(form.name, form.owner, form.description, form.isPrivate) + // Insert to the database at first + createRepository(form.name, form.owner, form.description, form.isPrivate) - // Add collaborators for group repository - if(ownerAccount.isGroupAccount){ - getGroupMembers(form.owner).foreach { userName => - addCollaborator(form.owner, form.name, userName) + // Add collaborators for group repository + if(ownerAccount.isGroupAccount){ + getGroupMembers(form.owner).foreach { userName => + addCollaborator(form.owner, form.name, userName) + } + } + + // Insert default labels + insertDefaultLabels(loginUserName, form.name) + + // Create the actual repository + val gitdir = getRepositoryDir(form.owner, form.name) + JGitUtil.initRepository(gitdir) + + if(form.createReadme){ + val tmpdir = getInitRepositoryDir(form.owner, form.name) + try { + // Clone the repository + Git.cloneRepository.setURI(gitdir.toURI.toString).setDirectory(tmpdir).call + + // Create README.md + FileUtils.writeStringToFile(new File(tmpdir, "README.md"), + if(form.description.nonEmpty){ + form.name + "\n" + + "===============\n" + + "\n" + + form.description.get + } else { + form.name + "\n" + + "===============\n" + }, "UTF-8") + + val git = Git.open(tmpdir) + git.add.addFilepattern("README.md").call + git.commit + .setCommitter(new PersonIdent(loginUserName, loginAccount.mailAddress)) + .setMessage("Initial commit").call + git.push.call + + } finally { + FileUtils.deleteDirectory(tmpdir) + } + } + + // Create Wiki repository + createWikiRepository(loginAccount, form.owner, form.name) + + // Record activity + recordCreateRepositoryActivity(form.owner, form.name, loginUserName) } + + // redirect to the repository + redirect(s"/${form.owner}/${form.name}") } - - // Insert default labels - createLabel(form.owner, form.name, "bug", "fc2929") - createLabel(form.owner, form.name, "duplicate", "cccccc") - createLabel(form.owner, form.name, "enhancement", "84b6eb") - createLabel(form.owner, form.name, "invalid", "e6e6e6") - createLabel(form.owner, form.name, "question", "cc317c") - createLabel(form.owner, form.name, "wontfix", "ffffff") - - // Create the actual repository - val gitdir = getRepositoryDir(form.owner, form.name) - JGitUtil.initRepository(gitdir) - - if(form.createReadme){ - val tmpdir = getInitRepositoryDir(form.owner, form.name) - try { - // Clone the repository - Git.cloneRepository.setURI(gitdir.toURI.toString).setDirectory(tmpdir).call - - // Create README.md - FileUtils.writeStringToFile(new File(tmpdir, "README.md"), - if(form.description.nonEmpty){ - form.name + "\n" + - "===============\n" + - "\n" + - form.description.get - } else { - form.name + "\n" + - "===============\n" - }, "UTF-8") - - val git = Git.open(tmpdir) - git.add.addFilepattern("README.md").call - git.commit.setMessage("Initial commit").call - git.push.call - - } finally { - FileUtils.deleteDirectory(tmpdir) - } - } - - // Create Wiki repository - createWikiRepository(loginAccount, form.owner, form.name) - - // Record activity - recordCreateRepositoryActivity(form.owner, form.name, loginUserName) - - // redirect to the repository - redirect(s"/${form.owner}/${form.name}") }) + post("/:owner/:repository/_fork")(referrersOnly { repository => + val loginAccount = context.loginAccount.get + val loginUserName = loginAccount.userName + + LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ + if(getRepository(loginUserName, repository.name, baseUrl).isEmpty){ + // Insert to the database at first + val originUserName = repository.repository.originUserName.getOrElse(repository.owner) + val originRepositoryName = repository.repository.originRepositoryName.getOrElse(repository.name) + + createRepository( + repositoryName = repository.name, + userName = loginUserName, + description = repository.repository.description, + isPrivate = repository.repository.isPrivate, + originRepositoryName = Some(originRepositoryName), + originUserName = Some(originUserName), + parentRepositoryName = Some(repository.name), + parentUserName = Some(repository.owner) + ) + + // Insert default labels + insertDefaultLabels(loginUserName, repository.name) + + // clone repository actually + JGitUtil.cloneRepository( + getRepositoryDir(repository.owner, repository.name), + getRepositoryDir(loginUserName, repository.name)) + + // Create Wiki repository + JGitUtil.cloneRepository( + getWikiRepositoryDir(repository.owner, repository.name), + getWikiRepositoryDir(loginUserName, repository.name)) + + // insert commit id + JGitUtil.withGit(getRepositoryDir(loginUserName, repository.name)){ git => + JGitUtil.getRepositoryInfo(loginUserName, repository.name, baseUrl).branchList.foreach { branch => + JGitUtil.getCommitLog(git, branch) match { + case Right((commits, _)) => commits.foreach { commit => + if(!existsCommitId(loginUserName, repository.name, commit.id)){ + insertCommitId(loginUserName, repository.name, commit.id) + } + } + case Left(_) => ??? + } + } + } + + // Record activity + recordForkActivity(repository.owner, repository.name, loginUserName) + } + // redirect to the repository + redirect("/%s/%s".format(loginUserName, repository.name)) + } + }) + + private def insertDefaultLabels(userName: String, repositoryName: String): Unit = { + createLabel(userName, repositoryName, "bug", "fc2929") + createLabel(userName, repositoryName, "duplicate", "cccccc") + createLabel(userName, repositoryName, "enhancement", "84b6eb") + createLabel(userName, repositoryName, "invalid", "e6e6e6") + createLabel(userName, repositoryName, "question", "cc317c") + createLabel(userName, repositoryName, "wontfix", "ffffff") + } + private def existsAccount: Constraint = new Constraint(){ def validate(name: String, value: String): Option[String] = if(getAccountByUserName(value).isEmpty) Some("User or group does not exist.") else None } - /** * Duplicate check for the repository name. */ @@ -120,4 +193,4 @@ } } -} \ No newline at end of file +} diff --git a/src/main/scala/app/DashboardController.scala b/src/main/scala/app/DashboardController.scala index 02e1668..2728b5e 100644 --- a/src/main/scala/app/DashboardController.scala +++ b/src/main/scala/app/DashboardController.scala @@ -33,17 +33,25 @@ session.put(sessionKey, condition) - val repositories = getAccessibleRepositories(context.loginAccount, baseUrl) + val userName = context.loginAccount.get.userName + val repositories = getUserRepositories(userName, baseUrl).map(repo => repo.owner -> repo.name) + val filterUser = Map(filter -> userName) + val page = IssueSearchCondition.page(request) // dashboard.html.issues( - issues.html.listparts(Nil, 0, 0, 0, condition), - 0, - 0, - 0, - repositories, + 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, repositories: _*), condition, filter) } -} \ No newline at end of file +} diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index 5612f9b..f827492 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -16,19 +16,22 @@ val loginAccount = context.loginAccount html.index(getRecentActivities(), - getAccessibleRepositories(loginAccount, baseUrl), + getVisibleRepositories(loginAccount, baseUrl), loadSystemSettings(), - loginAccount.map{ account => getRepositoryNamesOfUser(account.userName) }.getOrElse(Nil) + loginAccount.map{ account => getUserRepositories(account.userName, baseUrl) }.getOrElse(Nil) ) } /** * JSON API for collaborator completion. + * + * TODO Move to other controller? */ - // TODO Move to other controller? get("/_user/proposals")(usersOnly { contentType = formats("json") - org.json4s.jackson.Serialization.write(Map("options" -> getAllUsers.filter(!_.isGroupAccount).map(_.userName).toArray)) + org.json4s.jackson.Serialization.write( + Map("options" -> getAllUsers.filter(!_.isGroupAccount).map(_.userName).toArray) + ) }) diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index 0c8b35f..ccebc5e 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -128,14 +128,22 @@ }) post("/:owner/:repository/issue_comments/new", commentForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, Some(form.content), repository)() map { id => - redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + handleComment(form.issueId, Some(form.content), repository)() map { case (issue, id) => + if(issue.isPullRequest){ + redirect(s"/${repository.owner}/${repository.name}/pull/${form.issueId}#comment-${id}") + } else { + redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + } } getOrElse NotFound }) post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => - handleComment(form.issueId, form.content, repository)() map { id => - redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + handleComment(form.issueId, form.content, repository)() map { case (issue, id) => + if(issue.isPullRequest){ + redirect(s"/${repository.owner}/${repository.name}/pull/${form.issueId}#comment-${id}") + } else { + redirect(s"/${repository.owner}/${repository.name}/issues/${form.issueId}#comment-${id}") + } } getOrElse NotFound }) @@ -294,23 +302,17 @@ content foreach ( recordCommentIssueActivity(owner, name, userName, issueId, _) ) recordActivity foreach ( _ (owner, name, userName, issueId, issue.title) ) - commentId + (issue, commentId) } } private def searchIssues(filter: String, repository: RepositoryService.RepositoryInfo) = { val owner = repository.owner val repoName = repository.name - val userName = if(filter != "all") Some(params("userName")) else None + val filterUser = Map(filter -> params.getOrElse("userName", "")) + val page = IssueSearchCondition.page(request) val sessionKey = s"${owner}/${repoName}/issues" - val page = try { - val i = params.getOrElse("page", "1").toInt - if(i <= 0) 1 else i - } catch { - case e: NumberFormatException => 1 - } - // retrieve search condition val condition = if(request.getQueryString == null){ session.get(sessionKey).getOrElse(IssueSearchCondition()).asInstanceOf[IssueSearchCondition] @@ -319,17 +321,17 @@ session.put(sessionKey, condition) issues.html.list( - searchIssue(owner, repoName, condition, filter, userName, (page - 1) * IssueLimit, IssueLimit), + searchIssue(condition, filterUser, false, (page - 1) * IssueLimit, IssueLimit, owner -> repoName), page, (getCollaborators(owner, repoName) :+ owner).sorted, getMilestones(owner, repoName), getLabels(owner, repoName), - countIssue(owner, repoName, condition.copy(state = "open"), filter, userName), - countIssue(owner, repoName, condition.copy(state = "closed"), filter, userName), - countIssue(owner, repoName, condition, "all", None), - context.loginAccount.map(x => countIssue(owner, repoName, condition, "assigned", Some(x.userName))), - context.loginAccount.map(x => countIssue(owner, repoName, condition, "created_by", Some(x.userName))), - countIssueGroupByLabels(owner, repoName, condition, filter, userName), + 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, diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala new file mode 100644 index 0000000..d361d44 --- /dev/null +++ b/src/main/scala/app/PullRequestsController.scala @@ -0,0 +1,400 @@ +package app + +import util.{LockUtil, CollaboratorsAuthenticator, JGitUtil, ReferrerAuthenticator} +import util.Directory._ +import util.Implicits._ +import service._ +import org.eclipse.jgit.api.Git +import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.transport.RefSpec +import org.apache.commons.io.FileUtils +import scala.collection.JavaConverters._ +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.api.MergeCommand.FastForwardMode +import service.IssuesService._ +import service.PullRequestService._ +import util.JGitUtil.DiffInfo +import scala.Some +import service.RepositoryService.RepositoryTreeNode +import util.JGitUtil.CommitInfo + +class PullRequestsController extends PullRequestsControllerBase + with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with ActivityService + with ReferrerAuthenticator with CollaboratorsAuthenticator + +trait PullRequestsControllerBase extends ControllerBase { + self: RepositoryService with IssuesService with MilestonesService with ActivityService with PullRequestService + with ReferrerAuthenticator with CollaboratorsAuthenticator => + + val pullRequestForm = mapping( + "title" -> trim(label("Title" , text(required, maxlength(100)))), + "content" -> trim(label("Content", optional(text()))), + "targetUserName" -> trim(text(required, maxlength(100))), + "targetBranch" -> trim(text(required, maxlength(100))), + "requestUserName" -> trim(text(required, maxlength(100))), + "requestBranch" -> trim(text(required, maxlength(100))), + "commitIdFrom" -> trim(text(required, maxlength(40))), + "commitIdTo" -> trim(text(required, maxlength(40))) + )(PullRequestForm.apply) + + val mergeForm = mapping( + "message" -> trim(label("Message", text(required))) + )(MergeForm.apply) + + case class PullRequestForm( + title: String, + content: Option[String], + targetUserName: String, + targetBranch: String, + requestUserName: String, + requestBranch: String, + commitIdFrom: String, + commitIdTo: String) + + case class MergeForm(message: String) + + get("/:owner/:repository/pulls")(referrersOnly { repository => + searchPullRequests(None, repository) + }) + + get("/:owner/:repository/pulls/:userName")(referrersOnly { repository => + searchPullRequests(Some(params("userName")), repository) + }) + + get("/:owner/:repository/pull/:id")(referrersOnly { repository => + val owner = repository.owner + val name = repository.name + val issueId = params("id").toInt + + getPullRequest(owner, name, issueId) map { case(issue, pullreq) => + JGitUtil.withGit(getRepositoryDir(owner, name)){ git => + val requestCommitId = git.getRepository.resolve(pullreq.requestBranch) + + val (commits, diffs) = + getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo) + + pulls.html.pullreq( + issue, pullreq, + getComments(owner, name, issueId.toInt), + (getCollaborators(owner, name) :+ owner).sorted, + getMilestonesWithIssueCount(owner, name), + commits, + diffs, + requestCommitId.getName, + if(issue.closed){ + false + } else { + checkConflict(owner, name, pullreq.branch, owner, name, pullreq.requestBranch) + }, + hasWritePermission(owner, name, context.loginAccount), + repository, + s"${baseUrl}${context.path}/git/${pullreq.requestUserName}/${pullreq.requestRepositoryName}.git") + } + + } getOrElse NotFound + }) + + post("/:owner/:repository/pull/:id/merge", mergeForm)(collaboratorsOnly { (form, repository) => + LockUtil.lock(s"${repository.owner}/${repository.name}/merge"){ + val issueId = params("id").toInt + + getPullRequest(repository.owner, repository.name, issueId).map { case (issue, pullreq) => + val remote = getRepositoryDir(repository.owner, repository.name) + val tmpdir = new java.io.File(getTemporaryDir(repository.owner, repository.name), s"merge-${issueId}") + val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).call + + try { + // mark issue as merged and close. + val loginAccount = context.loginAccount.get + createComment(repository.owner, repository.name, loginAccount.userName, issueId, "Merge", "merge") + createComment(repository.owner, repository.name, loginAccount.userName, issueId, "Close", "close") + updateClosed(repository.owner, repository.name, issueId, true) + recordMergeActivity(repository.owner, repository.name, loginAccount.userName, issueId, form.message) + + // fetch pull request to working repository + val pullRequestBranchName = s"gitbucket-pullrequest-${issueId}" + + git.fetch + .setRemote(getRepositoryDir(repository.owner, repository.name).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/pull/${issueId}/head:refs/heads/${pullRequestBranchName}")).call + + // merge pull request + git.checkout.setName(pullreq.branch).call + + val result = git.merge + .include(git.getRepository.resolve(pullRequestBranchName)) + .setFastForward(FastForwardMode.NO_FF) + .setCommit(false) + .call + + if(result.getConflicts != null){ + throw new RuntimeException("This pull request can't merge automatically.") + } + + // merge commit + git.getRepository.writeMergeCommitMsg( + s"Merge pull request #${issueId} from ${pullreq.requestUserName}/${pullreq.requestRepositoryName}\n" + + form.message) + + git.commit + .setCommitter(new PersonIdent(loginAccount.userName, loginAccount.mailAddress)) + .call + + // push + git.push.call + + val (commits, _) = getRequestCompareInfo(repository.owner, repository.name, pullreq.commitIdFrom, + pullreq.requestUserName, pullreq.requestRepositoryName, pullreq.commitIdTo) + + commits.flatten.foreach { commit => + if(!existsCommitId(repository.owner, repository.name, commit.id)){ + insertCommitId(repository.owner, repository.name, commit.id) + } + } + + redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") + + } finally { + git.getRepository.close + FileUtils.deleteDirectory(tmpdir) + } + } getOrElse NotFound + } + }) + + /** + * Checks whether conflict will be caused in merging. + * Returns true if conflict will be caused. + */ + private def checkConflict(userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestBranch: String): Boolean = { + // TODO Are there more quick way? + LockUtil.lock(s"${userName}/${repositoryName}/merge-check"){ + val remote = getRepositoryDir(userName, repositoryName) + val tmpdir = new java.io.File(getTemporaryDir(userName, repositoryName), "merge-check") + if(tmpdir.exists()){ + FileUtils.deleteDirectory(tmpdir) + } + + val git = Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).call + try { + git.checkout.setName(branch).call + + git.fetch + .setRemote(getRepositoryDir(requestUserName, requestRepositoryName).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/heads/${branch}:refs/heads/${requestBranch}")).call + + val result = git.merge + .include(git.getRepository.resolve("FETCH_HEAD")) + .setCommit(false).call + + result.getConflicts != null + + } finally { + git.getRepository.close + FileUtils.deleteDirectory(tmpdir) + } + } + } + + get("/:owner/:repository/compare")(collaboratorsOnly { forkedRepository => + (forkedRepository.repository.originUserName, forkedRepository.repository.originRepositoryName) match { + case (Some(originUserName), Some(originRepositoryName)) => { + getRepository(originUserName, originRepositoryName, baseUrl).map { originRepository => + withGit( + getRepositoryDir(originUserName, originRepositoryName), + getRepositoryDir(forkedRepository.owner, forkedRepository.name) + ){ (oldGit, newGit) => + val oldBranch = JGitUtil.getDefaultBranch(oldGit, originRepository).get._2 + val newBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2 + + redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}") + } + } getOrElse NotFound + } + case _ => { + JGitUtil.withGit(getRepositoryDir(forkedRepository.owner, forkedRepository.name)){ git => + val defaultBranch = JGitUtil.getDefaultBranch(git, forkedRepository).get._2 + redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}") + } + } + } + }) + + get("/:owner/:repository/compare/*...*")(collaboratorsOnly { repository => + val Seq(origin, forked) = multiParams("splat") + val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner) + val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner) + + (getRepository(originOwner, repository.name, baseUrl), + getRepository(forkedOwner, repository.name, baseUrl)) match { + case (Some(originRepository), Some(forkedRepository)) => { + withGit( + getRepositoryDir(originOwner, repository.name), + getRepositoryDir(forkedOwner, repository.name) + ){ case (oldGit, newGit) => + val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 + val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 + + val forkedId = getForkedCommitId(oldGit, newGit, + originOwner, repository.name, originBranch, + forkedOwner, repository.name, forkedBranch) + + val oldId = oldGit.getRepository.resolve(forkedId) + val newId = newGit.getRepository.resolve(forkedBranch) + + val (commits, diffs) = getRequestCompareInfo( + originOwner, repository.name, oldId.getName, + forkedOwner, repository.name, newId.getName) + + pulls.html.compare( + commits, + diffs, + repository.repository.originUserName.map { userName => + getRepositoryNames(getForkedRepositoryTree(userName, repository.name)) + } getOrElse Nil, + originBranch, + forkedBranch, + oldId.getName, + newId.getName, + checkConflict(originOwner, repository.name, originBranch, forkedOwner, repository.name, forkedBranch), + repository, + originRepository, + forkedRepository) + } + } + case _ => NotFound + } + }) + + post("/:owner/:repository/pulls/new", pullRequestForm)(referrersOnly { (form, repository) => + val loginUserName = context.loginAccount.get.userName + + val issueId = createIssue( + owner = repository.owner, + repository = repository.name, + loginUser = loginUserName, + title = form.title, + content = form.content, + assignedUserName = None, + milestoneId = None, + isPullRequest = true) + + createPullRequest( + originUserName = repository.owner, + originRepositoryName = repository.name, + issueId = issueId, + originBranch = form.targetBranch, + requestUserName = form.requestUserName, + requestRepositoryName = repository.name, + requestBranch = form.requestBranch, + commitIdFrom = form.commitIdFrom, + commitIdTo = form.commitIdTo) + + // fetch requested branch + JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => + git.fetch + .setRemote(getRepositoryDir(form.requestUserName, repository.name).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/heads/${form.requestBranch}:refs/pull/${issueId}/head")) + .call + } + + recordPullRequestActivity(repository.owner, repository.name, loginUserName, issueId, form.title) + + redirect(s"/${repository.owner}/${repository.name}/pulls/${issueId}") + }) + + /** + * Handles w Git object simultaneously. + */ + private def withGit[T](oldDir: java.io.File, newDir: java.io.File)(action: (Git, Git) => T): T = { + val oldGit = Git.open(oldDir) + val newGit = Git.open(newDir) + try { + action(oldGit, newGit) + } finally { + oldGit.getRepository.close + newGit.getRepository.close + } + } + + /** + * Parses branch identifier and extracts owner and branch name as tuple. + * + * - "owner:branch" to ("owner", "branch") + * - "branch" to ("defaultOwner", "branch") + */ + private def parseCompareIdentifie(value: String, defaultOwner: String): (String, String) = + if(value.contains(':')){ + val array = value.split(":") + (array(0), array(1)) + } else { + (defaultOwner, value) + } + + /** + * Extracts all repository names from [[service.RepositoryService.RepositoryTreeNode]] as flat list. + */ + private def getRepositoryNames(node: RepositoryTreeNode): List[String] = + node.owner :: node.children.map { child => getRepositoryNames(child) }.flatten + + /** + * Returns the identifier of the root commit (or latest merge commit) of the specified branch. + */ + private def getForkedCommitId(oldGit: Git, newGit: Git, userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestBranch: String): String = + JGitUtil.getCommitLogs(newGit, requestBranch, true){ commit => + existsCommitId(userName, repositoryName, commit.getName) && + JGitUtil.getBranchesOfCommit(oldGit, commit.getName).contains(branch) + }.head.id + + private def getRequestCompareInfo(userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestCommitId: String): (Seq[Seq[CommitInfo]], Seq[DiffInfo]) = { + + withGit( + getRepositoryDir(userName, repositoryName), + getRepositoryDir(requestUserName, requestRepositoryName) + ){ (oldGit, newGit) => + val oldId = oldGit.getRepository.resolve(branch) + val newId = newGit.getRepository.resolve(requestCommitId) + + val commits = newGit.log.addRange(oldId, newId).call.iterator.asScala.map { revCommit => + new CommitInfo(revCommit) + }.toList.splitWith{ (commit1, commit2) => + view.helpers.date(commit1.time) == view.helpers.date(commit2.time) + } + + val diffs = JGitUtil.getDiffs(newGit, oldId.getName, newId.getName, true) + + (commits, diffs) + } + } + + private def searchPullRequests(userName: Option[String], repository: RepositoryService.RepositoryInfo) = { + val owner = repository.owner + val repoName = repository.name + val filterUser = userName.map { x => Map("created_by" -> x) } getOrElse Map("all" -> "") + val page = IssueSearchCondition.page(request) + val sessionKey = s"${owner}/${repoName}/pulls" + + // retrieve search condition + val condition = if(request.getQueryString == null){ + session.get(sessionKey).getOrElse(IssueSearchCondition()).asInstanceOf[IssueSearchCondition] + } else IssueSearchCondition(request) + + session.put(sessionKey, condition) + + pulls.html.list( + searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, owner -> repoName), + getPullRequestCount(condition.state == "closed", Some(owner, repoName)), + userName, + page, + countIssue(condition.copy(state = "open"), filterUser, true, owner -> repoName), + countIssue(condition.copy(state = "closed"), filterUser, true, owner -> repoName), + countIssue(condition, Map.empty, true, owner -> repoName), + condition, + repository, + hasWritePermission(owner, repoName, context.loginAccount)) + } + +} diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index 780a7f3..14c4f91 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -45,7 +45,15 @@ * Save the repository options. */ post("/:owner/:repository/settings/options", optionsForm)(ownerOnly { (form, repository) => - saveRepositoryOptions(repository.owner, repository.name, form.description, form.defaultBranch, form.isPrivate) + saveRepositoryOptions( + repository.owner, + repository.name, + form.description, + form.defaultBranch, + repository.repository.parentUserName.map { _ => + repository.repository.isPrivate + } getOrElse form.isPrivate + ) flash += "info" -> "Repository settings has been updated." redirect(s"/${repository.owner}/${repository.name}/settings/options") }) diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index 43cb126..f5e7dc7 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -38,48 +38,28 @@ }) /** - * Displays the file list of the repository root and the specified branch. - */ - get("/:owner/:repository/tree/:id")(referrersOnly { - fileList(_, params("id")) - }) - - /** * Displays the file list of the specified path and branch. */ - get("/:owner/:repository/tree/:id/*")(referrersOnly { - fileList(_, params("id"), multiParams("splat").head) - }) - - /** - * Displays the commit list of the specified branch. - */ - get("/:owner/:repository/commits/:branch")(referrersOnly { repository => - val branchName = params("branch") - val page = params.getOrElse("page", "1").toInt - JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => - JGitUtil.getCommitLog(git, branchName, page, 30) match { - case Right((logs, hasNext)) => - repo.html.commits(Nil, branchName, repository, logs.splitWith{ (commit1, commit2) => - view.helpers.date(commit1.time) == view.helpers.date(commit2.time) - }, page, hasNext) - case Left(_) => NotFound - } + get("/:owner/:repository/tree/*")(referrersOnly { repository => + val (id, path) = splitPath(repository, multiParams("splat").head) + if(path.isEmpty){ + fileList(repository, id) + } else { + fileList(repository, id, path) } }) /** * Displays the commit list of the specified resource. */ - get("/:owner/:repository/commits/:branch/*")(referrersOnly { repository => - val branchName = params("branch") - val path = multiParams("splat").head //.replaceFirst("^tree/.+?/", "") - val page = params.getOrElse("page", "1").toInt + get("/:owner/:repository/commits/*")(referrersOnly { repository => + val (branchName, path) = splitPath(repository, multiParams("splat").head) + val page = params.getOrElse("page", "1").toInt JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => JGitUtil.getCommitLog(git, branchName, page, 30, path) match { case Right((logs, hasNext)) => - repo.html.commits(path.split("/").toList, branchName, repository, + repo.html.commits(if(path.isEmpty) Nil else path.split("/").toList, branchName, repository, logs.splitWith{ (commit1, commit2) => view.helpers.date(commit1.time) == view.helpers.date(commit2.time) }, page, hasNext) @@ -91,10 +71,9 @@ /** * Displays the file content of the specified branch or commit. */ - get("/:owner/:repository/blob/:id/*")(referrersOnly { repository => - val id = params("id") // branch name or commit id - val raw = params.get("raw").getOrElse("false").toBoolean - val path = multiParams("splat").head //.replaceFirst("^tree/.+?/", "") + get("/:owner/:repository/blob/*")(referrersOnly { repository => + val (id, path) = splitPath(repository, multiParams("splat").head) + val raw = params.get("raw").getOrElse("false").toBoolean JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id)) @@ -202,7 +181,25 @@ BadRequest } }) + + get("/:owner/:repository/network/members")(referrersOnly { repository => + repo.html.forked( + getForkedRepositoryTree( + repository.repository.originUserName.getOrElse(repository.owner), + repository.repository.originRepositoryName.getOrElse(repository.name)), + repository) + }) + private def splitPath(repository: service.RepositoryService.RepositoryInfo, path: String): (String, String) = { + val id = repository.branchList.collectFirst { + case branch if(path == branch || path.startsWith(branch + "/")) => branch + } orElse repository.tags.collectFirst { + case tag if(path == tag.name || path.startsWith(tag.name + "/")) => tag.name + } orElse Some(path) get + + (id, path.substring(id.length).replaceFirst("^/", "")) + } + /** * Provides HTML of the file list. * @@ -218,7 +215,7 @@ JGitUtil.withGit(getRepositoryDir(repository.owner, repository.name)){ git => val revisions = Seq(if(revstr.isEmpty) repository.repository.defaultBranch else revstr, repository.branchList.head) // get specified commit - revisions.map { rev => (git.getRepository.resolve(rev), rev)}.find(_._1 != null).map { case (objectId, revision) => + JGitUtil.getDefaultBranch(git, repository, revstr).map { case (objectId, revision) => val revCommit = JGitUtil.getRevCommitFromId(git, objectId) // get files diff --git a/src/main/scala/app/SearchController.scala b/src/main/scala/app/SearchController.scala index 38bf35a..7a89006 100644 --- a/src/main/scala/app/SearchController.scala +++ b/src/main/scala/app/SearchController.scala @@ -22,7 +22,7 @@ case class SearchForm(query: String, owner: String, repository: String) post("/search", searchForm){ form => - redirect(s"${form.owner}/${form.repository}/search?q=${StringUtil.urlEncode(form.query)}") + redirect(s"/${form.owner}/${form.repository}/search?q=${StringUtil.urlEncode(form.query)}") } get("/:owner/:repository/search")(referrersOnly { repository => diff --git a/src/main/scala/app/WikiController.scala b/src/main/scala/app/WikiController.scala index 932f9e2..c1c1feb 100644 --- a/src/main/scala/app/WikiController.scala +++ b/src/main/scala/app/WikiController.scala @@ -59,7 +59,7 @@ val commitId = params("commitId").split("\\.\\.\\.") JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => - wiki.html.compare(Some(pageName), getWikiDiffs(git, commitId(0), commitId(1)), repository) + wiki.html.compare(Some(pageName), JGitUtil.getDiffs(git, commitId(0), commitId(1), true), repository) } }) @@ -67,7 +67,7 @@ val commitId = params("commitId").split("\\.\\.\\.") JGitUtil.withGit(getWikiRepositoryDir(repository.owner, repository.name)){ git => - wiki.html.compare(None, getWikiDiffs(git, commitId(0), commitId(1)), repository) + wiki.html.compare(None, JGitUtil.getDiffs(git, commitId(0), commitId(1), true), repository) } }) @@ -105,9 +105,10 @@ }) get("/:owner/:repository/wiki/:page/_delete")(collaboratorsOnly { repository => - val pageName = StringUtil.urlDecode(params("page")) + val pageName = StringUtil.urlDecode(params("page")) + val account = context.loginAccount.get - deleteWikiPage(repository.owner, repository.name, pageName, context.loginAccount.get.userName, s"Delete ${pageName}") + deleteWikiPage(repository.owner, repository.name, pageName, account.userName, account.mailAddress, s"Delete ${pageName}") updateLastActivityDate(repository.owner, repository.name) redirect(s"/${repository.owner}/${repository.name}/wiki") diff --git a/src/main/scala/model/Issue.scala b/src/main/scala/model/Issue.scala index d134b8e..d5ce8a3 100644 --- a/src/main/scala/model/Issue.scala +++ b/src/main/scala/model/Issue.scala @@ -7,6 +7,11 @@ def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository) } +object IssueOutline extends Table[(String, String, Int, Int)]("ISSUE_OUTLINE_VIEW") with IssueTemplate { + def commentCount = column[Int]("COMMENT_COUNT") + def * = userName ~ repositoryName ~ issueId ~ commentCount +} + object Issues extends Table[Issue]("ISSUE") with IssueTemplate with MilestoneTemplate { def openedUserName = column[String]("OPENED_USER_NAME") def assignedUserName = column[String]("ASSIGNED_USER_NAME") @@ -15,7 +20,8 @@ def closed = column[Boolean]("CLOSED") def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") - def * = userName ~ repositoryName ~ issueId ~ openedUserName ~ milestoneId.? ~ assignedUserName.? ~ title ~ content.? ~ closed ~ registeredDate ~ updatedDate <> (Issue, Issue.unapply _) + def pullRequest = column[Boolean]("PULL_REQUEST") + def * = userName ~ repositoryName ~ issueId ~ openedUserName ~ milestoneId.? ~ assignedUserName.? ~ title ~ content.? ~ closed ~ registeredDate ~ updatedDate ~ pullRequest <> (Issue, Issue.unapply _) def byPrimaryKey(owner: String, repository: String, issueId: Int) = byIssue(owner, repository, issueId) } @@ -31,4 +37,5 @@ content: Option[String], closed: Boolean, registeredDate: java.util.Date, - updatedDate: java.util.Date) \ No newline at end of file + updatedDate: java.util.Date, + isPullRequest: Boolean) \ No newline at end of file diff --git a/src/main/scala/model/PullRequest.scala b/src/main/scala/model/PullRequest.scala new file mode 100644 index 0000000..0fb3205 --- /dev/null +++ b/src/main/scala/model/PullRequest.scala @@ -0,0 +1,28 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object PullRequests extends Table[PullRequest]("PULL_REQUEST") with IssueTemplate { + def branch = column[String]("BRANCH") + def requestUserName = column[String]("REQUEST_USER_NAME") + def requestRepositoryName = column[String]("REQUEST_REPOSITORY_NAME") + def requestBranch = column[String]("REQUEST_BRANCH") + def commitIdFrom = column[String]("COMMIT_ID_FROM") + def commitIdTo = column[String]("COMMIT_ID_TO") + def * = userName ~ repositoryName ~ issueId ~ branch ~ requestUserName ~ requestRepositoryName ~ requestBranch ~ commitIdFrom ~ commitIdTo <> (PullRequest, PullRequest.unapply _) + + def byPrimaryKey(userName: String, repositoryName: String, issueId: Int) = byIssue(userName, repositoryName, issueId) + def byPrimaryKey(userName: Column[String], repositoryName: Column[String], issueId: Column[Int]) = byIssue(userName, repositoryName, issueId) +} + +case class PullRequest( + userName: String, + repositoryName: String, + issueId: Int, + branch: String, + requestUserName: String, + requestRepositoryName: String, + requestBranch: String, + commitIdFrom: String, + commitIdTo: String +) \ No newline at end of file diff --git a/src/main/scala/model/Repository.scala b/src/main/scala/model/Repository.scala index fcfb336..215b670 100644 --- a/src/main/scala/model/Repository.scala +++ b/src/main/scala/model/Repository.scala @@ -9,7 +9,11 @@ def registeredDate = column[java.util.Date]("REGISTERED_DATE") def updatedDate = column[java.util.Date]("UPDATED_DATE") def lastActivityDate = column[java.util.Date]("LAST_ACTIVITY_DATE") - def * = userName ~ repositoryName ~ isPrivate ~ description.? ~ defaultBranch ~ registeredDate ~ updatedDate ~ lastActivityDate <> (Repository, Repository.unapply _) + def originUserName = column[String]("ORIGIN_USER_NAME") + def originRepositoryName = column[String]("ORIGIN_REPOSITORY_NAME") + def parentUserName = column[String]("PARENT_USER_NAME") + def parentRepositoryName = column[String]("PARENT_REPOSITORY_NAME") + def * = userName ~ repositoryName ~ isPrivate ~ description.? ~ defaultBranch ~ registeredDate ~ updatedDate ~ lastActivityDate ~ originUserName.? ~ originRepositoryName.? ~ parentUserName.? ~ parentRepositoryName.? <> (Repository, Repository.unapply _) def byPrimaryKey(owner: String, repository: String) = byRepository(owner, repository) } @@ -22,5 +26,9 @@ defaultBranch: String, registeredDate: java.util.Date, updatedDate: java.util.Date, - lastActivityDate: java.util.Date + lastActivityDate: java.util.Date, + originUserName: Option[String], + originRepositoryName: Option[String], + parentUserName: Option[String], + parentRepositoryName: Option[String] ) diff --git a/src/main/scala/service/ActivityService.scala b/src/main/scala/service/ActivityService.scala index 0b7e261..389be4a 100644 --- a/src/main/scala/service/ActivityService.scala +++ b/src/main/scala/service/ActivityService.scala @@ -102,7 +102,28 @@ s"[user:${activityUserName}] created branch [tag:${userName}/${repositoryName}#${branchName}] at [repo:${userName}/${repositoryName}]", None, currentDate) - + + def recordForkActivity(userName: String, repositoryName: String, activityUserName: String) = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "fork", + s"[user:${activityUserName}] forked [repo:${userName}/${repositoryName}] to [repo:${activityUserName}/${repositoryName}]", + None, + currentDate) + + def recordPullRequestActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, title: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "open_pullreq", + s"[user:${activityUserName}] opened pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(title), + currentDate) + + def recordMergeActivity(userName: String, repositoryName: String, activityUserName: String, issueId: Int, message: String): Unit = + Activities.autoInc insert(userName, repositoryName, activityUserName, + "merge_pullreq", + s"[user:${activityUserName}] merged pull request [pullreq:${userName}/${repositoryName}#${issueId}]", + Some(message), + currentDate) + def insertCommitId(userName: String, repositoryName: String, commitId: String) = { CommitLog insert (userName, repositoryName, commitId) } diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 25eae21..46706d0 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -42,18 +42,18 @@ /** * Returns the count of the search result against issues. * - * @param owner the repository owner - * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @param onlyPullRequest if true then counts only pull request, false then counts both of issue and pull request. + * @param repos Tuple of the repository owner and the repository name * @return the count of the search result */ - def countIssue(owner: String, repository: String, condition: IssueSearchCondition, filter: String, userName: Option[String]): Int = { + def countIssue(condition: IssueSearchCondition, filterUser: Map[String, String], onlyPullRequest: Boolean, + repos: (String, String)*): Int = { // TODO It must be _.length instead of map (_.issueId) list).length. // But it does not work on Slick 1.0.1 (worked on Slick 1.0.0). // https://github.com/slick/slick/issues/170 - (searchIssueQuery(owner, repository, condition, filter, userName) map (_.issueId) list).length + (searchIssueQuery(repos, condition, filterUser, onlyPullRequest) map (_.issueId) list).length } /** * Returns the Map which contains issue count for each labels. @@ -61,14 +61,13 @@ * @param owner the repository owner * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" - * @return the Map which contains issue count for each labels (key is label name, value is issue count), + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @return the Map which contains issue count for each labels (key is label name, value is issue count) */ def countIssueGroupByLabels(owner: String, repository: String, condition: IssueSearchCondition, - filter: String, userName: Option[String]): Map[String, Int] = { + filterUser: Map[String, String]): Map[String, Int] = { - searchIssueQuery(owner, repository, condition.copy(labels = Set.empty), filter, userName) + searchIssueQuery(Seq(owner -> repository), condition.copy(labels = Set.empty), filterUser, false) .innerJoin(IssueLabels).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } @@ -83,76 +82,97 @@ } .toMap } + /** + * Returns list which contains issue count for each repository. + * If the issue does not exist, its repository is not included in the result. + * + * @param condition the search condition + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @param repos Tuple of the repository owner and the repository name + * @return list which contains issue count for each repository + */ + def countIssueGroupByRepository( + condition: IssueSearchCondition, filterUser: Map[String, String], repos: (String, String)*): List[(String, String, Int)] = { + searchIssueQuery(repos, condition.copy(repo = None), filterUser, false) + .groupBy { t => + t.userName ~ t.repositoryName + } + .map { case (repo, t) => + repo ~ t.length + } + .filter (_._3 > 0.bind) + .list + } /** * Returns the search result against issues. * - * @param owner the repository owner - * @param repository the repository name * @param condition the search condition - * @param filter the filter type ("all", "assigned" or "created_by") - * @param userName the filter user name required for "assigned" and "created_by" + * @param filterUser the filter user name (key is "all", "assigned" or "created_by", value is the user name) + * @param onlyPullRequest if true then returns only pull request, false then returns both of issue and pull request. * @param offset the offset for pagination * @param limit the limit for pagination + * @param repos Tuple of the repository owner and the repository name * @return the search result (list of tuples which contain issue, labels and comment count) */ - def searchIssue(owner: String, repository: String, condition: IssueSearchCondition, - filter: String, userName: Option[String], offset: Int, limit: Int): List[(Issue, List[Label], Int)] = { + def searchIssue(condition: IssueSearchCondition, filterUser: Map[String, String], onlyPullRequest: Boolean, + offset: Int, limit: Int, repos: (String, String)*): List[(Issue, List[Label], Int)] = { - // get issues and comment count - val issues = searchIssueQuery(owner, repository, condition, filter, userName) - .leftJoin(Query(IssueComments) - .filter { t => - (t.byRepository(owner, repository)) && - (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) + // get issues and comment count and labels + searchIssueQuery(repos, condition, filterUser, onlyPullRequest) + .innerJoin(IssueOutline).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } + .leftJoin (IssueLabels) .on { case ((t1, t2), t3) => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) } + .leftJoin (Labels) .on { case (((t1, t2), t3), t4) => t3.byLabel(t4.userName, t4.repositoryName, t4.labelId) } + .map { case (((t1, t2), t3), t4) => + (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?) } - .groupBy { _.issueId } - .map { case (issueId, t) => issueId ~ t.length }).on((t1, t2) => t1.issueId is t2._1) - .sortBy { case (t1, t2) => - (condition.sort match { - case "created" => t1.registeredDate - case "comments" => t2._2 - case "updated" => t1.updatedDate - }) match { - case sort => condition.direction match { - case "asc" => sort asc - case "desc" => sort desc + .sortBy(_._4) // labelName + .sortBy { case (t1, commentCount, _,_,_) => + (condition.sort match { + case "created" => t1.registeredDate + case "comments" => commentCount + case "updated" => t1.updatedDate + }) match { + case sort => condition.direction match { + case "asc" => sort asc + case "desc" => sort desc + } } } - } - .map { case (t1, t2) => (t1, t2._2.ifNull(0)) } - .drop(offset).take(limit) - .list - - // get labels - val labels = Query(IssueLabels) - .innerJoin(Labels).on { (t1, t2) => - t1.byLabel(t2.userName, t2.repositoryName, t2.labelId) - } - .filter { case (t1, t2) => - (t1.byRepository(owner, repository)) && - (t1.issueId inSetBind (issues.map(_._1.issueId))) - } - .sortBy { case (t1, t2) => t1.issueId ~ t2.labelName } - .map { case (t1, t2) => (t1.issueId, t2) } - .list - - issues.map { case (issue, commentCount) => - (issue, labels.collect { case (issueId, labels) if(issueId == issue.issueId) => labels }, commentCount) - } + .drop(offset).take(limit) + .list + .splitWith { (c1, c2) => + c1._1.userName == c2._1.userName && + c1._1.repositoryName == c2._1.repositoryName && + c1._1.issueId == c2._1.issueId + } + .map { issues => issues.head match { + case (issue, commentCount, _,_,_) => + (issue, + issues.flatMap { t => t._3.map ( + Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get) + )} toList, + commentCount) + }} toList } /** * Assembles query for conditional issue searching. */ - private def searchIssueQuery(owner: String, repository: String, condition: IssueSearchCondition, filter: String, userName: Option[String]) = + private def searchIssueQuery(repos: Seq[(String, String)], condition: IssueSearchCondition, + filterUser: Map[String, String], onlyPullRequest: Boolean) = Query(Issues) filter { t1 => - (t1.byRepository(owner, repository)) && + condition.repo + .map { _.split('/') match { case array => Seq(array(0) -> array(1)) } } + .getOrElse (repos) + .map { case (owner, repository) => t1.byRepository(owner, repository) } + .foldLeft[Column[Boolean]](false) ( _ || _ ) && (t1.closed is (condition.state == "closed").bind) && (t1.milestoneId is condition.milestoneId.get.get.bind, condition.milestoneId.flatten.isDefined) && (t1.milestoneId isNull, condition.milestoneId == Some(None)) && - (t1.assignedUserName is userName.get.bind, filter == "assigned") && - (t1.openedUserName is userName.get.bind, filter == "created_by") && + (t1.assignedUserName is filterUser("assigned").bind, filterUser.get("assigned").isDefined) && + (t1.openedUserName is filterUser("created_by").bind, filterUser.get("created_by").isDefined) && + (t1.pullRequest is true.bind, onlyPullRequest) && (IssueLabels filter { t2 => (t2.byIssue(t1.userName, t1.repositoryName, t1.issueId)) && (t2.labelId in @@ -164,7 +184,7 @@ } def createIssue(owner: String, repository: String, loginUser: String, title: String, content: Option[String], - assignedUserName: Option[String], milestoneId: Option[Int]) = + assignedUserName: Option[String], milestoneId: Option[Int], isPullRequest: Boolean = false) = // next id number sql"SELECT ISSUE_ID + 1 FROM ISSUE_ID WHERE USER_NAME = $owner AND REPOSITORY_NAME = $repository FOR UPDATE".as[Int] .firstOption.filter { id => @@ -179,7 +199,8 @@ content, false, currentDate, - currentDate) + currentDate, + isPullRequest) // increment issue id IssueId @@ -250,39 +271,44 @@ val keywords = splitWords(query.toLowerCase) // Search Issue - val issues = Query(Issues).filter { t => - keywords.map { keyword => - (t.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || - (t.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) - } .reduceLeft(_ && _) - }.map { t => (t, 0, t.content.?) } + val issues = Issues + .innerJoin(IssueOutline).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .filter { case (t1, t2) => + keywords.map { keyword => + (t1.title.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) || + (t1.content.toLowerCase like (s"%${likeEncode(keyword)}%", '^')) + } .reduceLeft(_ && _) + } + .map { case (t1, t2) => + (t1, 0, t1.content.?, t2.commentCount) + } // Search IssueComment - val comments = Query(IssueComments).innerJoin(Issues).on { case (t1, t2) => - t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) - }.filter { case (t1, t2) => - keywords.map { query => - t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') - }.reduceLeft(_ && _) - }.map { case (t1, t2) => (t2, t1.commentId, t1.content.?) } + val comments = IssueComments + .innerJoin(Issues).on { case (t1, t2) => + t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) + } + .innerJoin(IssueOutline).on { case ((t1, t2), t3) => + t2.byIssue(t3.userName, t3.repositoryName, t3.issueId) + } + .filter { case ((t1, t2), t3) => + keywords.map { query => + t1.content.toLowerCase like (s"%${likeEncode(query)}%", '^') + }.reduceLeft(_ && _) + } + .map { case ((t1, t2), t3) => + (t2, t1.commentId, t1.content.?, t3.commentCount) + } - def getCommentCount(issue: Issue): Int = { - Query(IssueComments) - .filter { t => - t.byIssue(issue.userName, issue.repositoryName, issue.issueId) && - (t.action inSetBind Seq("comment", "close_comment", "reopen_comment")) - } - .map(_.issueId) - .list.length - } - - issues.union(comments).sortBy { case (issue, commentId, _) => + issues.union(comments).sortBy { case (issue, commentId, _, _) => issue.issueId ~ commentId - }.list.splitWith { case ((issue1, _, _), (issue2, _, _)) => + }.list.splitWith { case ((issue1, _, _, _), (issue2, _, _, _)) => issue1.issueId == issue2.issueId - }.map { result => - val (issue, _, content) = result.head - (issue, getCommentCount(issue) , content.getOrElse("")) + }.map { _.head match { + case (issue, _, content, commentCount) => (issue, commentCount, content.getOrElse("")) + } }.toList } @@ -333,6 +359,13 @@ param(request, "state", Seq("open", "closed")).getOrElse("open"), param(request, "sort", Seq("created", "comments", "updated")).getOrElse("created"), param(request, "direction", Seq("asc", "desc")).getOrElse("desc")) + + def page(request: HttpServletRequest) = try { + val i = param(request, "page").getOrElse("1").toInt + if(i <= 0) 1 else i + } catch { + case e: NumberFormatException => 1 + } } } diff --git a/src/main/scala/service/PullRequestService.scala b/src/main/scala/service/PullRequestService.scala new file mode 100644 index 0000000..ab28675 --- /dev/null +++ b/src/main/scala/service/PullRequestService.scala @@ -0,0 +1,57 @@ +package service + +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession + +import model._ + +trait PullRequestService { self: IssuesService => + import PullRequestService._ + + def getPullRequest(owner: String, repository: String, issueId: Int): Option[(Issue, PullRequest)] = { + val issue = getIssue(owner, repository, issueId.toString) + if(issue.isDefined){ + Query(PullRequests).filter(_.byPrimaryKey(owner, repository, issueId)).firstOption match { + case Some(pullreq) => Some((issue.get, pullreq)) + case None => None + } + } else None + } + + def getPullRequestCount(closed: Boolean, repository: Option[(String, String)]): List[PullRequestCount] = + Query(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 repository.get._1, repository.isDefined) && + (t1.repositoryName is repository.get._2, repository.isDefined) + } + .groupBy { case (t1, t2) => t2.openedUserName } + .map { case (userName, t) => userName ~ t.length } + .list + .map { x => PullRequestCount(x._1, x._2) } + .sortBy(_.count).reverse + + def createPullRequest(originUserName: String, originRepositoryName: String, issueId: Int, + originBranch: String, requestUserName: String, requestRepositoryName: String, requestBranch: String, + commitIdFrom: String, commitIdTo: String): Unit = + PullRequests insert (PullRequest( + originUserName, + originRepositoryName, + issueId, + originBranch, + requestUserName, + requestRepositoryName, + requestBranch, + commitIdFrom, + commitIdTo)) + +} + +object PullRequestService { + + val PullRequestLimit = 25 + + case class PullRequestCount(userName: String, count: Int) + +} \ No newline at end of file diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index b8c7596..b03a0b5 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -15,19 +15,27 @@ * @param userName the user name of the repository owner * @param description the repository description * @param isPrivate the repository type (private is true, otherwise false) + * @param originRepositoryName specify for the forked repository. (default is None) + * @param originUserName specify for the forked repository. (default is None) */ - def createRepository(repositoryName: String, userName: String, description: Option[String], isPrivate: Boolean): Unit = { + def createRepository(repositoryName: String, userName: String, description: Option[String], isPrivate: Boolean, + originRepositoryName: Option[String] = None, originUserName: Option[String] = None, + parentRepositoryName: Option[String] = None, parentUserName: Option[String] = None): Unit = { Repositories insert Repository( - userName = userName, - repositoryName = repositoryName, - isPrivate = isPrivate, - description = description, - defaultBranch = "master", - registeredDate = currentDate, - updatedDate = currentDate, - lastActivityDate = currentDate) - + userName = userName, + repositoryName = repositoryName, + isPrivate = isPrivate, + description = description, + defaultBranch = "master", + registeredDate = currentDate, + updatedDate = currentDate, + lastActivityDate = currentDate, + originUserName = originUserName, + originRepositoryName = originRepositoryName, + parentUserName = parentUserName, + parentRepositoryName = parentRepositoryName) + IssueId insert (userName, repositoryName, 0) } @@ -54,39 +62,6 @@ Query(Repositories) filter(_.userName is userName.bind) map (_.repositoryName) list /** - * Returns the list of specified user's repositories information. - * - * @param userName the user name - * @param baseUrl the base url of this application - * @param loginUserName the logged in user name - * @return the list of repository information which is sorted in descending order of lastActivityDate. - */ - def getVisibleRepositories(userName: String, baseUrl: String, loginUserName: Option[String]): List[RepositoryInfo] = { - val q1 = Repositories - .filter { t => t.userName is userName.bind } - .map { r => r } - - val q2 = Collaborators - .innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName)) - .filter{ case (t1, t2) => t1.collaboratorName is userName.bind} - .map { case (t1, t2) => t2 } - - def visibleFor(t: Repositories.type, loginUserName: Option[String]) = { - loginUserName match { - case Some(x) => (t.isPrivate is false.bind) || ( - (t.isPrivate is true.bind) && ((t.userName is x.bind) || (Collaborators.filter { c => - c.byRepository(t.userName, t.repositoryName) && (c.collaboratorName is x.bind) - }.exists))) - case None => (t.isPrivate is false.bind) - } - } - - q1.union(q2).filter(visibleFor(_, loginUserName)).sortBy(_.lastActivityDate desc).list map { repository => - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) - } - } - - /** * Returns the specified repository information. * * @param userName the user name of the repository owner @@ -96,34 +71,62 @@ */ def getRepository(userName: String, repositoryName: String, baseUrl: String): Option[RepositoryInfo] = { (Query(Repositories) filter { t => t.byRepository(userName, repositoryName) } firstOption) map { repository => - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) + new RepositoryInfo( + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + repository, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) + } + } + + def getUserRepositories(userName: String, baseUrl: String): List[RepositoryInfo] = { + Query(Repositories).filter { t1 => + (t1.userName is userName.bind) || + (Query(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), + repository, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) } } /** - * Returns the list of accessible repositories information for the specified account user. - * - * @param account the account + * Returns the list of visible repositories for the specified user. + * If repositoryUserName is given then filters results by repository owner. + * + * @param loginAccount the logged in account * @param baseUrl the base url of this application - * @return the repository informations which is sorted in descending order of lastActivityDate. + * @param repositoryUserName the repository owner (if None then returns all repositories which are visible for logged in user) + * @return the repository information which is sorted in descending order of lastActivityDate. */ - def getAccessibleRepositories(account: Option[Account], baseUrl: String): List[RepositoryInfo] = { - - def newRepositoryInfo(repository: Repository): RepositoryInfo = { - new RepositoryInfo(JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), repository) - } - - (account match { + def getVisibleRepositories(loginAccount: Option[Account], baseUrl: String, repositoryUserName: Option[String] = None): List[RepositoryInfo] = { + (loginAccount match { // for Administrators case Some(x) if(x.isAdmin) => Query(Repositories) // for Normal Users case Some(x) if(!x.isAdmin) => Query(Repositories) filter { t => (t.isPrivate is false.bind) || - (Query(Collaborators).filter(t2 => t2.byRepository(t.userName, t.repositoryName) && (t2.collaboratorName is x.userName.bind)) exists) + (Query(Collaborators).filter { t2 => t2.byRepository(t.userName, t.repositoryName) && (t2.collaboratorName is x.userName.bind)} exists) } // for Guests case None => Query(Repositories) filter(_.isPrivate is false.bind) - }).sortBy(_.lastActivityDate desc).list.map(newRepositoryInfo _) + }).filter { t => + repositoryUserName.map { userName => t.userName is userName.bind } getOrElse ConstColumn.TRUE + }.sortBy(_.lastActivityDate desc).list.map{ repository => + new RepositoryInfo( + JGitUtil.getRepositoryInfo(repository.userName, repository.repositoryName, baseUrl), + repository, + getForkedCount( + repository.originUserName.getOrElse(repository.userName), + repository.originRepositoryName.getOrElse(repository.repositoryName) + )) + } } /** @@ -189,17 +192,39 @@ } } + // TODO It must be _.length instead of map (_.issueId) list).length. + // But it does not work on Slick 1.0.1 (worked on Slick 1.0.0). + // https://github.com/slick/slick/issues/170 + private def getForkedCount(userName: String, repositoryName: String): Int = + Query(Repositories).filter { t => + (t.originUserName is userName.bind) && (t.originRepositoryName is repositoryName.bind) + }.list.length + + + def getForkedRepositoryTree(userName: String, repositoryName: String): RepositoryTreeNode = { + RepositoryTreeNode(userName, repositoryName, + Query(Repositories).filter { t => + (t.parentUserName is userName.bind) && (t.parentRepositoryName is repositoryName.bind) + }.map { t => + t.userName ~ t.repositoryName + }.list.map { case (userName, repositoryName) => + getForkedRepositoryTree(userName, repositoryName) + } + ) + } + } object RepositoryService { case class RepositoryInfo(owner: String, name: String, url: String, repository: Repository, - commitCount: Int, branchList: List[String], tags: List[util.JGitUtil.TagInfo]){ + commitCount: Int, forkedCount: Int, branchList: List[String], tags: List[util.JGitUtil.TagInfo]){ - def this(repo: JGitUtil.RepositoryInfo, model: Repository) = { - this(repo.owner, repo.name, repo.url, model, repo.commitCount, repo.branchList, repo.tags) + def this(repo: JGitUtil.RepositoryInfo, model: Repository, forkedCount: Int) = { + this(repo.owner, repo.name, repo.url, model, repo.commitCount, forkedCount, repo.branchList, repo.tags) } - } + case class RepositoryTreeNode(owner: String, name: String, children: List[RepositoryTreeNode]) + } \ No newline at end of file diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index a9be43d..49f30aa 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -4,10 +4,7 @@ import java.util.Date import org.eclipse.jgit.api.Git import org.apache.commons.io.FileUtils -import util.JGitUtil.DiffInfo -import util.{Directory, JGitUtil} -import org.eclipse.jgit.treewalk.CanonicalTreeParser -import java.util.concurrent.ConcurrentHashMap +import util.{Directory, JGitUtil, LockUtil} object WikiService { @@ -31,40 +28,13 @@ */ case class WikiPageHistoryInfo(name: String, committer: String, message: String, date: Date) - /** - * lock objects - */ - private val locks = new ConcurrentHashMap[String, AnyRef]() - - /** - * Returns the lock object for the specified repository. - */ - private def getLockObject(owner: String, repository: String): AnyRef = synchronized { - val key = owner + "/" + repository - if(!locks.containsKey(key)){ - locks.put(key, new AnyRef()) - } - locks.get(key) - } - - /** - * Synchronizes a given function which modifies the working copy of the wiki repository. - * - * @param owner the repository owner - * @param repository the repository name - * @param f the function which modifies the working copy of the wiki repository - * @tparam T the return type of the given function - * @return the result of the given function - */ - def lock[T](owner: String, repository: String)(f: => T): T = getLockObject(owner, repository).synchronized(f) - } trait WikiService { import WikiService._ def createWikiRepository(loginAccount: model.Account, owner: String, repository: String): Unit = { - lock(owner, repository){ + LockUtil.lock(s"${owner}/${repository}/wiki"){ val dir = Directory.getWikiRepositoryDir(owner, repository) if(!dir.exists){ try { @@ -126,7 +96,7 @@ def saveWikiPage(owner: String, repository: String, currentPageName: String, newPageName: String, content: String, committer: model.Account, message: String): Unit = { - lock(owner, repository){ + LockUtil.lock(s"${owner}/${repository}/wiki"){ // clone working copy val workDir = Directory.getWikiWorkDir(owner, repository) cloneOrPullWorkingCopy(workDir, owner, repository) @@ -162,8 +132,9 @@ /** * Delete the wiki page. */ - def deleteWikiPage(owner: String, repository: String, pageName: String, committer: String, message: String): Unit = { - lock(owner, repository){ + def deleteWikiPage(owner: String, repository: String, pageName: String, + committer: String, mailAddress: String, message: String): Unit = { + LockUtil.lock(s"${owner}/${repository}/wiki"){ // clone working copy val workDir = Directory.getWikiWorkDir(owner, repository) cloneOrPullWorkingCopy(workDir, owner, repository) @@ -175,34 +146,12 @@ git.rm.addFilepattern(pageName + ".md").call // commit and push - // TODO committer's mail address - git.commit.setAuthor(committer, committer + "@devnull").setMessage(message).call + git.commit.setAuthor(committer, mailAddress).setMessage(message).call git.push.call } } } - /** - * Returns differences between specified commits. - */ - def getWikiDiffs(git: Git, commitId1: String, commitId2: String): List[DiffInfo] = { - // get diff between specified commit and its previous commit - val reader = git.getRepository.newObjectReader - - val oldTreeIter = new CanonicalTreeParser - oldTreeIter.reset(reader, git.getRepository.resolve(commitId1 + "^{tree}")) - - val newTreeIter = new CanonicalTreeParser - newTreeIter.reset(reader, git.getRepository.resolve(commitId2 + "^{tree}")) - - import scala.collection.JavaConverters._ - git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, - JGitUtil.getContent(git, diff.getOldId.toObjectId, false).map(new String(_, "UTF-8")), - JGitUtil.getContent(git, diff.getNewId.toObjectId, false).map(new String(_, "UTF-8"))) - }.toList - } - private def cloneOrPullWorkingCopy(workDir: File, owner: String, repository: String): Unit = { if(!workDir.exists){ val git = diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index 601af9b..51f8c2c 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -49,6 +49,7 @@ * The history of versions. A head of this sequence is the current BitBucket version. */ val versions = Seq( + Version(1, 5), Version(1, 4), new Version(1, 3){ override def update(conn: Connection): Unit = { diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index f42c68d..b647d53 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -15,6 +15,7 @@ import org.eclipse.jgit.errors.MissingObjectException import java.util.Date import org.eclipse.jgit.api.errors.NoHeadException +import service.RepositoryService /** * Provides complex JGit operations. @@ -132,15 +133,18 @@ } /** - * Returns RevCommit from the commit id. + * Returns RevCommit from the commit or tag id. * * @param git the Git object - * @param commitId the ObjectId of the commit - * @return the RevCommit for the specified commit + * @param objectId the ObjectId of the commit or tag + * @return the RevCommit for the specified commit or tag */ - def getRevCommitFromId(git: Git, commitId: ObjectId): RevCommit = { + def getRevCommitFromId(git: Git, objectId: ObjectId): RevCommit = { val revWalk = new RevWalk(git.getRepository) - val revCommit = revWalk.parseCommit(commitId) + val revCommit = revWalk.parseAny(objectId) match { + case r: RevTag => revWalk.parseCommit(r.getObject) + case _ => revWalk.parseCommit(objectId) + } revWalk.dispose revCommit } @@ -152,12 +156,7 @@ withGit(getRepositoryDir(owner, repository)){ git => try { // get commit count - val i = git.log.all.call.iterator - var commitCount = 0 - while(i.hasNext && commitCount <= 1000){ - i.next - commitCount = commitCount + 1 - } + val commitCount = git.log.all.call.iterator.asScala.map(_ => 1).take(1000).sum RepositoryInfo( owner, repository, s"${baseUrl}/git/${owner}/${repository}.git", @@ -294,6 +293,32 @@ Right(commits) } } + + def getCommitLogs(git: Git, begin: String, includesLastCommit: Boolean = false) + (endCondition: RevCommit => Boolean): List[CommitInfo] = { + @scala.annotation.tailrec + def getCommitLog(i: java.util.Iterator[RevCommit], logs: List[CommitInfo]): List[CommitInfo] = + i.hasNext match { + case true => { + val revCommit = i.next + if(endCondition(revCommit)){ + if(includesLastCommit) logs :+ new CommitInfo(revCommit) else logs + } else { + getCommitLog(i, logs :+ new CommitInfo(revCommit)) + } + } + case false => logs + } + + val revWalk = new RevWalk(git.getRepository) + revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(begin))) + + val commits = getCommitLog(revWalk.iterator, Nil) + revWalk.release + + commits.reverse + } + /** * Returns the commit list between two revisions. @@ -303,30 +328,9 @@ * @param to the to revision * @return the commit list */ - def getCommitLog(git: Git, from: String, to: String): List[CommitInfo] = { - @scala.annotation.tailrec - def getCommitLog(i: java.util.Iterator[RevCommit], logs: List[CommitInfo]): List[CommitInfo] = - i.hasNext match { - case true => { - val revCommit = i.next - if(revCommit.name == from){ - logs - } else { - getCommitLog(i, logs :+ new CommitInfo(revCommit)) - } - } - case false => logs - } - - val revWalk = new RevWalk(git.getRepository) - revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(to))) - - val commits = getCommitLog(revWalk.iterator, Nil) - revWalk.release - - commits.reverse - } - + // TODO swap parameters 'from' and 'to'!? + def getCommitLog(git: Git, from: String, to: String): List[CommitInfo] = + getCommitLogs(git, to)(_.getName == from) /** * Returns the latest RevCommit of the specified path. @@ -348,51 +352,11 @@ * @return the list of latest commit */ def getLatestCommitFromPaths(git: Git, paths: List[String], revision: String): Map[String, RevCommit] = { - - val map = new scala.collection.mutable.HashMap[String, RevCommit] - - val revWalk = new RevWalk(git.getRepository) - revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(revision))) - //revWalk.sort(RevSort.REVERSE); - val i = revWalk.iterator - - while(i.hasNext && map.size != paths.length){ - val commit = i.next - if(commit.getParentCount == 0){ - // Initial commit - val treeWalk = new TreeWalk(git.getRepository) - treeWalk.reset() - treeWalk.setRecursive(true) - treeWalk.addTree(commit.getTree) - while (treeWalk.next) { - paths.foreach { path => - if(treeWalk.getPathString.startsWith(path) && !map.contains(path)){ - map.put(path, commit) - } - } - } - treeWalk.release - } else { - (0 to commit.getParentCount - 1).foreach { i => - val parent = revWalk.parseCommit(commit.getParent(i).getId()) - val df = new DiffFormatter(DisabledOutputStream.INSTANCE) - df.setRepository(git.getRepository) - df.setDiffComparator(RawTextComparator.DEFAULT) - df.setDetectRenames(true) - val diffs = df.scan(parent.getTree(), commit.getTree) - diffs.asScala.foreach { diff => - paths.foreach { path => - if(diff.getChangeType != ChangeType.DELETE && diff.getNewPath.startsWith(path) && !map.contains(path)){ - map.put(path, commit) - } - } - } - } - } - - revWalk.release - } - map.toMap + val start = getRevCommitFromId(git, git.getRepository.resolve(revision)) + paths.map { path => + val commit = git.log.add(start).addPath(path).setMaxCount(1).call.iterator.next + (path, commit) + }.toMap } /** @@ -426,7 +390,7 @@ case true if(logs.size < 2) => getCommitLog(i, logs :+ i.next) case _ => logs } - + val revWalk = new RevWalk(git.getRepository) revWalk.markStart(revWalk.parseCommit(git.getRepository.resolve(id))) @@ -438,26 +402,8 @@ if(commits.length >= 2){ // not initial commit val oldCommit = commits(1) - - // get diff between specified commit and its previous commit - val reader = git.getRepository.newObjectReader - - val oldTreeIter = new CanonicalTreeParser - oldTreeIter.reset(reader, git.getRepository.resolve(oldCommit.name + "^{tree}")) - - val newTreeIter = new CanonicalTreeParser - newTreeIter.reset(reader, git.getRepository.resolve(id + "^{tree}")) - - import scala.collection.JavaConverters._ - git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => - if(!fetchContent || FileUtil.isImage(diff.getOldPath) || FileUtil.isImage(diff.getNewPath)){ - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None) - } else { - DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, - JGitUtil.getContent(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8")), - JGitUtil.getContent(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8"))) - } - }.toList + getDiffs(git, oldCommit.getName, id, fetchContent) + } else { // initial commit val walk = new TreeWalk(git.getRepository) @@ -476,6 +422,27 @@ } } + def getDiffs(git: Git, from: String, to: String, fetchContent: Boolean): List[DiffInfo] = { + val reader = git.getRepository.newObjectReader + val oldTreeIter = new CanonicalTreeParser + oldTreeIter.reset(reader, git.getRepository.resolve(from + "^{tree}")) + + val newTreeIter = new CanonicalTreeParser + newTreeIter.reset(reader, git.getRepository.resolve(to + "^{tree}")) + + import scala.collection.JavaConverters._ + git.diff.setNewTree(newTreeIter).setOldTree(oldTreeIter).call.asScala.map { diff => + if(!fetchContent || FileUtil.isImage(diff.getOldPath) || FileUtil.isImage(diff.getNewPath)){ + DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None) + } else { + DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, + JGitUtil.getContent(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8")), + JGitUtil.getContent(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(new String(_, "UTF-8"))) + } + }.toList + } + + /** * Returns the list of branch names of the specified commit. */ @@ -524,6 +491,15 @@ } } + def cloneRepository(from: java.io.File, to: java.io.File): Unit = { + val git = Git.cloneRepository.setURI(from.toURI.toString).setDirectory(to).setBare(true).call + try { + setReceivePack(git.getRepository) + } finally { + git.getRepository.close + } + } + def isEmpty(git: Git): Boolean = git.getRepository.resolve(Constants.HEAD) == null private def setReceivePack(repository: org.eclipse.jgit.lib.Repository): Unit = { @@ -532,4 +508,14 @@ config.save } + def getDefaultBranch(git: Git, repository: RepositoryService.RepositoryInfo, + revstr: String = ""): Option[(ObjectId, String)] = { + Seq( + if(revstr.isEmpty) repository.repository.defaultBranch else revstr, + repository.branchList.head + ).map { rev => + (git.getRepository.resolve(rev), rev) + }.find(_._1 != null) + } + } diff --git a/src/main/scala/util/LockUtil.scala b/src/main/scala/util/LockUtil.scala new file mode 100644 index 0000000..3b6c796 --- /dev/null +++ b/src/main/scala/util/LockUtil.scala @@ -0,0 +1,36 @@ +package util + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.{ReentrantLock, Lock} + +object LockUtil { + + /** + * lock objects + */ + private val locks = new ConcurrentHashMap[String, Lock]() + + /** + * Returns the lock object for the specified repository. + */ + private def getLockObject(key: String): Lock = synchronized { + if(!locks.containsKey(key)){ + locks.put(key, new ReentrantLock()) + } + locks.get(key) + } + + /** + * Synchronizes a given function which modifies the working copy of the wiki repository. + */ + def lock[T](key: String)(f: => T): T = { + val lock = getLockObject(key) + try { + lock.lock() + f + } finally { + lock.unlock() + } + } + +} diff --git a/src/main/scala/view/AvatarImageProvider.scala b/src/main/scala/view/AvatarImageProvider.scala index eb47085..c5ae04e 100644 --- a/src/main/scala/view/AvatarImageProvider.scala +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -13,18 +13,18 @@ protected def getAvatarImageHtml(userName: String, size: Int, mailAddress: String = "", tooltip: Boolean = false)(implicit context: app.Context): Html = { - val src = if(getSystemSettings().gravatar){ - getAccountByUserName(userName).collect { case account if(account.image.isEmpty && !account.isGroupAccount) => + val src = getAccountByUserName(userName).map { account => + if(account.image.isEmpty && getSystemSettings().gravatar){ s"""http://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress)}?s=${size}""" - } getOrElse { - if(mailAddress.nonEmpty){ - s"""http://www.gravatar.com/avatar/${StringUtil.md5(mailAddress)}?s=${size}""" - } else { - s"""${context.path}/${userName}/_avatar""" - } + } else { + s"""${context.path}/${userName}/_avatar""" } - } else { - s"""${context.path}/${userName}/_avatar""" + } getOrElse { + if(mailAddress.nonEmpty && getSystemSettings().gravatar){ + s"""http://www.gravatar.com/avatar/${StringUtil.md5(mailAddress)}?s=${size}""" + } else { + s"""${context.path}/${userName}/_avatar""" + } } if(tooltip){ diff --git a/src/main/scala/view/helpers.scala b/src/main/scala/view/helpers.scala index 2d9c0e0..6613a5f 100644 --- a/src/main/scala/view/helpers.scala +++ b/src/main/scala/view/helpers.scala @@ -51,9 +51,17 @@ def link(value: String, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context): Html = Html(convertRefsLinks(value, repository)) + def cut(value: String, length: Int): String = + if(value.length > length){ + value.substring(0, length) + "..." + } else { + value + } + def activityMessage(message: String)(implicit context: app.Context): Html = Html(message .replaceAll("\\[issue:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""$$1/$$2#$$3""") + .replaceAll("\\[pullreq:([^\\s]+?)/([^\\s]+?)#((\\d+))\\]" , s"""$$1/$$2#$$3""") .replaceAll("\\[repo:([^\\s]+?)/([^\\s]+?)\\]" , s"""$$1/$$2""") .replaceAll("\\[branch:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]", s"""$$3""") .replaceAll("\\[tag:([^\\s]+?)/([^\\s]+?)#([^\\s]+?)\\]" , s"""$$3""") diff --git a/src/main/twirl/account/repositories.scala.html b/src/main/twirl/account/repositories.scala.html index de47a3d..97045d9 100644 --- a/src/main/twirl/account/repositories.scala.html +++ b/src/main/twirl/account/repositories.scala.html @@ -15,6 +15,9 @@ } + @if(repository.repository.originUserName.isDefined){ +
forked from @repository.repository.parentUserName/@repository.repository.parentRepositoryName
+ } @if(repository.repository.description.isDefined){
@repository.repository.description
} diff --git a/src/main/twirl/dashboard/issues.scala.html b/src/main/twirl/dashboard/issues.scala.html index b1839ab..9ebdede 100644 --- a/src/main/twirl/dashboard/issues.scala.html +++ b/src/main/twirl/dashboard/issues.scala.html @@ -2,7 +2,7 @@ allCount: Int, assignedCount: Int, createdByCount: Int, - repositories: List[service.RepositoryService.RepositoryInfo], + repositories: List[(String, String, Int)], condition: service.IssuesService.IssueSearchCondition, filter: String)(implicit context: app.Context) @import context._ @@ -13,19 +13,19 @@

-
@avatar(issue.openedUserName, 48)
-
-
-
- @if(hasWritePermission || loginAccount.map(_.userName == issue.openedUserName).getOrElse(false)){ - Edit - } -
- @issue.openedUserName opened this issue @datetime(issue.registeredDate) -
-

@issue.title

-
-
- - @issue.assignedUserName.map { userName => - @avatar(userName, 20) @userName is assigned - }.getOrElse("No one is assigned") - - @if(hasWritePermission){ - @helper.html.dropdown() { -
  • Clear assignee
  • - @collaborators.map { collaborator => -
  • @avatar(collaborator, 20) @collaborator
  • - } - } - } -
    - - @issue.milestoneId.map { milestoneId => - @milestones.collect { case (milestone, _, _) if(milestone.milestoneId == milestoneId) => - Milestone: @milestone.title - } - }.getOrElse("No milestone") - -
    - @issue.milestoneId.map { milestoneId => - @milestones.collect { case (milestone, openCount, closeCount) if(milestone.milestoneId == milestoneId) => - @issues.milestones.html.progress(openCount + closeCount, closeCount, false) - } - } -
    - @if(hasWritePermission){ - @helper.html.dropdown() { -
  • No milestone
  • - @milestones.map { case (milestone, _, _) => -
  • - - @milestone.title -
    - @milestone.dueDate.map { dueDate => - @if(isPast(dueDate)){ - Due in @date(dueDate) - } else { - Due in @date(dueDate) - } - }.getOrElse { - No due date - } -
    -
    -
  • - } - } - } -
    -
    -
    - @markdown(issue.content getOrElse "No description given.", repository, false, true) -
    -
    -
    -
    - @defining((issue.openedUserName :: comments.map(_.commentedUserName)).distinct){ participants => - @participants.size @plural(participants.size, "participant") - @participants.map { participant => @avatar(participant, 20, tooltip = true) } - } -
    - @comments.map { comment => - @if(comment.action != "close" && comment.action != "reopen"){ -
    @avatar(comment.commentedUserName, 48)
    -
    -
    - - @comment.commentedUserName commented - - @datetime(comment.registeredDate) - @if(comment.action != "commit" && (hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false))){ - - } - -
    -
    - @markdown(comment.content, repository, false, true) -
    -
    - } - @if(comment.action == "close" || comment.action == "close_comment"){ -
    - Closed - @comment.commentedUserName closed the issue @datetime(comment.registeredDate) -
    - } - @if(comment.action == "reopen" || comment.action == "reopen_comment"){ -
    - Reopened - @comment.commentedUserName reopened the issue @datetime(comment.registeredDate) -
    - } - } - @if(loginAccount.isDefined){ -
    -
    @avatar(loginAccount.get.userName, 48)
    -
    -
    - @helper.html.preview(repository, "", false, true, "width: 680px; height: 100px;") -
    -
    -
    - - - @if(hasWritePermission || issue.openedUserName == loginAccount.get.userName){ - - } -
    -
    - } + @issuedetail(issue, comments, collaborators, milestones, hasWritePermission, repository) + @commentlist(comments, hasWritePermission, repository) + @commentform(issue, hasWritePermission, repository)
    @if(issue.closed) { @@ -182,82 +59,6 @@ } \ No newline at end of file diff --git a/src/main/twirl/issues/listparts.scala.html b/src/main/twirl/issues/listparts.scala.html index 3541dba..273dbab 100644 --- a/src/main/twirl/issues/listparts.scala.html +++ b/src/main/twirl/issues/listparts.scala.html @@ -145,12 +145,21 @@