/**
* Yona, 21st Century Project Hosting SW
*
* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp.
* https://yona.io
**/
package models;
import actors.RelatedPullRequestMergingActor;
import akka.actor.Props;
import com.avaje.ebean.*;
import controllers.PullRequestApp.SearchCondition;
import controllers.UserApp;
import errors.PullRequestException;
import models.enumeration.EventType;
import models.enumeration.ResourceType;
import models.enumeration.State;
import models.resource.Resource;
import models.resource.ResourceConvertible;
import org.apache.commons.lang.ObjectUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.*;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.FetchResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.joda.time.Duration;
import play.data.validation.Constraints;
import play.db.ebean.Model;
import play.db.ebean.Transactional;
import play.i18n.Messages;
import play.libs.Akka;
import playRepository.FileDiff;
import playRepository.GitCommit;
import playRepository.GitRepository;
import utils.Constants;
import utils.JodaDateUtil;
import javax.annotation.Nullable;
import javax.persistence.*;
import javax.persistence.OrderBy;
import javax.validation.constraints.Size;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.*;
import static com.avaje.ebean.Expr.*;
@Entity
public class PullRequest extends Model implements ResourceConvertible {
private static final long serialVersionUID = 1L;
public static final String DELIMETER = ",";
public static final Finder finder = new Finder<>(Long.class, PullRequest.class);
public static final int ITEMS_PER_PAGE = 15;
@Id
public Long id;
@Constraints.Required
@Size(max=255)
public String title;
@Lob
public String body;
@Transient
public Long toProjectId;
@Transient
public Long fromProjectId;
@ManyToOne
public Project toProject;
@ManyToOne
public Project fromProject;
@Constraints.Required
@Size(max=255)
public String toBranch;
@Constraints.Required
@Size(max=255)
public String fromBranch;
@ManyToOne
public User contributor;
@ManyToOne
public User receiver;
@Temporal(TemporalType.TIMESTAMP)
public Date created;
@Temporal(TemporalType.TIMESTAMP)
public Date updated;
@Temporal(TemporalType.TIMESTAMP)
public Date received;
public State state = State.OPEN;
public Boolean isConflict;
public Boolean isMerging;
@OneToMany(cascade = CascadeType.ALL)
public List pullRequestCommits;
@OneToMany(cascade = CascadeType.ALL)
@OrderBy("created ASC")
public List pullRequestEvents;
public String lastCommitId;
public String mergedCommitIdFrom;
public String mergedCommitIdTo;
public Long number;
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(
name = "pull_request_reviewers",
joinColumns = @JoinColumn(name = "pull_request_id", unique = false),
inverseJoinColumns = @JoinColumn(name = "user_id", unique = false),
uniqueConstraints = @UniqueConstraint(columnNames = {"pull_request_id", "user_id"})
)
public Set reviewers = new HashSet<>();
@OneToMany(mappedBy = "pullRequest")
public List commentThreads = new ArrayList<>();
@Transient
private Repository repository;
public static PullRequest createNewPullRequest(Project fromProject, Project toProject, String fromBranch, String toBranch) {
PullRequest pullRequest = new PullRequest();
pullRequest.toProject = toProject;
pullRequest.toBranch = toBranch;
pullRequest.fromProject = fromProject;
pullRequest.fromBranch = fromBranch;
return pullRequest;
}
@Override
public String toString() {
return "PullRequest{" +
"id=" + id +
", title='" + title + '\'' +
", body='" + body + '\'' +
", toProject=" + toProject +
", fromProject=" + fromProject +
", toBranch='" + toBranch + '\'' +
", fromBranch='" + fromBranch + '\'' +
", contributor=" + contributor +
", receiver=" + receiver +
", created=" + created +
", updated=" + updated +
", received=" + received +
", state=" + state +
'}';
}
public static void onStart() {
regulateNumbers();
changeStateToClosed();
}
public Duration createdAgo() {
return JodaDateUtil.ago(this.created);
}
public Duration receivedAgo() {
return JodaDateUtil.ago(this.received);
}
public boolean isOpen() {
return this.state == State.OPEN;
}
public boolean isAcceptable() {
return !isConflict && isOpen() && !isMerging && (isReviewed() || !toProject.isUsingReviewerCount);
}
public static PullRequest findById(long id) {
return finder.byId(id);
}
public static PullRequest findDuplicatedPullRequest(PullRequest pullRequest) {
return finder.where()
.eq("fromBranch", pullRequest.fromBranch)
.eq("toBranch", pullRequest.toBranch)
.eq("fromProject", pullRequest.fromProject)
.eq("toProject", pullRequest.toProject)
.eq("state", State.OPEN)
.findUnique();
}
public static List findOpendPullRequests(Project project) {
return finder.where()
.eq("toProject", project)
.eq("state", State.OPEN)
.order().desc("created")
.findList();
}
public static List findOpendPullRequestsByDaysAgo(User user, int days) {
return finder.where()
.eq("contributor.id", user.id)
.ge("updated", JodaDateUtil.before(days))
.order("updated desc, state asc")
.findList();
}
public static List findClosedPullRequests(Project project) {
return finder.where()
.eq("toProject", project)
.or(eq("state", State.CLOSED), eq("state", State.MERGED))
.order().desc("created")
.findList();
}
public static List findSentPullRequests(Project project) {
return finder.where()
.eq("fromProject", project)
.order().desc("created")
.findList();
}
public static List findAcceptedPullRequests(Project project) {
return finder.where()
.eq("fromProject", project)
.or(eq("state", State.CLOSED), eq("state", State.MERGED))
.order().desc("created")
.findList();
}
public static List allReceivedRequests(Project project) {
return finder.where()
.eq("toProject", project)
.order().desc("created")
.findList();
}
public static List findRecentlyReceived(Project project, int size) {
return finder.where()
.eq("toProject", project)
.order().desc("created")
.findPagingList(size).getPage(0)
.getList();
}
public static List findRecentlyReceivedOpen(Project project, int size) {
return finder.where()
.eq("toProject", project)
.eq("state", State.OPEN)
.order().desc("created")
.findPagingList(size).getPage(0)
.getList();
}
public static int countOpenedPullRequests(Project project) {
return finder.where()
.eq("toProject", project)
.eq("state", State.OPEN)
.findRowCount();
}
public static List findRelatedPullRequests(Project project, String branch) {
return finder.where()
.or(
Expr.and(
eq("fromProject", project),
eq("fromBranch", branch)),
Expr.and(
eq("toProject", project),
eq("toBranch", branch)))
.ne("state", State.CLOSED)
.ne("state", State.MERGED)
.findList();
}
@Override
public Resource asResource() {
return new Resource() {
@Override
public String getId() {
return id.toString();
}
@Override
public Project getProject() {
return toProject;
}
@Override
public ResourceType getType() {
return ResourceType.PULL_REQUEST;
}
@Override
public Long getAuthorId() {
return contributor.id;
}
};
}
public void updateWith(PullRequest newPullRequest) {
deleteIssueEvents();
this.toBranch = newPullRequest.toBranch;
this.fromBranch = newPullRequest.fromBranch;
this.title = newPullRequest.title;
this.body = newPullRequest.body;
update();
addNewIssueEvents();
}
public boolean hasSameBranchesWith(PullRequest pullRequest) {
return this.toBranch.equals(pullRequest.toBranch) && this.fromBranch.equals(pullRequest.fromBranch);
}
public boolean isClosed() {
return this.state == State.CLOSED;
}
public boolean isMerged() {
return this.state == State.MERGED;
}
/**
* @see #lastCommitId
*/
public void deleteFromBranch() {
this.lastCommitId = GitRepository.deleteFromBranch(this);
update();
}
public void restoreFromBranch() {
GitRepository.restoreBranch(this);
}
public class Merger {
private ThreeWayMerger merger;
private String leftRef;
private String rightRef;
public Merger(String leftRef, String rightRef) throws IOException {
this.leftRef = Objects.requireNonNull(leftRef);
this.rightRef = Objects.requireNonNull(rightRef);
}
public MergeResult merge() throws IOException {
merger = MergeStrategy.RECURSIVE.newMerger(getRepository(), true);
String refNotExistMessageFormat = "Ref '%s' does not exist in Git repository '%s'";
ObjectId leftParent = Objects.requireNonNull(getRepository().resolve(leftRef),
String.format(refNotExistMessageFormat, leftRef, getRepository()));
ObjectId rightParent = Objects.requireNonNull(getRepository().resolve(rightRef),
String.format(refNotExistMessageFormat, rightRef, getRepository()));
if (merger.merge(leftParent, rightParent)) {
return new Success(merger.getResultTreeId(), leftParent, rightParent);
} else {
return new Conflict(leftParent, rightParent);
}
}
public class Conflict extends MergeResult {
private Conflict(ObjectId leftParent, ObjectId rightParent) {
this.leftParent = Objects.requireNonNull(leftParent);
this.rightParent = Objects.requireNonNull(rightParent);
}
@Override
public MergeRefUpdate createCommit() throws IOException, GitAPIException {
throw new UnsupportedOperationException();
}
@Override
public MergeRefUpdate createCommit(PersonIdent whoMerges) throws IOException, GitAPIException {
throw new UnsupportedOperationException();
}
@Nullable
@Override
public ObjectId getMergeCommitId() {
throw new UnsupportedOperationException();
}
}
public class Success extends MergeResult {
private ObjectId mergeCommitId;
protected ObjectId treeId;
private Success(
ObjectId treeId, ObjectId leftParent, ObjectId rightParent) {
this.treeId = Objects.requireNonNull(treeId);
this.leftParent = Objects.requireNonNull(leftParent);
this.rightParent = Objects.requireNonNull(rightParent);
}
public MergeRefUpdate createCommit() throws IOException, GitAPIException {
return createCommit(new PersonIdent(utils.Config.getSiteName(),
utils.Config.getSystemEmailAddress()));
}
private ObjectId getMergedTreeIfReusable() {
String refName = getNameOfRefToMerged();
RevCommit commit = null;
try {
ObjectId objectId = getRepository().findRef(refName).getObjectId();
commit = new RevWalk(getRepository()).parseCommit(objectId);
} catch (Exception e) {
play.Logger.info("Failed to get the merged branch", e);
}
if (commit != null
&& commit.getParentCount() == 2
&& commit.getParent(0).equals(leftParent)
&& commit.getParent(1).equals(rightParent)) {
return commit.getTree().toObjectId();
}
return null;
}
public MergeRefUpdate createCommit(PersonIdent whoMerges) throws IOException,
GitAPIException {
// creates merge commit
CommitBuilder mergeCommit = new CommitBuilder();
ObjectId reusableMergedTreeId = getMergedTreeIfReusable();
if (reusableMergedTreeId != null) {
mergeCommit.setTreeId(reusableMergedTreeId);
} else {
mergeCommit.setTreeId(treeId);
}
mergeCommit.setParentIds(leftParent, rightParent);
mergeCommit.setAuthor(whoMerges);
mergeCommit.setCommitter(whoMerges);
List commitList = GitRepository.diffCommits(
getRepository(), leftParent, rightParent);
mergeCommit.setMessage(makeMergeCommitMessage(commitList));
// insertObject and got mergeCommit Object Id
ObjectInserter inserter = getRepository().newObjectInserter();
mergeCommitId = inserter.insert(mergeCommit);
inserter.flush();
inserter.close();
return new MergeRefUpdate(mergeCommitId, whoMerges);
}
@Nullable
public ObjectId getMergeCommitId() {
return mergeCommitId;
}
}
public abstract class MergeResult {
protected ObjectId leftParent;
protected ObjectId rightParent;
abstract public MergeRefUpdate createCommit() throws IOException, GitAPIException;
abstract public MergeRefUpdate createCommit(PersonIdent whoMerges) throws IOException,
GitAPIException;
@Nullable
abstract public ObjectId getMergeCommitId();
public ObjectId getLeftParentId() {
return leftParent;
}
public ObjectId getRightParentId() {
return rightParent;
}
boolean conflicts() {
return this instanceof Conflict;
}
}
public class MergeRefUpdate {
private ObjectId mergeCommitId;
private PersonIdent whoMerges;
private MergeRefUpdate(ObjectId mergeCommitId, PersonIdent whoMerges) {
this.mergeCommitId = Objects.requireNonNull(mergeCommitId);
this.whoMerges = Objects.requireNonNull(whoMerges);
}
public void updateRef(String ref) throws
IOException, ConcurrentRefUpdateException, PullRequestException {
RefUpdate refUpdate = getRepository().updateRef(ref);
refUpdate.setNewObjectId(mergeCommitId);
refUpdate.setForceUpdate(true);
refUpdate.setRefLogIdent(whoMerges);
refUpdate.setRefLogMessage("merged", true);
RefUpdate.Result rc = refUpdate.update();
switch (rc) {
case NEW:
case FAST_FORWARD:
case FORCED:
return;
case REJECTED:
case LOCK_FAILURE:
throw new ConcurrentRefUpdateException(
"Could not lock '" + refUpdate.getRef() + "'",
refUpdate.getRef(), rc);
default:
throw new PullRequestException(MessageFormat.format(
JGitText.get().updatingRefFailed, refUpdate.getRef(),
mergeCommitId, rc));
}
}
}
}
public void merge(final PullRequestEventMessage message) throws IOException, GitAPIException, PullRequestException {
Merger.MergeResult result =
new Merger(toBranch, fetchSourceBranch()).merge();
if (!result.conflicts()) {
User sender = message.getSender();
result.createCommit(new PersonIdent(sender.name,
sender.email)).updateRef(toBranch);
// Update the pull request
updateMergedCommitId(result);
changeState(State.MERGED, sender);
// Add event
NotificationEvent.afterPullRequestUpdated(sender, this, State.OPEN, State.MERGED);
PullRequestEvent.addStateEvent(sender, this, State.MERGED);
Akka.system().actorOf(Props.create(RelatedPullRequestMergingActor.class)).tell(message, null);
}
}
public String fetchSourceBranch() throws IOException, GitAPIException {
String destination = getRefNameToFetchedSource();
fetchSourceBranchTo(destination);
return destination;
}
public void updateMergedCommitId(Merger.MergeResult mergeResult) {
mergedCommitIdFrom = mergeResult.getLeftParentId().getName();
mergedCommitIdTo = mergeResult.getMergeCommitId().getName();
update();
}
public String getResourceKey() {
return ResourceType.PULL_REQUEST.resource() + Constants.RESOURCE_KEY_DELIM + this.id;
}
public Set getWatchers() {
return getWatchers(true);
}
public Set getWatchers(boolean allowedWatchersOnly) {
Set actualWatchers = new HashSet<>();
actualWatchers.add(this.contributor);
for (CommentThread thread : commentThreads) {
for (ReviewComment c : thread.reviewComments) {
User user = User.find.byId(c.author.id);
if (user != null) {
actualWatchers.add(user);
}
}
}
return Watch.findActualWatchers(actualWatchers, asResource(), allowedWatchersOnly);
}
/**
* Make merge commit message e.g.
*
* Merge branch 'dev' of dlab/hive into 'next'
*
* from pull-request 10
*
* @param commits
* @return
* @throws IOException
*/
private String makeMergeCommitMessage(List commits) throws IOException {
StringBuilder builder = new StringBuilder();
builder.append("Merge branch ");
builder.append("\'");
builder.append(Repository.shortenRefName(fromBranch));
builder.append("\'");
if (!fromProject.equals(toProject)) {
builder.append(" of ");
builder.append(fromProject.owner);
builder.append("/");
builder.append(fromProject.name);
}
if (toBranch.equals("refs/heads/master")) {
builder.append("\n\n");
} else {
builder.append(" into ");
builder.append("\'");
builder.append(Repository.shortenRefName(toBranch));
builder.append("\'");
builder.append("\n\n");
}
builder.append("from pull-request ");
builder.append(number);
builder.append("\n\n");
addCommitMessages(commits, builder);
addReviewers(builder);
return builder.toString();
}
private void addReviewers(StringBuilder builder) {
for(User user : reviewers) {
builder.append(String.format("Reviewed-by: %s <%s>\n", user.name, user.email));
}
}
public List getReviewerNames(){
List names = new ArrayList<>();
for(User user : reviewers){
names.add(user.name);
}
return names;
}
private void addCommitMessages(List commits, StringBuilder builder) {
builder.append(String.format("* %s:\n", Repository.shortenRefName(this.fromBranch)));
for(GitCommit gitCommit : commits) {
builder.append(String.format(" %s\n", gitCommit.getShortMessage()));
}
builder.append("\n");
}
private void changeState(State state) {
changeState(state, UserApp.currentUser());
}
private void changeState(State state, User updater) {
this.state = state;
this.received = JodaDateUtil.now();
this.receiver = updater;
}
public void reopen() {
changeState(State.OPEN);
PushedBranch.removeByPullRequestFrom(this);
}
public void close() {
changeState(State.CLOSED);
}
public static List findByToProject(Project project) {
return finder.where().eq("toProject", project).order().asc("created").findList();
}
public static List findByFromProjectAndBranch(Project fromProject, String fromBranch) {
return finder.where().eq("fromProject", fromProject).eq("fromBranch", fromBranch)
.or(eq("state", State.OPEN), eq("state", State.REJECTED)).findList();
}
@Transactional
@Override
public void save() {
this.number = nextPullRequestNumber(toProject);
super.save();
addNewIssueEvents();
}
public static long nextPullRequestNumber(Project project) {
PullRequest maxNumberedPullRequest = PullRequest.finder.where()
.eq("toProject", project)
.order().desc("number")
.setMaxRows(1).findUnique();
if(maxNumberedPullRequest == null || maxNumberedPullRequest.number == null) {
return 1;
} else {
return ++maxNumberedPullRequest.number;
}
}
public static PullRequest findOne(Project toProject, long number) {
if(toProject == null || number <= 0) {
return null;
}
return finder.where().eq("toProject", toProject).eq("number", number).findUnique();
}
@Transactional
public static void regulateNumbers() {
int nullNumberPullRequestCount = finder.where().eq("number", null).findRowCount();
if(nullNumberPullRequestCount > 0) {
List projects = Project.find.all();
for(Project project : projects) {
List pullRequests = PullRequest.findByToProject(project);
for(PullRequest pullRequest : pullRequests) {
if(pullRequest.number == null) {
pullRequest.number = nextPullRequestNumber(project);
pullRequest.update();
}
}
}
}
}
public List getDiff() throws IOException {
if (mergedCommitIdFrom == null || mergedCommitIdTo == null) {
throw new IllegalStateException("No mergedCommitIdFrom or mergedCommitIdTo");
}
return getDiff(mergedCommitIdFrom, mergedCommitIdTo);
}
public Repository getRepository() throws IOException {
if (repository == null) {
repository = new GitRepository(toProject).getRepository();
}
return repository;
}
@Transient
public List getDiff(String revA, String revB) throws IOException {
Repository repository = getRepository();
return GitRepository.getDiff(repository, revA, repository, revB);
}
public static Page findPagingList(SearchCondition condition) {
return createSearchExpressionList(condition)
.order().desc(condition.category.order())
.findPagingList(ITEMS_PER_PAGE)
.getPage(condition.pageNum - 1);
}
public static int count(SearchCondition condition) {
return createSearchExpressionList(condition).findRowCount();
}
private static ExpressionList createSearchExpressionList(SearchCondition condition) {
ExpressionList el = finder.where();
if (condition.project != null) {
el.eq(condition.category.project(), condition.project);
}
if (condition.organization != null) {
List projects = condition.organization.getVisibleProjects(UserApp.currentUser());
List projectsIds = new ArrayList<>();
for (Project project : projects) {
projectsIds.add(project.id.toString());
}
el.in("to_project_id", projectsIds);
el.in("from_project_id", projectsIds);
}
Expression state = createStateSearchExpression(condition.category.states());
if (state != null) {
el.add(state);
}
if (condition.contributorId != null) {
el.eq("contributor.id", condition.contributorId);
}
if (StringUtils.isNotBlank(condition.filter)) {
Set