diff --git a/project/build.scala b/project/build.scala index 7d41538..f29febf 100644 --- a/project/build.scala +++ b/project/build.scala @@ -69,6 +69,9 @@ 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/src/main/resources/update/2_9.sql b/src/main/resources/update/2_9.sql new file mode 100644 index 0000000..3ddc48a --- /dev/null +++ b/src/main/resources/update/2_9.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 cfa37e6..ac8f57e 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -1,4 +1,4 @@ -import _root_.servlet.{BasicAuthenticationFilter, TransactionFilter} +import _root_.servlet.{BasicAuthenticationFilter, TransactionFilter, AccessTokenAuthenticationFilter} import app._ import plugin.PluginRegistry @@ -14,7 +14,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, "/*") diff --git a/src/main/scala/api/ApiCombinedCommitStatus.scala b/src/main/scala/api/ApiCombinedCommitStatus.scala new file mode 100644 index 0000000..19f2ead --- /dev/null +++ b/src/main/scala/api/ApiCombinedCommitStatus.scala @@ -0,0 +1,26 @@ +package api + +import model.Account +import model.CommitStatus +import model.CommitState + +/** + * 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/api/ApiComment.scala b/src/main/scala/api/ApiComment.scala new file mode 100644 index 0000000..34197f0 --- /dev/null +++ b/src/main/scala/api/ApiComment.scala @@ -0,0 +1,24 @@ +package api + +import java.util.Date +import model.IssueComment + +/** + * https://developer.github.com/v3/issues/comments/ + */ +case class ApiComment( + id: Int, + user: ApiUser, + body: String, + created_at: Date, + updated_at: Date) + +object ApiComment{ + def apply(comment: IssueComment, user: ApiUser): ApiComment = + ApiComment( + id = comment.commentId, + user = user, + body = comment.content, + created_at = comment.registeredDate, + updated_at = comment.updatedDate) +} diff --git a/src/main/scala/api/ApiCommit.scala b/src/main/scala/api/ApiCommit.scala new file mode 100644 index 0000000..57ee02d --- /dev/null +++ b/src/main/scala/api/ApiCommit.scala @@ -0,0 +1,41 @@ +package api + +import java.util.Date +import org.eclipse.jgit.diff.DiffEntry +import util.JGitUtil +import util.JGitUtil.CommitInfo +import org.eclipse.jgit.api.Git +import util.RepositoryName + +/** + * 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/api/ApiCommitListItem.scala b/src/main/scala/api/ApiCommitListItem.scala new file mode 100644 index 0000000..4a80d8c --- /dev/null +++ b/src/main/scala/api/ApiCommitListItem.scala @@ -0,0 +1,41 @@ +package api + +import util.JGitUtil.CommitInfo +import ApiCommitListItem._ +import 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/api/ApiCommitStatus.scala b/src/main/scala/api/ApiCommitStatus.scala new file mode 100644 index 0000000..011be26 --- /dev/null +++ b/src/main/scala/api/ApiCommitStatus.scala @@ -0,0 +1,35 @@ +package api + +import java.util.Date +import model.CommitStatus +import util.RepositoryName + +/** + * 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/api/ApiError.scala b/src/main/scala/api/ApiError.scala new file mode 100644 index 0000000..8acdba2 --- /dev/null +++ b/src/main/scala/api/ApiError.scala @@ -0,0 +1,5 @@ +package api + +case class ApiError( + message: String, + documentation_url: Option[String] = None) diff --git a/src/main/scala/api/ApiIssue.scala b/src/main/scala/api/ApiIssue.scala new file mode 100644 index 0000000..1bbb55a --- /dev/null +++ b/src/main/scala/api/ApiIssue.scala @@ -0,0 +1,29 @@ +package api + +import java.util.Date +import model.Issue + +/** + * 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) + +object ApiIssue{ + def apply(issue: Issue, 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) +} diff --git a/src/main/scala/api/ApiPath.scala b/src/main/scala/api/ApiPath.scala new file mode 100644 index 0000000..1f6701e --- /dev/null +++ b/src/main/scala/api/ApiPath.scala @@ -0,0 +1,6 @@ +package 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/api/ApiPersonIdent.scala b/src/main/scala/api/ApiPersonIdent.scala new file mode 100644 index 0000000..010f539 --- /dev/null +++ b/src/main/scala/api/ApiPersonIdent.scala @@ -0,0 +1,22 @@ +package api + +import java.util.Date +import util.JGitUtil.CommitInfo + +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/api/ApiPullRequest.scala b/src/main/scala/api/ApiPullRequest.scala new file mode 100644 index 0000000..7ce6f6d --- /dev/null +++ b/src/main/scala/api/ApiPullRequest.scala @@ -0,0 +1,58 @@ +package api + +import java.util.Date +import model.{Issue, PullRequest} +import ApiPullRequest._ + +/** + * 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/api/ApiRepository.scala b/src/main/scala/api/ApiRepository.scala new file mode 100644 index 0000000..1962e47 --- /dev/null +++ b/src/main/scala/api/ApiRepository.scala @@ -0,0 +1,48 @@ +package api + +import util.JGitUtil.CommitInfo +import service.RepositoryService.RepositoryInfo +import model.{Account, Repository} + +// 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/api/ApiUser.scala b/src/main/scala/api/ApiUser.scala new file mode 100644 index 0000000..f180a9d --- /dev/null +++ b/src/main/scala/api/ApiUser.scala @@ -0,0 +1,33 @@ +package api + +import java.util.Date +import model.Account + +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.fullName, + email = user.mailAddress, + `type` = if(user.isGroupAccount){ "Organization" }else{ "User" }, + site_admin = user.isAdmin, + created_at = user.registeredDate + ) +} diff --git a/src/main/scala/api/CreateAComment.scala b/src/main/scala/api/CreateAComment.scala new file mode 100644 index 0000000..138f705 --- /dev/null +++ b/src/main/scala/api/CreateAComment.scala @@ -0,0 +1,7 @@ +package api + +/** + * https://developer.github.com/v3/issues/comments/#create-a-comment + * api form + */ +case class CreateAComment(body: String) diff --git a/src/main/scala/api/CreateAStatus.scala b/src/main/scala/api/CreateAStatus.scala new file mode 100644 index 0000000..51b82a2 --- /dev/null +++ b/src/main/scala/api/CreateAStatus.scala @@ -0,0 +1,26 @@ +package api + +import 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/api/JsonFormat.scala b/src/main/scala/api/JsonFormat.scala new file mode 100644 index 0000000..385a24c --- /dev/null +++ b/src/main/scala/api/JsonFormat.scala @@ -0,0 +1,37 @@ +package api +import org.json4s._ +import org.json4s.jackson.Serialization +import scala.util.Try +import org.joda.time.format._ +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.util.Date +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]() + 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/app/AccountController.scala b/src/main/scala/app/AccountController.scala index 8311654..b2650fc 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -14,14 +14,18 @@ import org.eclipse.jgit.lib.{FileMode, Constants} import org.eclipse.jgit.dircache.DirCache import model.GroupMember +import service.WebHookService._ +import api._ 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]) @@ -31,6 +35,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)))), @@ -54,6 +60,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) @@ -142,6 +152,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 => @@ -206,6 +235,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 + } + account.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/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index 8a184f0..a8b31fc 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -10,11 +10,11 @@ import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils import model._ -import service.{SystemSettingsService, AccountService} +import service.{SystemSettingsService, AccountService, AccessTokenService} 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 +51,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 +77,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 +106,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(api.ApiError("Not Found")) } else { org.scalatra.NotFound(html.error("Not Found")) } @@ -110,6 +116,9 @@ protected def Unauthorized()(implicit context: app.Context) = if(request.hasAttribute(Keys.Request.Ajax)){ org.scalatra.Unauthorized() + } else if(request.hasAttribute(Keys.Request.APIv3)){ + contentType = formats("json") + org.scalatra.Unauthorized(api.ApiError("Requires authentication")) } else { if(context.loginAccount.isDefined){ org.scalatra.Unauthorized(redirect("/")) @@ -146,6 +155,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/app/IndexController.scala b/src/main/scala/app/IndexController.scala index 9a2bc37..5986807 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -4,6 +4,7 @@ import util.Implicits._ import service._ import jp.sf.amateras.scalatra.forms._ +import api._ class IndexController extends IndexControllerBase with RepositoryService with ActivityService with AccountService with UsersAuthenticator @@ -103,4 +104,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(api.ApiError("Rate limiting is not enabled.")) + } } diff --git a/src/main/scala/app/IssuesController.scala b/src/main/scala/app/IssuesController.scala index 682ba9e..43e24f4 100644 --- a/src/main/scala/app/IssuesController.scala +++ b/src/main/scala/app/IssuesController.scala @@ -9,14 +9,17 @@ import util.ControlUtil._ import org.scalatra.Ok import model.Issue +import service.WebHookService._ +import scala.util.Try +import api._ 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]) @@ -73,6 +76,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, ApiUser(user)) }) + }).getOrElse(NotFound) + }) + get("/:owner/:repository/issues/new")(readableUsersOnly { repository => defining(repository.owner, repository.name){ case (owner, name) => issues.html.create( @@ -109,9 +124,12 @@ // 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("")) + + // call web hooks + callIssuesWebHook("opened", repository, issue, context.baseUrl, context.loginAccount.get) } // notifications @@ -160,6 +178,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, 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}/${ @@ -364,6 +396,22 @@ 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 => @@ -394,7 +442,7 @@ val condition = session.putAndGet(sessionKey, if(request.hasQueryString){ val q = request.getParameter("q") - if(q == null){ + if(q == null || q.trim.isEmpty){ IssueSearchCondition(request) } else { IssueSearchCondition(q, getMilestones(owner, repoName).map(x => (x.title, x.milestoneId)).toMap) @@ -416,5 +464,4 @@ hasWritePermission(owner, repoName, context.loginAccount)) } } - } diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala index 23ed289..f3c8cba 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -15,18 +15,21 @@ import org.slf4j.LoggerFactory import org.eclipse.jgit.merge.MergeStrategy import org.eclipse.jgit.errors.NoMergeBaseException -import service.WebHookService.WebHookPayload +import service.WebHookService._ import util.JGitUtil.DiffInfo import util.JGitUtil.CommitInfo - +import model.{PullRequest, Issue, CommitState} +import api._ 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]) @@ -68,6 +71,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[(model.Issue, model.Account, Int, model.PullRequest, model.Repository, model.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 @@ -76,7 +97,6 @@ using(Git.open(getRepositoryDir(owner, name))){ git => val (commits, diffs) = getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo) - pulls.html.pullreq( issue, pullreq, (commits.flatten.map(commit => getCommitComments(owner, name, commit.id, true)).flatten.toList ::: getComments(owner, name, issueId)) @@ -94,14 +114,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.userName), Set()) + baseOwner <- users.get(repository.owner) + headOwner <- users.get(pullRequest.requestUserName) + issueUser <- users.get(issue.userName) + 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 = util.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) pulls.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 @@ -138,43 +208,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) @@ -192,14 +229,7 @@ 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"){ @@ -315,9 +345,11 @@ val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2 val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2 - pulls.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) + } + pulls.html.mergecheck(conflict) } }) getOrElse NotFound }) @@ -347,16 +379,14 @@ 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}") @@ -366,62 +396,6 @@ }) /** - * 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") @@ -479,5 +453,4 @@ repository, hasWritePermission(owner, repoName, context.loginAccount)) } - } diff --git a/src/main/scala/app/RepositorySettingsController.scala b/src/main/scala/app/RepositorySettingsController.scala index cce95bb..233b1ef 100644 --- a/src/main/scala/app/RepositorySettingsController.scala +++ b/src/main/scala/app/RepositorySettingsController.scala @@ -7,7 +7,7 @@ import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils import org.scalatra.i18n.Messages -import service.WebHookService.WebHookPayload +import service.WebHookService.WebHookPushPayload import util.JGitUtil.CommitInfo import util.ControlUtil._ import org.eclipse.jgit.api.Git @@ -166,9 +166,9 @@ .call.iterator.asScala.map(new CommitInfo(_)) getAccountByUserName(repository.owner).foreach { ownerAccount => - callWebHook(repository.owner, repository.name, + callWebHook("push", List(model.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 diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index 57b1027..821900c 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -17,11 +17,14 @@ import jp.sf.amateras.scalatra.forms._ import org.eclipse.jgit.dircache.DirCache import org.eclipse.jgit.revwalk.RevCommit -import service.WebHookService.WebHookPayload +import service.WebHookService._ +import model.CommitState +import api._ 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 /** @@ -29,7 +32,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) @@ -106,6 +110,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 => @@ -136,6 +147,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 <- model.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) repo.html.editor(branch, repository, if(path.length == 0) Nil else path.split("/").toList, @@ -322,14 +383,11 @@ * Displays branches. */ get("/:owner/:repository/branches")(referrersOnly { repository => - using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => - // retrieve latest update date of each branch - val branchInfo = repository.branchList.map { branchName => - val revCommit = git.log.add(git.getRepository.resolve(branchName)).setMaxCount(1).call.iterator.next - (branchName, revCommit.getCommitterIdent.getWhen) - } - repo.html.branches(branchInfo, hasWritePermission(repository.owner, repository.name, context.loginAccount), repository) - } + val branches = JGitUtil.getBranches(repository.owner, repository.name, repository.repository.defaultBranch) + .sortBy(br => (br.mergeInfo.isEmpty, br.commitTime)) + .map(br => br -> getPullRequestByRequestCommit(repository.owner, repository.name, repository.repository.defaultBranch, br.name, br.commitId)) + .reverse + repo.html.branches(branches, hasWritePermission(repository.owner, repository.name, context.loginAccount), repository) }) /** @@ -506,14 +564,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/model/AccessToken.scala b/src/main/scala/model/AccessToken.scala new file mode 100644 index 0000000..2695c2f --- /dev/null +++ b/src/main/scala/model/AccessToken.scala @@ -0,0 +1,20 @@ +package 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/model/BasicTemplate.scala b/src/main/scala/model/BasicTemplate.scala index 52e0a11..1d012c1 100644 --- a/src/main/scala/model/BasicTemplate.scala +++ b/src/main/scala/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/model/CommitStatus.scala b/src/main/scala/model/CommitStatus.scala new file mode 100644 index 0000000..47a2343 --- /dev/null +++ b/src/main/scala/model/CommitStatus.scala @@ -0,0 +1,71 @@ +package 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.< (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/service/AccountService.scala b/src/main/scala/service/AccountService.scala index c502eb7..88d95fa 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/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/service/CommitStatusService.scala b/src/main/scala/service/CommitStatusService.scala new file mode 100644 index 0000000..8860f77 --- /dev/null +++ b/src/main/scala/service/CommitStatusService.scala @@ -0,0 +1,50 @@ +package service + +import model.Profile._ +import profile.simple._ +import model.{CommitState, CommitStatus, Account} +import util.Implicits._ +import util.StringUtil._ +import 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/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index 8740d9e..0edc884 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -20,6 +20,12 @@ def getComments(owner: String, repository: String, issueId: Int)(implicit s: Session) = IssueComments filter (_.byIssue(owner, repository, issueId)) list + def getCommentsForApi(owner: String, repository: String, issueId: Int)(implicit s: Session) = + IssueComments.filter(_.byIssue(owner, repository, issueId)) + .filter(_.action inSetBind Set("comment" , "close_comment", "reopen_comment")) + .innerJoin(Accounts).on( (t1, t2) => t1.userName === t2.userName ) + .list + def getComment(owner: String, repository: String, commentId: String)(implicit s: Session) = if (commentId forall (_.isDigit)) IssueComments filter { t => @@ -78,6 +84,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 model.Profile.commitStateColumnType + val query = Q.query[Seq[(String, String, Int)], (String, String, Int, Int, Int, Option[String], Option[model.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 +137,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, commentCount, pullRequest, headRepository, headOwner) + */ + def searchPullRequestByApi(condition: IssueSearchCondition, offset: Int, limit: Int, repos: (String, String)*) + (implicit s: Session): List[(Issue, model.Account, Int, model.PullRequest, model.Repository, model.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.userName } + .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 +199,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. @@ -139,11 +210,16 @@ .map { case (owner, repository) => t1.byRepository(owner, repository) } .foldLeft[Column[Boolean]](false) ( _ || _ ) && (t1.closed === (condition.state == "closed").bind) && - (t1.milestoneId === condition.milestoneId.get.get.bind, condition.milestoneId.flatten.isDefined) && - (t1.milestoneId.? isEmpty, condition.milestoneId == Some(None)) && + //(t1.milestoneId === condition.milestoneId.get.get.bind, condition.milestoneId.flatten.isDefined) && + (t1.milestoneId.? isEmpty, condition.milestone == Some(None)) && (t1.assignedUserName === condition.assigned.get.bind, condition.assigned.isDefined) && (t1.openedUserName === condition.author.get.bind, condition.author.isDefined) && (t1.pullRequest === pullRequest.bind) && + // Milestone filter + (Milestones filter { t2 => + (t2.byPrimaryKey(t1.userName, t1.repositoryName, t1.milestoneId)) && + (t2.title === condition.milestone.get.get.bind) + } exists, condition.milestone.flatten.isDefined) && // Label filter (IssueLabels filter { t2 => (t2.byIssue(t1.userName, t1.repositoryName, t1.issueId)) && @@ -322,7 +398,7 @@ case class IssueSearchCondition( labels: Set[String] = Set.empty, - milestoneId: Option[Option[Int]] = None, + milestone: Option[Option[String]] = None, author: Option[String] = None, assigned: Option[String] = None, mentioned: Option[String] = None, @@ -333,7 +409,7 @@ groups: Set[String] = Set.empty){ def isEmpty: Boolean = { - labels.isEmpty && milestoneId.isEmpty && author.isEmpty && assigned.isEmpty && + labels.isEmpty && milestone.isEmpty && author.isEmpty && assigned.isEmpty && state == "open" && sort == "created" && direction == "desc" && visibility.isEmpty } @@ -348,8 +424,8 @@ ).flatten ++ labels.map(label => s"label:${label}") ++ List( - milestoneId.map { _ match { - case Some(x) => s"milestone:${milestoneId}" + milestone.map { _ match { + case Some(x) => s"milestone:${x}" case None => "no:milestone" }}, (sort, direction) match { @@ -368,8 +444,8 @@ def toURL: String = "?" + List( if(labels.isEmpty) None else Some("labels=" + urlEncode(labels.mkString(","))), - milestoneId.map { _ match { - case Some(x) => "milestone=" + x + milestone.map { _ match { + case Some(x) => "milestone=" + urlEncode(x) case None => "milestone=none" }}, author .map(x => "author=" + urlEncode(x)), @@ -416,7 +492,7 @@ conditions.get("milestone").flatMap(_.headOption) match { case None => None case Some("none") => Some(None) - case Some(x) => milestones.get(x).map(x => Some(x)) + case Some(x) => Some(Some(x)) //milestones.get(x).map(x => Some(x)) }, conditions.get("author").flatMap(_.headOption), conditions.get("assignee").flatMap(_.headOption), @@ -437,7 +513,7 @@ param(request, "labels").map(_.split(",").toSet).getOrElse(Set.empty), param(request, "milestone").map { case "none" => None - case x => x.toIntOpt + case x => Some(x) }, param(request, "author"), param(request, "assigned"), @@ -457,6 +533,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[model.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/service/MergeService.scala b/src/main/scala/service/MergeService.scala new file mode 100644 index 0000000..b2168ac --- /dev/null +++ b/src/main/scala/service/MergeService.scala @@ -0,0 +1,168 @@ +package service +import util.LockUtil +import util.Directory._ +import util.Implicits._ +import 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 model.Account +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/service/PullRequestService.scala b/src/main/scala/service/PullRequestService.scala index 68121f9..19c20d9 100644 --- a/src/main/scala/service/PullRequestService.scala +++ b/src/main/scala/service/PullRequestService.scala @@ -2,7 +2,7 @@ import model.Profile._ import profile.simple._ -import model.{PullRequest, Issue} +import model.{PullRequest, Issue, WebHook, Account} import util.JGitUtil trait PullRequestService { self: IssuesService => @@ -94,6 +94,26 @@ updateCommitId(pullreq.userName, pullreq.repositoryName, pullreq.issueId, commitIdTo, commitIdFrom) } } + + def getPullRequestByRequestCommit(userName: String, repositoryName: String, toBranch:String, fromBranch: String, commitId: String) + (implicit s: Session): Option[(PullRequest, Issue)] = { + if(toBranch == fromBranch){ + None + } else { + PullRequests + .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) } + .filter { case (t1, t2) => + (t1.userName === userName.bind) && + (t1.repositoryName === repositoryName.bind) && + (t1.branch === toBranch.bind) && + (t1.requestUserName === userName.bind) && + (t1.requestRepositoryName === repositoryName.bind) && + (t1.requestBranch === fromBranch.bind) && + (t1.commitIdTo === commitId.bind) + } + .firstOption + } + } } object PullRequestService { diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index f54291b..34ece5b 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/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/service/WebHookService.scala b/src/main/scala/service/WebHookService.scala index a2dafbf..83c40ac 100644 --- a/src/main/scala/service/WebHookService.scala +++ b/src/main/scala/service/WebHookService.scala @@ -2,16 +2,17 @@ import model.Profile._ import profile.simple._ -import model.{WebHook, Account} +import model.{WebHook, Account, Issue, PullRequest, IssueComment, Repository, CommitStatus, CommitState} import org.slf4j.LoggerFactory import service.RepositoryService.RepositoryInfo import util.JGitUtil -import org.eclipse.jgit.diff.DiffEntry import util.JGitUtil.CommitInfo import org.eclipse.jgit.api.Git import org.apache.http.message.BasicNameValuePair import org.apache.http.client.entity.UrlEncodedFormEntity import org.apache.http.NameValuePair +import java.util.Date +import api._ trait WebHookService { import WebHookService._ @@ -27,26 +28,28 @@ 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("X-Github-Event", eventName) val params: java.util.List[NameValuePair] = new java.util.ArrayList() params.add(new BasicNameValuePair("payload", json)) @@ -66,78 +69,203 @@ } 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: model.Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + callWebHookOf(repository.owner, repository.name, "issues"){ + val users = getAccountsByUserNames(Set(repository.owner, issue.userName), Set(sender)) + for{ + repoOwner <- users.get(repository.owner) + issueUser <- users.get(issue.userName) + } yield { + WebHookIssuesPayload( + action = action, + number = issue.issueId, + repository = ApiRepository(repository, ApiUser(repoOwner)), + issue = ApiIssue(issue, ApiUser(issueUser)), + sender = ApiUser(sender)) + } + } + } + + def callPullRequestWebHook(action: String, repository: RepositoryService.RepositoryInfo, issueId: Int, baseUrl: String, sender: model.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), Set(sender)) + baseOwner <- users.get(repository.owner) + headOwner <- users.get(pullRequest.requestUserName) + headRepo <- getRepository(pullRequest.requestUserName, pullRequest.requestRepositoryName, baseUrl) + } yield { + WebHookPullRequestPayload( + action = action, + issue = issue, + pullRequest = pullRequest, + headRepository = headRepo, + headOwner = headOwner, + baseRepository = repository, + baseOwner = baseOwner, + sender = sender) + } + } + } + + def getPullRequestsByRequestForWebhook(userName:String, repositoryName:String, branch:String) + (implicit s: Session): Map[(Issue, 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 + wh <- WebHooks if wh.byRepository(is.userName , is.repositoryName) + } yield { + ((is, pr, bu, ru), wh) + }).list.groupBy(_._1).mapValues(_.map(_._2)) + + def callPullRequestWebHookByRequestBranch(action: String, requestRepository: RepositoryService.RepositoryInfo, requestBranch: String, baseUrl: String, sender: model.Account)(implicit s: Session, context:JsonFormat.Context): Unit = { + import WebHookService._ + for{ + ((issue, 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, + 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: model.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.userName, repository.owner, issueComment.userName), Set(sender)) + issueUser <- users.get(issue.userName) + repoOwner <- users.get(repository.owner) + commenter <- users.get(issueComment.userName) + } 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, util.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, + pullRequest: PullRequest, + headRepository: RepositoryInfo, + headOwner: Account, + baseRepository: RepositoryInfo, + baseOwner: Account, + sender: model.Account): WebHookPullRequestPayload = { + val headRepoPayload = ApiRepository(headRepository, headOwner) + val baseRepoPayload = ApiRepository(baseRepository, baseOwner) + val senderPayload = ApiUser(sender) + val pr = ApiPullRequest(issue, pullRequest, headRepoPayload, baseRepoPayload, senderPayload) + 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, ApiUser(issueUser)), + comment = ApiComment(comment, ApiUser(commentUser)), + sender = ApiUser(sender)) + } } diff --git a/src/main/scala/servlet/AccessTokenAuthenticationFilter.scala b/src/main/scala/servlet/AccessTokenAuthenticationFilter.scala new file mode 100644 index 0000000..56e50c2 --- /dev/null +++ b/src/main/scala/servlet/AccessTokenAuthenticationFilter.scala @@ -0,0 +1,41 @@ +package servlet + +import javax.servlet._ +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} + +import service.AccessTokenService +import util.Keys +import org.scalatra.servlet.ServletApiImplicits._ +import model.Account +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/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 7fde407..9ebb4ac 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -97,7 +97,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 @@ -121,6 +122,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") { @@ -137,8 +139,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) { @@ -175,20 +179,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/servlet/InitializeListener.scala b/src/main/scala/servlet/InitializeListener.scala index fdc3c48..a4b1e1e 100644 --- a/src/main/scala/servlet/InitializeListener.scala +++ b/src/main/scala/servlet/InitializeListener.scala @@ -19,6 +19,7 @@ * The history of versions. A head of this sequence is the current BitBucket version. */ val versions = Seq( + new Version(2, 9), new Version(2, 8), new Version(2, 7) { override def update(conn: Connection, cl: ClassLoader): Unit = { diff --git a/src/main/scala/util/Implicits.scala b/src/main/scala/util/Implicits.scala index f1b1115..02ecb82 100644 --- a/src/main/scala/util/Implicits.scala +++ b/src/main/scala/util/Implicits.scala @@ -14,6 +14,8 @@ // Convert to slick session. implicit def request2Session(implicit request: HttpServletRequest): JdbcBackend#Session = Database.getSession(request) + implicit def context2ApiJsonFormatContext(implicit context: app.Context): api.JsonFormat.Context = api.JsonFormat.Context(context.baseUrl) + implicit class RichSeq[A](seq: Seq[A]) { def splitWith(condition: (A, A) => Boolean): Seq[Seq[A]] = split(seq)(condition) @@ -55,7 +57,10 @@ implicit class RichRequest(request: HttpServletRequest){ - def paths: Array[String] = request.getRequestURI.substring(request.getContextPath.length + 1).split("/") + def paths: Array[String] = (request.getRequestURI match{ + case path if path.startsWith("/api/v3/repos/") => path.substring(13/* "/api/v3/repos".length */) + case path => path + }).substring(request.getContextPath.length + 1).split("/") def hasQueryString: Boolean = request.getQueryString != null diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index 0f76f1e..47e6994 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -134,6 +134,10 @@ */ case class SubmoduleInfo(name: String, path: String, url: String) + case class BranchMergeInfo(ahead: Int, behind: Int, isMerged: Boolean) + + case class BranchInfo(name: String, committerName: String, commitTime: Date, committerEmailAddress:String, mergeInfo: Option[BranchMergeInfo], commitId: String) + /** * Returns RevCommit from the commit or tag id. * @@ -705,4 +709,56 @@ return git.log.add(startCommit).addPath(path).setMaxCount(1).call.iterator.next } + def getBranches(owner: String, name: String, defaultBranch: String): Seq[BranchInfo] = { + using(Git.open(getRepositoryDir(owner, name))){ git => + val repo = git.getRepository + val defaultObject = repo.resolve(defaultBranch) + git.branchList.call.asScala.map { ref => + val walk = new RevWalk(repo) + try{ + val defaultCommit = walk.parseCommit(defaultObject) + val branchName = ref.getName.stripPrefix("refs/heads/") + val branchCommit = if(branchName == defaultBranch){ + defaultCommit + }else{ + walk.parseCommit(ref.getObjectId) + } + val when = branchCommit.getCommitterIdent.getWhen + val committer = branchCommit.getCommitterIdent.getName + val committerEmail = branchCommit.getCommitterIdent.getEmailAddress + val mergeInfo = if(branchName==defaultBranch){ + None + }else{ + walk.reset() + walk.setRevFilter( RevFilter.MERGE_BASE ) + walk.markStart(branchCommit) + walk.markStart(defaultCommit) + val mergeBase = walk.next() + walk.reset() + walk.setRevFilter(RevFilter.ALL) + Some(BranchMergeInfo( + ahead = RevWalkUtils.count(walk, branchCommit, mergeBase), + behind = RevWalkUtils.count(walk, defaultCommit, mergeBase), + isMerged = walk.isMergedInto(branchCommit, defaultCommit))) + } + BranchInfo(branchName, committer, when, committerEmail, mergeInfo, ref.getObjectId.name) + } finally { + walk.dispose(); + } + } + } + } + + /** + * 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/util/Keys.scala b/src/main/scala/util/Keys.scala index a934058..b6b6c79 100644 --- a/src/main/scala/util/Keys.scala +++ b/src/main/scala/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/util/RepoitoryName.scala b/src/main/scala/util/RepoitoryName.scala new file mode 100644 index 0000000..0e6edbf --- /dev/null +++ b/src/main/scala/util/RepoitoryName.scala @@ -0,0 +1,18 @@ +package 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: model.Repository): RepositoryName = RepositoryName(repository.userName, repository.repositoryName) + def apply(repository: util.JGitUtil.RepositoryInfo): RepositoryName = RepositoryName(repository.owner, repository.name) + def apply(repository: service.RepositoryService.RepositoryInfo): RepositoryName = RepositoryName(repository.owner, repository.name) + def apply(repository: model.CommitStatus): RepositoryName = RepositoryName(repository.userName, repository.repositoryName) +} diff --git a/src/main/scala/view/helpers.scala b/src/main/scala/view/helpers.scala index caea648..1f2b406 100644 --- a/src/main/scala/view/helpers.scala +++ b/src/main/scala/view/helpers.scala @@ -4,6 +4,7 @@ import play.twirl.api.Html import util.StringUtil import service.RequestCache +import model.CommitState /** * Provides helper methods for Twirl templates. @@ -260,4 +261,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/account/application.scala.html b/src/main/twirl/account/application.scala.html new file mode 100644 index 0000000..e6a5d06 --- /dev/null +++ b/src/main/twirl/account/application.scala.html @@ -0,0 +1,55 @@ +@(account: model.Account, personalTokens: List[model.AccessToken], gneratedToken:Option[(model.AccessToken, String)])(implicit context: app.Context) +@import context._ +@import 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/account/menu.scala.html b/src/main/twirl/account/menu.scala.html index a5d9139..914a92b 100644 --- a/src/main/twirl/account/menu.scala.html +++ b/src/main/twirl/account/menu.scala.html @@ -10,5 +10,8 @@ SSH Keys } + + Applications + diff --git a/src/main/twirl/dashboard/issueslist.scala.html b/src/main/twirl/dashboard/issueslist.scala.html index 42b5f95..4bf230b 100644 --- a/src/main/twirl/dashboard/issueslist.scala.html +++ b/src/main/twirl/dashboard/issueslist.scala.html @@ -14,7 +14,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){ @@ -28,6 +28,7 @@ } else { @issue.title } + @_root_.issues.html.commitstatus(issue, commitStatus) @labels.map { label => @label.labelName } @@ -48,7 +49,7 @@
#@issue.issueId opened by @user(issue.openedUserName, styleClass="username") @datetime(issue.registeredDate) @milestone.map { milestone => - @milestone + @milestone }
diff --git a/src/main/twirl/issues/commitstatus.scala.html b/src/main/twirl/issues/commitstatus.scala.html new file mode 100644 index 0000000..542b3f3 --- /dev/null +++ b/src/main/twirl/issues/commitstatus.scala.html @@ -0,0 +1,19 @@ +@(issue: model.Issue, statusInfo: Option[service.IssuesService.CommitStatusInfo])(implicit context: app.Context) +@import 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/issues/listparts.scala.html b/src/main/twirl/issues/listparts.scala.html index 1ff944f..5c8f2fe 100644 --- a/src/main/twirl/issues/listparts.scala.html +++ b/src/main/twirl/issues/listparts.scala.html @@ -60,14 +60,14 @@ } @helper.html.dropdown("Milestone", flat = true) {
  • - - @helper.html.checkicon(condition.milestoneId == Some(None)) Issues with no milestone + + @helper.html.checkicon(condition.milestone == Some(None)) Issues with no milestone
  • @milestones.filter(_.closedDate.isEmpty).map { milestone =>
  • - - @helper.html.checkicon(condition.milestoneId == Some(Some(milestone.milestoneId))) @milestone.title + + @helper.html.checkicon(condition.milestone == Some(Some(milestone.title))) @milestone.title
  • } @@ -156,8 +156,8 @@ } else { No pull requests to show. } - @if(condition.labels.nonEmpty || condition.milestoneId.isDefined){ - Clear active filters. + @if(condition.labels.nonEmpty || condition.milestone.isDefined){ + Clear active filters. } else { @if(repository.isDefined){ @if(target == "issues"){ @@ -170,7 +170,7 @@ } - @issues.map { case IssueInfo(issue, labels, milestone, commentCount) => + @issues.map { case IssueInfo(issue, labels, milestone, commentCount, commitStatus) => @if(hasWritePermission){ @@ -185,6 +185,7 @@ } else { @issue.title } + @commitstatus(issue, commitStatus) @labels.map { label => @label.labelName } @@ -205,7 +206,7 @@
    #@issue.issueId opened @helper.html.datetimeago(issue.registeredDate) by @user(issue.openedUserName, styleClass="username") @milestone.map { milestone => - @milestone + @milestone }
    diff --git a/src/main/twirl/pulls/compare.scala.html b/src/main/twirl/pulls/compare.scala.html index b3f2483..b606715 100644 --- a/src/main/twirl/pulls/compare.scala.html +++ b/src/main/twirl/pulls/compare.scala.html @@ -126,6 +126,9 @@ $(this).hide(); $('#pull-request-form').show(); }); + if(location.search.substr(1).split("&").indexOf("expand=1")!=-1){ + $('#show-form').click(); + } @if(hasWritePermission){ function checkConflict(from, to, noConflictHandler, hasConflictHandler){ diff --git a/src/main/twirl/pulls/conversation.scala.html b/src/main/twirl/pulls/conversation.scala.html index 33c923c..82a8982 100644 --- a/src/main/twirl/pulls/conversation.scala.html +++ b/src/main/twirl/pulls/conversation.scala.html @@ -8,7 +8,7 @@ hasWritePermission: Boolean, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ -@import model.IssueComment +@import model.{IssueComment, CommitState} @import view.helpers._
    @@ -20,25 +20,10 @@ case other => None }.exists(_.action == "merge")){ merged => @if(hasWritePermission && !issue.closed){ -
    -
    -
    - -
    - + \ No newline at end of file + diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index 0178bdd..f6f309c 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -166,7 +166,7 @@ } .monospace { - font-family: monospace; + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; } table.table th { @@ -472,6 +472,62 @@ margin-left: 40px; } +.branches .muted-link{ + color: #777; +} +.branches .muted-link:hover{ + color: #4183c4; +} +.branches .branch-details{ + display: inline-block; + width: 490px; + margin-right: 10px; +} +.branches .branch-name{ + color: #4183c4; + display: inline-block; + padding: 2px 6px; + font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; + background-color: rgba(209,227,237,0.5); + border-radius: 3px; +} +.branches .branch-meta{ + color: #aaa; + font-size: 12px; + line-height: 20px; +} +.branches .branch-action{ + display: inline-block; + float: right; + position: relative; + top: -3px; + height: 20px; +} +.branches .branch-a-b-count{ + display: inline-block; + vertical-align: middle; + width: 181px; + text-align: center; + color: rgba(0,0,0,0.5); +} +.branches .a-b-count-widget{ + font-size: 10px; +} +.branches .a-b-count-widget .count-half{ + width:90px; + position: relative; + text-align: right; + float: left; +} +.branches .a-b-count-widget .count-half:last-child { + text-align: left; + border-left: 1px solid #bbb; +} +.branches .a-b-count-widget .count-half .count-value{ + padding: 0 3px; +} + + /****************************************************************************/ /* Activity */ /****************************************************************************/ @@ -950,6 +1006,24 @@ font-size: 12px; } +.text-pending{ + color: #cea61b; +} +.text-failure{ + color: #bd2c00; +} + +.box-content .build-statuses{ + margin: -10px -10px 10px -10px; +} +.build-statuses .build-status-item{ + padding: 10px 15px 10px 12px; + border-bottom: 1px solid #eee; +} +.build-statuses-list .build-status-item{ + background-color: #fafafa; +} + /****************************************************************************/ /* Diff */ /****************************************************************************/ diff --git a/src/test/scala/api/JsonFormatSpec.scala b/src/test/scala/api/JsonFormatSpec.scala new file mode 100644 index 0000000..0f145a6 --- /dev/null +++ b/src/test/scala/api/JsonFormatSpec.scala @@ -0,0 +1,275 @@ +package api +import org.specs2.mutable.Specification +import java.util.{Date, Calendar, TimeZone} +import util.RepositoryName +import org.json4s.jackson.JsonMethods.{pretty, parse} +import org.json4s._ +import org.specs2.matcher._ +class JsonFormatSpec extends Specification { + val date1 = { + val d = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + d.set(2011,3,14,16,0,49) + d.getTime + } + val sha1 = "6dcb09b5b57875f334f61aebed695e2e4193db5e" + val repo1Name = RepositoryName("octocat/Hello-World") + implicit val context = JsonFormat.Context("http://gitbucket.exmple.com") + + val apiUser = ApiUser( + login= "octocat", + email= "octocat@example.com", + `type`= "User", + site_admin= false, + created_at= date1) + val apiUserJson = """{ + "login":"octocat", + "email":"octocat@example.com", + "type":"User", + "site_admin":false, + "created_at":"2011-04-14T16:00:49Z", + "url":"http://gitbucket.exmple.com/api/v3/users/octocat", + "html_url":"http://gitbucket.exmple.com/octocat" + }""" + + val repository = ApiRepository( + name = repo1Name.name, + full_name = repo1Name.fullName, + description = "This your first repo!", + watchers = 0, + forks = 0, + `private` = false, + default_branch = "master", + owner = apiUser) + val repositoryJson = s"""{ + "name" : "Hello-World", + "full_name" : "octocat/Hello-World", + "description" : "This your first repo!", + "watchers" : 0, + "forks" : 0, + "private" : false, + "default_branch" : "master", + "owner" : $apiUserJson, + "forks_count" : 0, + "watchers_coun" : 0, + "url" : "${context.baseUrl}/api/v3/repos/octocat/Hello-World", + "http_url" : "${context.baseUrl}/git/octocat/Hello-World.git", + "clone_url" : "${context.baseUrl}/git/octocat/Hello-World.git", + "html_url" : "${context.baseUrl}/octocat/Hello-World" + }""" + + val apiCommitStatus = ApiCommitStatus( + created_at = date1, + updated_at = date1, + state = "success", + target_url = Some("https://ci.example.com/1000/output"), + description = Some("Build has completed successfully"), + id = 1, + context = "Default", + creator = apiUser + )(sha1, repo1Name) + val apiCommitStatusJson = s"""{ + "created_at":"2011-04-14T16:00:49Z", + "updated_at":"2011-04-14T16:00:49Z", + "state":"success", + "target_url":"https://ci.example.com/1000/output", + "description":"Build has completed successfully", + "id":1, + "context":"Default", + "creator":$apiUserJson, + "url": "http://gitbucket.exmple.com/api/v3/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/statuses" + }""" + + val apiComment = ApiComment( + id =1, + user = apiUser, + body= "Me too", + created_at= date1, + updated_at= date1) + val apiCommentJson = s"""{ + "id": 1, + "body": "Me too", + "user": $apiUserJson, + "created_at": "2011-04-14T16:00:49Z", + "updated_at": "2011-04-14T16:00:49Z" + }""" + + val apiPersonIdent = ApiPersonIdent("Monalisa Octocat","support@example.com",date1) + val apiPersonIdentJson = """ { + "name": "Monalisa Octocat", + "email": "support@example.com", + "date": "2011-04-14T16:00:49Z" + }""" + + val apiCommitListItem = ApiCommitListItem( + sha = sha1, + commit = ApiCommitListItem.Commit( + message = "Fix all the bugs", + author = apiPersonIdent, + committer = apiPersonIdent + )(sha1, repo1Name), + author = Some(apiUser), + committer= Some(apiUser), + parents= Seq(ApiCommitListItem.Parent("6dcb09b5b57875f334f61aebed695e2e4193db5e")(repo1Name)))(repo1Name) + val apiCommitListItemJson = s"""{ + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "commit": { + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/git/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "author": $apiPersonIdentJson, + "committer": $apiPersonIdentJson, + "message": "Fix all the bugs" + }, + "author": $apiUserJson, + "committer": $apiUserJson, + "parents": [ + { + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + } + ] + }""" + + val apiCombinedCommitStatus = ApiCombinedCommitStatus( + state = "success", + sha = sha1, + total_count = 2, + statuses = List(apiCommitStatus), + repository = repository) + val apiCombinedCommitStatusJson = s"""{ + "state": "success", + "sha": "$sha1", + "total_count": 2, + "statuses": [ $apiCommitStatusJson ], + "repository": $repositoryJson, + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/commits/$sha1/status" + }""" + + val apiIssue = ApiIssue( + number = 1347, + title = "Found a bug", + user = apiUser, + state = "open", + body = "I'm having a problem with this.", + created_at = date1, + updated_at = date1) + val apiIssueJson = s"""{ + "number": 1347, + "state": "open", + "title": "Found a bug", + "body": "I'm having a problem with this.", + "user": $apiUserJson, + "created_at": "2011-04-14T16:00:49Z", + "updated_at": "2011-04-14T16:00:49Z" + }""" + + val apiPullRequest = ApiPullRequest( + number = 1347, + updated_at = date1, + created_at = date1, + head = ApiPullRequest.Commit( + sha = sha1, + ref = "new-topic", + repo = repository)("octocat"), + base = ApiPullRequest.Commit( + sha = sha1, + ref = "master", + repo = repository)("octocat"), + mergeable = None, + title = "new-feature", + body = "Please pull these awesome changes", + user = apiUser + ) + val apiPullRequestJson = s"""{ + "url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/pulls/1347", + "html_url": "${context.baseUrl}/octocat/Hello-World/pull/1347", + // "diff_url": "${context.baseUrl}/octocat/Hello-World/pull/1347.diff", + // "patch_url": "${context.baseUrl}/octocat/Hello-World/pull/1347.patch", + // "issue_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/issues/1347", + "commits_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/pulls/1347/commits", + "review_comments_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/pulls/1347/comments", + "review_comment_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/pulls/comments/{number}", + "comments_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/issues/1347/comments", + "statuses_url": "${context.baseUrl}/api/v3/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "number": 1347, + // "state": "open", + "title": "new-feature", + "body": "Please pull these awesome changes", + "created_at": "2011-04-14T16:00:49Z", + "updated_at": "2011-04-14T16:00:49Z", + // "closed_at": "2011-04-14T16:00:49Z", + // "merged_at": "2011-04-14T16:00:49Z", + "head": { + "label": "new-topic", + "ref": "new-topic", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "user": $apiUserJson, + "repo": $repositoryJson + }, + "base": { + "label": "master", + "ref": "master", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "user": $apiUserJson, + "repo": $repositoryJson + }, + "user": $apiUserJson + // "merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6", + // "merged": false, + // "mergeable": true, + // "merged_by": $$apiUserJson, + // "comments": 10, + // "commits": 3, + // "additions": 100, + // "deletions": 3, + // "changed_files": 5 + }""" + def beFormatted(json2Arg:String) = new Matcher[String] { + def apply[S <: String](e: Expectable[S]) = { + import java.util.regex.Pattern + val json2 = Pattern.compile("""^\s*//.*$""", Pattern.MULTILINE).matcher(json2Arg).replaceAll("") + val js2 = try{ + parse(json2) + }catch{ + case e:com.fasterxml.jackson.core.JsonParseException => { + val p = java.lang.Math.max(e.getLocation.getCharOffset()-10,0).toInt + val message = json2.substring(p,java.lang.Math.min(p+100,json2.length)) + throw new com.fasterxml.jackson.core.JsonParseException(message + e.getMessage , e.getLocation) + } + } + val js1 = parse(e.value) + result(js1 == js2, + "expected", + { + val diff = js2 diff js1 + s"${pretty(js1)} is not ${pretty(js2)} \n\n ${pretty(Extraction.decompose(diff)(org.json4s.DefaultFormats))}" + }, + e) + } + } + "JsonFormat" should { + "apiUser" in { + JsonFormat(apiUser) must beFormatted(apiUserJson) + } + "repository" in { + JsonFormat(repository) must beFormatted(repositoryJson) + } + "apiComment" in { + JsonFormat(apiComment) must beFormatted(apiCommentJson) + } + "apiCommitListItem" in { + JsonFormat(apiCommitListItem) must beFormatted(apiCommitListItemJson) + } + "apiCommitStatus" in { + JsonFormat(apiCommitStatus) must beFormatted(apiCommitStatusJson) + } + "apiCombinedCommitStatus" in { + JsonFormat(apiCombinedCommitStatus) must beFormatted(apiCombinedCommitStatusJson) + } + "apiIssue" in { + JsonFormat(apiIssue) must beFormatted(apiIssueJson) + } + "apiPullRequest" in { + JsonFormat(apiPullRequest) must beFormatted(apiPullRequestJson) + } + } +} diff --git a/src/test/scala/model/CommitStateSpec.scala b/src/test/scala/model/CommitStateSpec.scala new file mode 100644 index 0000000..231322f --- /dev/null +++ b/src/test/scala/model/CommitStateSpec.scala @@ -0,0 +1,25 @@ +package model + +import org.specs2.mutable.Specification + +import CommitState._ + +class CommitStateSpec extends Specification { + "CommitState" should { + "combine empty must eq PENDING" in { + combine(Set()) must_== PENDING + } + "combine includes ERROR must eq FAILURE" in { + combine(Set(ERROR, SUCCESS, PENDING)) must_== FAILURE + } + "combine includes FAILURE must eq peinding" in { + combine(Set(FAILURE, SUCCESS, PENDING)) must_== FAILURE + } + "combine includes PENDING must eq peinding" in { + combine(Set(PENDING, SUCCESS)) must_== PENDING + } + "combine only SUCCESS must eq SUCCESS" in { + combine(Set(SUCCESS)) must_== SUCCESS + } + } +} diff --git a/src/test/scala/service/AccessTokenServiceSpec.scala b/src/test/scala/service/AccessTokenServiceSpec.scala new file mode 100644 index 0000000..7729158 --- /dev/null +++ b/src/test/scala/service/AccessTokenServiceSpec.scala @@ -0,0 +1,88 @@ +package service + +import org.specs2.mutable.Specification +import java.util.Date +import model._ + +class AccessTokenServiceSpec extends Specification with ServiceSpecBase { + + "AccessTokenService" should { + "generateAccessToken" in { withTestDB { implicit session => + AccessTokenService.generateAccessToken("root", "note") must be like{ + case (id, token) if id != 0 => ok + } + }} + + "getAccessTokens" in { withTestDB { implicit session => + val (id, token) = AccessTokenService.generateAccessToken("root", "note") + val tokenHash = AccessTokenService.tokenToHash(token) + + AccessTokenService.getAccessTokens("root") must be like{ + case List(model.AccessToken(`id`, "root", `tokenHash`, "note")) => ok + } + }} + + "getAccessTokens(root) get root's tokens" in { withTestDB { implicit session => + val (id, token) = AccessTokenService.generateAccessToken("root", "note") + val tokenHash = AccessTokenService.tokenToHash(token) + val user2 = generateNewAccount("user2") + AccessTokenService.generateAccessToken("user2", "note2") + + AccessTokenService.getAccessTokens("root") must be like{ + case List(model.AccessToken(`id`, "root", `tokenHash`, "note")) => ok + } + }} + + "deleteAccessToken" in { withTestDB { implicit session => + val (id, token) = AccessTokenService.generateAccessToken("root", "note") + val user2 = generateNewAccount("user2") + AccessTokenService.generateAccessToken("user2", "note2") + + AccessTokenService.deleteAccessToken("root", id) + + AccessTokenService.getAccessTokens("root") must beEmpty + }} + + "getAccountByAccessToken" in { withTestDB { implicit session => + val (id, token) = AccessTokenService.generateAccessToken("root", "note") + AccessTokenService.getAccountByAccessToken(token) must beSome.like { + case user => user.userName must_== "root" + } + }} + + "getAccountByAccessToken don't get removed account" in { withTestDB { implicit session => + val user2 = generateNewAccount("user2") + val (id, token) = AccessTokenService.generateAccessToken("user2", "note") + AccountService.updateAccount(user2.copy(isRemoved=true)) + + AccessTokenService.getAccountByAccessToken(token) must beEmpty + }} + + "generateAccessToken create uniq token" in { withTestDB { implicit session => + val tokenIt = List("token1","token1","token1","token2").iterator + val service = new AccessTokenService{ + override def makeAccessTokenString:String = tokenIt.next + } + + service.generateAccessToken("root", "note1") must like{ + case (_, "token1") => ok + } + service.generateAccessToken("root", "note2") must like{ + case (_, "token2") => ok + } + }} + + "when update Account.userName then AccessToken.userName changed" in { withTestDB { implicit session => + val user2 = generateNewAccount("user2") + val (id, token) = AccessTokenService.generateAccessToken("user2", "note") + import model.Profile._ + import profile.simple._ + Accounts.filter(_.userName === "user2".bind).map(_.userName).update("user3") + + AccessTokenService.getAccountByAccessToken(token) must beSome.like { + case user => user.userName must_== "user3" + } + }} + } +} + diff --git a/src/test/scala/service/CommitStateServiceSpec.scala b/src/test/scala/service/CommitStateServiceSpec.scala new file mode 100644 index 0000000..4983fc5 --- /dev/null +++ b/src/test/scala/service/CommitStateServiceSpec.scala @@ -0,0 +1,73 @@ +package service +import org.specs2.mutable.Specification +import java.util.Date +import model._ +import model.Profile._ +import profile.simple._ +class CommitStatusServiceSpec extends Specification with ServiceSpecBase with CommitStatusService + with RepositoryService with AccountService{ + val now = new java.util.Date() + val fixture1 = CommitStatus( + userName = "root", + repositoryName = "repo", + commitId = "0e97b8f59f7cdd709418bb59de53f741fd1c1bd7", + context = "jenkins/test", + creator = "tester", + state = CommitState.PENDING, + targetUrl = Some("http://example.com/target"), + description = Some("description"), + updatedDate = now, + registeredDate = now) + def findById(id: Int)(implicit s:Session) = CommitStatuses.filter(_.byPrimaryKey(id)).firstOption + def generateFixture1(tester:Account)(implicit s:Session) = createCommitStatus( + userName = fixture1.userName, + repositoryName = fixture1.repositoryName, + sha = fixture1.commitId, + context = fixture1.context, + state = fixture1.state, + targetUrl = fixture1.targetUrl, + description = fixture1.description, + creator = tester, + now = fixture1.registeredDate) + "CommitStatusService" should { + "createCommitState can insert and update" in { withTestDB { implicit session => + val tester = generateNewAccount(fixture1.creator) + createRepository(fixture1.repositoryName,fixture1.userName,None,false) + val id = generateFixture1(tester:Account) + getCommitStatus(fixture1.userName, fixture1.repositoryName, id) must_== + Some(fixture1.copy(commitStatusId=id)) + // other one can update + val tester2 = generateNewAccount("tester2") + val time2 = new java.util.Date(); + val id2 = createCommitStatus( + userName = fixture1.userName, + repositoryName = fixture1.repositoryName, + sha = fixture1.commitId, + context = fixture1.context, + state = CommitState.SUCCESS, + targetUrl = Some("http://example.com/target2"), + description = Some("description2"), + creator = tester2, + now = time2) + getCommitStatus(fixture1.userName, fixture1.repositoryName, id2) must_== Some(fixture1.copy( + commitStatusId = id, + creator = "tester2", + state = CommitState.SUCCESS, + targetUrl = Some("http://example.com/target2"), + description = Some("description2"), + updatedDate = time2)) + }} + "getCommitStatus can find by commitId and context" in { withTestDB { implicit session => + val tester = generateNewAccount(fixture1.creator) + createRepository(fixture1.repositoryName,fixture1.userName,None,false) + val id = generateFixture1(tester:Account) + getCommitStatus(fixture1.userName, fixture1.repositoryName, fixture1.commitId, fixture1.context) must_== Some(fixture1.copy(commitStatusId=id)) + }} + "getCommitStatus can find by commitStatusId" in { withTestDB { implicit session => + val tester = generateNewAccount(fixture1.creator) + createRepository(fixture1.repositoryName,fixture1.userName,None,false) + val id = generateFixture1(tester:Account) + getCommitStatus(fixture1.userName, fixture1.repositoryName, id) must_== Some(fixture1.copy(commitStatusId=id)) + }} + } +} \ No newline at end of file diff --git a/src/test/scala/service/IssuesServiceSpec.scala b/src/test/scala/service/IssuesServiceSpec.scala new file mode 100644 index 0000000..8105f06 --- /dev/null +++ b/src/test/scala/service/IssuesServiceSpec.scala @@ -0,0 +1,47 @@ +package service + +import org.specs2.mutable.Specification +import java.util.Date +import model._ +import service.IssuesService._ + +class IssuesServiceSpec extends Specification with ServiceSpecBase { + "IssuesService" should { + "getCommitStatues" in { withTestDB { implicit session => + val user1 = generateNewUserWithDBRepository("user1","repo1") + + def getCommitStatues = dummyService.getCommitStatues(List(("user1","repo1",1),("user1","repo1",2))) + + getCommitStatues must_== Map.empty + + val now = new java.util.Date() + val issueId = generateNewIssue("user1","repo1") + issueId must_== 1 + + getCommitStatues must_== Map.empty + + val cs = dummyService.createCommitStatus("user1","repo1","shasha", "default", CommitState.SUCCESS, Some("http://exmple.com/ci"), Some("exampleService"), now, user1) + + getCommitStatues must_== Map.empty + + val (is2, pr2) = generateNewPullRequest("user1/repo1/master","user1/repo1/feature1") + pr2.issueId must_== 2 + + // if there are no statuses, state is none + getCommitStatues must_== Map.empty + + // if there is a status, state is that + val cs2 = dummyService.createCommitStatus("user1","repo1","feature1", "default", CommitState.SUCCESS, Some("http://exmple.com/ci"), Some("exampleService"), now, user1) + getCommitStatues must_== Map(("user1","repo1",2) -> CommitStatusInfo(1,1,Some("default"),Some(CommitState.SUCCESS),Some("http://exmple.com/ci"),Some("exampleService"))) + + // if there are two statuses, state is none + val cs3 = dummyService.createCommitStatus("user1","repo1","feature1", "pend", CommitState.PENDING, Some("http://exmple.com/ci"), Some("exampleService"), now, user1) + getCommitStatues must_== Map(("user1","repo1",2) -> CommitStatusInfo(2,1,None,None,None,None)) + + // get only statuses in query issues + val (is3, pr3) = generateNewPullRequest("user1/repo1/master","user1/repo1/feature3") + val cs4 = dummyService.createCommitStatus("user1","repo1","feature3", "none", CommitState.PENDING, None, None, now, user1) + getCommitStatues must_== Map(("user1","repo1",2) -> CommitStatusInfo(2,1,None,None,None,None)) + } } + } +} \ No newline at end of file diff --git a/src/test/scala/service/MergeServiceSpec.scala b/src/test/scala/service/MergeServiceSpec.scala new file mode 100644 index 0000000..9f09304 --- /dev/null +++ b/src/test/scala/service/MergeServiceSpec.scala @@ -0,0 +1,155 @@ +package service +import org.specs2.mutable.Specification +import java.util.Date +import model._ +import util.JGitUtil +import util.Directory._ +import java.nio.file._ +import util.Implicits._ +import util.ControlUtil._ +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.dircache.DirCache +import org.eclipse.jgit.lib._ +import org.eclipse.jgit.treewalk._ +import org.eclipse.jgit.revwalk._ +import org.apache.commons.io.FileUtils + +class MergeServiceSpec extends Specification { + sequential + val service = new MergeService{} + val branch = "master" + val issueId = 10 + def initRepository(owner:String, name:String) = { + val repo1Dir = getRepositoryDir(owner, name) + RepositoryCache.clear() + FileUtils.deleteQuietly(repo1Dir) + Files.createDirectories(repo1Dir.toPath()) + JGitUtil.initRepository(repo1Dir) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/heads/master", "test.txt", "hoge" ) + git.branchCreate().setStartPoint(s"refs/heads/master").setName(s"refs/pull/${issueId}/head").call() + } + repo1Dir + } + def createFile(git:Git, branch:String, name:String, content:String){ + val builder = DirCache.newInCore.builder() + val inserter = git.getRepository.newObjectInserter() + val headId = git.getRepository.resolve(branch + "^{commit}") + builder.add(JGitUtil.createDirCacheEntry(name, FileMode.REGULAR_FILE, + inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8")))) + builder.finish() + JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), + branch, "dummy", "dummy@example.com", "Initial commit") + } + def getFile(git:Git, branch:String, path:String) = { + val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(branch)) + val objectId = using(new TreeWalk(git.getRepository)){ walk => + walk.addTree(revCommit.getTree) + walk.setRecursive(true) + @scala.annotation.tailrec + def _getPathObjectId: ObjectId = walk.next match { + case true if(walk.getPathString == path) => walk.getObjectId(0) + case true => _getPathObjectId + case false => throw new Exception(s"not found ${branch} / ${path}") + } + _getPathObjectId + } + JGitUtil.getContentInfo(git, path, objectId) + } + def createConfrict(git:Git) = { + createFile(git, s"refs/heads/${branch}", "test.txt", "hoge2" ) + createFile(git, s"refs/pull/${issueId}/head", "test.txt", "hoge4" ) + } + "checkConflict, checkConflictCache" should { + "checkConflict false if not conflicted, and create cache" in { + val repo1Dir = initRepository("user1","repo1") + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + val conflicted = service.checkConflict("user1", "repo1", branch, issueId) + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(false) + conflicted mustEqual false + } + "checkConflict true if not conflicted, and create cache" in { + val repo1Dir = initRepository("user1","repo1") + using(Git.open(repo1Dir)){ git => + createConfrict(git) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + val conflicted = service.checkConflict("user1", "repo1", branch, issueId) + conflicted mustEqual true + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(true) + } + } + "checkConflictCache" should { + "merged cache invalid if origin branch moved" in { + val repo1Dir = initRepository("user1","repo1") + service.checkConflict("user1", "repo1", branch, issueId) mustEqual false + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(false) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/heads/${branch}", "test.txt", "hoge2" ) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + } + "merged cache invalid if request branch moved" in { + val repo1Dir = initRepository("user1","repo1") + service.checkConflict("user1", "repo1", branch, issueId) mustEqual false + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(false) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/pull/${issueId}/head", "test.txt", "hoge4" ) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + } + "merged cache invalid if origin branch moved" in { + val repo1Dir = initRepository("user1","repo1") + service.checkConflict("user1", "repo1", branch, issueId) mustEqual false + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(false) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/heads/${branch}", "test.txt", "hoge2" ) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + } + "conflicted cache invalid if request branch moved" in { + val repo1Dir = initRepository("user1","repo1") + using(Git.open(repo1Dir)){ git => + createConfrict(git) + } + service.checkConflict("user1", "repo1", branch, issueId) mustEqual true + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(true) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/pull/${issueId}/head", "test.txt", "hoge4" ) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + } + "conflicted cache invalid if origin branch moved" in { + val repo1Dir = initRepository("user1","repo1") + using(Git.open(repo1Dir)){ git => + createConfrict(git) + } + service.checkConflict("user1", "repo1", branch, issueId) mustEqual true + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual Some(true) + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/heads/${branch}", "test.txt", "hoge4" ) + } + service.checkConflictCache("user1", "repo1", branch, issueId) mustEqual None + } + } + "mergePullRequest" should { + "can merge" in { + val repo1Dir = initRepository("user1","repo1") + using(Git.open(repo1Dir)){ git => + createFile(git, s"refs/pull/${issueId}/head", "test.txt", "hoge2" ) + val committer = new PersonIdent("dummy2", "dummy2@example.com") + getFile(git, branch, "test.txt").content.get mustEqual "hoge" + val requestBranchId = git.getRepository.resolve(s"refs/pull/${issueId}/head") + val masterId = git.getRepository.resolve(branch) + service.mergePullRequest(git, branch, issueId, "merged", committer) + val lastCommitId = git.getRepository.resolve(branch); + val commit = using(new RevWalk(git.getRepository))(_.parseCommit(lastCommitId)) + commit.getCommitterIdent() mustEqual committer + commit.getAuthorIdent() mustEqual committer + commit.getFullMessage() mustEqual "merged" + commit.getParents.toSet mustEqual Set( requestBranchId, masterId ) + getFile(git, branch, "test.txt").content.get mustEqual "hoge2" + } + } + } +} \ No newline at end of file diff --git a/src/test/scala/service/RepositoryServiceSpec.scala b/src/test/scala/service/RepositoryServiceSpec.scala new file mode 100644 index 0000000..23a2215 --- /dev/null +++ b/src/test/scala/service/RepositoryServiceSpec.scala @@ -0,0 +1,33 @@ +package service +import org.specs2.mutable.Specification +import java.util.Date +import model._ +import model.Profile._ +import profile.simple._ +class RepositoryServiceSpec extends Specification with ServiceSpecBase with RepositoryService with AccountService{ + "RepositoryService" should { + "renameRepository can rename CommitState" in { withTestDB { implicit session => + val tester = generateNewAccount("tester") + createRepository("repo","root",None,false) + val commitStatusService = new CommitStatusService{} + val id = commitStatusService.createCommitStatus( + userName = "root", + repositoryName = "repo", + sha = "0e97b8f59f7cdd709418bb59de53f741fd1c1bd7", + context = "jenkins/test", + state = CommitState.PENDING, + targetUrl = Some("http://example.com/target"), + description = Some("description"), + creator = tester, + now = new java.util.Date) + val org = commitStatusService.getCommitStatus("root","repo", id).get + renameRepository("root","repo","tester","repo2") + val neo = commitStatusService.getCommitStatus("tester","repo2", org.commitId, org.context).get + neo must_== + org.copy( + commitStatusId=neo.commitStatusId, + repositoryName="repo2", + userName="tester") + }} + } +} diff --git a/src/test/scala/service/ServiceSpecBase.scala b/src/test/scala/service/ServiceSpecBase.scala index a87bb58..048227f 100644 --- a/src/test/scala/service/ServiceSpecBase.scala +++ b/src/test/scala/service/ServiceSpecBase.scala @@ -8,6 +8,7 @@ import org.apache.commons.io.FileUtils import scala.util.Random import java.io.File +import model._ trait ServiceSpecBase { @@ -24,4 +25,46 @@ } } + def generateNewAccount(name:String)(implicit s:Session):Account = { + AccountService.createAccount(name, name, name, s"${name}@example.com", false, None) + AccountService.getAccountByUserName(name).get + } + + lazy val dummyService = new RepositoryService with AccountService with IssuesService with PullRequestService + with CommitStatusService (){} + + def generateNewUserWithDBRepository(userName:String, repositoryName:String)(implicit s:Session):Account = { + val ac = generateNewAccount(userName) + dummyService.createRepository(repositoryName, userName, None, false) + ac + } + + def generateNewIssue(userName:String, repositoryName:String, requestUserName:String="root")(implicit s:Session): Int = { + dummyService.createIssue( + owner = userName, + repository = repositoryName, + loginUser = requestUserName, + title = "issue title", + content = None, + assignedUserName = None, + milestoneId = None, + isPullRequest = true) + } + + def generateNewPullRequest(base:String, request:String)(implicit s:Session):(Issue, PullRequest) = { + val Array(baseUserName, baseRepositoryName, baesBranch)=base.split("/") + val Array(requestUserName, requestRepositoryName, requestBranch)=request.split("/") + val issueId = generateNewIssue(baseUserName, baseRepositoryName, requestUserName) + dummyService.createPullRequest( + originUserName = baseUserName, + originRepositoryName = baseRepositoryName, + issueId = issueId, + originBranch = baesBranch, + requestUserName = requestUserName, + requestRepositoryName = requestRepositoryName, + requestBranch = requestBranch, + commitIdFrom = baesBranch, + commitIdTo = requestBranch) + dummyService.getPullRequest(baseUserName, baseRepositoryName, issueId).get + } } diff --git a/src/test/scala/service/WebHookServiceSpec.scala b/src/test/scala/service/WebHookServiceSpec.scala new file mode 100644 index 0000000..13c587c --- /dev/null +++ b/src/test/scala/service/WebHookServiceSpec.scala @@ -0,0 +1,43 @@ +package service + +import org.specs2.mutable.Specification +import java.util.Date +import model._ + +class WebHookServiceSpec extends Specification with ServiceSpecBase { + lazy val service = new WebHookPullRequestService with AccountService with RepositoryService with PullRequestService with IssuesService + + "WebHookPullRequestService.getPullRequestsByRequestForWebhook" should { + "find from request branch" in { withTestDB { implicit session => + val user1 = generateNewUserWithDBRepository("user1","repo1") + val user2 = generateNewUserWithDBRepository("user2","repo2") + val user3 = generateNewUserWithDBRepository("user3","repo3") + val (issue1, pullreq1) = generateNewPullRequest("user1/repo1/master1", "user2/repo2/master2") + val (issue3, pullreq3) = generateNewPullRequest("user3/repo3/master3", "user2/repo2/master2") + val (issue32, pullreq32) = generateNewPullRequest("user3/repo3/master32", "user2/repo2/master2") + generateNewPullRequest("user2/repo2/master2", "user1/repo1/master2") + service.addWebHookURL("user1", "repo1", "webhook1-1") + service.addWebHookURL("user1", "repo1", "webhook1-2") + service.addWebHookURL("user2", "repo2", "webhook2-1") + service.addWebHookURL("user2", "repo2", "webhook2-2") + service.addWebHookURL("user3", "repo3", "webhook3-1") + service.addWebHookURL("user3", "repo3", "webhook3-2") + + service.getPullRequestsByRequestForWebhook("user1","repo1","master1") must_== Map.empty + + var r = service.getPullRequestsByRequestForWebhook("user2","repo2","master2").mapValues(_.map(_.url).toSet) + r.size must_== 3 + r((issue1, pullreq1, user1, user2)) must_== Set("webhook1-1","webhook1-2") + r((issue3, pullreq3, user3, user2)) must_== Set("webhook3-1","webhook3-2") + r((issue32, pullreq32, user3, user2)) must_== Set("webhook3-1","webhook3-2") + + // when closed, it not founds. + service.updateClosed("user1","repo1",issue1.issueId, true) + + var r2 = service.getPullRequestsByRequestForWebhook("user2","repo2","master2").mapValues(_.map(_.url).toSet) + r2.size must_== 2 + r2((issue3, pullreq3, user3, user2)) must_== Set("webhook3-1","webhook3-2") + r2((issue32, pullreq32, user3, user2)) must_== Set("webhook3-1","webhook3-2") + } } + } +} \ No newline at end of file diff --git a/src/test/scala/util/DirectorySpec.scala b/src/test/scala/util/DirectorySpec.scala new file mode 100644 index 0000000..c40aafe --- /dev/null +++ b/src/test/scala/util/DirectorySpec.scala @@ -0,0 +1,14 @@ +package util + +import org.specs2.mutable._ + +class DirectorySpec extends Specification { + "GitBucketHome" should { + "set under target in test scope" in { + Directory.GitBucketHome mustEqual new java.io.File("target/gitbucket_home_for_test").getAbsolutePath + } + "exists" in { + new java.io.File(Directory.GitBucketHome).exists + } + } +}