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

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

package gitbucket.core.service

import gitbucket.core.util.JGitUtil.CommitInfo
import gitbucket.core.util.StringUtil._
import gitbucket.core.util.Implicits._
import gitbucket.core.util.SyntaxSugars._
import gitbucket.core.controller.Context
import gitbucket.core.model.{
  Account,
  Issue,
  IssueAssignee,
  IssueComment,
  IssueLabel,
  Label,
  Profile,
  PullRequest,
  Repository,
  Role
}
import gitbucket.core.model.Profile._
import gitbucket.core.model.Profile.profile._
import gitbucket.core.model.Profile.profile.blockingApi._
import gitbucket.core.model.Profile.dateColumnType
import gitbucket.core.plugin.PluginRegistry

import scala.jdk.CollectionConverters._

trait IssuesService {
  self: AccountService with RepositoryService with LabelsService with PrioritiesService with MilestonesService =>
  import IssuesService._

  def getIssue(owner: String, repository: String, issueId: String)(implicit s: Session) =
    if (isInteger(issueId))
      Issues filter (_.byPrimaryKey(owner, repository, issueId.toInt)) firstOption
    else None

  def getOpenIssues(owner: String, repository: String)(implicit s: Session): List[Issue] =
    Issues filter (_.byRepository(owner, repository)) filterNot (_.closed) sortBy (_.issueId desc) list

  def getComments(owner: String, repository: String, issueId: Int)(implicit s: Session) =
    IssueComments filter (_.byIssue(owner, repository, issueId)) sortBy (_.commentId asc) list

  /** @return IssueComment and commentedUser and Issue */
  def getCommentsForApi(owner: String, repository: String, issueId: Int)(implicit
    s: Session
  ): List[(IssueComment, Account, Issue)] =
    IssueComments
      .filter(_.byIssue(owner, repository, issueId))
      .filter(_.action inSetBind Set("comment", "close_comment", "reopen_comment"))
      .join(Accounts)
      .on { case t1 ~ t2 => t1.commentedUserName === t2.userName }
      .join(Issues)
      .on { case t1 ~ t2 ~ t3 => t3.byIssue(t1.userName, t1.repositoryName, t1.issueId) }
      .map { case t1 ~ t2 ~ t3 => (t1, t2, t3) }
      .list

  def getMergedComment(owner: String, repository: String, issueId: Int)(implicit
    s: Session
  ): Option[(IssueComment, Account)] = {
    IssueComments
      .filter(_.byIssue(owner, repository, issueId))
      .filter(_.action === "merge".bind)
      .join(Accounts)
      .on { case t1 ~ t2 => t1.commentedUserName === t2.userName }
      .map { case t1 ~ t2 => (t1, t2) }
      .firstOption
  }

  def getComment(owner: String, repository: String, commentId: String)(implicit s: Session): Option[IssueComment] = {
    if (commentId forall (_.isDigit))
      IssueComments filter { t =>
        t.byPrimaryKey(commentId.toInt) && t.byRepository(owner, repository)
      } firstOption
    else None
  }

  def getCommentForApi(owner: String, repository: String, commentId: Int)(implicit
    s: Session
  ): Option[(IssueComment, Account, Issue)] =
    IssueComments
      .filter(_.byRepository(owner, repository))
      .filter(_.commentId === commentId)
      .filter(_.action inSetBind Set("comment", "close_comment", "reopen_comment"))
      .join(Accounts)
      .on { case t1 ~ t2 => t1.commentedUserName === t2.userName }
      .join(Issues)
      .on { case t1 ~ t2 ~ t3 => t3.byIssue(t1.userName, t1.repositoryName, t1.issueId) }
      .map { case t1 ~ t2 ~ t3 => (t1, t2, t3) }
      .firstOption

  def getIssueLabels(owner: String, repository: String, issueId: Int)(implicit s: Session): List[Label] = {
    IssueLabels
      .join(Labels)
      .on { case t1 ~ t2 =>
        t1.byLabel(t2.userName, t2.repositoryName, t2.labelId)
      }
      .filter { case t1 ~ t2 => t1.byIssue(owner, repository, issueId) }
      .map { case t1 ~ t2 => t2 }
      .list
  }

  def getIssueLabel(owner: String, repository: String, issueId: Int, labelId: Int)(implicit
    s: Session
  ): Option[IssueLabel] = {
    IssueLabels filter (_.byPrimaryKey(owner, repository, issueId, labelId)) firstOption
  }

  /**
   * Returns the count of the search result against  issues.
   *
   * @param condition the search condition
   * @param searchOption if true then counts only pull request, false then counts both of issue and pull request.
   * @param repos Tuple of the repository owner and the repository name
   * @return the count of the search result
   */
  def countIssue(condition: IssueSearchCondition, searchOption: IssueSearchOption, repos: (String, String)*)(implicit
    s: Session
  ): Int = {
    Query(searchIssueQuery(repos, condition, searchOption).length).first
  }

  /**
   * Returns the Map which contains issue count for each labels.
   *
   * @param owner the repository owner
   * @param repository the repository name
   * @param condition the search condition
   * @return the Map which contains issue count for each labels (key is label name, value is issue count)
   */
  def countIssueGroupByLabels(
    owner: String,
    repository: String,
    condition: IssueSearchCondition
  )(implicit s: Session): Map[String, Int] = {

    searchIssueQuery(Seq(owner -> repository), condition.copy(labels = Set.empty), IssueSearchOption.Issues)
      .join(IssueLabels)
      .on { case t1 ~ t2 =>
        t1.byIssue(t2.userName, t2.repositoryName, t2.issueId)
      }
      .join(Labels)
      .on { case t1 ~ t2 ~ t3 =>
        t2.byLabel(t3.userName, t3.repositoryName, t3.labelId)
      }
      .groupBy { case t1 ~ t2 ~ t3 =>
        t3.labelName
      }
      .map { case (labelName, t) =>
        labelName -> t.length
      }
      .list
      .toMap
  }

  /**
   * Returns the Map which contains issue count for each priority.
   *
   * @param owner the repository owner
   * @param repository the repository name
   * @param condition the search condition
   * @return the Map which contains issue count for each priority (key is priority name, value is issue count)
   */
  def countIssueGroupByPriorities(
    owner: String,
    repository: String,
    condition: IssueSearchCondition
  )(implicit s: Session): Map[String, Int] = {

    searchIssueQuery(Seq(owner -> repository), condition.copy(labels = Set.empty), IssueSearchOption.Issues)
      .join(Priorities)
      .on { case t1 ~ t2 =>
        t1.byPriority(t2.userName, t2.repositoryName, t2.priorityId)
      }
      .groupBy { case t1 ~ t2 =>
        t2.priorityName
      }
      .map { case (priorityName, t) =>
        priorityName -> t.length
      }
      .list
      .toMap
  }

  /**
   * Returns the search result against issues.
   *
   * @param condition the search condition
   * @param searchOption if true then returns only pull requests, false then returns only issues.
   * @param offset the offset for pagination
   * @param limit the limit for pagination
   * @param repos Tuple of the repository owner and the repository name
   * @return the search result (list of tuples which contain issue, labels and comment count)
   */
  def searchIssue(
    condition: IssueSearchCondition,
    searchOption: IssueSearchOption,
    offset: Int,
    limit: Int,
    repos: (String, String)*
  )(implicit s: Session): List[IssueInfo] = {
    // get issues and comment count and labels
    val result = searchIssueQueryBase(condition, searchOption, offset, limit, repos)
      .joinLeft(IssueLabels)
      .on { case t1 ~ t2 ~ i ~ t3 => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) }
      .joinLeft(Labels)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 => t3.map(_.byLabel(t4.userName, t4.repositoryName, t4.labelId)) }
      .joinLeft(Milestones)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 => t1.byMilestone(t5.userName, t5.repositoryName, t5.milestoneId) }
      .joinLeft(Priorities)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 => t1.byPriority(t6.userName, t6.repositoryName, t6.priorityId) }
      .joinLeft(PullRequests)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 => t1.byIssue(t7.userName, t7.repositoryName, t7.issueId) }
      .joinLeft(IssueAssignees)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 => t1.byIssue(t8.userName, t8.repositoryName, t8.issueId) }
      .sortBy { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 => i asc }
      .map { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 =>
        (
          t1,
          t2.commentCount,
          t4.map(_.labelId),
          t4.map(_.labelName),
          t4.map(_.color),
          t5.map(_.title),
          t6.map(_.priorityName),
          t7.map(_.commitIdTo),
          t8.map(_.assigneeUserName)
        )
      }
      .list
      .splitWith { (c1, c2) =>
        c1._1.userName == c2._1.userName && c1._1.repositoryName == c2._1.repositoryName && c1._1.issueId == c2._1.issueId
      }

    result.map { issues =>
      issues.head match {
        case (issue, commentCount, _, _, _, milestone, priority, commitId, _) =>
          IssueInfo(
            issue,
            issues
              .flatMap { t =>
                t._3.map(Label(issue.userName, issue.repositoryName, _, t._4.get, t._5.get))
              }
              .distinct
              .toList,
            milestone,
            priority,
            commentCount,
            commitId,
            issues.flatMap(_._9).distinct
          )
      }
    } toList
  }

  /** for api
   * @return (issue, issueUser, Seq(assigneeUsers))
   */
  def searchIssueByApi(condition: IssueSearchCondition, offset: Int, limit: Int, repos: (String, String)*)(implicit
    s: Session
  ): List[(Issue, Account, List[Account])] = {
    // get issues and comment count and labels
    searchIssueQueryBase(condition, IssueSearchOption.Issues, offset, limit, repos)
      .join(Accounts)
      .on { case t1 ~ t2 ~ i ~ t3 => t3.userName === t1.openedUserName }
      .joinLeft(IssueAssignees)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 => t4.byIssue(t1.userName, t1.repositoryName, t1.issueId) }
      .joinLeft(Accounts)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 => t5.userName === t4.map(_.assigneeUserName) }
      .sortBy { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 => i asc }
      .map { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 => (t1, t3, t5) }
      .list
      .groupBy { case (issue, account, _) =>
        (issue, account)
      }
      .map { case (_, values) =>
        (values.head._1, values.head._2, values.flatMap(_._3))
      }
      .toList
  }

  /** for api
   * @return (issue, issueUser, commentCount, pullRequest, headRepo, headOwner)
   */
  def searchPullRequestByApi(condition: IssueSearchCondition, offset: Int, limit: Int, repos: (String, String)*)(
    implicit s: Session
  ): List[(Issue, Account, Int, PullRequest, Repository, Account, List[Account])] = {
    // get issues and comment count and labels
    searchIssueQueryBase(condition, IssueSearchOption.PullRequests, offset, limit, repos)
      .join(PullRequests)
      .on { case t1 ~ t2 ~ i ~ t3 => t3.byPrimaryKey(t1.userName, t1.repositoryName, t1.issueId) }
      .join(Repositories)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 => t4.byRepository(t1.userName, t1.repositoryName) }
      .join(Accounts)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 => t5.userName === t1.openedUserName }
      .join(Accounts)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 => t6.userName === t4.userName }
      .joinLeft(IssueAssignees)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 => t7.byIssue(t1.userName, t1.repositoryName, t1.issueId) }
      .joinLeft(Accounts)
      .on { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 => t8.userName === t7.map(_.assigneeUserName) }
      .sortBy { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 => i asc }
      .map { case t1 ~ t2 ~ i ~ t3 ~ t4 ~ t5 ~ t6 ~ t7 ~ t8 => (t1, t5, t2.commentCount, t3, t4, t6, t8) }
      .list
      .groupBy { case (issue, openedUser, commentCount, pullRequest, repository, account, assignedUser) =>
        (issue, openedUser, commentCount, pullRequest, repository, account)
      }
      .map { case (_, values) =>
        (
          values.head._1,
          values.head._2,
          values.head._3,
          values.head._4,
          values.head._5,
          values.head._6,
          values.flatMap(_._7)
        )
      }
      .toList
  }

  private def searchIssueQueryBase(
    condition: IssueSearchCondition,
    searchOption: IssueSearchOption,
    offset: Int,
    limit: Int,
    repos: Seq[(String, String)]
  )(implicit s: Session) =
    searchIssueQuery(repos, condition, searchOption)
      .join(IssueOutline)
      .on { (t1, t2) =>
        t1.byIssue(t2.userName, t2.repositoryName, t2.issueId)
      }
      .sortBy { case (t1, t2) => t1.issueId desc }
      .sortBy { case (t1, t2) =>
        condition.sort match {
          case "created" =>
            condition.direction match {
              case "asc"  => t1.registeredDate asc
              case "desc" => t1.registeredDate desc
            }
          case "comments" =>
            condition.direction match {
              case "asc"  => t2.commentCount asc
              case "desc" => t2.commentCount desc
            }
          case "updated" =>
            condition.direction match {
              case "asc"  => t1.updatedDate asc
              case "desc" => t1.updatedDate desc
            }
          case "priority" =>
            condition.direction match {
              case "asc"  => t2.priority asc
              case "desc" => t2.priority desc
            }
        }
      }
      .drop(offset)
      .take(limit)
      .zipWithIndex

  /**
   * Assembles query for conditional issue searching.
   */
  private def searchIssueQuery(
    repos: Seq[(String, String)],
    condition: IssueSearchCondition,
    searchOption: IssueSearchOption
  )(implicit
    s: Session
  ) = {
    val query = Issues filter { t1 =>
      (if (repos.sizeIs == 1) {
         t1.byRepository(repos.head._1, repos.head._2)
       } else {
         ((t1.userName ++ "/" ++ t1.repositoryName) inSetBind (repos.map { case (owner, repo) => s"$owner/$repo" }))
       }) &&
      (condition.state match {
        case "open"   => t1.closed === false
        case "closed" => t1.closed === true
        case _        => t1.closed === true || t1.closed === false
      }).&&(t1.milestoneId.? isEmpty, condition.milestone.contains(None))
        .&&(t1.priorityId.? isEmpty, condition.priority.contains(None))
        // .&&(t1.assignedUserName.? isEmpty, condition.assigned == Some(None))
        .&&(t1.openedUserName === condition.author.get.bind, condition.author.isDefined) &&
      (searchOption match {
        case IssueSearchOption.Issues       => t1.pullRequest === false
        case IssueSearchOption.PullRequests => t1.pullRequest === true
        case IssueSearchOption.Both         => t1.pullRequest === false || t1.pullRequest === true
      })
        // 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
        )
        // Priority filter
        .&&(
          Priorities filter { t2 =>
            (t2.byPrimaryKey(t1.userName, t1.repositoryName, t1.priorityId)) &&
            (t2.priorityName === condition.priority.get.get.bind)
          } exists,
          condition.priority.flatten.isDefined
        )
        // Assignee filter
        .&&(
          IssueAssignees filter { a =>
            a.byIssue(t1.userName, t1.repositoryName, t1.issueId) &&
            a.assigneeUserName === condition.assigned.get.get.bind
          } exists,
          condition.assigned.flatten.isDefined
        )
        // Label filter
        .&&(
          IssueLabels filter { t2 =>
            (t2.byIssue(t1.userName, t1.repositoryName, t1.issueId)) &&
            (t2.labelId in
              (Labels filter { t3 =>
                (t3.byRepository(t1.userName, t1.repositoryName)) &&
                (t3.labelName inSetBind condition.labels)
              } map (_.labelId)))
          } exists,
          condition.labels.nonEmpty
        )
        // Visibility filter
        .&&(
          Repositories filter { t2 =>
            (t2.byRepository(t1.userName, t1.repositoryName)) &&
            (t2.isPrivate === condition.visibility.contains("private").bind)
          } exists,
          condition.visibility.nonEmpty
        )
        // Organization (group) filter
        .&&(t1.userName inSetBind condition.groups, condition.groups.nonEmpty)
        // Mentioned filter
        .&&(
          (t1.openedUserName === condition.mentioned.get.bind) || (IssueAssignees filter { t1 =>
            t1.byIssue(
              t1.userName,
              t1.repositoryName,
              t1.issueId
            ) && t1.assigneeUserName === condition.mentioned.get.bind
          } exists) ||
            (IssueComments filter { t2 =>
              (t2.byIssue(
                t1.userName,
                t1.repositoryName,
                t1.issueId
              )) && (t2.commentedUserName === condition.mentioned.get.bind)
            } exists),
          condition.mentioned.isDefined
        )
    }

    condition.others.foldLeft(query) { case (query, cond) =>
      def condQuery(f: Rep[String] => Rep[Boolean]): Query[Profile.Issues, Issue, Seq] = {
        query.filter { t1 =>
          IssueCustomFields
            .join(CustomFields)
            .on { (t2, t3) =>
              t2.userName === t3.userName && t2.repositoryName === t3.repositoryName && t2.fieldId === t3.fieldId
            }
            .filter { case (t2, t3) =>
              t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) && t3.fieldName === cond.name.bind && f(
                t2.value
              )
            } exists
        }
      }
      cond.operator match {
        case "eq"  => condQuery(_ === cond.value.bind)
        case "lt"  => condQuery(_ < cond.value.bind)
        case "gt"  => condQuery(_ > cond.value.bind)
        case "lte" => condQuery(_ <= cond.value.bind)
        case "gte" => condQuery(_ >= cond.value.bind)
        case _     => throw new IllegalArgumentException("Unsupported operator")
      }
    }
  }

  def insertIssue(
    owner: String,
    repository: String,
    loginUser: String,
    title: String,
    content: Option[String],
    milestoneId: Option[Int],
    priorityId: Option[Int],
    isPullRequest: Boolean = false
  )(implicit s: Session): Int = {
    // next id number
    sql"SELECT ISSUE_ID + 1 FROM ISSUE_ID WHERE USER_NAME = $owner AND REPOSITORY_NAME = $repository FOR UPDATE"
      .as[Int]
      .firstOption
      .filter { id =>
        Issues insert Issue(
          owner,
          repository,
          id,
          loginUser,
          milestoneId,
          priorityId,
          title,
          content,
          false,
          currentDate,
          currentDate,
          isPullRequest
        )

        // increment issue id
        IssueId
          .filter(_.byPrimaryKey(owner, repository))
          .map(_.issueId)
          .update(id) > 0
      } get
  }

  def registerIssueLabel(owner: String, repository: String, issueId: Int, labelId: Int, insertComment: Boolean = false)(
    implicit
    context: Context,
    s: Session
  ): Int = {
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "add_label",
        commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
        content = getLabel(owner, repository, labelId).map(_.labelName).getOrElse("Unknown label"),
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    IssueLabels insert IssueLabel(owner, repository, issueId, labelId)
  }

  def deleteIssueLabel(owner: String, repository: String, issueId: Int, labelId: Int, insertComment: Boolean = false)(
    implicit
    context: Context,
    s: Session
  ): Int = {
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "delete_label",
        commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
        content = getLabel(owner, repository, labelId).map(_.labelName).getOrElse("Unknown label"),
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    IssueLabels filter (_.byPrimaryKey(owner, repository, issueId, labelId)) delete
  }

  def deleteAllIssueLabels(owner: String, repository: String, issueId: Int, insertComment: Boolean = false)(implicit
    context: Context,
    s: Session
  ): Int = {
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "delete_label",
        commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
        content = "All labels",
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    IssueLabels filter (_.byIssue(owner, repository, issueId)) delete
  }

  def createComment(
    owner: String,
    repository: String,
    loginUser: String,
    issueId: Int,
    content: String,
    action: String
  )(implicit s: Session): Int = {
    Issues.filter(_.byPrimaryKey(owner, repository, issueId)).map(_.updatedDate).update(currentDate)
    IssueComments returning IssueComments.map(_.commentId) insert IssueComment(
      userName = owner,
      repositoryName = repository,
      issueId = issueId,
      action = action,
      commentedUserName = loginUser,
      content = content,
      registeredDate = currentDate,
      updatedDate = currentDate
    )
  }

  def updateIssue(owner: String, repository: String, issueId: Int, title: String, content: Option[String])(implicit
    s: Session
  ): Int = {
    Issues
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map { t =>
        (t.title, t.content.?, t.updatedDate)
      }
      .update(title, content, currentDate)
  }

  def changeIssueToPullRequest(owner: String, repository: String, issueId: Int)(implicit s: Session): Int = {
    Issues
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map { t =>
        t.pullRequest
      }
      .update(true)
  }

  def getIssueAssignees(owner: String, repository: String, issueId: Int)(implicit
    s: Session
  ): List[IssueAssignee] = {
    IssueAssignees.filter(_.byIssue(owner, repository, issueId)).sortBy(_.assigneeUserName).list
  }

  def registerIssueAssignee(
    owner: String,
    repository: String,
    issueId: Int,
    assigneeUserName: String,
    insertComment: Boolean = false
  )(implicit
    context: Context,
    s: Session
  ): Int = {
    val assigner = context.loginAccount.map(_.userName)
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "add_assignee",
        commentedUserName = assigner.getOrElse("Unknown user"),
        content = assigneeUserName,
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    for (issue <- getIssue(owner, repository, issueId.toString); repo <- getRepository(owner, repository)) {
      PluginRegistry().getIssueHooks.foreach(_.assigned(issue, repo, assigner, Some(assigneeUserName), None))
    }
    IssueAssignees insert IssueAssignee(owner, repository, issueId, assigneeUserName)
  }

  def deleteIssueAssignee(
    owner: String,
    repository: String,
    issueId: Int,
    assigneeUserName: String,
    insertComment: Boolean = false
  )(implicit
    context: Context,
    s: Session
  ): Int = {
    val assigner = context.loginAccount.map(_.userName)
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "delete_assignee",
        commentedUserName = assigner.getOrElse("Unknown user"),
        content = assigneeUserName,
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }

    // TODO Notify plugins of unassignment as doing in registerIssueAssignee()?

    IssueAssignees filter (_.byPrimaryKey(owner, repository, issueId, assigneeUserName)) delete
  }

  def deleteAllIssueAssignees(owner: String, repository: String, issueId: Int, insertComment: Boolean = false)(implicit
    context: Context,
    s: Session
  ): Int = {
    val assigner = context.loginAccount.map(_.userName)
    if (insertComment) {
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "delete_assign",
        commentedUserName = assigner.getOrElse("Unknown user"),
        content = "All assignees",
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }

    // TODO Notify plugins of unassignment as doing in registerIssueAssignee()?

    IssueAssignees filter (_.byIssue(owner, repository, issueId)) delete
  }

  def updateMilestoneId(
    owner: String,
    repository: String,
    issueId: Int,
    milestoneId: Option[Int],
    insertComment: Boolean = false
  )(implicit context: Context, s: Session): Int = {
    if (insertComment) {
      val oldMilestoneName = getIssue(owner, repository, s"${issueId}").get.milestoneId
        .map(getMilestone(owner, repository, _).map(_.title).getOrElse("Unknown milestone"))
        .getOrElse("No milestone")
      val milestoneName = milestoneId
        .map(getMilestone(owner, repository, _).map(_.title).getOrElse("Unknown milestone"))
        .getOrElse("No milestone")
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "change_milestone",
        commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
        content = s"${oldMilestoneName}:${milestoneName}",
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    Issues
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(t => (t.milestoneId ?, t.updatedDate))
      .update(milestoneId, currentDate)
  }

  def updatePriorityId(
    owner: String,
    repository: String,
    issueId: Int,
    priorityId: Option[Int],
    insertComment: Boolean = false
  )(implicit context: Context, s: Session): Int = {
    if (insertComment) {
      val oldPriorityName = getIssue(owner, repository, s"${issueId}").get.priorityId
        .map(getPriority(owner, repository, _).map(_.priorityName).getOrElse("Unknown priority"))
        .getOrElse("No priority")
      val priorityName = priorityId
        .map(getPriority(owner, repository, _).map(_.priorityName).getOrElse("Unknown priority"))
        .getOrElse("No priority")
      IssueComments insert IssueComment(
        userName = owner,
        repositoryName = repository,
        issueId = issueId,
        action = "change_priority",
        commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
        content = s"${oldPriorityName}:${priorityName}",
        registeredDate = currentDate,
        updatedDate = currentDate
      )
    }
    Issues
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(t => (t.priorityId ?, t.updatedDate))
      .update(priorityId, currentDate)
  }

  def updateComment(owner: String, repository: String, issueId: Int, commentId: Int, content: String)(implicit
    s: Session
  ): Int = {
    Issues.filter(_.byPrimaryKey(owner, repository, issueId)).map(_.updatedDate).update(currentDate)
    IssueComments.filter(_.byPrimaryKey(commentId)).map(t => (t.content, t.updatedDate)).update(content, currentDate)
  }

  def deleteComment(owner: String, repository: String, issueId: Int, commentId: Int)(implicit
    context: Context,
    s: Session
  ): Int = {
    Issues.filter(_.byPrimaryKey(owner, repository, issueId)).map(_.updatedDate).update(currentDate)
    IssueComments.filter(_.byPrimaryKey(commentId)).first match {
      case c if c.action == "reopen_comment" =>
        IssueComments.filter(_.byPrimaryKey(commentId)).map(t => (t.content, t.action)).update("Reopen", "reopen")
      case c if c.action == "close_comment" =>
        IssueComments.filter(_.byPrimaryKey(commentId)).map(t => (t.content, t.action)).update("Close", "close")
      case _ =>
        IssueComments.filter(_.byPrimaryKey(commentId)).delete
        IssueComments insert IssueComment(
          userName = owner,
          repositoryName = repository,
          issueId = issueId,
          action = "delete_comment",
          commentedUserName = context.loginAccount.map(_.userName).getOrElse("Unknown user"),
          content = s"",
          registeredDate = currentDate,
          updatedDate = currentDate
        )
    }
  }

  def updateClosed(owner: String, repository: String, issueId: Int, closed: Boolean)(implicit s: Session): Int = {
    Issues
      .filter(_.byPrimaryKey(owner, repository, issueId))
      .map(t => (t.closed, t.updatedDate))
      .update(closed, currentDate)
  }

  /**
   * Search issues by keyword.
   *
   * @param owner the repository owner
   * @param repository the repository name
   * @param query the keywords separated by whitespace.
   * @return issues with comment count and matched content of issue or comment
   */
  def searchIssuesByKeyword(owner: String, repository: String, query: String, pullRequest: Boolean)(implicit
    s: Session
  ): List[(Issue, Int, String)] = {
    // import slick.driver.JdbcDriver.likeEncode
    val keywords = splitWords(query.toLowerCase)

    // Search Issue
    val issues = Issues
      .filter { t =>
        t.byRepository(owner, repository) && t.pullRequest === pullRequest.bind
      }
      .join(IssueOutline)
      .on { case (t1, t2) =>
        t1.byIssue(t2.userName, t2.repositoryName, t2.issueId)
      }
      .filter { case (t1, t2) =>
        keywords
          .map { keyword =>
            (t1.title.toLowerCase.like(s"%${likeEncode(keyword)}%", '^')) ||
            (t1.content.toLowerCase.like(s"%${likeEncode(keyword)}%", '^'))
          }
          .reduceLeft(_ && _)
      }
      .map { case (t1, t2) =>
        (t1, 0, t1.content.?, t2.commentCount)
      }

    // Search IssueComment
    val comments = IssueComments
      .filter(_.byRepository(owner, repository))
      .join(Issues)
      .on { case (t1, t2) =>
        t1.byIssue(t2.userName, t2.repositoryName, t2.issueId)
      }
      .join(IssueOutline)
      .on { case ((t1, t2), t3) =>
        t2.byIssue(t3.userName, t3.repositoryName, t3.issueId)
      }
      .filter { case ((t1, t2), t3) =>
        t2.pullRequest === pullRequest.bind &&
        keywords
          .map { query =>
            t1.content.toLowerCase.like(s"%${likeEncode(query)}%", '^')
          }
          .reduceLeft(_ && _)
      }
      .map { case ((t1, t2), t3) =>
        (t2, t1.commentId, t1.content.?, t3.commentCount)
      }

    issues
      .union(comments)
      .sortBy { case (issue, commentId, _, _) =>
        issue.issueId.desc -> commentId
      }
      .list
      .splitWith { case ((issue1, _, _, _), (issue2, _, _, _)) =>
        issue1.issueId == issue2.issueId
      }
      .map {
        _.head match {
          case (issue, _, content, commentCount) => (issue, commentCount, content.getOrElse(""))
        }
      }
      .toList
  }

  def closeIssuesFromMessage(message: String, userName: String, owner: String, repository: String)(implicit
    s: Session
  ): Seq[Int] = {
    extractCloseId(message).flatMap { issueId =>
      for (issue <- getIssue(owner, repository, issueId) if !issue.closed) yield {
        createComment(owner, repository, userName, issue.issueId, "Close", "close")
        updateClosed(owner, repository, issue.issueId, true)
        issue.issueId
      }
    }
  }

  def createReferComment(owner: String, repository: String, fromIssue: Issue, message: String, loginAccount: Account)(
    implicit s: Session
  ): Unit = {
    extractGlobalIssueId(message).foreach { case (_referredOwner, _referredRepository, referredIssueId) =>
      val referredOwner = _referredOwner.getOrElse(owner)
      val referredRepository = _referredRepository.getOrElse(repository)
      getRepository(referredOwner, referredRepository).foreach { repo =>
        if (isReadable(repo.repository, Option(loginAccount))) {
          getIssue(referredOwner, referredRepository, referredIssueId.get).foreach { _ =>
            val (content, action) = if (owner == referredOwner && repository == referredRepository) {
              (s"${fromIssue.issueId}:${fromIssue.title}", "refer")
            } else {
              (s"${fromIssue.issueId}:${owner}:${repository}:${fromIssue.title}", "refer_global")
            }
            referredIssueId.foreach(x =>
              // Not add if refer comment already exist.
              if (
                !getComments(referredOwner, referredRepository, x.toInt).exists { x =>
                  (x.action == "refer" || x.action == "refer_global") && x.content == content
                }
              ) {
                createComment(
                  referredOwner,
                  referredRepository,
                  loginAccount.userName,
                  x.toInt,
                  content,
                  action
                )
              }
            )
          }
        }
      }
    }
  }

  def createIssueComment(owner: String, repository: String, commit: CommitInfo)(implicit s: Session): Unit = {
    extractIssueId(commit.fullMessage).foreach { issueId =>
      if (getIssue(owner, repository, issueId).isDefined) {
        val userName =
          getAccountByMailAddress(commit.committerEmailAddress).map(_.userName).getOrElse(commit.committerName)
        createComment(owner, repository, userName, issueId.toInt, commit.fullMessage + " " + commit.id, "commit")
      }
    }
  }

  def getAssignableUserNames(owner: String, repository: String)(implicit s: Session): List[String] = {
    (getCollaboratorUserNames(owner, repository, Seq(Role.ADMIN, Role.DEVELOPER)) :::
      (getAccountByUserName(owner) match {
        case Some(x) if x.isGroupAccount =>
          getGroupMembers(owner).map(_.userName)
        case Some(_) =>
          List(owner)
        case None =>
          Nil
      })).distinct.sorted
  }

}

object IssuesService {
  import javax.servlet.http.HttpServletRequest

  val IssueLimit = 25

  case class CustomFieldCondition(name: String, value: String, operator: String)

  case class IssueSearchCondition(
    labels: Set[String] = Set.empty,
    milestone: Option[Option[String]] = None,
    priority: Option[Option[String]] = None,
    author: Option[String] = None,
    assigned: Option[Option[String]] = None,
    mentioned: Option[String] = None,
    state: String = "open",
    sort: String = "created",
    direction: String = "desc",
    visibility: Option[String] = None,
    groups: Set[String] = Set.empty,
    others: Seq[CustomFieldCondition] = Nil
  ) {
    def isEmpty: Boolean = {
      labels.isEmpty && milestone.isEmpty && author.isEmpty && assigned.isEmpty &&
      state == "open" && sort == "created" && direction == "desc" && visibility.isEmpty && others.isEmpty
    }

    def nonEmpty: Boolean = !isEmpty

    def toFilterString: String = if (isEmpty) ""
    else {
      (
        List(
          Some(s"is:${state}"),
          author.map(author => s"author:${author}"),
          assigned.map(assignee => s"assignee:${assignee}"),
          mentioned.map(mentioned => s"mentions:${mentioned}")
        ).flatten ++
          labels.map(label => s"label:${label}") ++
          List(
            milestone.map {
              case Some(x) => s"milestone:${x}"
              case None    => "no:milestone"
            },
            priority.map {
              case Some(x) => s"priority:${x}"
              case None    => "no:priority"
            },
            (sort, direction) match {
              case ("created", "desc")  => None
              case ("created", "asc")   => Some("sort:created-asc")
              case ("comments", "desc") => Some("sort:comments-desc")
              case ("comments", "asc")  => Some("sort:comments-asc")
              case ("updated", "desc")  => Some("sort:updated-desc")
              case ("updated", "asc")   => Some("sort:updated-asc")
              case ("priority", "desc") => Some("sort:priority-desc")
              case ("priority", "asc")  => Some("sort:priority-asc")
              case x                    => throw new MatchError(x)
            },
            visibility.map(visibility => s"visibility:${visibility}"),
          ).flatten ++
          others.map { cond =>
            cond.operator match {
              case "eq"  => s"custom.${cond.name}:${cond.value}"
              case "lt"  => s"custom.${cond.name}<${cond.value}"
              case "lte" => s"custom.${cond.name}<=${cond.value}"
              case "gt"  => s"custom.${cond.name}>${cond.value}"
              case "gte" => s"custom.${cond.name}>=${cond.value}"
            }
          } ++
          groups.map(group => s"group:${group}")
      ).mkString(" ")
    }

    def toURL: String = {
      "?" + (Seq(
        if (labels.isEmpty) None else Some("labels=" + urlEncode(labels.mkString(","))),
        milestone.map {
          case Some(x) => s"milestone=${urlEncode(x)}"
          case None    => "milestone=none"
        },
        priority.map {
          case Some(x) => s"priority=${urlEncode(x)}"
          case None    => "priority=none"
        },
        author.map(x => s"author=${urlEncode(x)}"),
        assigned.map {
          case Some(x) => s"assigned=${urlEncode(x)}"
          case None    => "assigned=none"
        },
        mentioned.map(x => s"mentioned=${urlEncode(x)}"),
        Some(s"state=${urlEncode(state)}"),
        Some(s"sort=${urlEncode(sort)}"),
        Some(s"direction=${urlEncode(direction)}"),
        visibility.map(x => s"visibility=${urlEncode(x)}"),
        if (groups.isEmpty) None else Some(s"groups=${urlEncode(groups.mkString(","))}")
      ).flatten ++ others.map { x =>
        s"custom.${urlEncode(x.name)}=${urlEncode(x.operator)}:${urlEncode(x.value)}"
      }).mkString("&")
    }
  }

  object IssueSearchCondition {

    private val SupportedOperators = Seq("eq", "lt", "gt", "lte", "gte")

    private def param(request: HttpServletRequest, name: String, allow: Seq[String] = Nil): Option[String] = {
      val value = request.getParameter(name)
      if (value == null || value.isEmpty || (allow.nonEmpty && !allow.contains(value))) None else Some(value)
    }

    /**
     * Restores IssueSearchCondition instance from filter query.
     */
    def apply(filter: String): IssueSearchCondition = {
      val conditions = filter
        .split("[  \t]+")
        .collect {
          case x if !x.startsWith("custom.") && x.indexOf(":") > 0 =>
            val dim = x.split(":")
            dim(0) -> dim(1)
        }
        .groupBy(_._1)
        .map { case (key, values) =>
          key -> values.map(_._2).toSeq
        }

      val (sort, direction) = conditions.get("sort").flatMap(_.headOption).getOrElse("created-desc") match {
        case "created-asc"   => ("created", "asc")
        case "comments-desc" => ("comments", "desc")
        case "comments-asc"  => ("comments", "asc")
        case "updated-desc"  => ("comments", "desc")
        case "updated-asc"   => ("comments", "asc")
        case _               => ("created", "desc")
      }

      val others = filter
        .split("[  \t]+")
        .collect {
          case x if x.startsWith("custom.") && x.indexOf(":") > 0 =>
            val dim = x.split(":")
            dim(0) -> ("eq", dim(1))
          case x if x.startsWith("custom.") && x.indexOf("<=") > 0 =>
            val dim = x.split("<=")
            dim(0) -> ("lte", dim(1))
          case x if x.startsWith("custom.") && x.indexOf("<") > 0 =>
            val dim = x.split("<")
            dim(0) -> ("lt", dim(1))
          case x if x.startsWith("custom.") && x.indexOf(">=") > 0 =>
            val dim = x.split(">=")
            dim(0) -> ("gte", dim(1))
          case x if x.startsWith("custom.") && x.indexOf(">") > 0 =>
            val dim = x.split(">")
            dim(0) -> ("gt", dim(1))
        }
        .map { case (key, (operator, value)) =>
          CustomFieldCondition(key.stripPrefix("custom."), value, operator)
        }
        .toSeq

      IssueSearchCondition(
        conditions.get("label").map(_.toSet).getOrElse(Set.empty),
        conditions.get("milestone").flatMap(_.headOption) match {
          case None         => None
          case Some("none") => Some(None)
          case Some(x)      => Some(Some(x)) // milestones.get(x).map(x => Some(x))
        },
        conditions.get("priority").map(_.headOption), // TODO
        conditions.get("author").flatMap(_.headOption),
        conditions.get("assignee").map(_.headOption), // TODO
        conditions.get("mentions").flatMap(_.headOption),
        conditions.get("is").getOrElse(Seq.empty).find(x => x == "open" || x == "closed").getOrElse("open"),
        sort,
        direction,
        conditions.get("visibility").flatMap(_.headOption),
        conditions.get("group").map(_.toSet).getOrElse(Set.empty),
        others
      )
    }

    /**
     * Restores IssueSearchCondition instance from request parameters.
     */
    def apply(request: HttpServletRequest): IssueSearchCondition = {
      val others = request.getParameterMap.asScala
        .collect {
          // custom. = :
          case (key, values) if key.startsWith("custom.") && values.nonEmpty && values.head.indexOf(":") > 0 =>
            val name = key.stripPrefix("custom.")
            val Array(operator, value) = values.head.split(":")
            CustomFieldCondition(name, value, operator)
          case (key, values) if key.startsWith("custom.") && values.nonEmpty =>
            val name = key.stripPrefix("custom.")
            CustomFieldCondition(name, values.head, "eq")
        }
        .filter { x =>
          SupportedOperators.contains(x.operator)
        }
        .toSeq

      IssueSearchCondition(
        param(request, "labels").map(_.split(",").toSet).getOrElse(Set.empty),
        param(request, "milestone").map {
          case "none" => None
          case x      => Some(x)
        },
        param(request, "priority").map {
          case "none" => None
          case x      => Some(x)
        },
        param(request, "author"),
        param(request, "assigned").map {
          case "none" => None
          case x      => Some(x)
        },
        param(request, "mentioned"),
        param(request, "state", Seq("open", "closed", "all")).getOrElse("open"),
        param(request, "sort", Seq("created", "comments", "updated", "priority")).getOrElse("created"),
        param(request, "direction", Seq("asc", "desc")).getOrElse("desc"),
        param(request, "visibility"),
        param(request, "groups").map(_.split(",").toSet).getOrElse(Set.empty),
        others
      )
    }

    def apply(request: HttpServletRequest, milestone: String): IssueSearchCondition = {
      apply(request).copy(milestone = Some(Some(milestone)))
    }

    def page(request: HttpServletRequest): Int = {
      PaginationHelper.page(param(request, "page"))
    }
  }

  case class IssueInfo(
    issue: Issue,
    labels: List[Label],
    milestone: Option[String],
    priority: Option[String],
    commentCount: Int,
    commitId: Option[String],
    assignees: Seq[String]
  )
}

sealed trait IssueSearchOption

object IssueSearchOption {
  case object Issues extends IssueSearchOption
  case object PullRequests extends IssueSearchOption
  case object Both extends IssueSearchOption
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy