All Downloads are FREE. Search and download functionalities are using the official Maven repository.

gitbucket.core.service.PullRequestService.scala Maven / Gradle / Ivy

The newest version!
package gitbucket.core.service

import com.github.difflib.DiffUtils
import com.github.difflib.patch.DeltaType
import gitbucket.core.api.JsonFormat
import gitbucket.core.controller.Context
import gitbucket.core.model.Profile.*
import gitbucket.core.model.Profile.profile.blockingApi.*
import gitbucket.core.model.activity.OpenPullRequestInfo
import gitbucket.core.model.{CommitComments => _, Session => _, *}
import gitbucket.core.plugin.PluginRegistry
import gitbucket.core.service.RepositoryService.RepositoryInfo
import gitbucket.core.service.SystemSettingsService.SystemSettings
import gitbucket.core.util.Directory.*
import gitbucket.core.util.Implicits.*
import gitbucket.core.util.JGitUtil
import gitbucket.core.util.JGitUtil.{CommitInfo, DiffInfo, getBranchesNoMergeInfo}
import gitbucket.core.util.StringUtil.*
import gitbucket.core.view
import gitbucket.core.view.helpers
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.ObjectId

import scala.jdk.CollectionConverters.*
import scala.util.Using

trait PullRequestService {
  self: IssuesService
    with CommitsService
    with WebHookService
    with WebHookPullRequestService
    with RepositoryService
    with MergeService
    with ActivityService =>
  import PullRequestService.*

  def getPullRequest(owner: String, repository: String, issueId: Int)(implicit
    s: Session
  ): Option[(Issue, PullRequest)] =
    getIssue(owner, repository, issueId.toString).flatMap { issue =>
      PullRequests.filter(_.byPrimaryKey(owner, repository, issueId)).firstOption.map { pullreq =>
        (issue, pullreq)
      }
    }

  def updateCommitId(owner: String, repository: String, issueId: Int, commitIdTo: String, commitIdFrom: String)(implicit
    s: Session
  ): Unit =
    PullRequests
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(pr => pr.commitIdTo -> pr.commitIdFrom)
      .update((commitIdTo, commitIdFrom))

  def updateDraftToPullRequest(owner: String, repository: String, issueId: Int)(implicit
    s: Session
  ): Unit =
    PullRequests
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(pr => pr.isDraft)
      .update(false)

  def updateBaseBranch(owner: String, repository: String, issueId: Int, baseBranch: String, commitIdTo: String)(implicit
    s: Session
  ): Unit = {
    PullRequests
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(pr => pr.branch -> pr.commitIdTo)
      .update((baseBranch, commitIdTo))
  }

  def getPullRequestCountGroupByUser(closed: Boolean, owner: Option[String], repository: Option[String])(implicit
    s: Session
  ): List[PullRequestCount] =
    PullRequests
      .join(Issues)
      .on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
      }
      .filter { case (t1, t2) =>
        (t2.closed === closed.bind)
          .&&(t1.userName === owner.get.bind, owner.isDefined)
          .&&(t1.repositoryName === repository.get.bind, repository.isDefined)
      }
      .groupBy { case (t1, t2) => t2.openedUserName }
      .map { case (userName, t) => userName -> t.length }
      .sortBy(_._2 desc)
      .list
      .map { x =>
        PullRequestCount(x._1, x._2)
      }

//  def getAllPullRequestCountGroupByUser(closed: Boolean, userName: String)(implicit s: Session): List[PullRequestCount] =
//    PullRequests
//      .innerJoin(Issues).on { (t1, t2) => t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId) }
//      .innerJoin(Repositories).on { case ((t1, t2), t3) => t2.byRepository(t3.userName, t3.repositoryName) }
//      .filter { case ((t1, t2), t3) =>
//        (t2.closed === closed.bind) &&
//          (
//            (t3.isPrivate === false.bind) ||
//            (t3.userName  === userName.bind) ||
//            (Collaborators.filter { t4 => t4.byRepository(t3.userName, t3.repositoryName) && (t4.collaboratorName === userName.bind)} exists)
//          )
//      }
//      .groupBy { case ((t1, t2), t3) => t2.openedUserName }
//      .map { case (userName, t) => userName -> t.length }
//      .sortBy(_._2 desc)
//      .list
//      .map { x => PullRequestCount(x._1, x._2) }

  def createPullRequest(
    originRepository: RepositoryInfo,
    issueId: Int,
    originBranch: String,
    requestUserName: String,
    requestRepositoryName: String,
    requestBranch: String,
    commitIdFrom: String,
    commitIdTo: String,
    isDraft: Boolean,
    loginAccount: Account,
    settings: SystemSettings
  )(implicit s: Session, context: Context): Unit = {
    getIssue(originRepository.owner, originRepository.name, issueId.toString).foreach { baseIssue =>
      PullRequests insert PullRequest(
        originRepository.owner,
        originRepository.name,
        issueId,
        originBranch,
        requestUserName,
        requestRepositoryName,
        requestBranch,
        commitIdFrom,
        commitIdTo,
        isDraft
      )

      // fetch requested branch
      fetchAsPullRequest(
        originRepository.owner,
        originRepository.name,
        requestUserName,
        requestRepositoryName,
        requestBranch,
        issueId
      )

      // record activity
      val openPullRequestInfo = OpenPullRequestInfo(
        originRepository.owner,
        originRepository.name,
        loginAccount.userName,
        issueId,
        baseIssue.title
      )
      recordActivity(openPullRequestInfo)

      // call web hook
      callPullRequestWebHook("opened", originRepository, issueId, loginAccount, settings)

      getIssue(originRepository.owner, originRepository.name, issueId.toString) foreach { issue =>
        // extract references and create refer comment
        createReferComment(
          originRepository.owner,
          originRepository.name,
          issue,
          baseIssue.title + " " + baseIssue.content,
          loginAccount
        )

        // call hooks
        PluginRegistry().getPullRequestHooks.foreach(_.created(issue, originRepository))
      }
    }
  }

  def getPullRequestsByRequest(userName: String, repositoryName: String, branch: String, closed: Option[Boolean])(
    implicit s: Session
  ): List[PullRequest] =
    PullRequests
      .join(Issues)
      .on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
      }
      .filter { case (t1, t2) =>
        (t1.requestUserName === userName.bind)
          .&&(t1.requestRepositoryName === repositoryName.bind)
          .&&(t1.requestBranch === branch.bind)
          .&&(t2.closed === closed.get.bind, closed.isDefined)
      }
      .map { case (t1, t2) => t1 }
      .list

  def getPullRequestsByBranch(userName: String, repositoryName: String, branch: String, closed: Option[Boolean])(
    implicit s: Session
  ): List[PullRequest] =
    PullRequests
      .join(Issues)
      .on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
      }
      .filter { case (t1, t2) =>
        (t1.requestUserName === userName.bind)
          .&&(t1.requestRepositoryName === repositoryName.bind)
          .&&(t1.branch === branch.bind)
          .&&(t2.closed === closed.get.bind, closed.isDefined)
      }
      .map { case (t1, t2) => t1 }
      .list

  /**
   * for repository viewer.
   * 1. find pull request from `branch` to other branch on same repository
   *   1. return if exists pull request to `defaultBranch`
   *   2. return if exists pull request to other branch
   * 2. return None
   */
  def getPullRequestFromBranch(userName: String, repositoryName: String, branch: String, defaultBranch: String)(implicit
    s: Session
  ): Option[(PullRequest, Issue)] =
    PullRequests
      .join(Issues)
      .on { (t1, t2) =>
        t1.byPrimaryKey(t2.userName, t2.repositoryName, t2.issueId)
      }
      .filter { case (t1, t2) =>
        (t1.requestUserName === userName.bind) &&
        (t1.requestRepositoryName === repositoryName.bind) &&
        (t1.requestBranch === branch.bind) &&
        (t1.userName === userName.bind) &&
        (t1.repositoryName === repositoryName.bind) &&
        (t2.closed === false.bind)
      }
      .sortBy { case (t1, t2) => t1.branch =!= defaultBranch.bind }
      .firstOption

  /**
   * Fetch pull request contents into refs/pull/${issueId}/head and update pull request table.
   */
  def updatePullRequests(
    owner: String,
    repository: String,
    branch: String,
    pusherAccount: Account,
    action: String,
    settings: SystemSettings
  )(implicit
    s: Session,
    c: JsonFormat.Context
  ): Unit = {
    getPullRequestsByRequest(owner, repository, branch, Some(false)).foreach { pullreq =>
      if (Repositories.filter(_.byRepository(pullreq.userName, pullreq.repositoryName)).exists.run) {
        // Update the git repository
        val (commitIdTo, commitIdFrom) = JGitUtil.updatePullRequest(
          pullreq.userName,
          pullreq.repositoryName,
          pullreq.branch,
          pullreq.issueId,
          pullreq.requestUserName,
          pullreq.requestRepositoryName,
          pullreq.requestBranch
        )

        // Collect comment positions
        val positions = getCommitComments(pullreq.userName, pullreq.repositoryName, pullreq.commitIdTo, true)
          .collect {
            case CommitComment(_, _, _, commentId, _, _, Some(file), None, Some(newLine), _, _, _, _, _, _) =>
              (file, commentId, Right(newLine))
            case CommitComment(_, _, _, commentId, _, _, Some(file), Some(oldLine), None, _, _, _, _, _, _) =>
              (file, commentId, Left(oldLine))
          }
          .groupBy { case (file, _, _) => file }
          .map { case (file, comments) =>
            file ->
              comments.map { case (_, commentId, lineNumber) => (commentId, lineNumber) }
          }

        // Update comments position
        updatePullRequestCommentPositions(
          positions,
          pullreq.requestUserName,
          pullreq.requestRepositoryName,
          pullreq.commitIdTo,
          commitIdTo,
          settings
        )

        // Update commit id in the PULL_REQUEST table
        updateCommitId(pullreq.userName, pullreq.repositoryName, pullreq.issueId, commitIdTo, commitIdFrom)

        // call web hook
        callPullRequestWebHookByRequestBranch(
          action,
          getRepository(owner, repository).get,
          pullreq.requestBranch,
          pusherAccount,
          settings
        )
      }
    }
  }

  def updatePullRequestsByApi(
    repository: RepositoryInfo,
    issueId: Int,
    loginAccount: Account,
    settings: SystemSettings,
    title: Option[String],
    body: Option[String],
    state: Option[String],
    base: Option[String]
  )(implicit
    s: Session,
    c: JsonFormat.Context
  ): Unit = {
    getPullRequest(repository.owner, repository.name, issueId).foreach { case (issue, pr) =>
      if (Repositories.filter(_.byRepository(pr.userName, pr.repositoryName)).exists.run) {
        // Update base branch
        base.foreach { _base =>
          if (pr.branch != _base) {
            Using.resource(Git.open(getRepositoryDir(repository.owner, repository.name))) { git =>
              getBranchesNoMergeInfo(git, repository.repository.defaultBranch)
                .find(_.name == _base)
                .foreach(br => updateBaseBranch(repository.owner, repository.name, issueId, br.name, br.commitId))
            }
            createComment(
              repository.owner,
              repository.name,
              loginAccount.userName,
              issue.issueId,
              pr.branch + "\r\n" + _base,
              "change_base_branch"
            )
          }
        }
        // Update title and content
        title.foreach { _title =>
          updateIssue(repository.owner, repository.name, issueId, _title, body)
          if (issue.title != _title) {
            createComment(
              repository.owner,
              repository.name,
              loginAccount.userName,
              issue.issueId,
              issue.title + "\r\n" + _title,
              "change_title"
            )
          }
        }
        // Update state
        val action = (state, issue.closed) match {
          case (Some("open"), true) =>
            updateClosed(repository.owner, repository.name, issueId, closed = false)
            "reopened"
          case (Some("closed"), false) =>
            updateClosed(repository.owner, repository.name, issueId, closed = true)
            "closed"
          case _ => "edited"
        }
        // Call web hook
        callPullRequestWebHookByRequestBranch(
          action,
          getRepository(repository.owner, repository.name).get,
          pr.requestBranch,
          loginAccount,
          settings
        )
      }
    }
  }

  def getPullRequestByRequestCommit(
    userName: String,
    repositoryName: String,
    toBranch: String,
    fromBranch: String,
    commitId: String
  )(implicit s: Session): Option[(PullRequest, Issue)] = {
    if (toBranch == fromBranch) {
      None
    } else {
      PullRequests
        .join(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
    }
  }

  private def updatePullRequestCommentPositions(
    positions: Map[String, Seq[(Int, Either[Int, Int])]],
    userName: String,
    repositoryName: String,
    oldCommitId: String,
    newCommitId: String,
    settings: SystemSettings
  )(implicit s: Session): Unit = {

    val (_, diffs) =
      getRequestCompareInfo(userName, repositoryName, oldCommitId, userName, repositoryName, newCommitId, settings)

    val patchs = positions.map { case (file, _) =>
      diffs
        .find(x => x.oldPath == file)
        .map { diff =>
          (diff.oldContent, diff.newContent) match {
            case (Some(oldContent), Some(newContent)) => {
              val oldLines = convertLineSeparator(oldContent, "LF").split("\n")
              val newLines = convertLineSeparator(newContent, "LF").split("\n")
              file -> Option(DiffUtils.diff(oldLines.toList.asJava, newLines.toList.asJava))
            }
            case _ =>
              file -> None
          }
        }
        .getOrElse {
          file -> None
        }
    }

    positions.foreach { case (file, comments) =>
      patchs(file) match {
        case Some(patch) =>
          file -> comments.foreach { case (commentId, lineNumber) =>
            lineNumber match {
              case Left(oldLine) => updateCommitCommentPosition(commentId, newCommitId, Some(oldLine), None)
              case Right(newLine) =>
                var counter = newLine
                patch.getDeltas.asScala.filter(_.getSource.getPosition < newLine).foreach { delta =>
                  delta.getType match {
                    case DeltaType.CHANGE =>
                      if (
                        delta.getSource.getPosition <= newLine - 1 && newLine <= delta.getSource.getPosition + delta.getTarget.getLines.size
                      ) {
                        counter = -1
                      } else {
                        counter = counter + (delta.getTarget.getLines.size - delta.getSource.getLines.size)
                      }
                    case DeltaType.INSERT => counter = counter + delta.getTarget.getLines.size
                    case DeltaType.DELETE => counter = counter - delta.getSource.getLines.size
                    case DeltaType.EQUAL  => // Do nothing
                  }
                }
                if (counter >= 0) {
                  updateCommitCommentPosition(commentId, newCommitId, None, Some(counter))
                }
            }
          }
        case _ =>
          comments.foreach { case (commentId, lineNumber) =>
            lineNumber match {
              case Right(oldLine) => updateCommitCommentPosition(commentId, newCommitId, Some(oldLine), None)
              case Left(newLine)  => updateCommitCommentPosition(commentId, newCommitId, None, Some(newLine))
            }
          }
      }
    }
  }

  def getSingleDiff(
    userName: String,
    repositoryName: String,
    commitId: String,
    path: String
  ): Option[DiffInfo] = {
    Using.resource(
      Git.open(getRepositoryDir(userName, repositoryName))
    ) { git =>
      val newId = git.getRepository.resolve(commitId)
      JGitUtil.getDiff(git, None, newId.getName, path)
    }
  }

  def getSingleDiff(
    userName: String,
    repositoryName: String,
    branch: String,
    requestUserName: String,
    requestRepositoryName: String,
    requestCommitId: String,
    path: String
  ): Option[DiffInfo] = {
    Using.resources(
      Git.open(getRepositoryDir(userName, repositoryName)),
      Git.open(getRepositoryDir(requestUserName, requestRepositoryName))
    ) { (oldGit, newGit) =>
      val oldId = oldGit.getRepository.resolve(branch)
      val newId = newGit.getRepository.resolve(requestCommitId)

      JGitUtil.getDiff(newGit, Some(oldId.getName), newId.getName, path)
    }
  }

  def getRequestCompareInfo(
    userName: String,
    repositoryName: String,
    branch: String,
    requestUserName: String,
    requestRepositoryName: String,
    requestCommitId: String,
    settings: SystemSettings
  ): (Seq[Seq[CommitInfo]], Seq[DiffInfo]) =
    Using.resources(
      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) =>
          helpers.date(commit1.commitTime) == view.helpers.date(commit2.commitTime)
        }

      // TODO Isolate to an another method?
      val diffs = JGitUtil.getDiffs(
        git = newGit,
        from = Some(oldId.getName),
        to = newId.getName,
        fetchContent = true,
        makePatch = false,
        maxFiles = settings.repositoryViewer.maxDiffFiles,
        maxLines = settings.repositoryViewer.maxDiffLines
      )

      (commits, diffs)
    }

  def getPullRequestComments(userName: String, repositoryName: String, issueId: Int, commits: Seq[CommitInfo])(implicit
    s: Session
  ): Seq[Comment] = {
    (commits.flatMap(commit => getCommitComments(userName, repositoryName, commit.id, true)) ++ getComments(
      userName,
      repositoryName,
      issueId
    )).groupBy {
      case x: IssueComment                        => (Some(x.commentId), None, None, None)
      case x: CommitComment if x.fileName.isEmpty => (Some(x.commentId), None, None, None)
      case x: CommitComment                       => (None, x.fileName, x.originalOldLine, x.originalNewLine)
      case x                                      => throw new MatchError(x)
    }.toSeq
      .map {
        // Normal comment
        case ((Some(_), _, _, _), comments) =>
          comments.head
        // Comment on a specific line of a commit
        case ((None, Some(fileName), oldLine, newLine), comments) =>
          gitbucket.core.model.CommitComments(
            fileName = fileName,
            commentedUserName = comments.head.commentedUserName,
            registeredDate = comments.head.registeredDate,
            comments = comments.map(_.asInstanceOf[CommitComment]),
            diff = loadCommitCommentDiff(
              userName,
              repositoryName,
              comments.head.asInstanceOf[CommitComment].originalCommitId,
              fileName,
              oldLine,
              newLine
            )
          )
      }
      .sortWith(_.registeredDate before _.registeredDate)
  }

  def markMergeAndClosePullRequest(userName: String, owner: String, repository: String, pull: PullRequest)(implicit
    s: Session
  ): Unit = {
    createComment(owner, repository, userName, pull.issueId, "Merged by user", "merge")
    createComment(owner, repository, userName, pull.issueId, "Close", "close")
    updateClosed(owner, repository, pull.issueId, true)
  }

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

  def getPullRequestCommitFromTo(
    originRepository: RepositoryInfo,
    forkedRepository: RepositoryInfo,
    originId: String,
    forkedId: String
  ): (Option[ObjectId], Option[ObjectId]) = {
    Using.resources(
      Git.open(getRepositoryDir(originRepository.owner, originRepository.name)),
      Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))
    ) { case (oldGit, newGit) =>
      if (originRepository.branchList.contains(originId)) {
        val forkedId2 =
          forkedRepository.tags.collectFirst { case x if x.name == forkedId => x.commitId }.getOrElse(forkedId)

        val originId2 = JGitUtil.getForkedCommitId(
          oldGit,
          newGit,
          originRepository.owner,
          originRepository.name,
          originId,
          forkedRepository.owner,
          forkedRepository.name,
          forkedId2
        )

        (Option(oldGit.getRepository.resolve(originId2)), Option(newGit.getRepository.resolve(forkedId2)))

      } else {
        val originId2 =
          originRepository.tags.collectFirst { case x if x.name == originId => x.commitId }.getOrElse(originId)
        val forkedId2 =
          forkedRepository.tags.collectFirst { case x if x.name == forkedId => x.commitId }.getOrElse(forkedId)

        (Option(oldGit.getRepository.resolve(originId2)), Option(newGit.getRepository.resolve(forkedId2)))
      }
    }
  }
}

object PullRequestService {

  val PullRequestLimit = 25

  case class PullRequestCount(userName: String, count: Int)

  case class MergeStatus(
    conflictMessage: Option[String],
    commitStatuses: List[CommitStatus],
    branchProtection: ProtectedBranchService.ProtectedBranchInfo,
    branchIsOutOfDate: Boolean,
    hasUpdatePermission: Boolean,
    needStatusCheck: Boolean,
    hasMergePermission: Boolean,
    commitIdTo: String
  ) {

    val hasConflict = conflictMessage.isDefined
    val statuses: List[CommitStatus] =
      commitStatuses ++ (branchProtection.contexts.toSet -- commitStatuses.map(_.context).toSet)
        .map(CommitStatus.pending(branchProtection.owner, branchProtection.repository, _))
    val hasRequiredStatusProblem = needStatusCheck && branchProtection.contexts.exists(context =>
      statuses.find(_.context == context).map(_.state) != Some(CommitState.SUCCESS)
    )
    val hasProblem = hasRequiredStatusProblem || hasConflict || (statuses.nonEmpty && CommitState.combine(
      statuses.map(_.state).toSet
    ) != CommitState.SUCCESS)
    val canUpdate = branchIsOutOfDate && !hasConflict
    val canMerge = hasMergePermission && !hasConflict && !hasRequiredStatusProblem
    lazy val commitStateSummary: (CommitState, String) = {
      val stateMap = statuses.groupBy(_.state)
      val state = CommitState.combine(stateMap.keySet)
      val summary = stateMap.map { case (keyState, states) => s"${states.size} ${keyState.name}" }.mkString(", ")
      state -> summary
    }
    lazy val statusesAndRequired: List[(CommitStatus, Boolean)] = statuses.map { s =>
      s -> branchProtection.contexts.contains(s.context)
    }
    lazy val isAllSuccess = commitStateSummary._1 == CommitState.SUCCESS
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy