diff --git a/.travis.yml b/.travis.yml index 0cfd95d..4c9cbea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: scala scala: - - 2.11.2 \ No newline at end of file + - 2.11.6 diff --git a/README.md b/README.md index f6d3110..17f1104 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,20 @@ Release Notes -------- +### 3.2 - 3 May 2015 +- Directory history button +- Compare / pull request button +- Limit of activity log + +### 3.1.1 - 4 Apr 2015 +- Rolled back H2 version to avoid version compatibility issue +- Plug-ins became possible to access ServletContext + +### 3.1 - 28 Mar 2015 +- Web APIs for Jenkins github pull-request builder +- Improved diff view +- Bump Scalatra to 2.3.1, sbt to 0.13.8 + ### 3.0 - 3 Mar 2015 - New plug-in system is available - Connection pooling by c3p0 diff --git a/build.xml b/build.xml index 5b3e67c..5724e8c 100644 --- a/build.xml +++ b/build.xml @@ -5,8 +5,8 @@ - - + + diff --git a/deploy-assembly/deploy-assembly-jar.sh b/deploy-assembly/deploy-assembly-jar.sh new file mode 100755 index 0000000..26fe060 --- /dev/null +++ b/deploy-assembly/deploy-assembly-jar.sh @@ -0,0 +1,11 @@ +#!/bin/sh +./sbt.sh clean assembly + +mvn deploy:deploy-file \ + -DgroupId=gitbucket\ + -DartifactId=gitbucket-assembly\ + -Dversion=3.2.0\ + -Dpackaging=jar\ + -Dfile=../target/scala-2.11/gitbucket-assembly-3.2.0.jar\ + -DrepositoryId=sourceforge.jp\ + -Durl=scp://shell.sourceforge.jp/home/groups/a/am/amateras/htdocs/mvn/ diff --git a/deploy-assembly/pom.xml b/deploy-assembly/pom.xml new file mode 100644 index 0000000..40693f2 --- /dev/null +++ b/deploy-assembly/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + jp.sf.amateras + gitbucket-assembly + 0.0.1 + + + + org.apache.maven.wagon + wagon-ssh + 1.0-beta-6 + + + + \ No newline at end of file diff --git a/doc/activity.md b/doc/activity.md new file mode 100644 index 0000000..7c45dfb --- /dev/null +++ b/doc/activity.md @@ -0,0 +1,22 @@ +Activity Timeline +======== +GitBucket records several types of user activity to ```ACTIVITY``` table. Activity types are shown below: + +type | message | additional information +------------------|------------------------------------------------------|------------------------ +create_repository |$user created $owner/$repo |- +open_issue |$user opened issue $owner/$repo#$issueId |- +close_issue |$user closed issue $owner/$repo#$issueId |- +close_issue |$user closed pull request $owner/$repo#$issueId |- +reopen_issue |$user reopened issue $owner/$repo#$issueId |- +comment_issue |$user commented on issue $owner/$repo#$issueId |- +comment_issue |$user commented on pull request $owner/$repo#$issueId |- +create_wiki |$user created the $owner/$repo wiki |$page +edit_wiki |$user edited the $owner/$repo wiki |$page
$page:$commitId(since 1.5) +push |$user pushed to $owner/$repo#$branch to $owner/$repo |$commitId:$shortMessage\n* +create_tag |$user created tag $tag at $owner/$repo |- +create_branch |$user created branch $branch at $owner/$repo |- +delete_branch |$user deleted branch $branch at $owner/$repo |- +fork |$user forked $owner/$repo to $owner/$repo |- +open_pullreq |$user opened pull request $owner/$repo#issueId |- +merge_pullreq |$user merge pull request $owner/$repo#issueId |- diff --git a/doc/auto_update.md b/doc/auto_update.md new file mode 100644 index 0000000..83aa711 --- /dev/null +++ b/doc/auto_update.md @@ -0,0 +1,37 @@ +Automatic Schema Updating +======== +GitBucket uses H2 database to manage project and account data. GitBucket updates database schema automatically in the first run after the upgrading. + +To release a new version of GitBucket, add the version definition to the [servlet.AutoUpdate](https://github.com/takezoe/gitbucket/blob/master/src/main/scala/servlet/AutoUpdateListener.scala) at first. + +```scala +object AutoUpdate { + ... + /** + * The history of versions. A head of this sequence is the current BitBucket version. + */ + val versions = Seq( + Version(1, 0) + ) + ... +``` + +Next, add a SQL file which updates database schema into [/src/main/resources/update/](https://github.com/takezoe/gitbucket/tree/master/src/main/resources/update) as ```MAJOR_MINOR.sql```. + +GitBucket stores the current version to ```GITBUCKET_HOME/version``` and checks it at start-up. If the stored version differs from the actual version, it executes differences of SQL files between the stored version and the actual version. And ```GITBUCKET_HOME/version``` is updated by the actual version. + +We can also add any Scala code for upgrade GitBucket which modifies resources other than database. Override ```Version.update``` like below: + +```scala +val versions = Seq( + new Version(1, 3){ + override def update(conn: Connection): Unit = { + super.update(conn) + // Add any code here! + } + }, + Version(1, 2), + Version(1, 1), + Version(1, 0) +) +``` diff --git a/doc/comment_action.md b/doc/comment_action.md new file mode 100644 index 0000000..d5f2d39 --- /dev/null +++ b/doc/comment_action.md @@ -0,0 +1,48 @@ +About Action in Issue Comment +======== +After the issue creation at GitBucket, users can add comments or close it. +The details are saved at ```ISSUE_COMMENT``` table. + +To determine if it was any operation, you see the ```ACTION``` column. + +|ACTION| +|--------| +|comment| +|close_comment| +|reopen_comment| +|close| +|reopen| +|commit| +|merge| +|delete_branch| +|refer| + +#####comment +This value is saved when users have made a normal comment. + +#####close_comment, reopen_comment +These values are saved when users have reopened or closed the issue with comments. + +#####close, reopen +These values are saved when users have reopened or closed the issue. +At the same time, store the fixed value(i.e. "Close" or "Reopen") to the ```CONTENT``` column. +Therefore, this comment is not displayed, and not counted as a comment. + +#####commit +This value is saved when users have pushed including the ```#issueId``` to the commit message. +At the same time, store it to the ```CONTENT``` column with its commit id. +This comment is displayed. But it can not be edited by all users, and also not counted as a comment. + +#####merge +This value is saved when users have merged the pull request. +At the same time, store the message to the ```CONTENT``` column. +This comment is displayed. But it can not be edited by all users, and also not counted as a comment. + +#####delete_branch +This value is saved when users have deleted the branch. Users can delete branch after merging pull request which is requested from the same repository. +At the same time, store it to the ```CONTENT``` column with the deleted branch name. +Therefore, this comment is not displayed, and not counted as a comment. + +#####refer +This value is saved when other issue or issue comment contains reference to the issue like ```#issueId```. +At the same time, store id and title of the referrer issue as ```id:title```. diff --git a/doc/directory.md b/doc/directory.md new file mode 100644 index 0000000..de73fe9 --- /dev/null +++ b/doc/directory.md @@ -0,0 +1,44 @@ +Directory Structure +======== +GitBucket persists all data into __HOME/.gitbucket__ in default (In 1.9 or before, HOME/gitbucket is default). + +This directory has following structure: + +``` +* /HOME/gitbucket + * /repositories + * /USER_NAME + * / REPO_NAME.git (substance of repository. GitServlet sees this directory) + * / REPO_NAME + * /issues (files which are attached to issue) + * / REPO_NAME.wiki.git (wiki repository) + * /data + * /USER_NAME + * /files + * avatar.xxx (image file of user avatar) + * /plugins + * /PLUGIN_NAME + * plugin.js + * /tmp + * /_upload + * /SESSION_ID (removed at session timeout) + * current time millis + random 10 alphanumeric chars (temporary file for file uploading) + * /USER_NAME + * /init-REPO_NAME (used in repository creation and removed after it) ... unused since 1.8 + * /REPO_NAME.wiki (working directory for wiki repository) ... unused since 1.8 + * /REPO_NAME + * /download (temporary directories are created under this directory) +``` + +There are some ways to specify the data directory instead of the default location. + +1. Environment variable __GITBUCKET_HOME__ +2. System property __gitbucket.home__ (e.g. ```-Dgitbucket.home=PATH_TO_DATADIR```) +3. Command line option for embedded Jetty (e.g. ```java -jar gitbucket.war --data=PATH_TO_DATADIR```) +4. Context parameter __gitbucket.home__ in web.xml like below: +```xml + + gitbucket.home + PATH_TO_DATADIR + +``` diff --git a/doc/gitbucket.erd b/doc/gitbucket.erd new file mode 100644 index 0000000..74f5fb4 --- /dev/null +++ b/doc/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/doc/how_to_run.md b/doc/how_to_run.md new file mode 100644 index 0000000..bb14bbc --- /dev/null +++ b/doc/how_to_run.md @@ -0,0 +1,38 @@ +How to run from the source tree +======== + +for Testers +-------- + +If you want to test GitBucket, input following command at the root directory of the source tree. + +``` +C:\gitbucket> sbt ~container:start +``` + +Then access to `http://localhost:8080/` by your browser. The default administrator account is `root` and password is `root`. + +for Developers +-------- +If you want to modify source code and confirm it, you can run GitBucket in auto reloading mode as following: + +``` +C:\gitbucket> sbt +... +> container:start +... +> ~ ;copy-resources;aux-compile +``` + +Build war file +-------- + +To build war file, run the following command: + +``` +C:\gitbucket> sbt package +``` + +`gitbucket_2.11-x.x.x.war` is generated into `target/scala-2.11`. + +To build executable war file, run Ant at the top of the source tree. It generates executable `gitbucket.war` into `target/scala-2.11`. We release this war file as release artifact. Please note the current build.xml works on Windows only. Replace `sbt.bat` with `sbt.sh` in build.xml if you want to run it on Linux. diff --git a/doc/icons.svg b/doc/icons.svg new file mode 100644 index 0000000..9372a97 --- /dev/null +++ b/doc/icons.svg @@ -0,0 +1,754 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/notification.md b/doc/notification.md new file mode 100644 index 0000000..90a88bb --- /dev/null +++ b/doc/notification.md @@ -0,0 +1,23 @@ +Notification Email +======== + +GitBucket sends email to target users by enabling the notification email by an administrator. + +The timing of the notification are as follows: + +##### at the issue registration (new issue, new pull request) +When a record is saved into the ```ISSUE``` table, GitBucket does the notification. + +##### at the comment registration +Among the records in the ```ISSUE_COMMENT``` table, them to be counted as a comment (i.e. the record ```ACTION``` column value is "comment" or "close_comment" or "reopen_comment") are saved, GitBucket does the notification. + +##### at the status update (close, reopen, merge) +When the ```CLOSED``` column value is updated, GitBucket does the notification. + +Notified users are as follows: + +* individual repository's owner +* collaborators +* participants + +However, the operation in person is excluded from the target. diff --git a/doc/readme.md b/doc/readme.md new file mode 100644 index 0000000..bf69b5c --- /dev/null +++ b/doc/readme.md @@ -0,0 +1,11 @@ +Developer's Guide +======== + * [How to run from source tree](how_to_run.md) + * [Directory Structure](directory.md) + * [Mapping and Validation](validation.md) + * Authentication in Controller (not yet) + * [About Action in Issue Comment](comment_action.md) + * [Activity Types](activity.md) + * [Notification Email](notification.md) + * [Automatic Schema Updating](auto_update.md) + * [Release Operation](release.md) diff --git a/doc/release.md b/doc/release.md new file mode 100644 index 0000000..d3feef5 --- /dev/null +++ b/doc/release.md @@ -0,0 +1,68 @@ +Release Operation +======== + +Update version number +-------- + +Note to update version number in files below: + +### project/build.scala + +```scala +object MyBuild extends Build { + val Organization = "gitbucket" + val Name = "gitbucket" + val Version = "3.2.0" // <---- update here!! + val ScalaVersion = "2.11.6" + val ScalatraVersion = "2.3.1" +``` + +### src/main/scala/gitbucket/core/servlet/AutoUpdate.scala + +```scala +object AutoUpdate { + + /** + * The history of versions. A head of this sequence is the current BitBucket version. + */ + val versions = Seq( + new Version(3, 2), // <---- add this!! + new Version(3, 1), + ... +``` + +### deploy-assembly/deploy-assembly-jar.sh + +```bash +#!/bin/sh +./sbt.sh assembly + +mvn deploy:deploy-file \ + -DgroupId=gitbucket\ + -DartifactId=gitbucket-assembly\ + -Dversion=3.2.0\ # <---- update here!! + -Dpackaging=jar\ + -Dfile=../target/scala-2.11/gitbucket-assembly-x.x.x.jar\ # <---- update here!! + -DrepositoryId=sourceforge.jp\ + -Durl=scp://shell.sourceforge.jp/home/groups/a/am/amateras/htdocs/mvn/ +``` + +Generate release files +-------- + +Note: Release operation requires [Ant](http://ant.apache.org/) and [Maven](https://maven.apache.org/). + +### Make release war file + +Run ant with `build.xml` in the root directory. The release war file is generated into `target/scala-2.11/gitbucket.war`. + +### Deploy assemnbly jar file + +For plug-in development, we have to publish the assembly jar file to the public Maven repository. + +``` +cd deploy-assembly/ +./deploy-assembly-jar.sh +``` + +This script runs `sbt assembly` and `mvn deploy`. diff --git a/doc/validation.md b/doc/validation.md new file mode 100644 index 0000000..69160fb --- /dev/null +++ b/doc/validation.md @@ -0,0 +1,71 @@ +Mapping and Validation +======== +GitBucket uses [scalatra-forms](https://github.com/takezoe/scalatra-forms) to validate request parameters and map them to the scala object. This is inspired by Play2 form mapping / validation. + +At first, define the mapping as following: + +```scala +import jp.sf.amateras.scalatra.forms._ + +case class RegisterForm(name: String, description: String) + +val form = mapping( + "name" -> text(required, maxlength(40)), + "description" -> text() +)(RegisterForm.apply) +``` + +The servlet have to mixed in ```jp.sf.amateras.scalatra.forms.ClientSideValidationFormSupport``` to validate request parameters and take mapped object. It validates request parameters before action. If any errors are detected, it throws an exception. + +```scala +class RegisterServlet extends ScalatraServlet with ClientSideValidationFormSupport { + post("/register", form) { form: RegisterForm => + ... + } +} +``` + +In the view template, you can add client-side validation by adding ```validate="true"``` to your form. Error messages are set to ```span#error-```. + +```html +
+ Name: + +
+ Description: + +
+ +
+``` + +Client-side validation calls ```/validate``` to validate form contents. It returns a validation result as JSON. In this case, form action is ```/register```, so ```/register/validate``` is called before submitting a form. ```ClientSideValidationFormSupport``` adds this JSON API automatically. + +For Ajax request, you have to use '''ajaxGet''' or '''ajaxPost''' to define action. It almost same as '''get''' or '''post'''. You can implement actions which handle Ajax request as same as normal actions. +Small difference is they return validation errors as JSON. + +```scala +ajaxPost("/register", form){ form => + ... +} +``` + +You can call these actions using jQuery as below: + +```javascript +$('#register').click(function(e){ + $.ajax($(this).attr('action'), { + type: 'POST', + data: { + name: $('#name').val(), + mail: $('#mail').val() + } + }) + .done(function(data){ + $('#result').text('Registered!'); + }) + .fail(function(data, status){ + displayErrors($.parseJSON(data.responseText)); + }); +}); +``` diff --git a/embed-jetty/jetty-continuation-8.1.16.v20140903.jar b/embed-jetty/jetty-continuation-8.1.16.v20140903.jar new file mode 100644 index 0000000..ce1acb1 --- /dev/null +++ b/embed-jetty/jetty-continuation-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-continuation-8.1.8.v20121106.jar b/embed-jetty/jetty-continuation-8.1.8.v20121106.jar deleted file mode 100644 index 1f3f59c..0000000 --- a/embed-jetty/jetty-continuation-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-http-8.1.16.v20140903.jar b/embed-jetty/jetty-http-8.1.16.v20140903.jar new file mode 100644 index 0000000..30189c7 --- /dev/null +++ b/embed-jetty/jetty-http-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-http-8.1.8.v20121106.jar b/embed-jetty/jetty-http-8.1.8.v20121106.jar deleted file mode 100644 index 80a2ba7..0000000 --- a/embed-jetty/jetty-http-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-io-8.1.16.v20140903.jar b/embed-jetty/jetty-io-8.1.16.v20140903.jar new file mode 100644 index 0000000..a9afd7c --- /dev/null +++ b/embed-jetty/jetty-io-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-io-8.1.8.v20121106.jar b/embed-jetty/jetty-io-8.1.8.v20121106.jar deleted file mode 100644 index 21d1d67..0000000 --- a/embed-jetty/jetty-io-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-security-8.1.16.v20140903.jar b/embed-jetty/jetty-security-8.1.16.v20140903.jar new file mode 100644 index 0000000..e5bde43 --- /dev/null +++ b/embed-jetty/jetty-security-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-security-8.1.8.v20121106.jar b/embed-jetty/jetty-security-8.1.8.v20121106.jar deleted file mode 100644 index aac3f19..0000000 --- a/embed-jetty/jetty-security-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-server-8.1.16.v20140903.jar b/embed-jetty/jetty-server-8.1.16.v20140903.jar new file mode 100644 index 0000000..ae8ac55 --- /dev/null +++ b/embed-jetty/jetty-server-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-server-8.1.8.v20121106.jar b/embed-jetty/jetty-server-8.1.8.v20121106.jar deleted file mode 100644 index b21842e..0000000 --- a/embed-jetty/jetty-server-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-servlet-8.1.16.v20140903.jar b/embed-jetty/jetty-servlet-8.1.16.v20140903.jar new file mode 100644 index 0000000..eb2fa57 --- /dev/null +++ b/embed-jetty/jetty-servlet-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-servlet-8.1.8.v20121106.jar b/embed-jetty/jetty-servlet-8.1.8.v20121106.jar deleted file mode 100644 index df9583f..0000000 --- a/embed-jetty/jetty-servlet-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-util-8.1.16.v20140903.jar b/embed-jetty/jetty-util-8.1.16.v20140903.jar new file mode 100644 index 0000000..5c3c346 --- /dev/null +++ b/embed-jetty/jetty-util-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-util-8.1.8.v20121106.jar b/embed-jetty/jetty-util-8.1.8.v20121106.jar deleted file mode 100644 index 18c2270..0000000 --- a/embed-jetty/jetty-util-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-webapp-8.1.16.v20140903.jar b/embed-jetty/jetty-webapp-8.1.16.v20140903.jar new file mode 100644 index 0000000..85fd7e0 --- /dev/null +++ b/embed-jetty/jetty-webapp-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-webapp-8.1.8.v20121106.jar b/embed-jetty/jetty-webapp-8.1.8.v20121106.jar deleted file mode 100644 index 23d18ab..0000000 --- a/embed-jetty/jetty-webapp-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/jetty-xml-8.1.16.v20140903.jar b/embed-jetty/jetty-xml-8.1.16.v20140903.jar new file mode 100644 index 0000000..1e485de --- /dev/null +++ b/embed-jetty/jetty-xml-8.1.16.v20140903.jar Binary files differ diff --git a/embed-jetty/jetty-xml-8.1.8.v20121106.jar b/embed-jetty/jetty-xml-8.1.8.v20121106.jar deleted file mode 100644 index f8daf47..0000000 --- a/embed-jetty/jetty-xml-8.1.8.v20121106.jar +++ /dev/null Binary files differ diff --git a/embed-jetty/update.sh b/embed-jetty/update.sh new file mode 100755 index 0000000..7d1cfd7 --- /dev/null +++ b/embed-jetty/update.sh @@ -0,0 +1,11 @@ +#!/bin/bash +version=$1 +output_dir=`dirname $0` +git rm -f ${output_dir}/jetty-*.jar +for name in 'io' 'servlet' 'xml' 'continuation' 'security' 'util' 'http' 'server' 'webapp' +do + jar_filename="jetty-${name}-${version}.jar" + wget "http://repo1.maven.org/maven2/org/eclipse/jetty/jetty-${name}/${version}/${jar_filename}" -O ${output_dir}/${jar_filename} +done +git add ${output_dir}/*.jar +git commit diff --git a/etc/deploy-assemby-jar.sh b/etc/deploy-assemby-jar.sh deleted file mode 100755 index 7c26b42..0000000 --- a/etc/deploy-assemby-jar.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -mvn deploy:deploy-file \ - -DgroupId=gitbucket\ - -DartifactId=gitbucket-assembly\ - -Dversion=3.0.0\ - -Dpackaging=jar\ - -Dfile=../target/scala-2.11/gitbucket-assembly-3.0.0.jar\ - -DrepositoryId=sourceforge.jp\ - -Durl=scp://shell.sourceforge.jp/home/groups/a/am/amateras/htdocs/mvn/ diff --git a/etc/gitbucket.erd b/etc/gitbucket.erd deleted file mode 100644 index 74f5fb4..0000000 --- a/etc/gitbucket.erd +++ /dev/null @@ -1,1745 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index 9372a97..0000000 --- a/etc/icons.svg +++ /dev/null @@ -1,754 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/etc/pom.xml b/etc/pom.xml deleted file mode 100644 index 40693f2..0000000 --- a/etc/pom.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - 4.0.0 - jp.sf.amateras - gitbucket-assembly - 0.0.1 - - - - org.apache.maven.wagon - wagon-ssh - 1.0-beta-6 - - - - \ No newline at end of file diff --git a/gitbucket-assembly.iml b/gitbucket-assembly.iml new file mode 100644 index 0000000..3f0a572 --- /dev/null +++ b/gitbucket-assembly.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/project/build.properties b/project/build.properties index be6c454..a6e117b 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.5 +sbt.version=0.13.8 diff --git a/project/build.scala b/project/build.scala index 5a7ba6e..f2f5058 100644 --- a/project/build.scala +++ b/project/build.scala @@ -10,9 +10,9 @@ object MyBuild extends Build { val Organization = "gitbucket" val Name = "gitbucket" - val Version = "3.0.0" - val ScalaVersion = "2.11.2" - val ScalatraVersion = "2.3.0" + val Version = "3.2.0" + val ScalaVersion = "2.11.6" + val ScalatraVersion = "2.3.1" lazy val project = Project ( "gitbucket", @@ -42,34 +42,39 @@ ), scalacOptions := Seq("-deprecation", "-language:postfixOps"), libraryDependencies ++= Seq( - "org.eclipse.jgit" % "org.eclipse.jgit.http.server" % "3.4.1.201406201815-r", - "org.eclipse.jgit" % "org.eclipse.jgit.archive" % "3.4.1.201406201815-r", + "org.eclipse.jgit" % "org.eclipse.jgit.http.server" % "3.4.2.201412180340-r", + "org.eclipse.jgit" % "org.eclipse.jgit.archive" % "3.4.2.201412180340-r", "org.scalatra" %% "scalatra" % ScalatraVersion, "org.scalatra" %% "scalatra-specs2" % ScalatraVersion % "test", "org.scalatra" %% "scalatra-json" % ScalatraVersion, - "org.json4s" %% "json4s-jackson" % "3.2.10", + "org.json4s" %% "json4s-jackson" % "3.2.11", "jp.sf.amateras" %% "scalatra-forms" % "0.1.0", "commons-io" % "commons-io" % "2.4", - "org.pegdown" % "pegdown" % "1.4.1", - "org.apache.commons" % "commons-compress" % "1.5", - "org.apache.commons" % "commons-email" % "1.3.1", - "org.apache.httpcomponents" % "httpclient" % "4.3", + "org.pegdown" % "pegdown" % "1.4.1", // 1.4.2 has incompatible APi changes + "org.apache.commons" % "commons-compress" % "1.9", + "org.apache.commons" % "commons-email" % "1.3.3", + "org.apache.httpcomponents" % "httpclient" % "4.3.6", "org.apache.sshd" % "apache-sshd" % "0.11.0", "com.typesafe.slick" %% "slick" % "2.1.0", "com.novell.ldap" % "jldap" % "2009-10-07", "com.h2database" % "h2" % "1.4.180", // "ch.qos.logback" % "logback-classic" % "1.0.13" % "runtime", - "org.eclipse.jetty" % "jetty-webapp" % "8.1.8.v20121106" % "container;provided", + "org.eclipse.jetty" % "jetty-webapp" % "8.1.16.v20140903" % "container;provided", "org.eclipse.jetty.orbit" % "javax.servlet" % "3.0.0.v201112011016" % "container;provided;test" artifacts Artifact("javax.servlet", "jar", "jar"), - "junit" % "junit" % "4.11" % "test", + "junit" % "junit" % "4.12" % "test", "com.mchange" % "c3p0" % "0.9.5", "com.typesafe" % "config" % "1.2.1", - "com.typesafe.play" %% "twirl-compiler" % "1.0.2" + "com.typesafe.play" %% "twirl-compiler" % "1.0.4", + "com.typesafe.akka" %% "akka-actor" % "2.3.10", + "com.enragedginger" %% "akka-quartz-scheduler" % "1.3.0-akka-2.3.x" ), play.twirl.sbt.Import.TwirlKeys.templateImports += "gitbucket.core._", EclipseKeys.withSource := true, javacOptions in compile ++= Seq("-target", "7", "-source", "7"), testOptions in Test += Tests.Argument(TestFrameworks.Specs2, "junitxml", "console"), + javaOptions in Test += "-Dgitbucket.home=target/gitbucket_home_for_test", + testOptions in Test += Tests.Setup( () => new java.io.File("target/gitbucket_home_for_test").mkdir() ), + fork in Test := true, packageOptions += Package.MainClass("JettyLauncher") ).enablePlugins(SbtTwirl) } diff --git a/project/plugins.sbt b/project/plugins.sbt index dee3cdf..e19dd70 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,11 +1,8 @@ +scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature") + addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.4.0") - -addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.6.0") - -addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.5") - -addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.0.2") - -addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.4") - -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.12.0") \ No newline at end of file +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.6.0") +addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.5") +addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.0.4") +addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.8") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.12.0") diff --git a/sbt-launch-0.13.5.jar b/sbt-launch-0.13.5.jar deleted file mode 100644 index 174a7e1..0000000 --- a/sbt-launch-0.13.5.jar +++ /dev/null Binary files differ diff --git a/sbt-launch-0.13.8.jar b/sbt-launch-0.13.8.jar new file mode 100644 index 0000000..0d9dd94 --- /dev/null +++ b/sbt-launch-0.13.8.jar Binary files differ diff --git a/sbt.bat b/sbt.bat index 6c83e1a..7e90f12 100644 --- a/sbt.bat +++ b/sbt.bat @@ -1,2 +1,2 @@ set SCRIPT_DIR=%~dp0 -java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar "%SCRIPT_DIR%\sbt-launch-0.13.5.jar" %* +java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar "%SCRIPT_DIR%\sbt-launch-0.13.8.jar" %* diff --git a/sbt.sh b/sbt.sh index 0ffa9fe..eae1ce3 100755 --- a/sbt.sh +++ b/sbt.sh @@ -1,2 +1,2 @@ #!/bin/sh -java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar `dirname $0`/sbt-launch-0.13.5.jar "$@" +java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar `dirname $0`/sbt-launch-0.13.8.jar "$@" diff --git a/src/main/resources/update/3_1.sql b/src/main/resources/update/3_1.sql new file mode 100644 index 0000000..3ddc48a --- /dev/null +++ b/src/main/resources/update/3_1.sql @@ -0,0 +1,42 @@ +DROP TABLE IF EXISTS ACCESS_TOKEN; + +CREATE TABLE ACCESS_TOKEN ( + ACCESS_TOKEN_ID INT NOT NULL AUTO_INCREMENT, + TOKEN_HASH VARCHAR(40) NOT NULL, + USER_NAME VARCHAR(100) NOT NULL, + NOTE TEXT NOT NULL +); + +ALTER TABLE ACCESS_TOKEN ADD CONSTRAINT IDX_ACCESS_TOKEN_PK PRIMARY KEY (ACCESS_TOKEN_ID); +ALTER TABLE ACCESS_TOKEN ADD CONSTRAINT IDX_ACCESS_TOKEN_FK0 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE ACCESS_TOKEN ADD CONSTRAINT IDX_ACCESS_TOKEN_TOKEN_HASH UNIQUE(TOKEN_HASH); + + +DROP TABLE IF EXISTS COMMIT_STATUS; +CREATE TABLE COMMIT_STATUS( + COMMIT_STATUS_ID INT AUTO_INCREMENT, + USER_NAME VARCHAR(100) NOT NULL, + REPOSITORY_NAME VARCHAR(100) NOT NULL, + COMMIT_ID VARCHAR(40) NOT NULL, + CONTEXT VARCHAR(255) NOT NULL, -- context is too long (maximum is 255 characters) + STATE VARCHAR(10) NOT NULL, -- pending, success, error, or failure + TARGET_URL VARCHAR(200), + DESCRIPTION TEXT, + CREATOR VARCHAR(100) NOT NULL, + REGISTERED_DATE TIMESTAMP NOT NULL, -- CREATED_AT + UPDATED_DATE TIMESTAMP NOT NULL -- UPDATED_AT +); +ALTER TABLE COMMIT_STATUS ADD CONSTRAINT IDX_COMMIT_STATUS_PK PRIMARY KEY (COMMIT_STATUS_ID); +ALTER TABLE COMMIT_STATUS ADD CONSTRAINT IDX_COMMIT_STATUS_1 + UNIQUE (USER_NAME, REPOSITORY_NAME, COMMIT_ID, CONTEXT); +ALTER TABLE COMMIT_STATUS ADD CONSTRAINT IDX_COMMIT_STATUS_FK1 + FOREIGN KEY (USER_NAME, REPOSITORY_NAME) + REFERENCES REPOSITORY (USER_NAME, REPOSITORY_NAME) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE COMMIT_STATUS ADD CONSTRAINT IDX_COMMIT_STATUS_FK2 + FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE COMMIT_STATUS ADD CONSTRAINT IDX_COMMIT_STATUS_FK3 + FOREIGN KEY (CREATOR) REFERENCES ACCOUNT (USER_NAME) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 81002e3..7ba1d02 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -1,13 +1,14 @@ import gitbucket.core.controller._ import gitbucket.core.plugin.PluginRegistry -import gitbucket.core.servlet.{TransactionFilter, BasicAuthenticationFilter} +import gitbucket.core.servlet.{AccessTokenAuthenticationFilter, BasicAuthenticationFilter, Database, TransactionFilter} import gitbucket.core.util.Directory -//import jp.sf.amateras.scalatra.forms.ValidationJavaScriptProvider -import org.scalatra._ -import javax.servlet._ import java.util.EnumSet +import javax.servlet._ + +import org.scalatra._ + class ScalatraBootstrap extends LifeCycle { override def init(context: ServletContext) { @@ -16,7 +17,8 @@ context.getFilterRegistration("transactionFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/*") context.addFilter("basicAuthenticationFilter", new BasicAuthenticationFilter) context.getFilterRegistration("basicAuthenticationFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/git/*") - + context.addFilter("accessTokenAuthenticationFilter", new AccessTokenAuthenticationFilter) + context.getFilterRegistration("accessTokenAuthenticationFilter").addMappingForUrlPatterns(EnumSet.allOf(classOf[DispatcherType]), true, "/api/v3/*") // Register controllers context.mount(new AnonymousAccessController, "/*") @@ -45,4 +47,8 @@ dir.mkdirs() } } -} \ No newline at end of file + + override def destroy(context: ServletContext): Unit = { + Database.closeDataSource() + } +} diff --git a/src/main/scala/gitbucket/core/api/ApiCombinedCommitStatus.scala b/src/main/scala/gitbucket/core/api/ApiCombinedCommitStatus.scala new file mode 100644 index 0000000..6a2ccf8 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiCombinedCommitStatus.scala @@ -0,0 +1,25 @@ +package gitbucket.core.api + +import gitbucket.core.model.{Account, CommitState, CommitStatus} + + +/** + * https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref + */ +case class ApiCombinedCommitStatus( + state: String, + sha: String, + total_count: Int, + statuses: Iterable[ApiCommitStatus], + repository: ApiRepository){ + // val commit_url = ApiPath(s"/api/v3/repos/${repository.full_name}/${sha}") + val url = ApiPath(s"/api/v3/repos/${repository.full_name}/commits/${sha}/status") +} +object ApiCombinedCommitStatus { + def apply(sha:String, statuses: Iterable[(CommitStatus, Account)], repository:ApiRepository): ApiCombinedCommitStatus = ApiCombinedCommitStatus( + state = CommitState.combine(statuses.map(_._1.state).toSet).name, + sha = sha, + total_count= statuses.size, + statuses = statuses.map{ case (s, a)=> ApiCommitStatus(s, ApiUser(a)) }, + repository = repository) +} diff --git a/src/main/scala/gitbucket/core/api/ApiComment.scala b/src/main/scala/gitbucket/core/api/ApiComment.scala new file mode 100644 index 0000000..47244f2 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiComment.scala @@ -0,0 +1,29 @@ +package gitbucket.core.api + +import gitbucket.core.model.IssueComment +import gitbucket.core.util.RepositoryName + +import java.util.Date + + +/** + * https://developer.github.com/v3/issues/comments/ + */ +case class ApiComment( + id: Int, + user: ApiUser, + body: String, + created_at: Date, + updated_at: Date)(repositoryName: RepositoryName, issueId: Int){ + val html_url = ApiPath(s"/${repositoryName.fullName}/issues/${issueId}#comment-${id}") +} + +object ApiComment{ + def apply(comment: IssueComment, repositoryName: RepositoryName, issueId: Int, user: ApiUser): ApiComment = + ApiComment( + id = comment.commentId, + user = user, + body = comment.content, + created_at = comment.registeredDate, + updated_at = comment.updatedDate)(repositoryName, issueId) +} diff --git a/src/main/scala/gitbucket/core/api/ApiCommit.scala b/src/main/scala/gitbucket/core/api/ApiCommit.scala new file mode 100644 index 0000000..15b41d4 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiCommit.scala @@ -0,0 +1,48 @@ +package gitbucket.core.api + +import gitbucket.core.util.JGitUtil +import gitbucket.core.util.JGitUtil.CommitInfo +import gitbucket.core.util.RepositoryName + +import org.eclipse.jgit.diff.DiffEntry +import org.eclipse.jgit.api.Git + +import java.util.Date + +/** + * https://developer.github.com/v3/repos/commits/ + */ +case class ApiCommit( + id: String, + message: String, + timestamp: Date, + added: List[String], + removed: List[String], + modified: List[String], + author: ApiPersonIdent, + committer: ApiPersonIdent)(repositoryName:RepositoryName){ + val url = ApiPath(s"/api/v3/${repositoryName.fullName}/commits/${id}") + val html_url = ApiPath(s"/${repositoryName.fullName}/commit/${id}") +} + +object ApiCommit{ + def apply(git: Git, repositoryName: RepositoryName, commit: CommitInfo): ApiCommit = { + val diffs = JGitUtil.getDiffs(git, commit.id, false) + ApiCommit( + id = commit.id, + message = commit.fullMessage, + timestamp = commit.commitTime, + added = diffs._1.collect { + case x if x.changeType == DiffEntry.ChangeType.ADD => x.newPath + }, + removed = diffs._1.collect { + case x if x.changeType == DiffEntry.ChangeType.DELETE => x.oldPath + }, + modified = diffs._1.collect { + case x if x.changeType != DiffEntry.ChangeType.ADD && x.changeType != DiffEntry.ChangeType.DELETE => x.newPath + }, + author = ApiPersonIdent.author(commit), + committer = ApiPersonIdent.committer(commit) + )(repositoryName) + } +} diff --git a/src/main/scala/gitbucket/core/api/ApiCommitListItem.scala b/src/main/scala/gitbucket/core/api/ApiCommitListItem.scala new file mode 100644 index 0000000..a57431f --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiCommitListItem.scala @@ -0,0 +1,42 @@ +package gitbucket.core.api + +import gitbucket.core.api.ApiCommitListItem._ +import gitbucket.core.util.JGitUtil.CommitInfo +import gitbucket.core.util.RepositoryName + + +/** + * https://developer.github.com/v3/repos/commits/ + */ +case class ApiCommitListItem( + sha: String, + commit: Commit, + author: Option[ApiUser], + committer: Option[ApiUser], + parents: Seq[Parent])(repositoryName: RepositoryName) { + val url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/commits/${sha}") +} + +object ApiCommitListItem { + def apply(commit: CommitInfo, repositoryName: RepositoryName): ApiCommitListItem = ApiCommitListItem( + sha = commit.id, + commit = Commit( + message = commit.fullMessage, + author = ApiPersonIdent.author(commit), + committer = ApiPersonIdent.committer(commit) + )(commit.id, repositoryName), + author = None, + committer = None, + parents = commit.parents.map(Parent(_)(repositoryName)))(repositoryName) + + case class Parent(sha: String)(repositoryName: RepositoryName){ + val url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/commits/${sha}") + } + + case class Commit( + message: String, + author: ApiPersonIdent, + committer: ApiPersonIdent)(sha:String, repositoryName: RepositoryName) { + val url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/git/commits/${sha}") + } +} diff --git a/src/main/scala/gitbucket/core/api/ApiCommitStatus.scala b/src/main/scala/gitbucket/core/api/ApiCommitStatus.scala new file mode 100644 index 0000000..03d8ef6 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiCommitStatus.scala @@ -0,0 +1,38 @@ +package gitbucket.core.api + +import gitbucket.core.model.CommitStatus +import gitbucket.core.util.RepositoryName + +import java.util.Date + + +/** + * https://developer.github.com/v3/repos/statuses/#create-a-status + * https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref + */ +case class ApiCommitStatus( + created_at: Date, + updated_at: Date, + state: String, + target_url: Option[String], + description: Option[String], + id: Int, + context: String, + creator: ApiUser +)(sha: String, repositoryName: RepositoryName) { + val url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/commits/${sha}/statuses") +} + + +object ApiCommitStatus { + def apply(status: CommitStatus, creator:ApiUser): ApiCommitStatus = ApiCommitStatus( + created_at = status.registeredDate, + updated_at = status.updatedDate, + state = status.state.name, + target_url = status.targetUrl, + description= status.description, + id = status.commitStatusId, + context = status.context, + creator = creator + )(status.commitId, RepositoryName(status)) +} diff --git a/src/main/scala/gitbucket/core/api/ApiError.scala b/src/main/scala/gitbucket/core/api/ApiError.scala new file mode 100644 index 0000000..95114d1 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiError.scala @@ -0,0 +1,5 @@ +package gitbucket.core.api + +case class ApiError( + message: String, + documentation_url: Option[String] = None) diff --git a/src/main/scala/gitbucket/core/api/ApiIssue.scala b/src/main/scala/gitbucket/core/api/ApiIssue.scala new file mode 100644 index 0000000..45b5d62 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiIssue.scala @@ -0,0 +1,35 @@ +package gitbucket.core.api + +import gitbucket.core.model.Issue +import gitbucket.core.util.RepositoryName + +import java.util.Date + + +/** + * https://developer.github.com/v3/issues/ + */ +case class ApiIssue( + number: Int, + title: String, + user: ApiUser, + // labels, + state: String, + created_at: Date, + updated_at: Date, + body: String)(repositoryName: RepositoryName){ + val comments_url = ApiPath(s"/api/v3/repos/${repositoryName.fullName}/issues/${number}/comments") + val html_url = ApiPath(s"/${repositoryName.fullName}/issues/${number}") +} + +object ApiIssue{ + def apply(issue: Issue, repositoryName: RepositoryName, user: ApiUser): ApiIssue = + ApiIssue( + number = issue.issueId, + title = issue.title, + user = user, + state = if(issue.closed){ "closed" }else{ "open" }, + body = issue.content.getOrElse(""), + created_at = issue.registeredDate, + updated_at = issue.updatedDate)(repositoryName) +} diff --git a/src/main/scala/gitbucket/core/api/ApiPath.scala b/src/main/scala/gitbucket/core/api/ApiPath.scala new file mode 100644 index 0000000..661ce47 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiPath.scala @@ -0,0 +1,6 @@ +package gitbucket.core.api + +/** + * path for api url. if set path '/repos/aa/bb' then, expand 'http://server:post/repos/aa/bb' when converted to json. + */ +case class ApiPath(path: String) diff --git a/src/main/scala/gitbucket/core/api/ApiPersonIdent.scala b/src/main/scala/gitbucket/core/api/ApiPersonIdent.scala new file mode 100644 index 0000000..3c31e15 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiPersonIdent.scala @@ -0,0 +1,25 @@ +package gitbucket.core.api + +import gitbucket.core.util.JGitUtil.CommitInfo + +import java.util.Date + + +case class ApiPersonIdent( + name: String, + email: String, + date: Date) + + +object ApiPersonIdent { + def author(commit: CommitInfo): ApiPersonIdent = + ApiPersonIdent( + name = commit.authorName, + email = commit.authorEmailAddress, + date = commit.authorTime) + def committer(commit: CommitInfo): ApiPersonIdent = + ApiPersonIdent( + name = commit.committerName, + email = commit.committerEmailAddress, + date = commit.commitTime) +} diff --git a/src/main/scala/gitbucket/core/api/ApiPullRequest.scala b/src/main/scala/gitbucket/core/api/ApiPullRequest.scala new file mode 100644 index 0000000..7577525 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiPullRequest.scala @@ -0,0 +1,59 @@ +package gitbucket.core.api + +import gitbucket.core.model.{Issue, PullRequest} + +import java.util.Date + + +/** + * https://developer.github.com/v3/pulls/ + */ +case class ApiPullRequest( + number: Int, + updated_at: Date, + created_at: Date, + head: ApiPullRequest.Commit, + base: ApiPullRequest.Commit, + mergeable: Option[Boolean], + title: String, + body: String, + user: ApiUser) { + val html_url = ApiPath(s"${base.repo.html_url.path}/pull/${number}") + //val diff_url = ApiPath(s"${base.repo.html_url.path}/pull/${number}.diff") + //val patch_url = ApiPath(s"${base.repo.html_url.path}/pull/${number}.patch") + val url = ApiPath(s"${base.repo.url.path}/pulls/${number}") + //val issue_url = ApiPath(s"${base.repo.url.path}/issues/${number}") + val commits_url = ApiPath(s"${base.repo.url.path}/pulls/${number}/commits") + val review_comments_url = ApiPath(s"${base.repo.url.path}/pulls/${number}/comments") + val review_comment_url = ApiPath(s"${base.repo.url.path}/pulls/comments/{number}") + val comments_url = ApiPath(s"${base.repo.url.path}/issues/${number}/comments") + val statuses_url = ApiPath(s"${base.repo.url.path}/statuses/${head.sha}") +} + +object ApiPullRequest{ + def apply(issue: Issue, pullRequest: PullRequest, headRepo: ApiRepository, baseRepo: ApiRepository, user: ApiUser): ApiPullRequest = ApiPullRequest( + number = issue.issueId, + updated_at = issue.updatedDate, + created_at = issue.registeredDate, + head = Commit( + sha = pullRequest.commitIdTo, + ref = pullRequest.requestBranch, + repo = headRepo)(issue.userName), + base = Commit( + sha = pullRequest.commitIdFrom, + ref = pullRequest.branch, + repo = baseRepo)(issue.userName), + mergeable = None, // TODO: need check mergeable. + title = issue.title, + body = issue.content.getOrElse(""), + user = user + ) + + case class Commit( + sha: String, + ref: String, + repo: ApiRepository)(baseOwner:String){ + val label = if( baseOwner == repo.owner.login ){ ref }else{ s"${repo.owner.login}:${ref}" } + val user = repo.owner + } +} diff --git a/src/main/scala/gitbucket/core/api/ApiRepository.scala b/src/main/scala/gitbucket/core/api/ApiRepository.scala new file mode 100644 index 0000000..0911882 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiRepository.scala @@ -0,0 +1,48 @@ +package gitbucket.core.api + +import gitbucket.core.model.{Account, Repository} +import gitbucket.core.service.RepositoryService.RepositoryInfo + + +// https://developer.github.com/v3/repos/ +case class ApiRepository( + name: String, + full_name: String, + description: String, + watchers: Int, + forks: Int, + `private`: Boolean, + default_branch: String, + owner: ApiUser) { + val forks_count = forks + val watchers_coun = watchers + val url = ApiPath(s"/api/v3/repos/${full_name}") + val http_url = ApiPath(s"/git/${full_name}.git") + val clone_url = ApiPath(s"/git/${full_name}.git") + val html_url = ApiPath(s"/${full_name}") +} + +object ApiRepository{ + def apply( + repository: Repository, + owner: ApiUser, + forkedCount: Int =0, + watchers: Int = 0): ApiRepository = + ApiRepository( + name = repository.repositoryName, + full_name = s"${repository.userName}/${repository.repositoryName}", + description = repository.description.getOrElse(""), + watchers = 0, + forks = forkedCount, + `private` = repository.isPrivate, + default_branch = repository.defaultBranch, + owner = owner + ) + + def apply(repositoryInfo: RepositoryInfo, owner: ApiUser): ApiRepository = + ApiRepository(repositoryInfo.repository, owner, forkedCount=repositoryInfo.forkedCount) + + def apply(repositoryInfo: RepositoryInfo, owner: Account): ApiRepository = + this(repositoryInfo.repository, ApiUser(owner)) + +} diff --git a/src/main/scala/gitbucket/core/api/ApiUser.scala b/src/main/scala/gitbucket/core/api/ApiUser.scala new file mode 100644 index 0000000..fa69900 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/ApiUser.scala @@ -0,0 +1,36 @@ +package gitbucket.core.api + +import gitbucket.core.model.Account + +import java.util.Date + + +case class ApiUser( + login: String, + email: String, + `type`: String, + site_admin: Boolean, + created_at: Date) { + val url = ApiPath(s"/api/v3/users/${login}") + val html_url = ApiPath(s"/${login}") + // val followers_url = ApiPath(s"/api/v3/users/${login}/followers") + // val following_url = ApiPath(s"/api/v3/users/${login}/following{/other_user}") + // val gists_url = ApiPath(s"/api/v3/users/${login}/gists{/gist_id}") + // val starred_url = ApiPath(s"/api/v3/users/${login}/starred{/owner}{/repo}") + // val subscriptions_url = ApiPath(s"/api/v3/users/${login}/subscriptions") + // val organizations_url = ApiPath(s"/api/v3/users/${login}/orgs") + // val repos_url = ApiPath(s"/api/v3/users/${login}/repos") + // val events_url = ApiPath(s"/api/v3/users/${login}/events{/privacy}") + // val received_events_url = ApiPath(s"/api/v3/users/${login}/received_events") +} + + +object ApiUser{ + def apply(user: Account): ApiUser = ApiUser( + login = user.userName, + email = user.mailAddress, + `type` = if(user.isGroupAccount){ "Organization" }else{ "User" }, + site_admin = user.isAdmin, + created_at = user.registeredDate + ) +} diff --git a/src/main/scala/gitbucket/core/api/CreateAComment.scala b/src/main/scala/gitbucket/core/api/CreateAComment.scala new file mode 100644 index 0000000..f636e8a --- /dev/null +++ b/src/main/scala/gitbucket/core/api/CreateAComment.scala @@ -0,0 +1,7 @@ +package gitbucket.core.api + +/** + * https://developer.github.com/v3/issues/comments/#create-a-comment + * api form + */ +case class CreateAComment(body: String) diff --git a/src/main/scala/gitbucket/core/api/CreateAStatus.scala b/src/main/scala/gitbucket/core/api/CreateAStatus.scala new file mode 100644 index 0000000..3871999 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/CreateAStatus.scala @@ -0,0 +1,26 @@ +package gitbucket.core.api + +import gitbucket.core.model.CommitState + +/** + * https://developer.github.com/v3/repos/statuses/#create-a-status + * api form + */ +case class CreateAStatus( + /* state is Required. The state of the status. Can be one of pending, success, error, or failure. */ + state: String, + /* context is a string label to differentiate this status from the status of other systems. Default: "default" */ + context: Option[String], + /* The target URL to associate with this status. This URL will be linked from the GitHub UI to allow users to easily see the ‘source’ of the Status. */ + target_url: Option[String], + /* description is a short description of the status.*/ + description: Option[String] +) { + def isValid: Boolean = { + CommitState.valueOf(state).isDefined && + // only http + target_url.filterNot(f => "\\Ahttps?://".r.findPrefixOf(f).isDefined && f.length<255).isEmpty && + context.filterNot(f => f.length<255).isEmpty && + description.filterNot(f => f.length<1000).isEmpty + } +} diff --git a/src/main/scala/gitbucket/core/api/JsonFormat.scala b/src/main/scala/gitbucket/core/api/JsonFormat.scala new file mode 100644 index 0000000..a14a116 --- /dev/null +++ b/src/main/scala/gitbucket/core/api/JsonFormat.scala @@ -0,0 +1,44 @@ +package gitbucket.core.api + +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format._ +import org.json4s._ +import org.json4s.jackson.Serialization + +import java.util.Date + +import scala.util.Try + + +object JsonFormat { + case class Context(baseUrl:String) + val parserISO = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + val jsonFormats = Serialization.formats(NoTypeHints) + new CustomSerializer[Date](format => + ( + { case JString(s) => Try(parserISO.parseDateTime(s)).toOption.map(_.toDate) + .getOrElse(throw new MappingException("Can't convert " + s + " to Date")) }, + { case x: Date => JString(parserISO.print(new DateTime(x).withZone(DateTimeZone.UTC))) } + ) + ) + FieldSerializer[ApiUser]() + FieldSerializer[ApiPullRequest]() + FieldSerializer[ApiRepository]() + + FieldSerializer[ApiCommitListItem.Parent]() + FieldSerializer[ApiCommitListItem]() + FieldSerializer[ApiCommitListItem.Commit]() + + FieldSerializer[ApiCommitStatus]() + FieldSerializer[ApiCommit]() + FieldSerializer[ApiCombinedCommitStatus]() + + FieldSerializer[ApiPullRequest.Commit]() + FieldSerializer[ApiIssue]() + FieldSerializer[ApiComment]() + + + def apiPathSerializer(c: Context) = new CustomSerializer[ApiPath](format => + ( + { + case JString(s) if s.startsWith(c.baseUrl) => ApiPath(s.substring(c.baseUrl.length)) + case JString(s) => throw new MappingException("Can't convert " + s + " to ApiPath") + }, + { + case ApiPath(path) => JString(c.baseUrl+path) + } + ) + ) + /** + * convert object to json string + */ + def apply(obj: AnyRef)(implicit c: Context): String = Serialization.write(obj)(jsonFormats + apiPathSerializer(c)) +} diff --git a/src/main/scala/gitbucket/core/controller/AccountController.scala b/src/main/scala/gitbucket/core/controller/AccountController.scala index afbe12f..29c89f0 100644 --- a/src/main/scala/gitbucket/core/controller/AccountController.scala +++ b/src/main/scala/gitbucket/core/controller/AccountController.scala @@ -1,30 +1,35 @@ package gitbucket.core.controller import gitbucket.core.account.html +import gitbucket.core.api._ import gitbucket.core.helper import gitbucket.core.model.GroupMember -import gitbucket.core.util._ -import gitbucket.core.util.Implicits._ -import gitbucket.core.util.Directory._ -import gitbucket.core.util.ControlUtil._ -import gitbucket.core.util.StringUtil._ -import gitbucket.core.ssh.SshUtil import gitbucket.core.service._ +import gitbucket.core.ssh.SshUtil +import gitbucket.core.util.ControlUtil._ +import gitbucket.core.util.Directory._ +import gitbucket.core.util.Implicits._ +import gitbucket.core.util.StringUtil._ +import gitbucket.core.util._ + import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils -import org.scalatra.i18n.Messages import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.{FileMode, Constants} import org.eclipse.jgit.dircache.DirCache +import org.eclipse.jgit.lib.{FileMode, Constants} +import org.scalatra.i18n.Messages class AccountController extends AccountControllerBase with AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator + with AccessTokenService with WebHookService + trait AccountControllerBase extends AccountManagementControllerBase { self: AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService - with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator => + with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator + with AccessTokenService with WebHookService => case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, url: Option[String], fileId: Option[String]) @@ -34,6 +39,8 @@ case class SshKeyForm(title: String, publicKey: String) + case class PersonalTokenForm(note: String) + val newForm = mapping( "userName" -> trim(label("User name" , text(required, maxlength(100), identifier, uniqueUserName))), "password" -> trim(label("Password" , text(required, maxlength(20)))), @@ -57,6 +64,10 @@ "publicKey" -> trim(label("Key" , text(required, validPublicKey))) )(SshKeyForm.apply) + val personalTokenForm = mapping( + "note" -> trim(label("Token", text(required, maxlength(100)))) + )(PersonalTokenForm.apply) + case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String) case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String, clearImage: Boolean) @@ -145,6 +156,25 @@ } } + /** + * https://developer.github.com/v3/users/#get-a-single-user + */ + get("/api/v3/users/:userName") { + getAccountByUserName(params("userName")).map { account => + JsonFormat(ApiUser(account)) + } getOrElse NotFound + } + + /** + * https://developer.github.com/v3/users/#get-the-authenticated-user + */ + get("/api/v3/user") { + context.loginAccount.map { account => + JsonFormat(ApiUser(account)) + } getOrElse Unauthorized + } + + get("/:userName/_edit")(oneselfOnly { val userName = params("userName") getAccountByUserName(userName).map { x => @@ -209,6 +239,40 @@ redirect(s"/${userName}/_ssh") }) + get("/:userName/_application")(oneselfOnly { + val userName = params("userName") + getAccountByUserName(userName).map { x => + var tokens = getAccessTokens(x.userName) + val generatedToken = flash.get("generatedToken") match { + case Some((tokenId:Int, token:String)) => { + val gt = tokens.find(_.accessTokenId == tokenId) + gt.map{ t => + tokens = tokens.filterNot(_ == t) + (t, token) + } + } + case _ => None + } + html.application(x, tokens, generatedToken) + } getOrElse NotFound + }) + + post("/:userName/_personalToken", personalTokenForm)(oneselfOnly { form => + val userName = params("userName") + getAccountByUserName(userName).map { x => + val (tokenId, token) = generateAccessToken(userName, form.note) + flash += "generatedToken" -> (tokenId, token) + } + redirect(s"/${userName}/_application") + }) + + get("/:userName/_personalToken/delete/:id")(oneselfOnly { + val userName = params("userName") + val tokenId = params("id").toInt + deleteAccessToken(userName, tokenId) + redirect(s"/${userName}/_application") + }) + get("/register"){ if(context.settings.allowAccountRegistration){ if(context.loginAccount.isDefined){ diff --git a/src/main/scala/gitbucket/core/controller/ControllerBase.scala b/src/main/scala/gitbucket/core/controller/ControllerBase.scala index 1fa9710..09cdc85 100644 --- a/src/main/scala/gitbucket/core/controller/ControllerBase.scala +++ b/src/main/scala/gitbucket/core/controller/ControllerBase.scala @@ -1,19 +1,25 @@ package gitbucket.core.controller +import gitbucket.core.api.ApiError +import gitbucket.core.model.Account import gitbucket.core.service.{AccountService, SystemSettingsService} -import gitbucket.core.util._ -import gitbucket.core.util.Implicits._ import gitbucket.core.util.ControlUtil._ import gitbucket.core.util.Directory._ -import gitbucket.core.model.Account -import org.scalatra._ -import org.scalatra.json._ -import org.json4s._ +import gitbucket.core.util.Implicits._ +import gitbucket.core.util._ + import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils +import org.json4s._ +import org.scalatra._ +import org.scalatra.i18n._ +import org.scalatra.json._ + import javax.servlet.http.{HttpServletResponse, HttpServletRequest} import javax.servlet.{FilterChain, ServletResponse, ServletRequest} -import org.scalatra.i18n._ + +import scala.util.Try + /** * Provides generic features for controller implementations. @@ -51,6 +57,9 @@ // Git repository chain.doFilter(request, response) } else { + if(path.startsWith("/api/v3/")){ + httpRequest.setAttribute(Keys.Request.APIv3, true) + } // Scalatra actions super.doFilter(request, response, chain) } @@ -74,7 +83,7 @@ } } - private def LoginAccount: Option[Account] = session.getAs[Account](Keys.Session.LoginAccount) + private def LoginAccount: Option[Account] = request.getAs[Account](Keys.Session.LoginAccount).orElse(session.getAs[Account](Keys.Session.LoginAccount)) def ajaxGet(path : String)(action : => Any) : Route = super.get(path){ @@ -103,6 +112,9 @@ protected def NotFound() = if(request.hasAttribute(Keys.Request.Ajax)){ org.scalatra.NotFound() + } else if(request.hasAttribute(Keys.Request.APIv3)){ + contentType = formats("json") + org.scalatra.NotFound(ApiError("Not Found")) } else { org.scalatra.NotFound(gitbucket.core.html.error("Not Found")) } @@ -110,6 +122,9 @@ protected def Unauthorized()(implicit context: Context) = if(request.hasAttribute(Keys.Request.Ajax)){ org.scalatra.Unauthorized() + } else if(request.hasAttribute(Keys.Request.APIv3)){ + contentType = formats("json") + org.scalatra.Unauthorized(ApiError("Requires authentication")) } else { if(context.loginAccount.isDefined){ org.scalatra.Unauthorized(redirect("/")) @@ -146,6 +161,15 @@ response.addHeader("X-Content-Type-Options", "nosniff") rawData } + + // jenkins send message as 'application/x-www-form-urlencoded' but scalatra already parsed as multi-part-request. + def extractFromJsonBody[A](implicit request:HttpServletRequest, mf:Manifest[A]): Option[A] = { + (request.contentType.map(_.split(";").head.toLowerCase) match{ + case Some("application/x-www-form-urlencoded") => multiParams.keys.headOption.map(parse(_)) + case Some("application/json") => Some(parsedBody) + case _ => Some(parse(request.body)) + }).filterNot(_ == JNothing).flatMap(j => Try(j.extract[A]).toOption) + } } /** diff --git a/src/main/scala/gitbucket/core/controller/IndexController.scala b/src/main/scala/gitbucket/core/controller/IndexController.scala index 242d67d..deb3982 100644 --- a/src/main/scala/gitbucket/core/controller/IndexController.scala +++ b/src/main/scala/gitbucket/core/controller/IndexController.scala @@ -1,16 +1,20 @@ package gitbucket.core.controller -import gitbucket.core.html +import gitbucket.core.api._ import gitbucket.core.helper.xml +import gitbucket.core.html import gitbucket.core.model.Account import gitbucket.core.service.{RepositoryService, ActivityService, AccountService} -import gitbucket.core.util.{LDAPUtil, Keys, UsersAuthenticator} import gitbucket.core.util.Implicits._ +import gitbucket.core.util.{LDAPUtil, Keys, UsersAuthenticator} + import jp.sf.amateras.scalatra.forms._ + class IndexController extends IndexControllerBase with RepositoryService with ActivityService with AccountService with UsersAuthenticator + trait IndexControllerBase extends ControllerBase { self: RepositoryService with ActivityService with AccountService with UsersAuthenticator => @@ -95,7 +99,7 @@ get("/_user/proposals")(usersOnly { contentType = formats("json") org.json4s.jackson.Serialization.write( - Map("options" -> getAllUsers().filter(!_.isGroupAccount).map(_.userName).toArray) + Map("options" -> getAllUsers(false).filter(!_.isGroupAccount).map(_.userName).toArray) ) }) @@ -106,4 +110,13 @@ getAccountByUserName(params("userName")).isDefined }) + /** + * @see https://developer.github.com/v3/rate_limit/#get-your-current-rate-limit-status + * but not enabled. + */ + get("/api/v3/rate_limit"){ + contentType = formats("json") + // this message is same as github enterprise... + org.scalatra.NotFound(ApiError("Rate limiting is not enabled.")) + } } diff --git a/src/main/scala/gitbucket/core/controller/IssuesController.scala b/src/main/scala/gitbucket/core/controller/IssuesController.scala index da67d04..1aafbeb 100644 --- a/src/main/scala/gitbucket/core/controller/IssuesController.scala +++ b/src/main/scala/gitbucket/core/controller/IssuesController.scala @@ -1,25 +1,27 @@ package gitbucket.core.controller +import gitbucket.core.api._ import gitbucket.core.issues.html import gitbucket.core.model.Issue +import gitbucket.core.service.IssuesService._ import gitbucket.core.service._ -import gitbucket.core.util._ import gitbucket.core.util.ControlUtil._ import gitbucket.core.util.Implicits._ +import gitbucket.core.util._ import gitbucket.core.view import gitbucket.core.view.Markdown -import jp.sf.amateras.scalatra.forms._ -import IssuesService._ +import jp.sf.amateras.scalatra.forms._ import org.scalatra.Ok + class IssuesController extends IssuesControllerBase with IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with WebHookIssueCommentService trait IssuesControllerBase extends ControllerBase { self: IssuesService with RepositoryService with AccountService with LabelsService with MilestonesService with ActivityService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator => + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with WebHookIssueCommentService => case class IssueCreateForm(title: String, content: Option[String], assignedUserName: Option[String], milestoneId: Option[Int], labelNames: Option[String]) @@ -76,6 +78,18 @@ } }) + /** + * https://developer.github.com/v3/issues/comments/#list-comments-on-an-issue + */ + get("/api/v3/repos/:owner/:repository/issues/:id/comments")(referrersOnly { repository => + (for{ + issueId <- params("id").toIntOpt + comments = getCommentsForApi(repository.owner, repository.name, issueId.toInt) + } yield { + JsonFormat(comments.map{ case (issueComment, user) => ApiComment(issueComment, RepositoryName(repository), issueId, ApiUser(user)) }) + }).getOrElse(NotFound) + }) + get("/:owner/:repository/issues/new")(readableUsersOnly { repository => defining(repository.owner, repository.name){ case (owner, name) => html.create( @@ -112,14 +126,17 @@ // record activity recordCreateIssueActivity(owner, name, userName, issueId, form.title) - // extract references and create refer comment getIssue(owner, name, issueId.toString).foreach { issue => + // extract references and create refer comment createReferComment(owner, name, issue, form.title + " " + form.content.getOrElse("")) - } - // notifications - Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ - Notifier.msgIssue(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") + // call web hooks + callIssuesWebHook("opened", repository, issue, context.baseUrl, context.loginAccount.get) + + // notifications + Notifier().toNotify(repository, issue, form.content.getOrElse("")){ + Notifier.msgIssue(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") + } } redirect(s"/${owner}/${name}/issues/${issueId}") @@ -163,6 +180,20 @@ } getOrElse NotFound }) + /** + * https://developer.github.com/v3/issues/comments/#create-a-comment + */ + post("/api/v3/repos/:owner/:repository/issues/:id/comments")(readableUsersOnly { repository => + (for{ + issueId <- params("id").toIntOpt + body <- extractFromJsonBody[CreateAComment].map(_.body) if ! body.isEmpty + (issue, id) <- handleComment(issueId, Some(body), repository)() + issueComment <- getComment(repository.owner, repository.name, id.toString()) + } yield { + JsonFormat(ApiComment(issueComment, RepositoryName(repository), issueId, ApiUser(context.loginAccount.get))) + }) getOrElse NotFound + }) + post("/:owner/:repository/issue_comments/state", issueStateForm)(readableUsersOnly { (form, repository) => handleComment(form.issueId, form.content, repository)() map { case (issue, id) => redirect(s"/${repository.owner}/${repository.name}/${ @@ -367,17 +398,33 @@ createReferComment(owner, name, issue, content) } + // call web hooks + action match { + case None => commentId.map{ commentIdSome => callIssueCommentWebHook(repository, issue, commentIdSome, context.loginAccount.get) } + case Some(act) => val webHookAction = act match { + case "open" => "opened" + case "reopen" => "reopened" + case "close" => "closed" + case _ => act + } + if(issue.isPullRequest){ + callPullRequestWebHook(webHookAction, repository, issue.issueId, context.baseUrl, context.loginAccount.get) + } else { + callIssuesWebHook(webHookAction, repository, issue, context.baseUrl, context.loginAccount.get) + } + } + // notifications Notifier() match { case f => content foreach { - f.toNotify(repository, issueId, _){ + f.toNotify(repository, issue, _){ Notifier.msgComment(s"${context.baseUrl}/${owner}/${name}/${ if(issue.isPullRequest) "pull" else "issues"}/${issueId}#comment-${commentId.get}") } } action foreach { - f.toNotify(repository, issueId, _){ + f.toNotify(repository, issue, _){ Notifier.msgStatus(s"${context.baseUrl}/${owner}/${name}/issues/${issueId}") } } @@ -419,5 +466,4 @@ hasWritePermission(owner, repoName, context.loginAccount)) } } - } diff --git a/src/main/scala/gitbucket/core/controller/PullRequestsController.scala b/src/main/scala/gitbucket/core/controller/PullRequestsController.scala index a7200ca..5c6d6dd 100644 --- a/src/main/scala/gitbucket/core/controller/PullRequestsController.scala +++ b/src/main/scala/gitbucket/core/controller/PullRequestsController.scala @@ -1,34 +1,39 @@ package gitbucket.core.controller +import gitbucket.core.api._ +import gitbucket.core.model.{Account, CommitState, Repository, PullRequest, Issue} import gitbucket.core.pulls.html -import gitbucket.core.util._ -import gitbucket.core.util.JGitUtil._ -import gitbucket.core.util.ControlUtil._ -import gitbucket.core.util.Implicits._ -import gitbucket.core.util.Directory._ -import gitbucket.core.view -import gitbucket.core.view.helpers -import org.eclipse.jgit.api.Git -import jp.sf.amateras.scalatra.forms._ -import org.eclipse.jgit.transport.RefSpec -import scala.collection.JavaConverters._ -import org.eclipse.jgit.lib.{ObjectId, CommitBuilder, PersonIdent} -import gitbucket.core.service._ +import gitbucket.core.service.CommitStatusService +import gitbucket.core.service.MergeService import gitbucket.core.service.IssuesService._ import gitbucket.core.service.PullRequestService._ -import gitbucket.core.service.WebHookService.WebHookPayload +import gitbucket.core.service._ +import gitbucket.core.util.ControlUtil._ +import gitbucket.core.util.Directory._ +import gitbucket.core.util.Implicits._ +import gitbucket.core.util.JGitUtil._ +import gitbucket.core.util._ +import gitbucket.core.view +import gitbucket.core.view.helpers + +import jp.sf.amateras.scalatra.forms._ +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.PersonIdent import org.slf4j.LoggerFactory -import org.eclipse.jgit.merge.MergeStrategy -import org.eclipse.jgit.errors.NoMergeBaseException + +import scala.collection.JavaConverters._ class PullRequestsController extends PullRequestsControllerBase with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with LabelsService - with CommitsService with ActivityService with WebHookService with ReferrerAuthenticator with CollaboratorsAuthenticator + with CommitsService with ActivityService with WebHookPullRequestService with ReferrerAuthenticator with CollaboratorsAuthenticator + with CommitStatusService with MergeService + trait PullRequestsControllerBase extends ControllerBase { self: RepositoryService with AccountService with IssuesService with MilestonesService with LabelsService - with CommitsService with ActivityService with PullRequestService with WebHookService with ReferrerAuthenticator with CollaboratorsAuthenticator => + with CommitsService with ActivityService with PullRequestService with WebHookPullRequestService with ReferrerAuthenticator with CollaboratorsAuthenticator + with CommitStatusService with MergeService => private val logger = LoggerFactory.getLogger(classOf[PullRequestsControllerBase]) @@ -70,6 +75,24 @@ } }) + /** + * https://developer.github.com/v3/pulls/#list-pull-requests + */ + get("/api/v3/repos/:owner/:repository/pulls")(referrersOnly { repository => + val page = IssueSearchCondition.page(request) + // TODO: more api spec condition + val condition = IssueSearchCondition(request) + val baseOwner = getAccountByUserName(repository.owner).get + val issues:List[(Issue, Account, Int, PullRequest, Repository, Account)] = searchPullRequestByApi(condition, (page - 1) * PullRequestLimit, PullRequestLimit, repository.owner -> repository.name) + JsonFormat(issues.map{case (issue, issueUser, commentCount, pullRequest, headRepo, headOwner) => + ApiPullRequest( + issue, + pullRequest, + ApiRepository(headRepo, ApiUser(headOwner)), + ApiRepository(repository, ApiUser(baseOwner)), + ApiUser(issueUser)) }) + }) + get("/:owner/:repository/pull/:id")(referrersOnly { repository => params("id").toIntOpt.flatMap{ issueId => val owner = repository.owner @@ -78,7 +101,6 @@ using(Git.open(getRepositoryDir(owner, name))){ git => val (commits, diffs) = getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo) - html.pullreq( issue, pullreq, (commits.flatten.map(commit => getCommitComments(owner, name, commit.id, true)).flatten.toList ::: getComments(owner, name, issueId)) @@ -96,14 +118,64 @@ } getOrElse NotFound }) + /** + * https://developer.github.com/v3/pulls/#get-a-single-pull-request + */ + get("/api/v3/repos/:owner/:repository/pulls/:id")(referrersOnly { repository => + (for{ + issueId <- params("id").toIntOpt + (issue, pullRequest) <- getPullRequest(repository.owner, repository.name, issueId) + users = getAccountsByUserNames(Set(repository.owner, pullRequest.requestUserName, issue.openedUserName), Set()) + baseOwner <- users.get(repository.owner) + headOwner <- users.get(pullRequest.requestUserName) + issueUser <- users.get(issue.openedUserName) + headRepo <- getRepository(pullRequest.requestUserName, pullRequest.requestRepositoryName, baseUrl) + } yield { + JsonFormat(ApiPullRequest( + issue, + pullRequest, + ApiRepository(headRepo, ApiUser(headOwner)), + ApiRepository(repository, ApiUser(baseOwner)), + ApiUser(issueUser))) + }).getOrElse(NotFound) + }) + + /** + * https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request + */ + get("/api/v3/repos/:owner/:repository/pulls/:id/commits")(referrersOnly { repository => + val owner = repository.owner + val name = repository.name + params("id").toIntOpt.flatMap{ issueId => + getPullRequest(owner, name, issueId) map { case(issue, pullreq) => + using(Git.open(getRepositoryDir(owner, name))){ git => + val oldId = git.getRepository.resolve(pullreq.commitIdFrom) + val newId = git.getRepository.resolve(pullreq.commitIdTo) + val repoFullName = RepositoryName(repository) + val commits = git.log.addRange(oldId, newId).call.iterator.asScala.map(c => ApiCommitListItem(new CommitInfo(c), repoFullName)).toList + JsonFormat(commits) + } + } + } getOrElse NotFound + }) + ajaxGet("/:owner/:repository/pull/:id/mergeguide")(collaboratorsOnly { repository => params("id").toIntOpt.flatMap{ issueId => val owner = repository.owner val name = repository.name getPullRequest(owner, name, issueId) map { case(issue, pullreq) => + val statuses = getCommitStatues(owner, name, pullreq.commitIdTo) + val hasConfrict = LockUtil.lock(s"${owner}/${name}"){ + checkConflict(owner, name, pullreq.branch, issueId) + } + val hasProblem = hasConfrict || (!statuses.isEmpty && CommitState.combine(statuses.map(_.state).toSet) != CommitState.SUCCESS) html.mergeguide( - checkConflictInPullRequest(owner, name, pullreq.branch, pullreq.requestUserName, name, pullreq.requestBranch, issueId), + hasConfrict, + hasProblem, + issue, pullreq, + statuses, + repository, s"${context.baseUrl}/git/${pullreq.requestUserName}/${pullreq.requestRepositoryName}.git") } } getOrElse NotFound @@ -140,43 +212,10 @@ // record activity recordMergeActivity(owner, name, loginAccount.userName, issueId, form.message) - // merge - val mergeBaseRefName = s"refs/heads/${pullreq.branch}" - val merger = MergeStrategy.RECURSIVE.newMerger(git.getRepository, true) - val mergeBaseTip = git.getRepository.resolve(mergeBaseRefName) - val mergeTip = git.getRepository.resolve(s"refs/pull/${issueId}/head") - val conflicted = try { - !merger.merge(mergeBaseTip, mergeTip) - } catch { - case e: NoMergeBaseException => true - } - if (conflicted) { - throw new RuntimeException("This pull request can't merge automatically.") - } - - // creates merge commit - val mergeCommit = new CommitBuilder() - mergeCommit.setTreeId(merger.getResultTreeId) - mergeCommit.setParentIds(Array[ObjectId](mergeBaseTip, mergeTip): _*) - val personIdent = new PersonIdent(loginAccount.fullName, loginAccount.mailAddress) - mergeCommit.setAuthor(personIdent) - mergeCommit.setCommitter(personIdent) - mergeCommit.setMessage(s"Merge pull request #${issueId} from ${pullreq.requestUserName}/${pullreq.requestBranch}\n\n" + - form.message) - - // insertObject and got mergeCommit Object Id - val inserter = git.getRepository.newObjectInserter - val mergeCommitId = inserter.insert(mergeCommit) - inserter.flush() - inserter.release() - - // update refs - val refUpdate = git.getRepository.updateRef(mergeBaseRefName) - refUpdate.setNewObjectId(mergeCommitId) - refUpdate.setForceUpdate(false) - refUpdate.setRefLogIdent(personIdent) - refUpdate.setRefLogMessage("merged", true) - refUpdate.update() + // merge git repository + mergePullRequest(git, pullreq.branch, issueId, + s"Merge pull request #${issueId} from ${pullreq.requestUserName}/${pullreq.requestBranch}\n\n" + form.message, + new PersonIdent(loginAccount.fullName, loginAccount.mailAddress)) val (commits, _) = getRequestCompareInfo(owner, name, pullreq.commitIdFrom, pullreq.requestUserName, pullreq.requestRepositoryName, pullreq.commitIdTo) @@ -194,17 +233,10 @@ closeIssuesFromMessage(form.message, loginAccount.userName, owner, name) } // call web hook - getWebHookURLs(owner, name) match { - case webHookURLs if(webHookURLs.nonEmpty) => - for(ownerAccount <- getAccountByUserName(owner)){ - callWebHook(owner, name, webHookURLs, - WebHookPayload(git, loginAccount, mergeBaseRefName, repository, commits.flatten.toList, ownerAccount)) - } - case _ => - } + callPullRequestWebHook("closed", repository, issueId, context.baseUrl, context.loginAccount.get) // notifications - Notifier().toNotify(repository, issueId, "merge"){ + Notifier().toNotify(repository, issue, "merge"){ Notifier.msgStatus(s"${context.baseUrl}/${owner}/${name}/pull/${issueId}") } @@ -216,6 +248,7 @@ }) get("/:owner/:repository/compare")(referrersOnly { forkedRepository => + val headBranch:Option[String] = params.get("head") (forkedRepository.repository.originUserName, forkedRepository.repository.originRepositoryName) match { case (Some(originUserName), Some(originRepositoryName)) => { getRepository(originUserName, originRepositoryName, context.baseUrl).map { originRepository => @@ -223,8 +256,8 @@ Git.open(getRepositoryDir(originUserName, originRepositoryName)), Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name)) ){ (oldGit, newGit) => - val oldBranch = JGitUtil.getDefaultBranch(oldGit, originRepository).get._2 - val newBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2 + val newBranch = headBranch.getOrElse(JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2) + val oldBranch = originRepository.branchList.find( _ == newBranch).getOrElse(JGitUtil.getDefaultBranch(oldGit, originRepository).get._2) redirect(s"/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}") } @@ -233,7 +266,7 @@ case _ => { using(Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))){ git => JGitUtil.getDefaultBranch(git, forkedRepository).map { case (_, defaultBranch) => - redirect(s"/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}") + redirect(s"/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${headBranch.getOrElse(defaultBranch)}") } getOrElse { redirect(s"/${forkedRepository.owner}/${forkedRepository.name}") } @@ -319,10 +352,11 @@ ){ case (oldGit, newGit) => val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 - - html.mergecheck( + val conflict = LockUtil.lock(s"${originRepository.owner}/${originRepository.name}"){ checkConflict(originRepository.owner, originRepository.name, originBranch, - forkedRepository.owner, forkedRepository.name, forkedBranch)) + forkedRepository.owner, forkedRepository.name, forkedBranch) + } + html.mergecheck(conflict) } }) getOrElse NotFound }) @@ -352,81 +386,25 @@ commitIdTo = form.commitIdTo) // fetch requested branch - using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => - git.fetch - .setRemote(getRepositoryDir(form.requestUserName, form.requestRepositoryName).toURI.toString) - .setRefSpecs(new RefSpec(s"refs/heads/${form.requestBranch}:refs/pull/${issueId}/head")) - .call - } + fetchAsPullRequest(repository.owner, repository.name, form.requestUserName, form.requestRepositoryName, form.requestBranch, issueId) // record activity recordPullRequestActivity(repository.owner, repository.name, loginUserName, issueId, form.title) + // call web hook + callPullRequestWebHook("opened", repository, issueId, context.baseUrl, context.loginAccount.get) + // notifications - Notifier().toNotify(repository, issueId, form.content.getOrElse("")){ - Notifier.msgPullRequest(s"${context.baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}") + getIssue(repository.owner, repository.name, issueId.toString) foreach { issue => + Notifier().toNotify(repository, issue, form.content.getOrElse("")){ + Notifier.msgPullRequest(s"${context.baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}") + } } redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}") }) /** - * 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 = { - LockUtil.lock(s"${userName}/${repositoryName}"){ - using(Git.open(getRepositoryDir(requestUserName, requestRepositoryName))) { git => - val remoteRefName = s"refs/heads/${branch}" - val tmpRefName = s"refs/merge-check/${userName}/${branch}" - val refSpec = new RefSpec(s"${remoteRefName}:${tmpRefName}").setForceUpdate(true) - try { - // fetch objects from origin repository branch - git.fetch - .setRemote(getRepositoryDir(userName, repositoryName).toURI.toString) - .setRefSpecs(refSpec) - .call - - // merge conflict check - val merger = MergeStrategy.RECURSIVE.newMerger(git.getRepository, true) - val mergeBaseTip = git.getRepository.resolve(s"refs/heads/${requestBranch}") - val mergeTip = git.getRepository.resolve(tmpRefName) - try { - !merger.merge(mergeBaseTip, mergeTip) - } catch { - case e: NoMergeBaseException => true - } - } finally { - val refUpdate = git.getRepository.updateRef(refSpec.getDestination) - refUpdate.setForceUpdate(true) - refUpdate.delete() - } - } - } - } - - /** - * Checks whether conflict will be caused in merging within pull request. Returns true if conflict will be caused. - */ - private def checkConflictInPullRequest(userName: String, repositoryName: String, branch: String, - requestUserName: String, requestRepositoryName: String, requestBranch: String, - issueId: Int): Boolean = { - LockUtil.lock(s"${userName}/${repositoryName}") { - using(Git.open(getRepositoryDir(userName, repositoryName))) { git => - // merge - val merger = MergeStrategy.RECURSIVE.newMerger(git.getRepository, true) - val mergeBaseTip = git.getRepository.resolve(s"refs/heads/${branch}") - val mergeTip = git.getRepository.resolve(s"refs/pull/${issueId}/head") - try { - !merger.merge(mergeBaseTip, mergeTip) - } catch { - case e: NoMergeBaseException => true - } - } - } - } - - /** * Parses branch identifier and extracts owner and branch name as tuple. * * - "owner:branch" to ("owner", "branch") @@ -484,5 +462,4 @@ repository, hasWritePermission(owner, repoName, context.loginAccount)) } - } diff --git a/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala b/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala index d6135df..5fd8ec0 100644 --- a/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala @@ -3,7 +3,7 @@ import gitbucket.core.settings.html import gitbucket.core.model.WebHook import gitbucket.core.service.{RepositoryService, AccountService, WebHookService} -import gitbucket.core.service.WebHookService.WebHookPayload +import gitbucket.core.service.WebHookService._ import gitbucket.core.util._ import gitbucket.core.util.JGitUtil._ import gitbucket.core.util.ControlUtil._ @@ -15,6 +15,7 @@ import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.Constants + class RepositorySettingsController extends RepositorySettingsControllerBase with RepositoryService with AccountService with WebHookService with OwnerAuthenticator with UsersAuthenticator @@ -162,15 +163,15 @@ post("/:owner/:repository/settings/hooks/test", webHookForm)(ownerOnly { (form, repository) => using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => import scala.collection.JavaConverters._ - val commits = git.log + val commits = if(repository.commitCount == 0) List.empty else git.log .add(git.getRepository.resolve(repository.repository.defaultBranch)) .setMaxCount(3) .call.iterator.asScala.map(new CommitInfo(_)) getAccountByUserName(repository.owner).foreach { ownerAccount => - callWebHook(repository.owner, repository.name, + callWebHook("push", List(WebHook(repository.owner, repository.name, form.url)), - WebHookPayload(git, ownerAccount, "refs/heads/" + repository.repository.defaultBranch, repository, commits.toList, ownerAccount) + WebHookPushPayload(git, ownerAccount, "refs/heads/" + repository.repository.defaultBranch, repository, commits.toList, ownerAccount) ) } flash += "url" -> form.url @@ -273,4 +274,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala index 629410a..5236123 100644 --- a/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositoryViewerController.scala @@ -1,31 +1,36 @@ package gitbucket.core.controller +import gitbucket.core.api._ import gitbucket.core.repo.html import gitbucket.core.helper import gitbucket.core.service._ import gitbucket.core.util._ import gitbucket.core.util.JGitUtil._ +import gitbucket.core.util.StringUtil._ import gitbucket.core.util.ControlUtil._ import gitbucket.core.util.Implicits._ import gitbucket.core.util.Directory._ -import gitbucket.core.model.Account -import gitbucket.core.service.WebHookService.WebHookPayload +import gitbucket.core.model.{Account, CommitState} +import gitbucket.core.service.CommitStatusService +import gitbucket.core.service.WebHookService._ import gitbucket.core.view import gitbucket.core.view.helpers -import org.scalatra._ +import jp.sf.amateras.scalatra.forms._ +import org.apache.commons.io.FileUtils import org.eclipse.jgit.api.{ArchiveCommand, Git} import org.eclipse.jgit.archive.{TgzFormat, ZipFormat} -import org.eclipse.jgit.lib._ -import org.apache.commons.io.FileUtils -import org.eclipse.jgit.treewalk._ -import jp.sf.amateras.scalatra.forms._ import org.eclipse.jgit.dircache.DirCache +import org.eclipse.jgit.lib._ import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.treewalk._ +import org.scalatra._ + class RepositoryViewerController extends RepositoryViewerControllerBase with RepositoryService with AccountService with ActivityService with IssuesService with WebHookService with CommitsService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService + with WebHookPullRequestService /** @@ -33,7 +38,8 @@ */ trait RepositoryViewerControllerBase extends ControllerBase { self: RepositoryService with AccountService with ActivityService with IssuesService with WebHookService with CommitsService - with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService => + with ReadableUsersAuthenticator with ReferrerAuthenticator with CollaboratorsAuthenticator with PullRequestService with CommitStatusService + with WebHookPullRequestService => ArchiveCommand.registerFormat("zip", new ZipFormat) ArchiveCommand.registerFormat("tar.gz", new TgzFormat) @@ -110,6 +116,13 @@ }) /** + * https://developer.github.com/v3/repos/#get + */ + get("/api/v3/repos/:owner/:repository")(referrersOnly { repository => + JsonFormat(ApiRepository(repository, ApiUser(getAccountByUserName(repository.owner).get))) + }) + + /** * Displays the file list of the specified path and branch. */ get("/:owner/:repository/tree/*")(referrersOnly { repository => @@ -140,6 +153,56 @@ } }) + /** + * https://developer.github.com/v3/repos/statuses/#create-a-status + */ + post("/api/v3/repos/:owner/:repo/statuses/:sha")(collaboratorsOnly { repository => + (for{ + ref <- params.get("sha") + sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref) + data <- extractFromJsonBody[CreateAStatus] if data.isValid + creator <- context.loginAccount + state <- CommitState.valueOf(data.state) + statusId = createCommitStatus(repository.owner, repository.name, sha, data.context.getOrElse("default"), + state, data.target_url, data.description, new java.util.Date(), creator) + status <- getCommitStatus(repository.owner, repository.name, statusId) + } yield { + JsonFormat(ApiCommitStatus(status, ApiUser(creator))) + }) getOrElse NotFound + }) + + /** + * https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref + * + * ref is Ref to list the statuses from. It can be a SHA, a branch name, or a tag name. + */ + get("/api/v3/repos/:owner/:repo/commits/:ref/statuses")(referrersOnly { repository => + (for{ + ref <- params.get("ref") + sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref) + } yield { + JsonFormat(getCommitStatuesWithCreator(repository.owner, repository.name, sha).map{ case(status, creator) => + ApiCommitStatus(status, ApiUser(creator)) + }) + }) getOrElse NotFound + }) + + /** + * https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref + * + * ref is Ref to list the statuses from. It can be a SHA, a branch name, or a tag name. + */ + get("/api/v3/repos/:owner/:repo/commits/:ref/status")(referrersOnly { repository => + (for{ + ref <- params.get("ref") + owner <- getAccountByUserName(repository.owner) + sha <- JGitUtil.getShaByRef(repository.owner, repository.name, ref) + } yield { + val statuses = getCommitStatuesWithCreator(repository.owner, repository.name, sha) + JsonFormat(ApiCombinedCommitStatus(sha, statuses, ApiRepository(repository, owner))) + }) getOrElse NotFound + }) + get("/:owner/:repository/new/*")(collaboratorsOnly { repository => val (branch, path) = splitPath(repository, multiParams("splat").head) html.editor(branch, repository, if(path.length == 0) Nil else path.split("/").toList, @@ -174,9 +237,16 @@ }) post("/:owner/:repository/create", editorForm)(collaboratorsOnly { (form, repository) => - commitFile(repository, form.branch, form.path, Some(form.newFileName), None, - StringUtil.convertLineSeparator(form.content, form.lineSeparator), form.charset, - form.message.getOrElse(s"Create ${form.newFileName}")) + commitFile( + repository = repository, + branch = form.branch, + path = form.path, + newFileName = Some(form.newFileName), + oldFileName = None, + content = appendNewLine(convertLineSeparator(form.content, form.lineSeparator), form.lineSeparator), + charset = form.charset, + message = form.message.getOrElse(s"Create ${form.newFileName}") + ) redirect(s"/${repository.owner}/${repository.name}/blob/${form.branch}/${ if(form.path.length == 0) form.newFileName else s"${form.path}/${form.newFileName}" @@ -184,13 +254,20 @@ }) post("/:owner/:repository/update", editorForm)(collaboratorsOnly { (form, repository) => - commitFile(repository, form.branch, form.path, Some(form.newFileName), form.oldFileName, - StringUtil.convertLineSeparator(form.content, form.lineSeparator), form.charset, - if(form.oldFileName.exists(_ == form.newFileName)){ + commitFile( + repository = repository, + branch = form.branch, + path = form.path, + newFileName = Some(form.newFileName), + oldFileName = form.oldFileName, + content = appendNewLine(convertLineSeparator(form.content, form.lineSeparator), form.lineSeparator), + charset = form.charset, + message = if(form.oldFileName.exists(_ == form.newFileName)){ form.message.getOrElse(s"Update ${form.newFileName}") } else { form.message.getOrElse(s"Rename ${form.oldFileName.get} to ${form.newFileName}") - }) + } + ) redirect(s"/${repository.owner}/${repository.name}/blob/${form.branch}/${ if(form.path.length == 0) form.newFileName else s"${form.path}/${form.newFileName}" @@ -213,16 +290,18 @@ using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id)) - val lastModifiedCommit = JGitUtil.getLastModifiedCommit(git, revCommit, path) getPathObjectId(git, path, revCommit).map { objectId => if(raw){ // Download - defining(JGitUtil.getContentFromId(git, objectId, false).get){ bytes => - RawData(FileUtil.getContentType(path, bytes), bytes) - } + JGitUtil.getContentFromId(git, objectId, true).map { bytes => + RawData("application/octet-stream", bytes) + } getOrElse NotFound } else { - html.blob(id, repository, path.split("/").toList, JGitUtil.getContentInfo(git, path, objectId), - new JGitUtil.CommitInfo(lastModifiedCommit), hasWritePermission(repository.owner, repository.name, context.loginAccount)) + html.blob(id, repository, path.split("/").toList, + JGitUtil.getContentInfo(git, path, objectId), + new JGitUtil.CommitInfo(JGitUtil.getLastModifiedCommit(git, revCommit, path)), + hasWritePermission(repository.owner, repository.name, context.loginAccount) + ) } } getOrElse NotFound } @@ -448,6 +527,7 @@ }, // groups of current user new JGitUtil.CommitInfo(lastModifiedCommit), // last modified commit files, readme, hasWritePermission(repository.owner, repository.name, context.loginAccount), + getPullRequestFromBranch(repository.owner, repository.name, revstr, repository.repository.defaultBranch), flash.get("info"), flash.get("error")) } } getOrElse NotFound @@ -507,14 +587,12 @@ closeIssuesFromMessage(message, loginAccount.userName, repository.owner, repository.name) // call web hook + callPullRequestWebHookByRequestBranch("synchronize", repository, branch, context.baseUrl, loginAccount) val commit = new JGitUtil.CommitInfo(JGitUtil.getRevCommitFromId(git, commitId)) - getWebHookURLs(repository.owner, repository.name) match { - case webHookURLs if(webHookURLs.nonEmpty) => - for(ownerAccount <- getAccountByUserName(repository.owner)){ - callWebHook(repository.owner, repository.name, webHookURLs, - WebHookPayload(git, loginAccount, headName, repository, List(commit), ownerAccount)) - } - case _ => + callWebHookOf(repository.owner, repository.name, "push") { + getAccountByUserName(repository.owner).map{ ownerAccount => + WebHookPushPayload(git, loginAccount, headName, repository, List(commit), ownerAccount) + } } } } diff --git a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala index 6c558ba..1c1271d 100644 --- a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala +++ b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala @@ -21,6 +21,7 @@ "isCreateRepoOptionPublic" -> trim(label("Default option to create a new repository", boolean())), "gravatar" -> trim(label("Gravatar", boolean())), "notification" -> trim(label("Notification", boolean())), + "activityLogLimit" -> trim(label("Limit of activity logs", optional(number()))), "ssh" -> trim(label("SSH access", boolean())), "sshPort" -> trim(label("SSH port", optional(number()))), "smtp" -> optionalIfNotChecked("notification", mapping( diff --git a/src/main/scala/gitbucket/core/controller/UserManagementController.scala b/src/main/scala/gitbucket/core/controller/UserManagementController.scala index 48dcac0..26c9f88 100644 --- a/src/main/scala/gitbucket/core/controller/UserManagementController.scala +++ b/src/main/scala/gitbucket/core/controller/UserManagementController.scala @@ -194,7 +194,7 @@ protected def disableByNotYourself(paramName: String): Constraint = new Constraint() { override def validate(name: String, value: String, messages: Messages): Option[String] = { params.get(paramName).flatMap { userName => - if(userName == context.loginAccount.get.userName) + if(userName == context.loginAccount.get.userName && params.get("removed") == Some("true")) Some("You can't disable your account yourself") else None diff --git a/src/main/scala/gitbucket/core/controller/WikiController.scala b/src/main/scala/gitbucket/core/controller/WikiController.scala index 2bdc7a0..7f876d0 100644 --- a/src/main/scala/gitbucket/core/controller/WikiController.scala +++ b/src/main/scala/gitbucket/core/controller/WikiController.scala @@ -3,6 +3,7 @@ import gitbucket.core.wiki.html import gitbucket.core.service.{RepositoryService, WikiService, ActivityService, AccountService} import gitbucket.core.util._ +import gitbucket.core.util.StringUtil._ import gitbucket.core.util.ControlUtil._ import gitbucket.core.util.Implicits._ import gitbucket.core.util.Directory._ @@ -110,8 +111,16 @@ post("/:owner/:repository/wiki/_edit", editForm)(collaboratorsOnly { (form, repository) => defining(context.loginAccount.get){ loginAccount => - saveWikiPage(repository.owner, repository.name, form.currentPageName, form.pageName, - form.content, loginAccount, form.message.getOrElse(""), Some(form.id)).map { commitId => + saveWikiPage( + repository.owner, + repository.name, + form.currentPageName, + form.pageName, + appendNewLine(convertLineSeparator(form.content, "LF"), "LF"), + loginAccount, + form.message.getOrElse(""), + Some(form.id) + ).map { commitId => updateLastActivityDate(repository.owner, repository.name) recordEditWikiPageActivity(repository.owner, repository.name, loginAccount.userName, form.pageName, commitId) } diff --git a/src/main/scala/gitbucket/core/model/AccessToken.scala b/src/main/scala/gitbucket/core/model/AccessToken.scala new file mode 100644 index 0000000..de29857 --- /dev/null +++ b/src/main/scala/gitbucket/core/model/AccessToken.scala @@ -0,0 +1,21 @@ +package gitbucket.core.model + + +trait AccessTokenComponent { self: Profile => + import profile.simple._ + lazy val AccessTokens = TableQuery[AccessTokens] + + class AccessTokens(tag: Tag) extends Table[AccessToken](tag, "ACCESS_TOKEN") { + val accessTokenId = column[Int]("ACCESS_TOKEN_ID", O AutoInc) + val userName = column[String]("USER_NAME") + val tokenHash = column[String]("TOKEN_HASH") + val note = column[String]("NOTE") + def * = (accessTokenId, userName, tokenHash, note) <> (AccessToken.tupled, AccessToken.unapply) + } +} +case class AccessToken( + accessTokenId: Int = 0, + userName: String, + tokenHash: String, + note: String +) diff --git a/src/main/scala/gitbucket/core/model/BasicTemplate.scala b/src/main/scala/gitbucket/core/model/BasicTemplate.scala index 687d7a4..db3e1b4 100644 --- a/src/main/scala/gitbucket/core/model/BasicTemplate.scala +++ b/src/main/scala/gitbucket/core/model/BasicTemplate.scala @@ -49,6 +49,9 @@ def byCommit(owner: String, repository: String, commitId: String) = byRepository(owner, repository) && (this.commitId === commitId) + + def byCommit(owner: Column[String], repository: Column[String], commitId: Column[String]) = + byRepository(userName, repositoryName) && (this.commitId === commitId) } } diff --git a/src/main/scala/gitbucket/core/model/CommitStatus.scala b/src/main/scala/gitbucket/core/model/CommitStatus.scala new file mode 100644 index 0000000..87b74f1 --- /dev/null +++ b/src/main/scala/gitbucket/core/model/CommitStatus.scala @@ -0,0 +1,83 @@ +package gitbucket.core.model + +import scala.slick.lifted.MappedTo +import scala.slick.jdbc._ + +trait CommitStatusComponent extends TemplateComponent { self: Profile => + import profile.simple._ + import self._ + + implicit val commitStateColumnType = MappedColumnType.base[CommitState, String](b => b.name , i => CommitState(i)) + + lazy val CommitStatuses = TableQuery[CommitStatuses] + class CommitStatuses(tag: Tag) extends Table[CommitStatus](tag, "COMMIT_STATUS") with CommitTemplate { + val commitStatusId = column[Int]("COMMIT_STATUS_ID", O AutoInc) + val context = column[String]("CONTEXT") + val state = column[CommitState]("STATE") + val targetUrl = column[Option[String]]("TARGET_URL") + val description = column[Option[String]]("DESCRIPTION") + val creator = column[String]("CREATOR") + val registeredDate = column[java.util.Date]("REGISTERED_DATE") + val updatedDate = column[java.util.Date]("UPDATED_DATE") + def * = (commitStatusId, userName, repositoryName, commitId, context, state, targetUrl, description, creator, registeredDate, updatedDate) <> (CommitStatus.tupled, CommitStatus.unapply) + def byPrimaryKey(id: Int) = commitStatusId === id.bind + } +} + + +case class CommitStatus( + commitStatusId: Int = 0, + userName: String, + repositoryName: String, + commitId: String, + context: String, + state: CommitState, + targetUrl: Option[String], + description: Option[String], + creator: String, + registeredDate: java.util.Date, + updatedDate: java.util.Date +) + + +sealed abstract class CommitState(val name: String) + + +object CommitState { + object ERROR extends CommitState("error") + + object FAILURE extends CommitState("failure") + + object PENDING extends CommitState("pending") + + object SUCCESS extends CommitState("success") + + val values: Vector[CommitState] = Vector(PENDING, SUCCESS, ERROR, FAILURE) + + private val map: Map[String, CommitState] = values.map(enum => enum.name -> enum).toMap + + def apply(name: String): CommitState = map(name) + + def valueOf(name: String): Option[CommitState] = map.get(name) + + /** + * failure if any of the contexts report as error or failure + * pending if there are no statuses or a context is pending + * success if the latest status for all contexts is success + */ + def combine(statuses: Set[CommitState]): CommitState = { + if(statuses.isEmpty){ + PENDING + } else if(statuses.contains(CommitState.ERROR) || statuses.contains(CommitState.FAILURE)) { + FAILURE + } else if(statuses.contains(CommitState.PENDING)) { + PENDING + } else { + SUCCESS + } + } + + implicit val getResult: GetResult[CommitState] = GetResult(r => CommitState(r.<<)) + implicit val getResultOpt: GetResult[Option[CommitState]] = GetResult(r => r.< try { - pluginInfo.pluginClass.shutdown(instance) + pluginInfo.pluginClass.shutdown(instance, context, settings) } catch { case e: Exception => { logger.error(s"Error during plugin shutdown", e) diff --git a/src/main/scala/gitbucket/core/service/AccesTokenService.scala b/src/main/scala/gitbucket/core/service/AccesTokenService.scala new file mode 100644 index 0000000..0a8109d --- /dev/null +++ b/src/main/scala/gitbucket/core/service/AccesTokenService.scala @@ -0,0 +1,55 @@ +package gitbucket.core.service + +import gitbucket.core.model.Profile._ +import profile.simple._ + +import gitbucket.core.model.{Account, AccessToken} +import gitbucket.core.util.StringUtil + +import scala.util.Random + + +trait AccessTokenService { + + def makeAccessTokenString: String = { + val bytes = new Array[Byte](20) + Random.nextBytes(bytes) + bytes.map("%02x".format(_)).mkString + } + + def tokenToHash(token: String): String = StringUtil.sha1(token) + + /** + * @retuen (TokenId, Token) + */ + def generateAccessToken(userName: String, note: String)(implicit s: Session): (Int, String) = { + var token: String = null + var hash: String = null + do{ + token = makeAccessTokenString + hash = tokenToHash(token) + }while(AccessTokens.filter(_.tokenHash === hash.bind).exists.run) + val newToken = AccessToken( + userName = userName, + note = note, + tokenHash = hash) + val tokenId = (AccessTokens returning AccessTokens.map(_.accessTokenId)) += newToken + (tokenId, token) + } + + def getAccountByAccessToken(token: String)(implicit s: Session): Option[Account] = + Accounts + .innerJoin(AccessTokens) + .filter{ case (ac, t) => (ac.userName === t.userName) && (t.tokenHash === tokenToHash(token).bind) && (ac.removed === false.bind) } + .map{ case (ac, t) => ac } + .firstOption + + def getAccessTokens(userName: String)(implicit s: Session): List[AccessToken] = + AccessTokens.filter(_.userName === userName.bind).sortBy(_.accessTokenId.desc).list + + def deleteAccessToken(userName: String, accessTokenId: Int)(implicit s: Session): Unit = + AccessTokens filter (t => t.userName === userName.bind && t.accessTokenId === accessTokenId) delete + +} + +object AccessTokenService extends AccessTokenService diff --git a/src/main/scala/gitbucket/core/service/AccountService.scala b/src/main/scala/gitbucket/core/service/AccountService.scala index 917ef96..dbdc3d9 100644 --- a/src/main/scala/gitbucket/core/service/AccountService.scala +++ b/src/main/scala/gitbucket/core/service/AccountService.scala @@ -77,6 +77,16 @@ def getAccountByUserName(userName: String, includeRemoved: Boolean = false)(implicit s: Session): Option[Account] = Accounts filter(t => (t.userName === userName.bind) && (t.removed === false.bind, !includeRemoved)) firstOption + def getAccountsByUserNames(userNames: Set[String], knowns:Set[Account], includeRemoved: Boolean = false)(implicit s: Session): Map[String, Account] = { + val map = knowns.map(a => a.userName -> a).toMap + val needs = userNames -- map.keySet + if(needs.isEmpty){ + map + }else{ + map ++ Accounts.filter(t => (t.userName inSetBind needs) && (t.removed === false.bind, !includeRemoved)).list.map(a => a.userName -> a).toMap + } + } + def getAccountByMailAddress(mailAddress: String, includeRemoved: Boolean = false)(implicit s: Session): Option[Account] = Accounts filter(t => (t.mailAddress.toLowerCase === mailAddress.toLowerCase.bind) && (t.removed === false.bind, !includeRemoved)) firstOption diff --git a/src/main/scala/gitbucket/core/service/ActivityService.scala b/src/main/scala/gitbucket/core/service/ActivityService.scala index be9205f..618d917 100644 --- a/src/main/scala/gitbucket/core/service/ActivityService.scala +++ b/src/main/scala/gitbucket/core/service/ActivityService.scala @@ -7,6 +7,12 @@ trait ActivityService { + def deleteOldActivities(limit: Int)(implicit s: Session): Int = { + Activities.map(_.activityId).sortBy(_ desc).drop(limit).firstOption.map { id => + Activities.filter(_.activityId <= id.bind).delete + } getOrElse 0 + } + def getActivitiesByUser(activityUserName: String, isPublic: Boolean)(implicit s: Session): List[Activity] = Activities .innerJoin(Repositories).on((t1, t2) => t1.byRepository(t2.userName, t2.repositoryName)) diff --git a/src/main/scala/gitbucket/core/service/CommitStatusService.scala b/src/main/scala/gitbucket/core/service/CommitStatusService.scala new file mode 100644 index 0000000..2ebea2b --- /dev/null +++ b/src/main/scala/gitbucket/core/service/CommitStatusService.scala @@ -0,0 +1,52 @@ +package gitbucket.core.service + +import gitbucket.core.model.Profile._ +import profile.simple._ + +import gitbucket.core.model.{CommitState, CommitStatus, Account} +import gitbucket.core.util.Implicits._ +import gitbucket.core.util.StringUtil._ +import gitbucket.core.service.RepositoryService.RepositoryInfo + + +trait CommitStatusService { + /** insert or update */ + def createCommitStatus(userName: String, repositoryName: String, sha:String, context:String, state:CommitState, targetUrl:Option[String], description:Option[String], now:java.util.Date, creator:Account)(implicit s: Session): Int = + CommitStatuses.filter(t => t.byCommit(userName, repositoryName, sha) && t.context===context.bind ) + .map(_.commitStatusId).firstOption match { + case Some(id:Int) => { + CommitStatuses.filter(_.byPrimaryKey(id)).map{ + t => (t.state , t.targetUrl , t.updatedDate , t.creator, t.description) + }.update( (state, targetUrl, now, creator.userName, description) ) + id + } + case None => (CommitStatuses returning CommitStatuses.map(_.commitStatusId)) += CommitStatus( + userName = userName, + repositoryName = repositoryName, + commitId = sha, + context = context, + state = state, + targetUrl = targetUrl, + description = description, + creator = creator.userName, + registeredDate = now, + updatedDate = now) + } + + def getCommitStatus(userName: String, repositoryName: String, id: Int)(implicit s: Session) :Option[CommitStatus] = + CommitStatuses.filter(t => t.byPrimaryKey(id) && t.byRepository(userName, repositoryName)).firstOption + + def getCommitStatus(userName: String, repositoryName: String, sha: String, context: String)(implicit s: Session) :Option[CommitStatus] = + CommitStatuses.filter(t => t.byCommit(userName, repositoryName, sha) && t.context===context.bind ).firstOption + + def getCommitStatues(userName: String, repositoryName: String, sha: String)(implicit s: Session) :List[CommitStatus] = + byCommitStatues(userName, repositoryName, sha).list + + def getCommitStatuesWithCreator(userName: String, repositoryName: String, sha: String)(implicit s: Session) :List[(CommitStatus, Account)] = + byCommitStatues(userName, repositoryName, sha).innerJoin(Accounts) + .filter{ case (t,a) => t.creator === a.userName }.list + + protected def byCommitStatues(userName: String, repositoryName: String, sha: String)(implicit s: Session) = + CommitStatuses.filter(t => t.byCommit(userName, repositoryName, sha) ).sortBy(_.updatedDate desc) + +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/service/IssuesService.scala b/src/main/scala/gitbucket/core/service/IssuesService.scala index 0d41287..251abbd 100644 --- a/src/main/scala/gitbucket/core/service/IssuesService.scala +++ b/src/main/scala/gitbucket/core/service/IssuesService.scala @@ -1,13 +1,15 @@ package gitbucket.core.service -import gitbucket.core.model._ +import gitbucket.core.model.Profile._ +import profile.simple._ + import gitbucket.core.util.StringUtil._ import gitbucket.core.util.Implicits._ +import gitbucket.core.model._ + import scala.slick.jdbc.{StaticQuery => Q} import Q.interpolation -import gitbucket.core.model.Profile._ -import profile.simple._ trait IssuesService { import IssuesService._ @@ -20,6 +22,13 @@ def getComments(owner: String, repository: String, issueId: Int)(implicit s: Session) = IssueComments filter (_.byIssue(owner, repository, issueId)) list + /** @return IssueComment and commentedUser */ + def getCommentsForApi(owner: String, repository: String, issueId: Int)(implicit s: Session): List[(IssueComment, Account)] = + IssueComments.filter(_.byIssue(owner, repository, issueId)) + .filter(_.action inSetBind Set("comment" , "close_comment", "reopen_comment")) + .innerJoin(Accounts).on( (t1, t2) => t1.commentedUserName === t2.userName ) + .list + def getComment(owner: String, repository: String, commentId: String)(implicit s: Session) = if (commentId forall (_.isDigit)) IssueComments filter { t => @@ -78,6 +87,47 @@ .toMap } + def getCommitStatues(issueList:Seq[(String, String, Int)])(implicit s: Session) :Map[(String, String, Int), CommitStatusInfo] ={ + if(issueList.isEmpty){ + Map.empty + }else{ + import scala.slick.jdbc._ + val issueIdQuery = issueList.map(i => "(PR.USER_NAME=? AND PR.REPOSITORY_NAME=? AND PR.ISSUE_ID=?)").mkString(" OR ") + implicit val qset = SetParameter[Seq[(String, String, Int)]] { + case (seq, pp) => + for (a <- seq) { + pp.setString(a._1) + pp.setString(a._2) + pp.setInt(a._3) + } + } + import gitbucket.core.model.Profile.commitStateColumnType + val query = Q.query[Seq[(String, String, Int)], (String, String, Int, Int, Int, Option[String], Option[CommitState], Option[String], Option[String])](s""" + SELECT SUMM.USER_NAME, SUMM.REPOSITORY_NAME, SUMM.ISSUE_ID, CS_ALL, CS_SUCCESS + , CSD.CONTEXT, CSD.STATE, CSD.TARGET_URL, CSD.DESCRIPTION + FROM (SELECT + PR.USER_NAME + , PR.REPOSITORY_NAME + , PR.ISSUE_ID + , COUNT(CS.STATE) AS CS_ALL + , SUM(CS.STATE='success') AS CS_SUCCESS + , PR.COMMIT_ID_TO AS COMMIT_ID + FROM PULL_REQUEST PR + JOIN COMMIT_STATUS CS + ON PR.USER_NAME=CS.USER_NAME + AND PR.REPOSITORY_NAME=CS.REPOSITORY_NAME + AND PR.COMMIT_ID_TO=CS.COMMIT_ID + WHERE $issueIdQuery + GROUP BY PR.USER_NAME, PR.REPOSITORY_NAME, PR.ISSUE_ID) as SUMM + LEFT OUTER JOIN COMMIT_STATUS CSD + ON SUMM.CS_ALL = 1 AND SUMM.COMMIT_ID = CSD.COMMIT_ID"""); + query(issueList).list.map{ + case(userName, repositoryName, issueId, count, successCount, context, state, targetUrl, description) => + (userName, repositoryName, issueId) -> CommitStatusInfo(count, successCount, context, state, targetUrl, description) + }.toMap + } + } + /** * Returns the search result against issues. * @@ -90,8 +140,53 @@ */ def searchIssue(condition: IssueSearchCondition, pullRequest: Boolean, offset: Int, limit: Int, repos: (String, String)*) (implicit s: Session): List[IssueInfo] = { - // get issues and comment count and labels + val result = searchIssueQueryBase(condition, pullRequest, offset, limit, repos) + .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) } + .leftJoin (Milestones) .on { case ((((t1, t2), t3), t4), t5) => t1.byMilestone(t5.userName, t5.repositoryName, t5.milestoneId) } + .map { case ((((t1, t2), t3), t4), t5) => + (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?, t5.title.?) + } + .list + .splitWith { (c1, c2) => + c1._1.userName == c2._1.userName && + c1._1.repositoryName == c2._1.repositoryName && + c1._1.issueId == c2._1.issueId + } + val status = getCommitStatues(result.map(_.head._1).map(is => (is.userName, is.repositoryName, is.issueId))) + + result.map { issues => issues.head match { + case (issue, commentCount, _, _, _, milestone) => + IssueInfo(issue, + issues.flatMap { t => t._3.map ( + Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get) + )} toList, + milestone, + commentCount, + status.get(issue.userName, issue.repositoryName, issue.issueId)) + }} toList + } + + /** for api + * @return (issue, issueUser, commentCount, pullRequest, headRepo, headOwner) + */ + def searchPullRequestByApi(condition: IssueSearchCondition, offset: Int, limit: Int, repos: (String, String)*) + (implicit s: Session): List[(Issue, Account, Int, PullRequest, Repository, Account)] = { + // get issues and comment count and labels + searchIssueQueryBase(condition, true, offset, limit, repos) + .innerJoin(PullRequests).on { case ((t1, t2), t3) => t3.byPrimaryKey(t1.userName, t1.repositoryName, t1.issueId) } + .innerJoin(Repositories).on { case (((t1, t2), t3), t4) => t4.byRepository(t1.userName, t1.repositoryName) } + .innerJoin(Accounts).on { case ((((t1, t2), t3), t4), t5) => t5.userName === t1.openedUserName } + .innerJoin(Accounts).on { case (((((t1, t2), t3), t4), t5), t6) => t6.userName === t4.userName } + .map { case (((((t1, t2), t3), t4), t5), t6) => + (t1, t5, t2.commentCount, t3, t4, t6) + } + .list + } + + private def searchIssueQueryBase(condition: IssueSearchCondition, pullRequest: Boolean, offset: Int, limit: Int, repos: Seq[(String, String)]) + (implicit s: Session) = searchIssueQuery(repos, condition, pullRequest) .innerJoin(IssueOutline).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } .sortBy { case (t1, t2) => @@ -107,28 +202,7 @@ } } .drop(offset).take(limit) - .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) } - .leftJoin (Milestones) .on { case ((((t1, t2), t3), t4), t5) => t1.byMilestone(t5.userName, t5.repositoryName, t5.milestoneId) } - .map { case ((((t1, t2), t3), t4), t5) => - (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?, t5.title.?) - } - .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, _, _, _, milestone) => - IssueInfo(issue, - issues.flatMap { t => t._3.map ( - Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get) - )} toList, - milestone, - commentCount) - }} toList - } + /** * Assembles query for conditional issue searching. @@ -462,6 +536,8 @@ } } - case class IssueInfo(issue: Issue, labels: List[Label], milestone: Option[String], commentCount: Int) + case class CommitStatusInfo(count: Int, successCount: Int, context: Option[String], state: Option[CommitState], targetUrl: Option[String], description: Option[String]) + + case class IssueInfo(issue: Issue, labels: List[Label], milestone: Option[String], commentCount: Int, status:Option[CommitStatusInfo]) } diff --git a/src/main/scala/gitbucket/core/service/MergeService.scala b/src/main/scala/gitbucket/core/service/MergeService.scala new file mode 100644 index 0000000..4b6cc3a --- /dev/null +++ b/src/main/scala/gitbucket/core/service/MergeService.scala @@ -0,0 +1,172 @@ +package gitbucket.core.service + +import gitbucket.core.model.Account +import gitbucket.core.util.LockUtil +import gitbucket.core.util.Directory._ +import gitbucket.core.util.Implicits._ +import gitbucket.core.util.ControlUtil._ + +import org.eclipse.jgit.merge.MergeStrategy +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.transport.RefSpec +import org.eclipse.jgit.errors.NoMergeBaseException +import org.eclipse.jgit.lib.{ObjectId, CommitBuilder, PersonIdent} +import org.eclipse.jgit.revwalk.RevWalk + + +trait MergeService { + import MergeService._ + /** + * Checks whether conflict will be caused in merging within pull request. + * Returns true if conflict will be caused. + */ + def checkConflict(userName: String, repositoryName: String, branch: String, issueId: Int): Boolean = { + using(Git.open(getRepositoryDir(userName, repositoryName))) { git => + MergeCacheInfo(git, branch, issueId).checkConflict() + } + } + /** + * Checks whether conflict will be caused in merging within pull request. + * only cache check. + * Returns Some(true) if conflict will be caused. + * Returns None if cache has not created yet. + */ + def checkConflictCache(userName: String, repositoryName: String, branch: String, issueId: Int): Option[Boolean] = { + using(Git.open(getRepositoryDir(userName, repositoryName))) { git => + MergeCacheInfo(git, branch, issueId).checkConflictCache() + } + } + /** merge pull request */ + def mergePullRequest(git:Git, branch: String, issueId: Int, message:String, committer:PersonIdent): Unit = { + MergeCacheInfo(git, branch, issueId).merge(message, committer) + } + /** fetch remote branch to my repository refs/pull/{issueId}/head */ + def fetchAsPullRequest(userName: String, repositoryName: String, requestUserName: String, requestRepositoryName: String, requestBranch:String, issueId:Int){ + using(Git.open(getRepositoryDir(userName, repositoryName))){ git => + git.fetch + .setRemote(getRepositoryDir(requestUserName, requestRepositoryName).toURI.toString) + .setRefSpecs(new RefSpec(s"refs/heads/${requestBranch}:refs/pull/${issueId}/head")) + .call + } + } + /** + * Checks whether conflict will be caused in merging. Returns true if conflict will be caused. + */ + def checkConflict(userName: String, repositoryName: String, branch: String, + requestUserName: String, requestRepositoryName: String, requestBranch: String): Boolean = { + using(Git.open(getRepositoryDir(requestUserName, requestRepositoryName))) { git => + val remoteRefName = s"refs/heads/${branch}" + val tmpRefName = s"refs/merge-check/${userName}/${branch}" + val refSpec = new RefSpec(s"${remoteRefName}:${tmpRefName}").setForceUpdate(true) + try { + // fetch objects from origin repository branch + git.fetch + .setRemote(getRepositoryDir(userName, repositoryName).toURI.toString) + .setRefSpecs(refSpec) + .call + // merge conflict check + val merger = MergeStrategy.RECURSIVE.newMerger(git.getRepository, true) + val mergeBaseTip = git.getRepository.resolve(s"refs/heads/${requestBranch}") + val mergeTip = git.getRepository.resolve(tmpRefName) + try { + !merger.merge(mergeBaseTip, mergeTip) + } catch { + case e: NoMergeBaseException => true + } + } finally { + val refUpdate = git.getRepository.updateRef(refSpec.getDestination) + refUpdate.setForceUpdate(true) + refUpdate.delete() + } + } + } +} +object MergeService{ + case class MergeCacheInfo(git:Git, branch:String, issueId:Int){ + val repository = git.getRepository + val mergedBranchName = s"refs/pull/${issueId}/merge" + val conflictedBranchName = s"refs/pull/${issueId}/conflict" + lazy val mergeBaseTip = repository.resolve(s"refs/heads/${branch}") + lazy val mergeTip = repository.resolve(s"refs/pull/${issueId}/head") + def checkConflictCache(): Option[Boolean] = { + Option(repository.resolve(mergedBranchName)).flatMap{ merged => + if(parseCommit( merged ).getParents().toSet == Set( mergeBaseTip, mergeTip )){ + // merged branch exists + Some(false) + }else{ + None + } + }.orElse(Option(repository.resolve(conflictedBranchName)).flatMap{ conflicted => + if(parseCommit( conflicted ).getParents().toSet == Set( mergeBaseTip, mergeTip )){ + // conflict branch exists + Some(true) + }else{ + None + } + }) + } + def checkConflict():Boolean ={ + checkConflictCache.getOrElse(checkConflictForce) + } + def checkConflictForce():Boolean ={ + val merger = MergeStrategy.RECURSIVE.newMerger(repository, true) + val conflicted = try { + !merger.merge(mergeBaseTip, mergeTip) + } catch { + case e: NoMergeBaseException => true + } + val mergeTipCommit = using(new RevWalk( repository ))(_.parseCommit( mergeTip )) + val committer = mergeTipCommit.getCommitterIdent; + def updateBranch(treeId:ObjectId, message:String, branchName:String){ + // creates merge commit + val mergeCommitId = createMergeCommit(treeId, committer, message) + // update refs + val refUpdate = repository.updateRef(branchName) + refUpdate.setNewObjectId(mergeCommitId) + refUpdate.setForceUpdate(true) + refUpdate.setRefLogIdent(committer) + refUpdate.update() + } + if(!conflicted){ + updateBranch(merger.getResultTreeId, s"Merge ${mergeTip.name} into ${mergeBaseTip.name}", mergedBranchName) + git.branchDelete().setForce(true).setBranchNames(conflictedBranchName).call() + }else{ + updateBranch(mergeTipCommit.getTree().getId(), s"can't merge ${mergeTip.name} into ${mergeBaseTip.name}", conflictedBranchName) + git.branchDelete().setForce(true).setBranchNames(mergedBranchName).call() + } + conflicted + } + // update branch from cache + def merge(message:String, committer:PersonIdent) = { + if(checkConflict()){ + throw new RuntimeException("This pull request can't merge automatically.") + } + val mergeResultCommit = parseCommit( Option(repository.resolve(mergedBranchName)).getOrElse(throw new RuntimeException(s"not found branch ${mergedBranchName}")) ) + // creates merge commit + val mergeCommitId = createMergeCommit(mergeResultCommit.getTree().getId(), committer, message) + // update refs + val refUpdate = repository.updateRef(s"refs/heads/${branch}") + refUpdate.setNewObjectId(mergeCommitId) + refUpdate.setForceUpdate(false) + refUpdate.setRefLogIdent(committer) + refUpdate.setRefLogMessage("merged", true) + refUpdate.update() + } + // return treeId + private def createMergeCommit(treeId:ObjectId, committer:PersonIdent, message:String) = { + val mergeCommit = new CommitBuilder() + mergeCommit.setTreeId(treeId) + mergeCommit.setParentIds(Array[ObjectId](mergeBaseTip, mergeTip): _*) + mergeCommit.setAuthor(committer) + mergeCommit.setCommitter(committer) + mergeCommit.setMessage(message) + // insertObject and got mergeCommit Object Id + val inserter = repository.newObjectInserter + val mergeCommitId = inserter.insert(mergeCommit) + inserter.flush() + inserter.release() + mergeCommitId + } + private def parseCommit(id:ObjectId) = using(new RevWalk( repository ))(_.parseCommit(id)) + } +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/service/PullRequestService.scala b/src/main/scala/gitbucket/core/service/PullRequestService.scala index 456980a..67db3c8 100644 --- a/src/main/scala/gitbucket/core/service/PullRequestService.scala +++ b/src/main/scala/gitbucket/core/service/PullRequestService.scala @@ -1,10 +1,11 @@ package gitbucket.core.service -import gitbucket.core.model.{Issue, PullRequest} +import gitbucket.core.model.{Account, Issue, PullRequest, WebHook} import gitbucket.core.model.Profile._ import gitbucket.core.util.JGitUtil import profile.simple._ + trait PullRequestService { self: IssuesService => import PullRequestService._ @@ -83,6 +84,28 @@ .list /** + * for repository viewer. + * 1. find pull request from from `branch` to othre branch on same repository + * 1. return if exists pull request to `defaultBranch` + * 2. return if exists pull request to othre branch + * 2. return None + */ + def getPullRequestFromBranch(userName: String, repositoryName: String, branch: String, defaultBranch: String) + (implicit s: Session): Option[(PullRequest, Issue)] = + PullRequests + .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) } + .filter { case (t1, t2) => + (t1.requestUserName === userName.bind) && + (t1.requestRepositoryName === repositoryName.bind) && + (t1.requestBranch === branch.bind) && + (t1.userName === userName.bind) && + (t1.repositoryName === repositoryName.bind) && + (t2.closed === false.bind) + } + .sortBy{ case (t1, t2) => t1.branch =!= defaultBranch.bind } + .firstOption + + /** * Fetch pull request contents into refs/pull/${issueId}/head and update pull request table. */ def updatePullRequests(owner: String, repository: String, branch: String)(implicit s: Session): Unit = diff --git a/src/main/scala/gitbucket/core/service/RepositoryService.scala b/src/main/scala/gitbucket/core/service/RepositoryService.scala index ef99da1..9473722 100644 --- a/src/main/scala/gitbucket/core/service/RepositoryService.scala +++ b/src/main/scala/gitbucket/core/service/RepositoryService.scala @@ -55,6 +55,7 @@ val issueComments = IssueComments .filter(_.byRepository(oldUserName, oldRepositoryName)).list val issueLabels = IssueLabels .filter(_.byRepository(oldUserName, oldRepositoryName)).list val commitComments = CommitComments.filter(_.byRepository(oldUserName, oldRepositoryName)).list + val commitStatuses = CommitStatuses.filter(_.byRepository(oldUserName, oldRepositoryName)).list val collaborators = Collaborators .filter(_.byRepository(oldUserName, oldRepositoryName)).list Repositories.filter { t => @@ -95,6 +96,7 @@ IssueComments .insertAll(issueComments .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) Labels .insertAll(labels .map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) CommitComments.insertAll(commitComments.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) + CommitStatuses.insertAll(commitStatuses.map(_.copy(userName = newUserName, repositoryName = newRepositoryName)) :_*) // Convert labelId val oldLabelMap = labels.map(x => (x.labelId, x.labelName)).toMap @@ -395,5 +397,4 @@ } case class RepositoryTreeNode(owner: String, name: String, children: List[RepositoryTreeNode]) - } diff --git a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala index 6a3b8d3..af4a6ef 100644 --- a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala +++ b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala @@ -19,6 +19,7 @@ props.setProperty(IsCreateRepoOptionPublic, settings.isCreateRepoOptionPublic.toString) props.setProperty(Gravatar, settings.gravatar.toString) props.setProperty(Notification, settings.notification.toString) + settings.activityLogLimit.foreach(x => props.setProperty(ActivityLogLimit, x.toString)) props.setProperty(Ssh, settings.ssh.toString) settings.sshPort.foreach(x => props.setProperty(SshPort, x.toString)) if(settings.notification) { @@ -65,12 +66,13 @@ } SystemSettings( getOptionValue[String](props, BaseURL, None).map(x => x.replaceFirst("/\\Z", "")), - getOptionValue[String](props, Information, None), + getOptionValue(props, Information, None), getValue(props, AllowAccountRegistration, false), getValue(props, AllowAnonymousAccess, true), getValue(props, IsCreateRepoOptionPublic, true), getValue(props, Gravatar, true), getValue(props, Notification, false), + getOptionValue[Int](props, ActivityLogLimit, None), getValue(props, Ssh, false), getOptionValue(props, SshPort, Some(DefaultSshPort)), if(getValue(props, Notification, false)){ @@ -120,6 +122,7 @@ isCreateRepoOptionPublic: Boolean, gravatar: Boolean, notification: Boolean, + activityLogLimit: Option[Int], ssh: Boolean, sshPort: Option[Int], smtp: Option[Smtp], @@ -166,6 +169,7 @@ private val IsCreateRepoOptionPublic = "is_create_repository_option_public" private val Gravatar = "gravatar" private val Notification = "notification" + private val ActivityLogLimit = "activity_log_limit" private val Ssh = "ssh" private val SshPort = "ssh.port" private val SmtpHost = "smtp.host" diff --git a/src/main/scala/gitbucket/core/service/WebHookService.scala b/src/main/scala/gitbucket/core/service/WebHookService.scala index f4d8d4c..04d4d50 100644 --- a/src/main/scala/gitbucket/core/service/WebHookService.scala +++ b/src/main/scala/gitbucket/core/service/WebHookService.scala @@ -1,18 +1,19 @@ package gitbucket.core.service -import gitbucket.core.model.{WebHook, Account} +import gitbucket.core.api._ +import gitbucket.core.model.{WebHook, Account, Issue, PullRequest, IssueComment} import gitbucket.core.model.Profile._ -import gitbucket.core.service.RepositoryService.RepositoryInfo -import gitbucket.core.util.JGitUtil import profile.simple._ -import org.slf4j.LoggerFactory -import RepositoryService.RepositoryInfo -import org.eclipse.jgit.diff.DiffEntry -import JGitUtil.CommitInfo -import org.eclipse.jgit.api.Git -import org.apache.http.message.BasicNameValuePair -import org.apache.http.client.entity.UrlEncodedFormEntity +import gitbucket.core.util.JGitUtil.CommitInfo +import gitbucket.core.util.RepositoryName +import gitbucket.core.service.RepositoryService.RepositoryInfo + import org.apache.http.NameValuePair +import org.apache.http.client.entity.UrlEncodedFormEntity +import org.apache.http.message.BasicNameValuePair +import org.eclipse.jgit.api.Git +import org.slf4j.LoggerFactory + trait WebHookService { import WebHookService._ @@ -28,26 +29,29 @@ def deleteWebHookURL(owner: String, repository: String, url :String)(implicit s: Session): Unit = WebHooks.filter(_.byPrimaryKey(owner, repository, url)).delete - def callWebHook(owner: String, repository: String, webHookURLs: List[WebHook], payload: WebHookPayload): Unit = { - import org.json4s._ - import org.json4s.jackson.Serialization - import org.json4s.jackson.Serialization.{read, write} + def callWebHookOf(owner: String, repository: String, eventName: String)(makePayload: => Option[WebHookPayload])(implicit s: Session, c: JsonFormat.Context): Unit = { + val webHookURLs = getWebHookURLs(owner, repository) + if(webHookURLs.nonEmpty){ + makePayload.map(callWebHook(eventName, webHookURLs, _)) + } + } + + def callWebHook(eventName: String, webHookURLs: List[WebHook], payload: WebHookPayload)(implicit c: JsonFormat.Context): Unit = { import org.apache.http.client.methods.HttpPost import org.apache.http.impl.client.HttpClientBuilder import scala.concurrent._ import ExecutionContext.Implicits.global - logger.debug("start callWebHook") - implicit val formats = Serialization.formats(NoTypeHints) - if(webHookURLs.nonEmpty){ - val json = write(payload) + val json = JsonFormat(payload) val httpClient = HttpClientBuilder.create.build webHookURLs.foreach { webHookUrl => val f = Future { logger.debug(s"start web hook invocation for ${webHookUrl}") val httpPost = new HttpPost(webHookUrl.url) + httpPost.addHeader("Content-Type", "application/x-www-form-urlencoded") + httpPost.addHeader("X-Github-Event", eventName) val params: java.util.List[NameValuePair] = new java.util.ArrayList() params.add(new BasicNameValuePair("payload", json)) @@ -67,78 +71,209 @@ } logger.debug("end callWebHook") } +} + +trait WebHookPullRequestService extends WebHookService { + self: AccountService with RepositoryService with PullRequestService with IssuesService => + + import WebHookService._ + // https://developer.github.com/v3/activity/events/types/#issuesevent + def callIssuesWebHook(action: String, repository: RepositoryService.RepositoryInfo, issue: Issue, baseUrl: String, sender: Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + callWebHookOf(repository.owner, repository.name, "issues"){ + val users = getAccountsByUserNames(Set(repository.owner, issue.openedUserName), Set(sender)) + for{ + repoOwner <- users.get(repository.owner) + issueUser <- users.get(issue.openedUserName) + } yield { + WebHookIssuesPayload( + action = action, + number = issue.issueId, + repository = ApiRepository(repository, ApiUser(repoOwner)), + issue = ApiIssue(issue, RepositoryName(repository), ApiUser(issueUser)), + sender = ApiUser(sender)) + } + } + } + + def callPullRequestWebHook(action: String, repository: RepositoryService.RepositoryInfo, issueId: Int, baseUrl: String, sender: Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + import WebHookService._ + callWebHookOf(repository.owner, repository.name, "pull_request"){ + for{ + (issue, pullRequest) <- getPullRequest(repository.owner, repository.name, issueId) + users = getAccountsByUserNames(Set(repository.owner, pullRequest.requestUserName, issue.openedUserName), Set(sender)) + baseOwner <- users.get(repository.owner) + headOwner <- users.get(pullRequest.requestUserName) + issueUser <- users.get(issue.openedUserName) + headRepo <- getRepository(pullRequest.requestUserName, pullRequest.requestRepositoryName, baseUrl) + } yield { + WebHookPullRequestPayload( + action = action, + issue = issue, + issueUser = issueUser, + pullRequest = pullRequest, + headRepository = headRepo, + headOwner = headOwner, + baseRepository = repository, + baseOwner = baseOwner, + sender = sender) + } + } + } + + /** @return Map[(issue, issueUser, pullRequest, baseOwner, headOwner), webHooks] */ + def getPullRequestsByRequestForWebhook(userName:String, repositoryName:String, branch:String) + (implicit s: Session): Map[(Issue, Account, PullRequest, Account, Account), List[WebHook]] = + (for{ + is <- Issues if is.closed === false.bind + pr <- PullRequests if pr.byPrimaryKey(is.userName, is.repositoryName, is.issueId) + if pr.requestUserName === userName.bind + if pr.requestRepositoryName === repositoryName.bind + if pr.requestBranch === branch.bind + bu <- Accounts if bu.userName === pr.userName + ru <- Accounts if ru.userName === pr.requestUserName + iu <- Accounts if iu.userName === is.openedUserName + wh <- WebHooks if wh.byRepository(is.userName , is.repositoryName) + } yield { + ((is, iu, pr, bu, ru), wh) + }).list.groupBy(_._1).mapValues(_.map(_._2)) + + def callPullRequestWebHookByRequestBranch(action: String, requestRepository: RepositoryService.RepositoryInfo, requestBranch: String, baseUrl: String, sender: Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + import WebHookService._ + for{ + ((issue, issueUser, pullRequest, baseOwner, headOwner), webHooks) <- getPullRequestsByRequestForWebhook(requestRepository.owner, requestRepository.name, requestBranch) + baseRepo <- getRepository(pullRequest.userName, pullRequest.repositoryName, baseUrl) + } yield { + val payload = WebHookPullRequestPayload( + action = action, + issue = issue, + issueUser = issueUser, + pullRequest = pullRequest, + headRepository = requestRepository, + headOwner = headOwner, + baseRepository = baseRepo, + baseOwner = baseOwner, + sender = sender) + callWebHook("pull_request", webHooks, payload) + } + } +} + +trait WebHookIssueCommentService extends WebHookPullRequestService { + self: AccountService with RepositoryService with PullRequestService with IssuesService => + + import WebHookService._ + def callIssueCommentWebHook(repository: RepositoryService.RepositoryInfo, issue: Issue, issueCommentId: Int, sender: Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + callWebHookOf(repository.owner, repository.name, "issue_comment"){ + for{ + issueComment <- getComment(repository.owner, repository.name, issueCommentId.toString()) + users = getAccountsByUserNames(Set(issue.openedUserName, repository.owner, issueComment.commentedUserName), Set(sender)) + issueUser <- users.get(issue.openedUserName) + repoOwner <- users.get(repository.owner) + commenter <- users.get(issueComment.commentedUserName) + } yield { + WebHookIssueCommentPayload( + issue = issue, + issueUser = issueUser, + comment = issueComment, + commentUser = commenter, + repository = repository, + repositoryUser = repoOwner, + sender = sender) + } + } + } } object WebHookService { + trait WebHookPayload - case class WebHookPayload( - pusher: WebHookUser, + // https://developer.github.com/v3/activity/events/types/#pushevent + case class WebHookPushPayload( + pusher: ApiUser, ref: String, - commits: List[WebHookCommit], - repository: WebHookRepository) + commits: List[ApiCommit], + repository: ApiRepository + ) extends WebHookPayload - object WebHookPayload { + object WebHookPushPayload { def apply(git: Git, pusher: Account, refName: String, repositoryInfo: RepositoryInfo, - commits: List[CommitInfo], repositoryOwner: Account): WebHookPayload = - WebHookPayload( - WebHookUser(pusher.fullName, pusher.mailAddress), + commits: List[CommitInfo], repositoryOwner: Account): WebHookPushPayload = + WebHookPushPayload( + ApiUser(pusher), refName, - commits.map { commit => - val diffs = JGitUtil.getDiffs(git, commit.id, false) - val commitUrl = repositoryInfo.httpUrl.replaceFirst("/git/", "/").stripSuffix(".git") + "/commit/" + commit.id - - WebHookCommit( - id = commit.id, - message = commit.fullMessage, - timestamp = commit.commitTime.toString, - url = commitUrl, - added = diffs._1.collect { case x if(x.changeType == DiffEntry.ChangeType.ADD) => x.newPath }, - removed = diffs._1.collect { case x if(x.changeType == DiffEntry.ChangeType.DELETE) => x.oldPath }, - modified = diffs._1.collect { case x if(x.changeType != DiffEntry.ChangeType.ADD && - x.changeType != DiffEntry.ChangeType.DELETE) => x.newPath }, - author = WebHookUser( - name = commit.committerName, - email = commit.committerEmailAddress - ) - ) - }, - WebHookRepository( - name = repositoryInfo.name, - url = repositoryInfo.httpUrl, - description = repositoryInfo.repository.description.getOrElse(""), - watchers = 0, - forks = repositoryInfo.forkedCount, - `private` = repositoryInfo.repository.isPrivate, - owner = WebHookUser( - name = repositoryOwner.userName, - email = repositoryOwner.mailAddress - ) + commits.map{ commit => ApiCommit(git, RepositoryName(repositoryInfo), commit) }, + ApiRepository( + repositoryInfo, + owner= ApiUser(repositoryOwner) ) ) } - case class WebHookCommit( - id: String, - message: String, - timestamp: String, - url: String, - added: List[String], - removed: List[String], - modified: List[String], - author: WebHookUser) + // https://developer.github.com/v3/activity/events/types/#issuesevent + case class WebHookIssuesPayload( + action: String, + number: Int, + repository: ApiRepository, + issue: ApiIssue, + sender: ApiUser) extends WebHookPayload - case class WebHookRepository( - name: String, - url: String, - description: String, - watchers: Int, - forks: Int, - `private`: Boolean, - owner: WebHookUser) + // https://developer.github.com/v3/activity/events/types/#pullrequestevent + case class WebHookPullRequestPayload( + action: String, + number: Int, + repository: ApiRepository, + pull_request: ApiPullRequest, + sender: ApiUser + ) extends WebHookPayload - case class WebHookUser( - name: String, - email: String) + object WebHookPullRequestPayload{ + def apply(action: String, + issue: Issue, + issueUser: Account, + pullRequest: PullRequest, + headRepository: RepositoryInfo, + headOwner: Account, + baseRepository: RepositoryInfo, + baseOwner: Account, + sender: Account): WebHookPullRequestPayload = { + val headRepoPayload = ApiRepository(headRepository, headOwner) + val baseRepoPayload = ApiRepository(baseRepository, baseOwner) + val senderPayload = ApiUser(sender) + val pr = ApiPullRequest(issue, pullRequest, headRepoPayload, baseRepoPayload, ApiUser(issueUser)) + WebHookPullRequestPayload( + action = action, + number = issue.issueId, + repository = pr.base.repo, + pull_request = pr, + sender = senderPayload + ) + } + } + // https://developer.github.com/v3/activity/events/types/#issuecommentevent + case class WebHookIssueCommentPayload( + action: String, + repository: ApiRepository, + issue: ApiIssue, + comment: ApiComment, + sender: ApiUser + ) extends WebHookPayload + + object WebHookIssueCommentPayload{ + def apply( + issue: Issue, + issueUser: Account, + comment: IssueComment, + commentUser: Account, + repository: RepositoryInfo, + repositoryUser: Account, + sender: Account): WebHookIssueCommentPayload = + WebHookIssueCommentPayload( + action = "created", + repository = ApiRepository(repository, repositoryUser), + issue = ApiIssue(issue, RepositoryName(repository), ApiUser(issueUser)), + comment = ApiComment(comment, RepositoryName(repository), issue.issueId, ApiUser(commentUser)), + sender = ApiUser(sender)) + } } diff --git a/src/main/scala/gitbucket/core/service/WikiService.scala b/src/main/scala/gitbucket/core/service/WikiService.scala index f317c51..4a8d1eb 100644 --- a/src/main/scala/gitbucket/core/service/WikiService.scala +++ b/src/main/scala/gitbucket/core/service/WikiService.scala @@ -202,7 +202,7 @@ } /** - * Save the wiki page. + * Save the wiki page and return the commit id. */ def saveWikiPage(owner: String, repository: String, currentPageName: String, newPageName: String, content: String, committer: Account, message: String, currentId: Option[String]): Option[String] = { diff --git a/src/main/scala/gitbucket/core/servlet/AccessTokenAuthenticationFilter.scala b/src/main/scala/gitbucket/core/servlet/AccessTokenAuthenticationFilter.scala new file mode 100644 index 0000000..7cc3754 --- /dev/null +++ b/src/main/scala/gitbucket/core/servlet/AccessTokenAuthenticationFilter.scala @@ -0,0 +1,43 @@ +package gitbucket.core.servlet + +import javax.servlet._ +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} + +import gitbucket.core.model.Account +import gitbucket.core.service.AccessTokenService +import gitbucket.core.util.Keys + +import org.scalatra.servlet.ServletApiImplicits._ +import org.scalatra._ + + +class AccessTokenAuthenticationFilter extends Filter with AccessTokenService { + private val tokenHeaderPrefix = "token " + + override def init(filterConfig: FilterConfig): Unit = {} + + override def destroy(): Unit = {} + + override def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = { + implicit val request = req.asInstanceOf[HttpServletRequest] + implicit val session = req.getAttribute(Keys.Request.DBSession).asInstanceOf[slick.jdbc.JdbcBackend#Session] + val response = res.asInstanceOf[HttpServletResponse] + Option(request.getHeader("Authorization")).map{ + case auth if auth.startsWith("token ") => AccessTokenService.getAccountByAccessToken(auth.substring(6).trim).toRight(Unit) + // TODO Basic Authentication Support + case _ => Left(Unit) + }.orElse{ + Option(request.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account]).map(Right(_)) + } match { + case Some(Right(account)) => request.setAttribute(Keys.Session.LoginAccount, account); chain.doFilter(req, res) + case None => chain.doFilter(req, res) + case Some(Left(_)) => { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED) + response.setContentType("Content-Type: application/json; charset=utf-8") + val w = response.getWriter() + w.print("""{ "message": "Bad credentials" }""") + w.close() + } + } + } +} diff --git a/src/main/scala/gitbucket/core/servlet/AutoUpdate.scala b/src/main/scala/gitbucket/core/servlet/AutoUpdate.scala new file mode 100644 index 0000000..e60ca30 --- /dev/null +++ b/src/main/scala/gitbucket/core/servlet/AutoUpdate.scala @@ -0,0 +1,167 @@ +package gitbucket.core.servlet + +import java.io.File +import java.sql.{DriverManager, Connection} +import gitbucket.core.plugin.PluginRegistry +import gitbucket.core.service.SystemSettingsService +import gitbucket.core.util._ +import org.apache.commons.io.FileUtils +import javax.servlet.{ServletContextListener, ServletContextEvent} +import org.slf4j.LoggerFactory +import Directory._ +import ControlUtil._ +import JDBCUtil._ +import org.eclipse.jgit.api.Git +import gitbucket.core.util.Versions +import gitbucket.core.util.Directory + +object AutoUpdate { + + /** + * The history of versions. A head of this sequence is the current BitBucket version. + */ + val versions = Seq( + new Version(3, 2), + new Version(3, 1), + new Version(3, 0), + new Version(2, 8), + new Version(2, 7) { + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) + conn.select("SELECT * FROM REPOSITORY"){ rs => + // Rename attached files directory from /issues to /comments + val userName = rs.getString("USER_NAME") + val repoName = rs.getString("REPOSITORY_NAME") + defining(Directory.getAttachedDir(userName, repoName)){ newDir => + val oldDir = new File(newDir.getParentFile, "issues") + if(oldDir.exists && oldDir.isDirectory){ + oldDir.renameTo(newDir) + } + } + // Update ORIGIN_USER_NAME and ORIGIN_REPOSITORY_NAME if it does not exist + val originalUserName = rs.getString("ORIGIN_USER_NAME") + val originalRepoName = rs.getString("ORIGIN_REPOSITORY_NAME") + if(originalUserName != null && originalRepoName != null){ + if(conn.selectInt("SELECT COUNT(*) FROM REPOSITORY WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", + originalUserName, originalRepoName) == 0){ + conn.update("UPDATE REPOSITORY SET ORIGIN_USER_NAME = NULL, ORIGIN_REPOSITORY_NAME = NULL " + + "WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", userName, repoName) + } + } + // Update PARENT_USER_NAME and PARENT_REPOSITORY_NAME if it does not exist + val parentUserName = rs.getString("PARENT_USER_NAME") + val parentRepoName = rs.getString("PARENT_REPOSITORY_NAME") + if(parentUserName != null && parentRepoName != null){ + if(conn.selectInt("SELECT COUNT(*) FROM REPOSITORY WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", + parentUserName, parentRepoName) == 0){ + conn.update("UPDATE REPOSITORY SET PARENT_USER_NAME = NULL, PARENT_REPOSITORY_NAME = NULL " + + "WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", userName, repoName) + } + } + } + } + }, + new Version(2, 6), + new Version(2, 5), + new Version(2, 4), + new Version(2, 3) { + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) + conn.select("SELECT ACTIVITY_ID, ADDITIONAL_INFO FROM ACTIVITY WHERE ACTIVITY_TYPE='push'"){ rs => + val curInfo = rs.getString("ADDITIONAL_INFO") + val newInfo = curInfo.split("\n").filter(_ matches "^[0-9a-z]{40}:.*").mkString("\n") + if (curInfo != newInfo) { + conn.update("UPDATE ACTIVITY SET ADDITIONAL_INFO = ? WHERE ACTIVITY_ID = ?", newInfo, rs.getInt("ACTIVITY_ID")) + } + } + ignore { + FileUtils.deleteDirectory(Directory.getPluginCacheDir()) + //FileUtils.deleteDirectory(new File(Directory.PluginHome)) + } + } + }, + new Version(2, 2), + new Version(2, 1), + new Version(2, 0){ + override def update(conn: Connection, cl: ClassLoader): Unit = { + import eu.medsea.mimeutil.{MimeUtil2, MimeType} + + val mimeUtil = new MimeUtil2() + mimeUtil.registerMimeDetector("eu.medsea.mimeutil.detector.MagicMimeMimeDetector") + + super.update(conn, cl) + conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => + defining(Directory.getAttachedDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME"))){ dir => + if(dir.exists && dir.isDirectory){ + dir.listFiles.foreach { file => + if(file.getName.indexOf('.') < 0){ + val mimeType = MimeUtil2.getMostSpecificMimeType(mimeUtil.getMimeTypes(file, new MimeType("application/octet-stream"))).toString + if(mimeType.startsWith("image/")){ + file.renameTo(new File(file.getParent, file.getName + "." + mimeType.split("/")(1))) + } + } + } + } + } + } + } + }, + Version(1, 13), + Version(1, 12), + Version(1, 11), + Version(1, 10), + Version(1, 9), + Version(1, 8), + Version(1, 7), + Version(1, 6), + Version(1, 5), + Version(1, 4), + new Version(1, 3){ + override def update(conn: Connection, cl: ClassLoader): Unit = { + super.update(conn, cl) + // Fix wiki repository configuration + conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => + using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => + defining(git.getRepository.getConfig){ config => + if(!config.getBoolean("http", "receivepack", false)){ + config.setBoolean("http", null, "receivepack", true) + config.save + } + } + } + } + } + }, + Version(1, 2), + Version(1, 1), + Version(1, 0), + Version(0, 0) + ) + + /** + * The head version of BitBucket. + */ + val headVersion = versions.head + + /** + * The version file (GITBUCKET_HOME/version). + */ + lazy val versionFile = new File(GitBucketHome, "version") + + /** + * Returns the current version from the version file. + */ + def getCurrentVersion(): Version = { + if(versionFile.exists){ + FileUtils.readFileToString(versionFile, "UTF-8").trim.split("\\.") match { + case Array(majorVersion, minorVersion) => { + versions.find { v => + v.majorVersion == majorVersion.toInt && v.minorVersion == minorVersion.toInt + }.getOrElse(Version(0, 0)) + } + case _ => Version(0, 0) + } + } else Version(0, 0) + } + +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/servlet/BasicAuthenticationFilter.scala b/src/main/scala/gitbucket/core/servlet/BasicAuthenticationFilter.scala index ebd7bc8..42acffd 100644 --- a/src/main/scala/gitbucket/core/servlet/BasicAuthenticationFilter.scala +++ b/src/main/scala/gitbucket/core/servlet/BasicAuthenticationFilter.scala @@ -28,7 +28,6 @@ } val isUpdating = request.getRequestURI.endsWith("/git-receive-pack") || "service=git-receive-pack".equals(request.getQueryString) - val settings = loadSystemSettings() try { @@ -41,16 +40,22 @@ } else { request.getHeader("Authorization") match { case null => requireAuth(response) - case auth => decodeAuthHeader(auth).split(":") match { + case auth => decodeAuthHeader(auth).split(":", 2) match { case Array(username, password) => { authenticate(settings, username, password) match { case Some(account) => { - if(isUpdating && hasWritePermission(repository.owner, repository.name, Some(account))){ - request.setAttribute(Keys.Request.UserName, account.userName) + if (isUpdating || repository.repository.isPrivate) { + if(hasWritePermission(repository.owner, repository.name, Some(account))){ + request.setAttribute(Keys.Request.UserName, account.userName) + chain.doFilter(req, wrappedResponse) + } else { + requireAuth(response) + } + } else { + chain.doFilter(req, wrappedResponse) } - chain.doFilter(req, wrappedResponse) } - case None => requireAuth(response) + case _ => requireAuth(response) } } case _ => requireAuth(response) diff --git a/src/main/scala/gitbucket/core/servlet/GitRepositoryServlet.scala b/src/main/scala/gitbucket/core/servlet/GitRepositoryServlet.scala index cea10d2..88b8124 100644 --- a/src/main/scala/gitbucket/core/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/gitbucket/core/servlet/GitRepositoryServlet.scala @@ -1,8 +1,16 @@ package gitbucket.core.servlet +import gitbucket.core.api import gitbucket.core.model.Session +import gitbucket.core.service.IssuesService.IssueSearchCondition +import gitbucket.core.service.WebHookService._ import gitbucket.core.service._ +import gitbucket.core.util.ControlUtil._ +import gitbucket.core.util.Implicits._ +import gitbucket.core.util.JGitUtil.CommitInfo import gitbucket.core.util._ + +import org.eclipse.jgit.api.Git import org.eclipse.jgit.http.server.GitServlet import org.eclipse.jgit.lib._ import org.eclipse.jgit.transport._ @@ -12,13 +20,7 @@ import javax.servlet.ServletConfig import javax.servlet.ServletContext import javax.servlet.http.{HttpServletResponse, HttpServletRequest} -import gitbucket.core.util.StringUtil -import gitbucket.core.util.ControlUtil._ -import gitbucket.core.util.Implicits._ -import WebHookService._ -import org.eclipse.jgit.api.Git -import JGitUtil.CommitInfo -import IssuesService.IssueSearchCondition + /** * Provides Git repository via HTTP. @@ -98,7 +100,8 @@ class CommitLogHook(owner: String, repository: String, pusher: String, baseUrl: String)(implicit session: Session) extends PostReceiveHook with PreReceiveHook - with RepositoryService with AccountService with IssuesService with ActivityService with PullRequestService with WebHookService { + with RepositoryService with AccountService with IssuesService with ActivityService with PullRequestService with WebHookService + with WebHookPullRequestService { private val logger = LoggerFactory.getLogger(classOf[CommitLogHook]) private var existIds: Seq[String] = Nil @@ -122,6 +125,7 @@ val pushedIds = scala.collection.mutable.Set[String]() commands.asScala.foreach { command => logger.debug(s"commandType: ${command.getType}, refName: ${command.getRefName}") + implicit val apiContext = api.JsonFormat.Context(baseUrl) val refName = command.getRefName.split("/") val branchName = refName.drop(2).mkString("/") val commits = if (refName(1) == "tags") { @@ -138,8 +142,10 @@ countIssue(IssueSearchCondition(state = "open"), false, owner -> repository) + countIssue(IssueSearchCondition(state = "closed"), false, owner -> repository) + val repositoryInfo = getRepository(owner, repository, baseUrl).get + // Extract new commit and apply issue comment - val defaultBranch = getRepository(owner, repository, baseUrl).get.repository.defaultBranch + val defaultBranch = repositoryInfo.repository.defaultBranch val newCommits = commits.flatMap { commit => if (!existIds.contains(commit.id) && !pushedIds.contains(commit.id)) { if (issueCount > 0) { @@ -176,20 +182,19 @@ ReceiveCommand.Type.UPDATE | ReceiveCommand.Type.UPDATE_NONFASTFORWARD => updatePullRequests(owner, repository, branchName) + getAccountByUserName(pusher).map{ pusherAccount => + callPullRequestWebHookByRequestBranch("synchronize", repositoryInfo, branchName, baseUrl, pusherAccount) + } case _ => } } // call web hook - getWebHookURLs(owner, repository) match { - case webHookURLs if(webHookURLs.nonEmpty) => - for(pusherAccount <- getAccountByUserName(pusher); - ownerAccount <- getAccountByUserName(owner); - repositoryInfo <- getRepository(owner, repository, baseUrl)){ - callWebHook(owner, repository, webHookURLs, - WebHookPayload(git, pusherAccount, command.getRefName, repositoryInfo, newCommits, ownerAccount)) - } - case _ => + callWebHookOf(owner, repository, "push"){ + for(pusherAccount <- getAccountByUserName(pusher); + ownerAccount <- getAccountByUserName(owner)) yield { + WebHookPushPayload(git, pusherAccount, command.getRefName, repositoryInfo, newCommits, ownerAccount) + } } } } diff --git a/src/main/scala/gitbucket/core/servlet/InitializeListener.scala b/src/main/scala/gitbucket/core/servlet/InitializeListener.scala index 2a20bdf..a375b37 100644 --- a/src/main/scala/gitbucket/core/servlet/InitializeListener.scala +++ b/src/main/scala/gitbucket/core/servlet/InitializeListener.scala @@ -1,175 +1,22 @@ package gitbucket.core.servlet -import java.io.File -import java.sql.{DriverManager, Connection} +import akka.event.Logging +import com.typesafe.config.ConfigFactory import gitbucket.core.plugin.PluginRegistry -import gitbucket.core.util._ +import gitbucket.core.service.{ActivityService, SystemSettingsService} import org.apache.commons.io.FileUtils import javax.servlet.{ServletContextListener, ServletContextEvent} import org.slf4j.LoggerFactory -import Directory._ -import ControlUtil._ -import JDBCUtil._ -import org.eclipse.jgit.api.Git import gitbucket.core.util.Versions -import gitbucket.core.util.Directory -import gitbucket.core.plugin._ - -object AutoUpdate { - - /** - * The history of versions. A head of this sequence is the current BitBucket version. - */ - val versions = Seq( - new Version(3, 0), - new Version(2, 8), - new Version(2, 7) { - override def update(conn: Connection, cl: ClassLoader): Unit = { - super.update(conn, cl) - conn.select("SELECT * FROM REPOSITORY"){ rs => - // Rename attached files directory from /issues to /comments - val userName = rs.getString("USER_NAME") - val repoName = rs.getString("REPOSITORY_NAME") - defining(Directory.getAttachedDir(userName, repoName)){ newDir => - val oldDir = new File(newDir.getParentFile, "issues") - if(oldDir.exists && oldDir.isDirectory){ - oldDir.renameTo(newDir) - } - } - // Update ORIGIN_USER_NAME and ORIGIN_REPOSITORY_NAME if it does not exist - val originalUserName = rs.getString("ORIGIN_USER_NAME") - val originalRepoName = rs.getString("ORIGIN_REPOSITORY_NAME") - if(originalUserName != null && originalRepoName != null){ - if(conn.selectInt("SELECT COUNT(*) FROM REPOSITORY WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", - originalUserName, originalRepoName) == 0){ - conn.update("UPDATE REPOSITORY SET ORIGIN_USER_NAME = NULL, ORIGIN_REPOSITORY_NAME = NULL " + - "WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", userName, repoName) - } - } - // Update PARENT_USER_NAME and PARENT_REPOSITORY_NAME if it does not exist - val parentUserName = rs.getString("PARENT_USER_NAME") - val parentRepoName = rs.getString("PARENT_REPOSITORY_NAME") - if(parentUserName != null && parentRepoName != null){ - if(conn.selectInt("SELECT COUNT(*) FROM REPOSITORY WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", - parentUserName, parentRepoName) == 0){ - conn.update("UPDATE REPOSITORY SET PARENT_USER_NAME = NULL, PARENT_REPOSITORY_NAME = NULL " + - "WHERE USER_NAME = ? AND REPOSITORY_NAME = ?", userName, repoName) - } - } - } - } - }, - new Version(2, 6), - new Version(2, 5), - new Version(2, 4), - new Version(2, 3) { - override def update(conn: Connection, cl: ClassLoader): Unit = { - super.update(conn, cl) - conn.select("SELECT ACTIVITY_ID, ADDITIONAL_INFO FROM ACTIVITY WHERE ACTIVITY_TYPE='push'"){ rs => - val curInfo = rs.getString("ADDITIONAL_INFO") - val newInfo = curInfo.split("\n").filter(_ matches "^[0-9a-z]{40}:.*").mkString("\n") - if (curInfo != newInfo) { - conn.update("UPDATE ACTIVITY SET ADDITIONAL_INFO = ? WHERE ACTIVITY_ID = ?", newInfo, rs.getInt("ACTIVITY_ID")) - } - } - ignore { - FileUtils.deleteDirectory(Directory.getPluginCacheDir()) - //FileUtils.deleteDirectory(new File(Directory.PluginHome)) - } - } - }, - new Version(2, 2), - new Version(2, 1), - new Version(2, 0){ - override def update(conn: Connection, cl: ClassLoader): Unit = { - import eu.medsea.mimeutil.{MimeUtil2, MimeType} - - val mimeUtil = new MimeUtil2() - mimeUtil.registerMimeDetector("eu.medsea.mimeutil.detector.MagicMimeMimeDetector") - - super.update(conn, cl) - conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => - defining(Directory.getAttachedDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME"))){ dir => - if(dir.exists && dir.isDirectory){ - dir.listFiles.foreach { file => - if(file.getName.indexOf('.') < 0){ - val mimeType = MimeUtil2.getMostSpecificMimeType(mimeUtil.getMimeTypes(file, new MimeType("application/octet-stream"))).toString - if(mimeType.startsWith("image/")){ - file.renameTo(new File(file.getParent, file.getName + "." + mimeType.split("/")(1))) - } - } - } - } - } - } - } - }, - Version(1, 13), - Version(1, 12), - Version(1, 11), - Version(1, 10), - Version(1, 9), - Version(1, 8), - Version(1, 7), - Version(1, 6), - Version(1, 5), - Version(1, 4), - new Version(1, 3){ - override def update(conn: Connection, cl: ClassLoader): Unit = { - super.update(conn, cl) - // Fix wiki repository configuration - conn.select("SELECT USER_NAME, REPOSITORY_NAME FROM REPOSITORY"){ rs => - using(Git.open(getWikiRepositoryDir(rs.getString("USER_NAME"), rs.getString("REPOSITORY_NAME")))){ git => - defining(git.getRepository.getConfig){ config => - if(!config.getBoolean("http", "receivepack", false)){ - config.setBoolean("http", null, "receivepack", true) - config.save - } - } - } - } - } - }, - Version(1, 2), - Version(1, 1), - Version(1, 0), - Version(0, 0) - ) - - /** - * The head version of BitBucket. - */ - val headVersion = versions.head - - /** - * The version file (GITBUCKET_HOME/version). - */ - lazy val versionFile = new File(GitBucketHome, "version") - - /** - * Returns the current version from the version file. - */ - def getCurrentVersion(): Version = { - if(versionFile.exists){ - FileUtils.readFileToString(versionFile, "UTF-8").trim.split("\\.") match { - case Array(majorVersion, minorVersion) => { - versions.find { v => - v.majorVersion == majorVersion.toInt && v.minorVersion == minorVersion.toInt - }.getOrElse(Version(0, 0)) - } - case _ => Version(0, 0) - } - } else Version(0, 0) - } - -} +import akka.actor.{Actor, Props, ActorSystem} +import com.typesafe.akka.extension.quartz.QuartzSchedulerExtension +import AutoUpdate._ /** * Initialize GitBucket system. * Update database schema and load plug-ins automatically in the context initializing. */ -class InitializeListener extends ServletContextListener { - import AutoUpdate._ +class InitializeListener extends ServletContextListener with SystemSettingsService { private val logger = LoggerFactory.getLogger(classOf[InitializeListener]) @@ -180,28 +27,62 @@ } org.h2.Driver.load() - defining(getConnection()){ conn => + Database() withTransaction { session => + val conn = session.conn + // Migration logger.debug("Start schema update") Versions.update(conn, headVersion, getCurrentVersion(), versions, Thread.currentThread.getContextClassLoader){ conn => FileUtils.writeStringToFile(versionFile, headVersion.versionString, "UTF-8") } + // Load plugins logger.debug("Initialize plugins") - PluginRegistry.initialize(event.getServletContext, conn) + PluginRegistry.initialize(event.getServletContext, loadSystemSettings(), conn) } + // Start Quartz scheduler + val system = ActorSystem("job", ConfigFactory.parseString( + """ + |akka { + | quartz { + | schedules { + | Daily { + | expression = "0 0 0 * * ?" + | } + | } + | } + |} + """.stripMargin)) + + val scheduler = QuartzSchedulerExtension(system) + + scheduler.schedule("Daily", system.actorOf(Props[DeleteOldActivityActor]), "DeleteOldActivity") } - def contextDestroyed(event: ServletContextEvent): Unit = { + override def contextDestroyed(event: ServletContextEvent): Unit = { // Shutdown plugins - PluginRegistry.shutdown(event.getServletContext) + PluginRegistry.shutdown(event.getServletContext, loadSystemSettings()) + // Close datasource + Database.closeDataSource() } - private def getConnection(): Connection = - DriverManager.getConnection( - DatabaseConfig.url, - DatabaseConfig.user, - DatabaseConfig.password) - } + +class DeleteOldActivityActor extends Actor with SystemSettingsService with ActivityService { + + private val logger = Logging(context.system, this) + + def receive = { + case s: String => { + loadSystemSettings().activityLogLimit.foreach { limit => + if(limit > 0){ + Database() withTransaction { implicit session => + val rows = deleteOldActivities(limit) + logger.info(s"Deleted ${rows} activity logs") + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/scala/gitbucket/core/servlet/TransactionFilter.scala b/src/main/scala/gitbucket/core/servlet/TransactionFilter.scala index 2fdd17a..fc2e457 100644 --- a/src/main/scala/gitbucket/core/servlet/TransactionFilter.scala +++ b/src/main/scala/gitbucket/core/servlet/TransactionFilter.scala @@ -4,6 +4,7 @@ import javax.servlet.http.HttpServletRequest import com.mchange.v2.c3p0.ComboPooledDataSource import gitbucket.core.util.DatabaseConfig +import org.scalatra.ScalatraBase import org.slf4j.LoggerFactory import slick.jdbc.JdbcBackend.{Database => SlickDatabase, Session} import gitbucket.core.util.Keys @@ -20,11 +21,17 @@ def destroy(): Unit = {} def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = { - if(req.asInstanceOf[HttpServletRequest].getRequestURI().startsWith("/assets/")){ + if(req.asInstanceOf[HttpServletRequest].getServletPath().startsWith("/assets/")){ // assets don't need transaction chain.doFilter(req, res) } else { Database() withTransaction { session => + // Register Scalatra error callback to rollback transaction + ScalatraBase.onFailure { _ => + logger.debug("Rolled back transaction") + session.rollback() + }(req.asInstanceOf[HttpServletRequest]) + logger.debug("begin transaction") req.setAttribute(Keys.Request.DBSession, session) chain.doFilter(req, res) @@ -39,17 +46,18 @@ private val logger = LoggerFactory.getLogger(Database.getClass) - private val db: SlickDatabase = { - val datasource = new ComboPooledDataSource - - datasource.setDriverClass(DatabaseConfig.driver) - datasource.setJdbcUrl(DatabaseConfig.url) - datasource.setUser(DatabaseConfig.user) - datasource.setPassword(DatabaseConfig.password) - + private val dataSource: ComboPooledDataSource = { + val ds = new ComboPooledDataSource + ds.setDriverClass(DatabaseConfig.driver) + ds.setJdbcUrl(DatabaseConfig.url) + ds.setUser(DatabaseConfig.user) + ds.setPassword(DatabaseConfig.password) logger.debug("load database connection pool") + ds + } - SlickDatabase.forDataSource(datasource) + private val db: SlickDatabase = { + SlickDatabase.forDataSource(dataSource) } def apply(): SlickDatabase = db @@ -57,4 +65,6 @@ def getSession(req: ServletRequest): Session = req.getAttribute(Keys.Request.DBSession).asInstanceOf[Session] + def closeDataSource(): Unit = dataSource.close + } diff --git a/src/main/scala/gitbucket/core/util/Implicits.scala b/src/main/scala/gitbucket/core/util/Implicits.scala index abe71ea..13c316a 100644 --- a/src/main/scala/gitbucket/core/util/Implicits.scala +++ b/src/main/scala/gitbucket/core/util/Implicits.scala @@ -1,11 +1,16 @@ package gitbucket.core.util +import gitbucket.core.api.JsonFormat +import gitbucket.core.controller.Context import gitbucket.core.servlet.Database +import javax.servlet.http.{HttpSession, HttpServletRequest} + import scala.util.matching.Regex import scala.util.control.Exception._ + import slick.jdbc.JdbcBackend -import javax.servlet.http.{HttpSession, HttpServletRequest} + /** * Provides some usable implicit conversions. @@ -15,6 +20,8 @@ // Convert to slick session. implicit def request2Session(implicit request: HttpServletRequest): JdbcBackend#Session = Database.getSession(request) + implicit def context2ApiJsonFormatContext(implicit context: Context): JsonFormat.Context = JsonFormat.Context(context.baseUrl) + implicit class RichSeq[A](seq: Seq[A]) { def splitWith(condition: (A, A) => Boolean): Seq[Seq[A]] = split(seq)(condition) @@ -56,7 +63,10 @@ implicit class RichRequest(request: HttpServletRequest){ - def paths: Array[String] = request.getRequestURI.substring(request.getContextPath.length + 1).split("/") + def paths: Array[String] = (request.getRequestURI.substring(request.getContextPath.length + 1) match{ + case path if path.startsWith("api/v3/repos/") => path.substring(13/* "/api/v3/repos".length */) + case path => path + }).split("/") def hasQueryString: Boolean = request.getQueryString != null diff --git a/src/main/scala/gitbucket/core/util/JGitUtil.scala b/src/main/scala/gitbucket/core/util/JGitUtil.scala index d2d531b..c373340 100644 --- a/src/main/scala/gitbucket/core/util/JGitUtil.scala +++ b/src/main/scala/gitbucket/core/util/JGitUtil.scala @@ -176,7 +176,7 @@ git.tagList.call.asScala.map { ref => val revCommit = getRevCommitFromId(git, ref.getObjectId) TagInfo(ref.getName.stripPrefix("refs/tags/"), revCommit.getCommitterIdent.getWhen, revCommit.getName) - }.toList + }.sortBy(_.time).toList ) } catch { // not initialized @@ -196,80 +196,121 @@ * @return HTML of the file list */ def getFileList(git: Git, revision: String, path: String = "."): List[FileInfo] = { - var list = new scala.collection.mutable.ListBuffer[(ObjectId, FileMode, String, String, Option[String])] - using(new RevWalk(git.getRepository)){ revWalk => val objectId = git.getRepository.resolve(revision) + if(objectId==null) return Nil val revCommit = revWalk.parseCommit(objectId) - val treeWalk = if (path == ".") { + def useTreeWalk(rev:RevCommit)(f:TreeWalk => Any): Unit = if (path == ".") { val treeWalk = new TreeWalk(git.getRepository) - treeWalk.addTree(revCommit.getTree) - treeWalk + treeWalk.addTree(rev.getTree) + using(treeWalk)(f) } else { - val treeWalk = TreeWalk.forPath(git.getRepository, path, revCommit.getTree) - treeWalk.enterSubtree() - treeWalk + val treeWalk = TreeWalk.forPath(git.getRepository, path, rev.getTree) + if(treeWalk != null){ + treeWalk.enterSubtree + using(treeWalk)(f) + } + } + @tailrec + def simplifyPath(tuple: (ObjectId, FileMode, String, Option[String], RevCommit)): (ObjectId, FileMode, String, Option[String], RevCommit) = tuple match { + case (oid, FileMode.TREE, name, _, commit ) => + (using(new TreeWalk(git.getRepository)) { walk => + walk.addTree(oid) + // single tree child, or None + if(walk.next() && walk.getFileMode(0) == FileMode.TREE){ + Some((walk.getObjectId(0), walk.getFileMode(0), name + "/" + walk.getNameString, None, commit)).filterNot(_ => walk.next()) + } else { + None + } + }) match { + case Some(child) => simplifyPath(child) + case _ => tuple + } + case _ => tuple } - using(treeWalk) { treeWalk => + def tupleAdd(tuple:(ObjectId, FileMode, String, Option[String]), rev:RevCommit) = tuple match { + case (oid, fmode, name, opt) => (oid, fmode, name, opt, rev) + } + + @tailrec + def findLastCommits(result:List[(ObjectId, FileMode, String, Option[String], RevCommit)], + restList:List[((ObjectId, FileMode, String, Option[String]), Map[RevCommit, RevCommit])], + revIterator:java.util.Iterator[RevCommit]): List[(ObjectId, FileMode, String, Option[String], RevCommit)] ={ + if(restList.isEmpty){ + result + }else if(!revIterator.hasNext){ // maybe, revCommit has only 1 log. other case, restList be empty + result ++ restList.map{ case (tuple, map) => tupleAdd(tuple, map.values.headOption.getOrElse(revCommit)) } + }else{ + val newCommit = revIterator.next + val (thisTimeChecks,skips) = restList.partition{ case (tuple, parentsMap) => parentsMap.contains(newCommit) } + if(thisTimeChecks.isEmpty){ + findLastCommits(result, restList, revIterator) + }else{ + var nextRest = skips + var nextResult = result + // Map[(name, oid), (tuple, parentsMap)] + val rest = scala.collection.mutable.Map(thisTimeChecks.map{ t => (t._1._3 -> t._1._1) -> t }:_*) + lazy val newParentsMap = newCommit.getParents.map(_ -> newCommit).toMap + useTreeWalk(newCommit){ walk => + while(walk.next){ + rest.remove(walk.getNameString -> walk.getObjectId(0)).map{ case (tuple, _) => + if(newParentsMap.isEmpty){ + nextResult +:= tupleAdd(tuple, newCommit) + }else{ + nextRest +:= tuple -> newParentsMap + } + } + } + } + rest.values.map{ case (tuple, parentsMap) => + val restParentsMap = parentsMap - newCommit + if(restParentsMap.isEmpty){ + nextResult +:= tupleAdd(tuple, parentsMap(newCommit)) + }else{ + nextRest +:= tuple -> restParentsMap + } + } + findLastCommits(nextResult, nextRest, revIterator) + } + } + } + + var fileList: List[(ObjectId, FileMode, String, Option[String])] = Nil + useTreeWalk(revCommit){ treeWalk => while (treeWalk.next()) { - // submodule - val linkUrl = if(treeWalk.getFileMode(0) == FileMode.GITLINK){ + val linkUrl =if (treeWalk.getFileMode(0) == FileMode.GITLINK) { getSubmodules(git, revCommit.getTree).find(_.path == treeWalk.getPathString).map(_.url) } else None - - list.append((treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getPathString, treeWalk.getNameString, linkUrl)) + fileList +:= (treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getNameString, linkUrl) } - - list.transform(tuple => - if (tuple._2 != FileMode.TREE) - tuple - else - simplifyPath(tuple) - ) - - @tailrec - def simplifyPath(tuple: (ObjectId, FileMode, String, String, Option[String])): (ObjectId, FileMode, String, String, Option[String]) = { - val list = new scala.collection.mutable.ListBuffer[(ObjectId, FileMode, String, String, Option[String])] - using(new TreeWalk(git.getRepository)) { walk => - walk.addTree(tuple._1) - while (walk.next() && list.size < 2) { - val linkUrl = if (walk.getFileMode(0) == FileMode.GITLINK) { - getSubmodules(git, revCommit.getTree).find(_.path == walk.getPathString).map(_.url) - } else None - list.append((walk.getObjectId(0), walk.getFileMode(0), tuple._3 + "/" + walk.getPathString, tuple._4 + "/" + walk.getNameString, linkUrl)) - } + } + revWalk.markStart(revCommit) + val it = revWalk.iterator + val lastCommit = it.next + val nextParentsMap = Option(lastCommit).map(_.getParents.map(_ -> lastCommit).toMap).getOrElse(Map()) + findLastCommits(List.empty, fileList.map(a => a -> nextParentsMap), it) + .map(simplifyPath) + .map { case (objectId, fileMode, name, linkUrl, commit) => + FileInfo( + objectId, + fileMode == FileMode.TREE || fileMode == FileMode.GITLINK, + name, + getSummaryMessage(commit.getFullMessage, commit.getShortMessage), + commit.getName, + commit.getAuthorIdent.getWhen, + commit.getAuthorIdent.getName, + commit.getAuthorIdent.getEmailAddress, + linkUrl) + }.sortWith { (file1, file2) => + (file1.isDirectory, file2.isDirectory) match { + case (true , false) => true + case (false, true ) => false + case _ => file1.name.compareTo(file2.name) < 0 } - if (list.size != 1 || list.exists(_._2 != FileMode.TREE)) - tuple - else - simplifyPath(list(0)) - } - } + }.toList } - - val commits = getLatestCommitFromPaths(git, list.toList.map(_._3), revision) - list.map { case (objectId, fileMode, path, name, linkUrl) => - defining(commits(path)){ commit => - FileInfo( - objectId, - fileMode == FileMode.TREE || fileMode == FileMode.GITLINK, - name, - getSummaryMessage(commit.getFullMessage, commit.getShortMessage), - commit.getName, - commit.getAuthorIdent.getWhen, - commit.getAuthorIdent.getName, - commit.getAuthorIdent.getEmailAddress, - linkUrl) - } - }.sortWith { (file1, file2) => - (file1.isDirectory, file2.isDirectory) match { - case (true , false) => true - case (false, true ) => false - case _ => file1.name.compareTo(file2.name) < 0 - } - }.toList } /** @@ -313,12 +354,7 @@ } else { revWalk.markStart(revWalk.parseCommit(objectId)) if(path.nonEmpty){ - revWalk.setRevFilter(new RevFilter(){ - def include(walk: RevWalk, commit: RevCommit): Boolean = { - getDiffs(git, commit.getName, false)._1.find(_.newPath == path).nonEmpty - } - override def clone(): RevFilter = this - }) + revWalk.setTreeFilter(AndTreeFilter.create(PathFilter.create(path), TreeFilter.ANY_DIFF)) } Right(getCommitLog(revWalk.iterator, 0, Nil)) } @@ -442,6 +478,7 @@ newTreeIter.reset(reader, git.getRepository.resolve(to + "^{tree}")) import scala.collection.JavaConverters._ + git.getRepository.getConfig.setString("diff", null, "renames", "copies") 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) @@ -602,21 +639,24 @@ def getContentInfo(git: Git, path: String, objectId: ObjectId): ContentInfo = { // Viewer - val large = FileUtil.isLarge(git.getRepository.getObjectDatabase.open(objectId).getSize) - val viewer = if(FileUtil.isImage(path)) "image" else if(large) "large" else "other" - val bytes = if(viewer == "other") JGitUtil.getContentFromId(git, objectId, false) else None + using(git.getRepository.getObjectDatabase){ db => + val loader = db.open(objectId) + val large = FileUtil.isLarge(loader.getSize) + val viewer = if(FileUtil.isImage(path)) "image" else if(large) "large" else "other" + val bytes = if(viewer == "other") JGitUtil.getContentFromId(git, objectId, false) else None - if(viewer == "other"){ - if(bytes.isDefined && FileUtil.isText(bytes.get)){ - // text - ContentInfo("text", Some(StringUtil.convertFromByteArray(bytes.get)), Some(StringUtil.detectEncoding(bytes.get))) + if(viewer == "other"){ + if(bytes.isDefined && FileUtil.isText(bytes.get)){ + // text + ContentInfo("text", Some(StringUtil.convertFromByteArray(bytes.get)), Some(StringUtil.detectEncoding(bytes.get))) + } else { + // binary + ContentInfo("binary", None, None) + } } else { - // binary - ContentInfo("binary", None, None) + // image or large + ContentInfo(viewer, None, None) } - } else { - // image or large - ContentInfo(viewer, None, None) } } @@ -629,12 +669,12 @@ * @return the byte array of content or None if object does not exist */ def getContentFromId(git: Git, id: ObjectId, fetchLargeFile: Boolean): Option[Array[Byte]] = try { - val loader = git.getRepository.getObjectDatabase.open(id) - if(fetchLargeFile == false && FileUtil.isLarge(loader.getSize)){ - None - } else { - using(git.getRepository.getObjectDatabase){ db => - Some(db.open(id).getBytes) + using(git.getRepository.getObjectDatabase){ db => + val loader = db.open(id) + if(fetchLargeFile == false && FileUtil.isLarge(loader.getSize)){ + None + } else { + Some(loader.getBytes) } } } catch { @@ -748,4 +788,17 @@ } } } + + /** + * Returns sha1 + * @param owner repository owner + * @param name repository name + * @param revstr A git object references expression + * @return sha1 + */ + def getShaByRef(owner:String, name:String,revstr: String): Option[String] = { + using(Git.open(getRepositoryDir(owner, name))){ git => + Option(git.getRepository.resolve(revstr)).map(ObjectId.toString(_)) + } + } } diff --git a/src/main/scala/gitbucket/core/util/Keys.scala b/src/main/scala/gitbucket/core/util/Keys.scala index a830344..3581879 100644 --- a/src/main/scala/gitbucket/core/util/Keys.scala +++ b/src/main/scala/gitbucket/core/util/Keys.scala @@ -72,6 +72,11 @@ val Ajax = "AJAX" /** + * Request key for the /api/v3 request flag. + */ + val APIv3 = "APIv3" + + /** * Request key for the username which is used during Git repository access. */ val UserName = "USER_NAME" diff --git a/src/main/scala/gitbucket/core/util/Notifier.scala b/src/main/scala/gitbucket/core/util/Notifier.scala index 9e80917..b7dbbe8 100644 --- a/src/main/scala/gitbucket/core/util/Notifier.scala +++ b/src/main/scala/gitbucket/core/util/Notifier.scala @@ -15,7 +15,7 @@ import ControlUtil.defining trait Notifier extends RepositoryService with AccountService with IssuesService { - def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + def toNotify(r: RepositoryService.RepositoryInfo, issue: Issue, content: String) (msg: String => String)(implicit context: Context): Unit protected def recipients(issue: Issue)(notify: String => Unit)(implicit session: Session, context: Context) = @@ -67,16 +67,15 @@ class Mailer(private val smtp: Smtp) extends Notifier { private val logger = LoggerFactory.getLogger(classOf[Mailer]) - def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + def toNotify(r: RepositoryService.RepositoryInfo, issue: Issue, content: String) (msg: String => String)(implicit context: Context) = { val database = Database() val f = Future { database withSession { implicit session => - getIssue(r.owner, r.name, issueId.toString) foreach { issue => - defining( - s"[${r.name}] ${issue.title} (#${issueId})" -> - msg(Markdown.toHtml(content, r, false, true))) { case (subject, msg) => + defining( + s"[${r.name}] ${issue.title} (#${issue.issueId})" -> + msg(Markdown.toHtml(content, r, false, true))) { case (subject, msg) => recipients(issue) { to => val email = new HtmlEmail email.setHostName(smtp.host) @@ -92,14 +91,13 @@ .orElse (Some("notifications@gitbucket.com" -> context.loginAccount.get.userName)) .foreach { case (address, name) => email.setFrom(address, name) - } + } email.setCharset("UTF-8") email.setSubject(subject) email.setHtmlMsg(msg) email.addTo(to).send } - } } } "Notifications Successful." @@ -113,6 +111,6 @@ } } class MockMailer extends Notifier { - def toNotify(r: RepositoryService.RepositoryInfo, issueId: Int, content: String) + def toNotify(r: RepositoryService.RepositoryInfo, issue: Issue, content: String) (msg: String => String)(implicit context: Context): Unit = {} } diff --git a/src/main/scala/gitbucket/core/util/RepoitoryName.scala b/src/main/scala/gitbucket/core/util/RepoitoryName.scala new file mode 100644 index 0000000..415b20b --- /dev/null +++ b/src/main/scala/gitbucket/core/util/RepoitoryName.scala @@ -0,0 +1,18 @@ +package gitbucket.core.util + +case class RepositoryName(owner:String, name:String){ + val fullName = s"${owner}/${name}" +} + +object RepositoryName{ + def apply(fullName: String): RepositoryName = { + fullName.split("/").toList match { + case owner :: name :: Nil => RepositoryName(owner, name) + case _ => throw new IllegalArgumentException(s"${fullName} is not repositoryName (only 'owner/name')") + } + } + def apply(repository: gitbucket.core.model.Repository): RepositoryName = RepositoryName(repository.userName, repository.repositoryName) + def apply(repository: gitbucket.core.util.JGitUtil.RepositoryInfo): RepositoryName = RepositoryName(repository.owner, repository.name) + def apply(repository: gitbucket.core.service.RepositoryService.RepositoryInfo): RepositoryName = RepositoryName(repository.owner, repository.name) + def apply(repository: gitbucket.core.model.CommitStatus): RepositoryName = RepositoryName(repository.userName, repository.repositoryName) +} diff --git a/src/main/scala/gitbucket/core/util/StringUtil.scala b/src/main/scala/gitbucket/core/util/StringUtil.scala index 4bb1cb0..5633598 100644 --- a/src/main/scala/gitbucket/core/util/StringUtil.scala +++ b/src/main/scala/gitbucket/core/util/StringUtil.scala @@ -63,6 +63,21 @@ } /** + * Appends LF if the given string does not end with LF. + * + * @param content the content + * @param lineSeparator "LF" or "CRLF" + * @return the converted content + */ + def appendNewLine(content: String, lineSeparator: String): String = { + if(lineSeparator == "CRLF") { + if (content.endsWith("\r\n")) content else content + "\r\n" + } else { + if (content.endsWith("\n")) content else content + "\n" + } + } + + /** * Extract issue id like ```#issueId``` from the given message. * *@param message the message which may contains issue id diff --git a/src/main/scala/gitbucket/core/view/Markdown.scala b/src/main/scala/gitbucket/core/view/Markdown.scala index fd0b19c..7f7e1fd 100644 --- a/src/main/scala/gitbucket/core/view/Markdown.scala +++ b/src/main/scala/gitbucket/core/view/Markdown.scala @@ -123,8 +123,10 @@ } private def fixUrl(url: String, isImage: Boolean = false): String = { - if(url.startsWith("http://") || url.startsWith("https://") || url.startsWith("#") || url.startsWith("/")){ + if(url.startsWith("http://") || url.startsWith("https://") || url.startsWith("/")){ url + } else if(url.startsWith("#")){ + ("#" + GitBucketHtmlSerializer.generateAnchorName(url.substring(1))) } else if(!enableWikiLink){ if(context.currentPath.contains("/blob/")){ url + (if(isImage) "?raw=true" else "") diff --git a/src/main/scala/gitbucket/core/view/helpers.scala b/src/main/scala/gitbucket/core/view/helpers.scala index 7d7b2b2..3b7099f 100644 --- a/src/main/scala/gitbucket/core/view/helpers.scala +++ b/src/main/scala/gitbucket/core/view/helpers.scala @@ -4,10 +4,15 @@ import java.util.{Date, Locale, TimeZone} import gitbucket.core.controller.Context +import gitbucket.core.model.CommitState import gitbucket.core.service.{RepositoryService, RequestCache} import gitbucket.core.util.{JGitUtil, StringUtil} + import play.twirl.api.Html + + + /** * Provides helper methods for Twirl templates. */ @@ -143,7 +148,7 @@ import scala.util.matching.Regex._ implicit class RegexReplaceString(s: String) { def replaceAll(pattern: String, replacer: (Match) => String): String = { - pattern.r.replaceAllIn(s, replacer) + pattern.r.replaceAllIn(s, (m: Match) => replacer(m).replace("$", "\\$")) } } @@ -263,4 +268,17 @@ def mkHtml(separator: scala.xml.Elem) = Html(seq.mkString(separator.toString)) } + def commitStateIcon(state: CommitState) = Html(state match { + case CommitState.PENDING => "●" + case CommitState.SUCCESS => "✔" + case CommitState.ERROR => "×" + case CommitState.FAILURE => "×" + }) + + def commitStateText(state: CommitState, commitId:String) = state match { + case CommitState.PENDING => "Waiting to hear about "+commitId.substring(0,8) + case CommitState.SUCCESS => "All is well" + case CommitState.ERROR => "Failed" + case CommitState.FAILURE => "Failed" + } } diff --git a/src/main/twirl/gitbucket/core/account/application.scala.html b/src/main/twirl/gitbucket/core/account/application.scala.html new file mode 100644 index 0000000..ba8915e --- /dev/null +++ b/src/main/twirl/gitbucket/core/account/application.scala.html @@ -0,0 +1,57 @@ +@(account: gitbucket.core.model.Account, + personalTokens: List[gitbucket.core.model.AccessToken], + gneratedToken: Option[(gitbucket.core.model.AccessToken, String)])(implicit context: gitbucket.core.controller.Context) +@import context._ +@import gitbucket.core.view.helpers._ +@html.main("Applications"){ +
+
+
+ @menu("application", settings.ssh) +
+
+
+
Personal access tokens
+
+ @if(personalTokens.isEmpty && gneratedToken.isEmpty){ + No tokens. + }else{ + Tokens you have generated that can be used to access the GitBucket API.
+ } + @gneratedToken.map{ case (token, tokenString) => +
+ Make sure to copy your new personal access token now. You won't be able to see it again! +
+ @helper.html.copy("generated-token-copy", tokenString){ + + } + Delete +
+ } + @personalTokens.zipWithIndex.map { case (token, i) => + @if(i != 0){ +
+ } + @token.note + Delete + } +
+
+
+
+
Generate new token
+
+
+ +
+ +

What's this token for?

+
+ +
+
+
+
+
+
+} diff --git a/src/main/twirl/gitbucket/core/account/edit.scala.html b/src/main/twirl/gitbucket/core/account/edit.scala.html index b2691a1..c49d482 100644 --- a/src/main/twirl/gitbucket/core/account/edit.scala.html +++ b/src/main/twirl/gitbucket/core/account/edit.scala.html @@ -22,7 +22,7 @@ - + } diff --git a/src/main/twirl/gitbucket/core/account/menu.scala.html b/src/main/twirl/gitbucket/core/account/menu.scala.html index 60f0229..47734f9 100644 --- a/src/main/twirl/gitbucket/core/account/menu.scala.html +++ b/src/main/twirl/gitbucket/core/account/menu.scala.html @@ -10,5 +10,8 @@ SSH Keys } + + Applications + diff --git a/src/main/twirl/gitbucket/core/account/newrepo.scala.html b/src/main/twirl/gitbucket/core/account/newrepo.scala.html index 29e7c03..9316faa 100644 --- a/src/main/twirl/gitbucket/core/account/newrepo.scala.html +++ b/src/main/twirl/gitbucket/core/account/newrepo.scala.html @@ -21,7 +21,7 @@ / - +
diff --git a/src/main/twirl/gitbucket/core/account/register.scala.html b/src/main/twirl/gitbucket/core/account/register.scala.html index 5810d95..507b5cf 100644 --- a/src/main/twirl/gitbucket/core/account/register.scala.html +++ b/src/main/twirl/gitbucket/core/account/register.scala.html @@ -9,7 +9,7 @@
- +
diff --git a/src/main/twirl/gitbucket/core/admin/system.scala.html b/src/main/twirl/gitbucket/core/admin/system.scala.html index 47e0221..6bb5caa 100644 --- a/src/main/twirl/gitbucket/core/admin/system.scala.html +++ b/src/main/twirl/gitbucket/core/admin/system.scala.html @@ -81,6 +81,15 @@
+ + +
+ +
+ + +
+
diff --git a/src/main/twirl/gitbucket/core/dashboard/issueslist.scala.html b/src/main/twirl/gitbucket/core/dashboard/issueslist.scala.html index ed5bbf5..f0df114 100644 --- a/src/main/twirl/gitbucket/core/dashboard/issueslist.scala.html +++ b/src/main/twirl/gitbucket/core/dashboard/issueslist.scala.html @@ -15,7 +15,7 @@ @dashboard.html.header(openCount, closedCount, condition, groups) - @issues.map { case IssueInfo(issue, labels, milestone, commentCount) => + @issues.map { case IssueInfo(issue, labels, milestone, commentCount, commitStatus) => @if(issue.isPullRequest){ @@ -29,6 +29,7 @@ } else { @issue.title } + @gitbucket.core.issues.html.commitstatus(issue, commitStatus) @labels.map { label => @label.labelName } diff --git a/src/main/twirl/gitbucket/core/helper/commitcomment.scala.html b/src/main/twirl/gitbucket/core/helper/commitcomment.scala.html index 0df308b..d9ff70c 100644 --- a/src/main/twirl/gitbucket/core/helper/commitcomment.scala.html +++ b/src/main/twirl/gitbucket/core/helper/commitcomment.scala.html @@ -29,7 +29,7 @@ }
-
+
@markdown(comment.content, repository, false, true, true, hasWritePermission)
diff --git a/src/main/twirl/gitbucket/core/helper/diff.scala.html b/src/main/twirl/gitbucket/core/helper/diff.scala.html index 605efd2..c12f07c 100644 --- a/src/main/twirl/gitbucket/core/helper/diff.scala.html +++ b/src/main/twirl/gitbucket/core/helper/diff.scala.html @@ -10,7 +10,7 @@ @import gitbucket.core.view.helpers._ @import org.eclipse.jgit.diff.DiffEntry.ChangeType @if(showIndex){ -
+
@@ -22,6 +22,7 @@
-
+
@markdown(issue.get.content getOrElse "No description provided.", repository, false, true, true, hasWritePermission)
@@ -41,8 +41,8 @@ @if(comment.action != "commit" && comment.action != "merge" && comment.action != "refer" && (hasWritePermission || loginAccount.map(_.userName == comment.commentedUserName).getOrElse(false))){ -   - +   + }
@@ -50,7 +50,7 @@ @if(comment.action == "commit" && comment.content.split(" ").last.matches("[a-f0-9]{40}")){ @defining(comment.content.substring(comment.content.length - 40)){ id => - @markdown(comment.content.substring(0, comment.content.length - 41), repository, false, true, true, hasWritePermission) +
@markdown(comment.content.substring(0, comment.content.length - 41), repository, false, true, true, hasWritePermission)
} } else { @if(comment.action == "refer"){ @@ -58,7 +58,7 @@ Issue #@issueId: @rest.mkString(":") } } else { - @markdown(comment.content, repository, false, true, true, hasWritePermission) +
@markdown(comment.content, repository, false, true, true, hasWritePermission)
} }
diff --git a/src/main/twirl/gitbucket/core/issues/commitstatus.scala.html b/src/main/twirl/gitbucket/core/issues/commitstatus.scala.html new file mode 100644 index 0000000..944951f --- /dev/null +++ b/src/main/twirl/gitbucket/core/issues/commitstatus.scala.html @@ -0,0 +1,19 @@ +@(issue: gitbucket.core.model.Issue, statusInfo: Option[gitbucket.core.service.IssuesService.CommitStatusInfo])(implicit context: gitbucket.core.controller.Context) +@import gitbucket.core.view.helpers._ +@statusInfo.map{ status => + @if(status.count==1 && status.state.isDefined){ + @if(status.targetUrl.isDefined){ + @commitStateIcon(status.state.get) + }else{ + @commitStateIcon(status.state.get) + } + }else{ + @defining(status.count==status.successCount){ isSuccess => + @if(isSuccess){ + ✔ + }else{ + × + } + } + } +} \ No newline at end of file diff --git a/src/main/twirl/gitbucket/core/issues/create.scala.html b/src/main/twirl/gitbucket/core/issues/create.scala.html index ddbd086..a68717b 100644 --- a/src/main/twirl/gitbucket/core/issues/create.scala.html +++ b/src/main/twirl/gitbucket/core/issues/create.scala.html @@ -16,7 +16,7 @@
- +
No one is assigned @if(hasWritePermission){ diff --git a/src/main/twirl/gitbucket/core/issues/listparts.scala.html b/src/main/twirl/gitbucket/core/issues/listparts.scala.html index 85dbd1c..1ea3e34 100644 --- a/src/main/twirl/gitbucket/core/issues/listparts.scala.html +++ b/src/main/twirl/gitbucket/core/issues/listparts.scala.html @@ -11,12 +11,11 @@ hasWritePermission: Boolean = false)(implicit context: gitbucket.core.controller.Context) @import context._ @import gitbucket.core.view.helpers._ -@import gitbucket.core.service.IssuesService @import gitbucket.core.service.IssuesService.IssueInfo
@if(condition.nonEmpty){
- + Clear current search query, filters, and sorts @@ -25,10 +24,8 @@ @@ -175,7 +169,7 @@ } - @issues.map { case IssueInfo(issue, labels, milestone, commentCount) => + @issues.map { case IssueInfo(issue, labels, milestone, commentCount, commitStatus) =>
- @if(hasWritePermission){ - - } - + + @openCount Open @@ -37,119 +34,116 @@ @closedCount Closed -
- @helper.html.dropdown("Author", flat = true) { - @collaborators.map { collaborator => -
  • - - @helper.html.checkicon(condition.author == Some(collaborator)) - @avatar(collaborator, 20) @collaborator - -
  • - } + +
    + @helper.html.dropdown("Author", flat = true) { + @collaborators.map { collaborator => +
  • + + @helper.html.checkicon(condition.author == Some(collaborator)) + @avatar(collaborator, 20) @collaborator + +
  • + } + } + @helper.html.dropdown("Label", flat = true) { + @labels.map { label => +
  • + + @helper.html.checkicon(condition.labels.contains(label.labelName)) +    + @label.labelName + +
  • + } + } + @helper.html.dropdown("Milestone", flat = true) { +
  • + + @helper.html.checkicon(condition.milestone == Some(None)) Issues with no milestone + +
  • + @milestones.filter(_.closedDate.isEmpty).map { milestone => +
  • + + @helper.html.checkicon(condition.milestone == Some(Some(milestone.title))) @milestone.title + +
  • + } + } + @helper.html.dropdown("Assignee", flat = true) { + @collaborators.map { collaborator => +
  • + + @helper.html.checkicon(condition.assigned == Some(collaborator)) + @avatar(collaborator, 20) @collaborator + +
  • + } + } + @helper.html.dropdown("Sort", flat = true){ +
  • + + @helper.html.checkicon(condition.sort == "created" && condition.direction == "desc") Newest + +
  • +
  • + + @helper.html.checkicon(condition.sort == "created" && condition.direction == "asc") Oldest + +
  • +
  • + + @helper.html.checkicon(condition.sort == "comments" && condition.direction == "desc") Most commented + +
  • +
  • + + @helper.html.checkicon(condition.sort == "comments" && condition.direction == "asc") Least commented + +
  • +
  • + + @helper.html.checkicon(condition.sort == "updated" && condition.direction == "desc") Recently updated + +
  • +
  • + + @helper.html.checkicon(condition.sort == "updated" && condition.direction == "asc") Least recently updated + +
  • + } +
    + @if(hasWritePermission){ +
    + @helper.html.dropdown("Mark as", flat = true) { +
  • Open
  • +
  • Close
  • } @helper.html.dropdown("Label", flat = true) { @labels.map { label =>
  • - - @helper.html.checkicon(condition.labels.contains(label.labelName)) -    + + +   @label.labelName
  • } } @helper.html.dropdown("Milestone", flat = true) { -
  • - - @helper.html.checkicon(condition.milestone == Some(None)) Issues with no milestone - -
  • +
  • No milestone
  • @milestones.filter(_.closedDate.isEmpty).map { milestone => -
  • - - @helper.html.checkicon(condition.milestone == Some(Some(milestone.title))) @milestone.title - -
  • +
  • @milestone.title
  • } } @helper.html.dropdown("Assignee", flat = true) { +
  • Clear assignee
  • @collaborators.map { collaborator => -
  • - - @helper.html.checkicon(condition.assigned == Some(collaborator)) - @avatar(collaborator, 20) @collaborator - -
  • +
  • @avatar(collaborator, 20) @collaborator
  • } } - @helper.html.dropdown("Sort", flat = true){ -
  • - - @helper.html.checkicon(condition.sort == "created" && condition.direction == "desc") Newest - -
  • -
  • - - @helper.html.checkicon(condition.sort == "created" && condition.direction == "asc") Oldest - -
  • -
  • - - @helper.html.checkicon(condition.sort == "comments" && condition.direction == "desc") Most commented - -
  • -
  • - - @helper.html.checkicon(condition.sort == "comments" && condition.direction == "asc") Least commented - -
  • -
  • - - @helper.html.checkicon(condition.sort == "updated" && condition.direction == "desc") Recently updated - -
  • -
  • - - @helper.html.checkicon(condition.sort == "updated" && condition.direction == "asc") Least recently updated - -
  • - }
    - - @if(hasWritePermission){ - - selected -
    - @helper.html.dropdown("Mark as", flat = true) { -
  • Open
  • -
  • Close
  • - } - @helper.html.dropdown("Label", flat = true) { - @labels.map { label => -
  • - - -   - @label.labelName - -
  • - } - } - @helper.html.dropdown("Milestone", flat = true) { -
  • No milestone
  • - @milestones.filter(_.closedDate.isEmpty).map { milestone => -
  • @milestone.title
  • - } - } - @helper.html.dropdown("Assign", flat = true) { -
  • Assign to nobody
  • - @collaborators.map { collaborator => -
  • @avatar(collaborator, 20) @collaborator
  • - } - } -
    -
    }
    @if(hasWritePermission){ @@ -190,6 +184,7 @@ } else { @issue.title } + @commitstatus(issue, commitStatus) @labels.map { label => @label.labelName } @@ -210,7 +205,7 @@
    #@issue.issueId opened @helper.html.datetimeago(issue.registeredDate) by @user(issue.openedUserName, styleClass="username") @milestone.map { milestone => - + @milestone }
    @@ -219,6 +214,5 @@ }
    - @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), IssuesService.IssueLimit, 10, condition.toURL) + @helper.html.paginator(page, (if(condition.state == "open") openCount else closedCount), gitbucket.core.service.IssuesService.IssueLimit, 10, condition.toURL)
    - diff --git a/src/main/twirl/gitbucket/core/issues/milestones/list.scala.html b/src/main/twirl/gitbucket/core/issues/milestones/list.scala.html index e1e8f7c..11d5332 100644 --- a/src/main/twirl/gitbucket/core/issues/milestones/list.scala.html +++ b/src/main/twirl/gitbucket/core/issues/milestones/list.scala.html @@ -31,7 +31,7 @@
    - @milestone.title + @milestone.title
    @if(milestone.closedDate.isDefined){ Closed @helper.html.datetimeago(milestone.closedDate.get) @@ -75,7 +75,7 @@
    @if(milestone.description.isDefined){ -
    +
    @markdown(milestone.description.get, repository, false, false)
    } diff --git a/src/main/twirl/gitbucket/core/main.scala.html b/src/main/twirl/gitbucket/core/main.scala.html index 9d946ec..2c1d3a3 100644 --- a/src/main/twirl/gitbucket/core/main.scala.html +++ b/src/main/twirl/gitbucket/core/main.scala.html @@ -10,20 +10,15 @@ @title - - - - + @@ -64,11 +59,11 @@
  • New repository
  • New group
  • - + @if(loginAccount.get.isAdmin){ - + } - + } else { Sign in } diff --git a/src/main/twirl/gitbucket/core/menu.scala.html b/src/main/twirl/gitbucket/core/menu.scala.html index e37ba29..8f088bc 100644 --- a/src/main/twirl/gitbucket/core/menu.scala.html +++ b/src/main/twirl/gitbucket/core/menu.scala.html @@ -35,18 +35,23 @@ @if(repository.commitCount > 0){
    - @if(loginAccount.isEmpty){ - Fork - } else { - @if(isNoGroup) { - Fork + @if(loginAccount.isEmpty){ + Fork } else { - Fork + @if(isNoGroup) { + Fork + } else { + Fork + } } - } @repository.forkedCount
    + @if(loginAccount.isDefined && isNoGroup){ +
    + +
    + } }
    @helper.html.repositoryicon(repository, true) @@ -151,7 +156,7 @@ $('a[rel*=facebox]').facebox({ 'loadingImage': '@assets/vendors/facebox/loading.gif', - 'closeImage': '@assets/vendors/facebox/closelabel.png', + 'closeImage': '@assets/vendors/facebox/closelabel.png' }); $(document).on("click", ".js-fork-owner-select-target", function() { @@ -163,19 +168,9 @@ }); @if(loginAccount.isDefined){ - $(document).on("click", "a[data-account]", function(e) { + $(document).on("click", "a#fork-link", function(e) { e.preventDefault(); - var form = $('
    ', { - action: $(this).attr('href'), - method: "post" - }); - var account = $('', { - type: "hidden", - name: "account", - value: $(this).data('account') - }); - form.append(account); - form.submit(); + $('#fork-form').submit(); }); } diff --git a/src/main/twirl/gitbucket/core/pulls/conversation.scala.html b/src/main/twirl/gitbucket/core/pulls/conversation.scala.html index 3bead01..d005b40 100644 --- a/src/main/twirl/gitbucket/core/pulls/conversation.scala.html +++ b/src/main/twirl/gitbucket/core/pulls/conversation.scala.html @@ -10,35 +10,21 @@ @import context._ @import gitbucket.core.view.helpers._ @import gitbucket.core.model._ +
    @issues.html.commentlist(Some(issue), comments, hasWritePermission, repository, Some(pullreq))
    @defining(comments.flatMap { - case comment: IssueComment => Some(comment) + case comment: gitbucket.core.model.IssueComment => Some(comment) case other => None }.exists(_.action == "merge")){ merged => @if(hasWritePermission && !issue.closed){ -
    -
    -
    - -
    - + - -