Newer
Older
gitbucket_jkp / src / main / scala / app / PullRequestsController.scala
package app

import util.{LockUtil, CollaboratorsAuthenticator, JGitUtil, ReferrerAuthenticator, Notifier, Keys}
import util.Directory._
import util.Implicits._
import util.ControlUtil._
import util.FileUtil._
import service._
import org.eclipse.jgit.api.Git
import jp.sf.amateras.scalatra.forms._
import org.eclipse.jgit.transport.RefSpec
import org.apache.commons.io.FileUtils
import scala.collection.JavaConverters._
import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.api.MergeCommand.FastForwardMode
import service.IssuesService._
import service.PullRequestService._
import util.JGitUtil.DiffInfo
import service.RepositoryService.RepositoryTreeNode
import util.JGitUtil.CommitInfo

class PullRequestsController extends PullRequestsControllerBase
  with RepositoryService with AccountService with IssuesService with PullRequestService with MilestonesService with ActivityService
  with ReferrerAuthenticator with CollaboratorsAuthenticator

trait PullRequestsControllerBase extends ControllerBase {
  self: RepositoryService with IssuesService with MilestonesService with ActivityService with PullRequestService
    with ReferrerAuthenticator with CollaboratorsAuthenticator =>

  val pullRequestForm = mapping(
    "title"           -> trim(label("Title"  , text(required, maxlength(100)))),
    "content"         -> trim(label("Content", optional(text()))),
    "targetUserName"  -> trim(text(required, maxlength(100))),
    "targetBranch"    -> trim(text(required, maxlength(100))),
    "requestUserName" -> trim(text(required, maxlength(100))),
    "requestBranch"   -> trim(text(required, maxlength(100))),
    "commitIdFrom"    -> trim(text(required, maxlength(40))),
    "commitIdTo"      -> trim(text(required, maxlength(40)))
  )(PullRequestForm.apply)

  val mergeForm = mapping(
    "message" -> trim(label("Message", text(required)))
  )(MergeForm.apply)

  case class PullRequestForm(
    title: String,
    content: Option[String],
    targetUserName: String,
    targetBranch: String,
    requestUserName: String,
    requestBranch: String,
    commitIdFrom: String,
    commitIdTo: String)

  case class MergeForm(message: String)

  get("/:owner/:repository/pulls")(referrersOnly { repository =>
    searchPullRequests(None, repository)
  })

  get("/:owner/:repository/pulls/:userName")(referrersOnly { repository =>
    searchPullRequests(Some(params("userName")), repository)
  })

  get("/:owner/:repository/pull/:id")(referrersOnly { repository =>
    params("id").toIntOpt.flatMap{ issueId =>
      val owner = repository.owner
      val name = repository.name
      getPullRequest(owner, name, issueId) map { case(issue, pullreq) =>
        using(Git.open(getRepositoryDir(owner, name))){ git =>
          val (commits, diffs) =
            getRequestCompareInfo(owner, name, pullreq.commitIdFrom, owner, name, pullreq.commitIdTo)

          pulls.html.pullreq(
            issue, pullreq,
            getComments(owner, name, issueId),
            (getCollaborators(owner, name) :+ owner).sorted,
            getMilestonesWithIssueCount(owner, name),
            commits,
            diffs,
            hasWritePermission(owner, name, context.loginAccount),
            repository)
        }
      }
    } 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) =>
        pulls.html.mergeguide(
          checkConflict(owner, name, pullreq.branch, owner, name, pullreq.requestBranch),
          pullreq,
          s"${baseUrl}${context.path}/git/${pullreq.requestUserName}/${pullreq.requestRepositoryName}.git")
      }
    } getOrElse NotFound
  })

  post("/:owner/:repository/pull/:id/merge", mergeForm)(collaboratorsOnly { (form, repository) =>
    params("id").toIntOpt.flatMap{ issueId =>
      val owner = repository.owner
      val name = repository.name
      LockUtil.lock(s"${owner}/${name}/merge"){
        getPullRequest(owner, name, issueId).map { case (issue, pullreq) =>
          val remote = getRepositoryDir(owner, name)
          withTmpDir(new java.io.File(getTemporaryDir(owner, name), s"merge-${issueId}")){ tmpdir =>
            using(Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(pullreq.branch).call){ git =>

              // mark issue as merged and close.
              val loginAccount = context.loginAccount.get
              createComment(owner, name, loginAccount.userName, issueId, form.message, "merge")
              createComment(owner, name, loginAccount.userName, issueId, "Close", "close")
              updateClosed(owner, name, issueId, true)

              // record activity
              recordMergeActivity(owner, name, loginAccount.userName, issueId, form.message)

              // fetch pull request to temporary working repository
              val pullRequestBranchName = s"gitbucket-pullrequest-${issueId}"

              git.fetch
                .setRemote(getRepositoryDir(owner, name).toURI.toString)
                .setRefSpecs(new RefSpec(s"refs/pull/${issueId}/head:refs/heads/${pullRequestBranchName}")).call

              // merge pull request
              git.checkout.setName(pullreq.branch).call

              val result = git.merge
                .include(git.getRepository.resolve(pullRequestBranchName))
                .setFastForward(FastForwardMode.NO_FF)
                .setCommit(false)
                .call

              if(result.getConflicts != null){
                throw new RuntimeException("This pull request can't merge automatically.")
              }

              // merge commit
              git.getRepository.writeMergeCommitMsg(
                s"Merge pull request #${issueId} from ${pullreq.requestUserName}/${pullreq.requestRepositoryName}\n"
                + form.message)

              git.commit
                .setCommitter(new PersonIdent(loginAccount.fullName, loginAccount.mailAddress))
                .call

              // push
              git.push.call

              val (commits, _) = getRequestCompareInfo(owner, name, pullreq.commitIdFrom,
                pullreq.requestUserName, pullreq.requestRepositoryName, pullreq.commitIdTo)

              commits.flatten.foreach { commit =>
                if(!existsCommitId(owner, name, commit.id)){
                  insertCommitId(owner, name, commit.id)
                }
              }

              // notifications
              Notifier().toNotify(repository, issueId, "merge"){
                Notifier.msgStatus(s"${baseUrl}/${owner}/${name}/pull/${issueId}")
              }

              redirect(s"/${owner}/${name}/pull/${issueId}")
            }
          }
        }
      }
    } getOrElse NotFound
  })

  get("/:owner/:repository/compare")(referrersOnly { forkedRepository =>
    (forkedRepository.repository.originUserName, forkedRepository.repository.originRepositoryName) match {
      case (Some(originUserName), Some(originRepositoryName)) => {
        getRepository(originUserName, originRepositoryName, baseUrl).map { originRepository =>
          using(
            Git.open(getRepositoryDir(originUserName, originRepositoryName)),
            Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))
          ){ (oldGit, newGit) =>
            val oldBranch = JGitUtil.getDefaultBranch(oldGit, originRepository).get._2
            val newBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2

            redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}")
          }
        } getOrElse NotFound
      }
      case _ => {
        using(Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))){ git =>
          JGitUtil.getDefaultBranch(git, forkedRepository).map { case (_, defaultBranch) =>
            redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}")
          } getOrElse {
            redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}")
          }
        }
      }
    }
  })

  get("/:owner/:repository/compare/*...*")(referrersOnly { repository =>
    val Seq(origin, forked) = multiParams("splat")
    val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner)
    val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner)

    (getRepository(originOwner, repository.name, baseUrl),
     getRepository(forkedOwner, repository.name, baseUrl)) match {
      case (Some(originRepository), Some(forkedRepository)) => {
        using(
          Git.open(getRepositoryDir(originOwner, repository.name)),
          Git.open(getRepositoryDir(forkedOwner, repository.name))
        ){ case (oldGit, newGit) =>
          val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2
          val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2

          val forkedId = getForkedCommitId(oldGit, newGit,
            originOwner, repository.name, originBranch,
            forkedOwner, repository.name, forkedBranch)

          val oldId = oldGit.getRepository.resolve(forkedId)
          val newId = newGit.getRepository.resolve(forkedBranch)

          val (commits, diffs) = getRequestCompareInfo(
            originOwner, repository.name, oldId.getName,
            forkedOwner, repository.name, newId.getName)

          pulls.html.compare(
            commits,
            diffs,
            repository.repository.originUserName.map { userName =>
              userName :: getForkedRepositories(userName, repository.name)
            } getOrElse List(repository.owner),
            originBranch,
            forkedBranch,
            oldId.getName,
            newId.getName,
            repository,
            originRepository,
            forkedRepository,
            hasWritePermission(repository.owner, repository.name, context.loginAccount))
        }
      }
      case _ => NotFound
    }
  })

  ajaxGet("/:owner/:repository/compare/*...*/mergecheck")(collaboratorsOnly { repository =>
    val Seq(origin, forked) = multiParams("splat")
    val (originOwner, tmpOriginBranch) = parseCompareIdentifie(origin, repository.owner)
    val (forkedOwner, tmpForkedBranch) = parseCompareIdentifie(forked, repository.owner)

    (getRepository(originOwner, repository.name, baseUrl),
      getRepository(forkedOwner, repository.name, baseUrl)) match {
      case (Some(originRepository), Some(forkedRepository)) => {
        using(
          Git.open(getRepositoryDir(originOwner, repository.name)),
          Git.open(getRepositoryDir(forkedOwner, repository.name))
        ){ case (oldGit, newGit) =>
          val originBranch = JGitUtil.getDefaultBranch(oldGit, originRepository, tmpOriginBranch).get._2
          val forkedBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository, tmpForkedBranch).get._2

          pulls.html.mergecheck(
            checkConflict(originOwner, repository.name, originBranch, forkedOwner, repository.name, forkedBranch))
        }
      }
      case _ => NotFound()
    }
  })

  post("/:owner/:repository/pulls/new", pullRequestForm)(referrersOnly { (form, repository) =>
    val loginUserName = context.loginAccount.get.userName

    val issueId = createIssue(
      owner            = repository.owner,
      repository       = repository.name,
      loginUser        = loginUserName,
      title            = form.title,
      content          = form.content,
      assignedUserName = None,
      milestoneId      = None,
      isPullRequest    = true)

    createPullRequest(
      originUserName        = repository.owner,
      originRepositoryName  = repository.name,
      issueId               = issueId,
      originBranch          = form.targetBranch,
      requestUserName       = form.requestUserName,
      requestRepositoryName = repository.name,
      requestBranch         = form.requestBranch,
      commitIdFrom          = form.commitIdFrom,
      commitIdTo            = form.commitIdTo)

    // fetch requested branch
    using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git =>
      git.fetch
        .setRemote(getRepositoryDir(form.requestUserName, repository.name).toURI.toString)
        .setRefSpecs(new RefSpec(s"refs/heads/${form.requestBranch}:refs/pull/${issueId}/head"))
        .call
    }

    // record activity
    recordPullRequestActivity(repository.owner, repository.name, loginUserName, issueId, form.title)

    // notifications
    Notifier().toNotify(repository, issueId, form.content.getOrElse("")){
      Notifier.msgPullRequest(s"${baseUrl}/${repository.owner}/${repository.name}/pull/${issueId}")
    }

    redirect(s"/${repository.owner}/${repository.name}/pull/${issueId}")
  })

  /**
   * Checks whether conflict will be caused in merging. Returns true if conflict will be caused.
   */
  private def checkConflict(userName: String, repositoryName: String, branch: String,
                            requestUserName: String, requestRepositoryName: String, requestBranch: String): Boolean = {
    // TODO Are there more quick way?
    LockUtil.lock(s"${userName}/${repositoryName}/merge-check"){
      val remote = getRepositoryDir(userName, repositoryName)
      withTmpDir(new java.io.File(getTemporaryDir(userName, repositoryName), "merge-check")){ tmpdir =>
        using(Git.cloneRepository.setDirectory(tmpdir).setURI(remote.toURI.toString).setBranch(branch).call){ git =>

          git.checkout.setName(branch).call

          git.fetch
            .setRemote(getRepositoryDir(requestUserName, requestRepositoryName).toURI.toString)
            .setRefSpecs(new RefSpec(s"refs/heads/${branch}:refs/heads/${requestBranch}")).call

          val result = git.merge
            .include(git.getRepository.resolve("FETCH_HEAD"))
            .setCommit(false).call

          result.getConflicts != null
        }
      }
    }
  }

  /**
   * Parses branch identifier and extracts owner and branch name as tuple.
   *
   * - "owner:branch" to ("owner", "branch")
   * - "branch" to ("defaultOwner", "branch")
   */
  private def parseCompareIdentifie(value: String, defaultOwner: String): (String, String) =
    if(value.contains(':')){
      val array = value.split(":")
      (array(0), array(1))
    } else {
      (defaultOwner, value)
    }

  /**
   * Extracts all repository names from [[service.RepositoryService.RepositoryTreeNode]] as flat list.
   */
  private def getRepositoryNames(node: RepositoryTreeNode): List[String] =
    node.owner :: node.children.map { child => getRepositoryNames(child) }.flatten

  /**
   * Returns the identifier of the root commit (or latest merge commit) of the specified branch.
   */
  private def getForkedCommitId(oldGit: Git, newGit: Git, userName: String, repositoryName: String, branch: String,
      requestUserName: String, requestRepositoryName: String, requestBranch: String): String =
    JGitUtil.getCommitLogs(newGit, requestBranch, true){ commit =>
      existsCommitId(userName, repositoryName, commit.getName) &&
        JGitUtil.getBranchesOfCommit(oldGit, commit.getName).contains(branch)
    }.head.id

  private def getRequestCompareInfo(userName: String, repositoryName: String, branch: String,
      requestUserName: String, requestRepositoryName: String, requestCommitId: String): (Seq[Seq[CommitInfo]], Seq[DiffInfo]) = {

    using(
      Git.open(getRepositoryDir(userName, repositoryName)),
      Git.open(getRepositoryDir(requestUserName, requestRepositoryName))
    ){ (oldGit, newGit) =>
      val oldId = oldGit.getRepository.resolve(branch)
      val newId = newGit.getRepository.resolve(requestCommitId)

      val commits = newGit.log.addRange(oldId, newId).call.iterator.asScala.map { revCommit =>
        new CommitInfo(revCommit)
      }.toList.splitWith{ (commit1, commit2) =>
        view.helpers.date(commit1.time) == view.helpers.date(commit2.time)
      }

      val diffs = JGitUtil.getDiffs(newGit, oldId.getName, newId.getName, true)

      (commits, diffs)
    }
  }

  private def searchPullRequests(userName: Option[String], repository: RepositoryService.RepositoryInfo) =
    defining(repository.owner, repository.name){ case (owner, repoName) =>
      val filterUser = userName.map { x => Map("created_by" -> x) } getOrElse Map("all" -> "")
      val page       = IssueSearchCondition.page(request)
      val sessionKey = Keys.Session.Pulls(owner, repoName)

      // retrieve search condition
      val condition = session.putAndGet(sessionKey,
        if(request.hasQueryString) IssueSearchCondition(request)
        else session.getAs[IssueSearchCondition](sessionKey).getOrElse(IssueSearchCondition())
      )

      pulls.html.list(
        searchIssue(condition, filterUser, true, (page - 1) * PullRequestLimit, PullRequestLimit, owner -> repoName),
        getPullRequestCountGroupByUser(condition.state == "closed", owner, Some(repoName)),
        userName,
        page,
        countIssue(condition.copy(state = "open"  ), filterUser, true, owner -> repoName),
        countIssue(condition.copy(state = "closed"), filterUser, true, owner -> repoName),
        countIssue(condition, Map.empty, true, owner -> repoName),
        condition,
        repository,
        hasWritePermission(owner, repoName, context.loginAccount))
    }

}