blob: bddd86af8c3b2fae27316a5ce1058f9990ebc6c4 [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toCollection;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.Lists;
import com.google.common.collect.Ordering;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOperations;
import com.google.gerrit.server.patch.DiffOptions;
import com.google.gerrit.server.patch.filediff.FileDiffOutput;
import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
import com.google.gerrit.server.update.ChangeContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
/** Utility functions to manipulate Comments. */
@Singleton
public class CommentsUtil {
public static final Ordering<Comment> COMMENT_ORDER =
new Ordering<>() {
@Override
public int compare(Comment c1, Comment c2) {
return ComparisonChain.start()
.compare(c1.key.filename, c2.key.filename)
.compare(c1.key.patchSetId, c2.key.patchSetId)
.compare(c1.side, c2.side)
.compare(c1.lineNbr, c2.lineNbr)
.compare(c1.writtenOn, c2.writtenOn)
.result();
}
};
public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
new Ordering<>() {
@Override
public int compare(CommentInfo a, CommentInfo b) {
return ComparisonChain.start()
.compare(a.path, b.path, NULLS_FIRST)
.compare(a.patchSet, b.patchSet, NULLS_FIRST)
.compare(side(a), side(b))
.compare(a.line, b.line, NULLS_FIRST)
.compare(a.inReplyTo, b.inReplyTo, NULLS_FIRST)
.compare(a.message, b.message)
.compare(a.id, b.id)
.result();
}
private int side(CommentInfo c) {
return firstNonNull(c.side, Side.REVISION).ordinal();
}
};
public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
return PatchSet.id(changeId, comment.key.patchSetId);
}
@Nullable
public static String extractMessageId(@Nullable String tag) {
if (tag == null || !tag.startsWith("mailMessageId=")) {
return null;
}
return tag.substring("mailMessageId=".length());
}
private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
private final DiffOperations diffOperations;
private final GitRepositoryManager repoManager;
private final AllUsersName allUsers;
private final String serverId;
private final ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm;
@Inject
CommentsUtil(
DiffOperations diffOperations,
GitRepositoryManager repoManager,
AllUsersName allUsers,
@GerritServerId String serverId,
@Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm) {
this.diffOperations = diffOperations;
this.repoManager = repoManager;
this.allUsers = allUsers;
this.serverId = serverId;
this.virtualIdAlgorithm = virtualIdAlgorithm;
}
public HumanComment newHumanComment(
ChangeNotes changeNotes,
CurrentUser currentUser,
Instant when,
String path,
PatchSet.Id psId,
short side,
String message,
@Nullable Boolean unresolved,
@Nullable String parentUuid) {
if (unresolved == null) {
if (parentUuid == null) {
// Default to false if comment is not descended from another.
unresolved = false;
} else {
// Inherit unresolved value from inReplyTo comment if not specified.
Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
Optional<HumanComment> parent = getPublishedHumanComment(changeNotes, key);
// If the comment was not found, it is descended from a robot comment, or the UUID is
// invalid. Either way, we use the default.
unresolved = parent.map(p -> p.unresolved).orElse(false);
}
}
HumanComment c =
new HumanComment(
new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
currentUser.getAccountId(),
when,
side,
message,
serverId,
unresolved);
c.parentUuid = parentUuid;
currentUser.updateRealAccountId(c::setRealAuthor);
return c;
}
public RobotComment newRobotComment(
ChangeContext ctx,
String path,
PatchSet.Id psId,
short side,
String message,
String robotId,
String robotRunId) {
RobotComment c =
new RobotComment(
new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
ctx.getUser().getAccountId(),
ctx.getWhen(),
side,
message,
serverId,
robotId,
robotRunId);
ctx.getUser().updateRealAccountId(c::setRealAuthor);
return c;
}
public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, Comment.Key key) {
return publishedHumanCommentsByChange(notes).stream()
.filter(c -> key.equals(c.key))
.findFirst();
}
public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, String uuid) {
return publishedHumanCommentsByChange(notes).stream()
.filter(c -> c.key.uuid.equals(uuid))
.findFirst();
}
public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
notes.load();
return sort(Lists.newArrayList(notes.getHumanComments().values()));
}
public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
notes.load();
return sort(Lists.newArrayList(notes.getRobotComments().values()));
}
public Optional<RobotComment> getRobotComment(ChangeNotes notes, String uuid) {
return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
}
public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
return commentsOnFile(notes.load().getHumanComments().values(), file);
}
public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
return removeCommentsOnAncestorOfCommitMessage(
commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
}
public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
}
/**
* This method populates the "changeMessageId" field of the comments parameter based on timestamp
* matching. The comments objects will be modified.
*
* <p>Each comment will be matched to the nearest next change message in timestamp
*
* @param comments the list of comments
* @param changeMessages list of change messages
*/
public static void linkCommentsToChangeMessages(
List<? extends CommentInfo> comments,
List<ChangeMessage> changeMessages,
boolean skipAutoGeneratedMessages) {
// First sort by timestamp, then by authorId so that we could move on to the next change message
// in case multiple accounts left comments at the same timestamp.
ArrayList<ChangeMessage> sortedChangeMessages =
changeMessages.stream()
.sorted(
comparing(ChangeMessage::getWrittenOn)
.thenComparingInt(c -> c.getAuthor() == null ? 0 : c.getAuthor().get()))
.collect(toCollection(ArrayList::new));
ArrayList<CommentInfo> sortedCommentInfos =
comments.stream()
.sorted(
comparing(CommentInfo::getUpdated)
.thenComparingInt(c -> c.author == null ? 0 : c.author._accountId))
.collect(toCollection(ArrayList::new));
int cmItr = 0;
int lastMatch = 0;
for (CommentInfo comment : sortedCommentInfos) {
// Keep advancing the change message pointer until we associate the comment to the next change
// message in timestamp
while (cmItr < sortedChangeMessages.size()) {
ChangeMessage cm = sortedChangeMessages.get(cmItr);
if (isAfter(comment, cm)
|| !haveSameAuthor(cm, comment)
|| (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
cmItr += 1;
} else {
lastMatch = cmItr;
break;
}
}
if (cmItr < changeMessages.size()) {
comment.changeMessageId = sortedChangeMessages.get(cmItr).getKey().uuid();
} else {
// In case of no match "cmItr" will never be less than "changeMessages" size, hence the
// changeMessageId won't be set for any comment.
//
// Reset the search to the last succesful match, since we can't assume there will always be
// a match between change messages and comments. This could be the case of imported changes.
//
// More details here: https://issues.gerritcodereview.com/issues/318079520
cmItr = lastMatch;
}
}
}
private static boolean isAutoGenerated(ChangeMessage cm) {
// Ignore Gerrit auto-generated messages, allowing to link against human change messages that
// have an auto-generated tag
return ChangeMessagesUtil.isAutogeneratedByGerrit(cm.getTag());
}
private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
return c.getUpdated().isAfter(cm.getWrittenOn());
}
private static boolean haveSameAuthor(ChangeMessage cm, CommentInfo comment) {
return Objects.equals(
Optional.ofNullable(cm.getAuthor()).map(a -> a.get()),
Optional.ofNullable(comment.author).map(a -> a._accountId));
}
/**
* For the commit message the A side in a diff view is always empty when a comparison against an
* ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
* the auto-merge commit message on side A when for a merge commit a comparison against the
* auto-merge was done. From that time there may still be comments on the auto-merge commit
* message and those we want to filter out.
*/
private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
return list.stream()
.filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
.collect(toList());
}
public List<HumanComment> draftByPatchSetAuthor(
PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
return commentsOnPatchSet(notes.load().getDraftComments(author, getVirtualId(notes)), psId);
}
public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
List<HumanComment> comments = new ArrayList<>();
comments.addAll(notes.getDraftComments(author, getVirtualId(notes)));
return sort(comments);
}
public void putHumanComments(
ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
for (HumanComment c : comments) {
update.putComment(status, c);
}
}
public void putRobotComments(ChangeUpdate update, Iterable<RobotComment> comments) {
for (RobotComment c : comments) {
update.putRobotComment(c);
}
}
public void deleteHumanComments(ChangeUpdate update, Iterable<HumanComment> comments) {
for (HumanComment c : comments) {
update.deleteComment(c);
}
}
public void deleteCommentByRewritingHistory(
ChangeUpdate update, Comment.Key commentKey, String newMessage) {
update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
}
private static List<HumanComment> commentsOnFile(
Collection<HumanComment> allComments, String file) {
List<HumanComment> result = new ArrayList<>(allComments.size());
for (HumanComment c : allComments) {
String currentFilename = c.key.filename;
if (currentFilename.equals(file)) {
result.add(c);
}
}
return sort(result);
}
private static <T extends Comment> List<T> commentsOnPatchSet(
Collection<T> allComments, PatchSet.Id psId) {
List<T> result = new ArrayList<>(allComments.size());
for (T c : allComments) {
if (c.key.patchSetId == psId.get()) {
result.add(c);
}
}
return sort(result);
}
public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
if (c.getCommitId() == null) {
checkArgument(
c.key.patchSetId == ps.id().get(),
"cannot set commit ID for patch set %s on comment %s",
ps.id(),
c);
// This code is very much down into our stack and shouldn't be used for validation. Hence,
// don't throw an exception here if we can't find a commit for the indicated side but
// simply use the all-null ObjectId.
c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
}
}
/**
* Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
*
* @param change the change to which the commit belongs
* @param patchset the patchset to which the commit belongs
* @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
* 0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
* parent commit of a merge commit
* @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
* change/patchset
* @throws StorageException if the SHA-1 is unavailable for an unknown reason
*/
public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
if (Side.fromShort(side) == Side.PARENT) {
if (side < 0) {
int parentNumber = Math.abs(side);
return resolveParentCommit(change.getProject(), patchset, parentNumber);
}
return Optional.ofNullable(resolveAutoMergeCommit(change, patchset));
}
return Optional.of(patchset.commitId());
}
private Optional<ObjectId> resolveParentCommit(
Project.NameKey project, PatchSet patchset, int parentNumber) {
try (Repository repository = repoManager.openRepository(project)) {
RevCommit commit = repository.parseCommit(patchset.commitId());
if (commit.getParentCount() < parentNumber) {
return Optional.empty();
}
return Optional.of(commit.getParent(parentNumber - 1));
} catch (IOException e) {
throw new StorageException(e);
}
}
@Nullable
private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
try {
// TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
// unignore the test in PortedCommentsIT.
Map<String, FileDiffOutput> modifiedFiles =
diffOperations.listModifiedFilesAgainstParent(
change.getProject(), patchset.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
return modifiedFiles.isEmpty()
? null
: modifiedFiles.values().iterator().next().oldCommitId();
} catch (DiffNotAvailableException e) {
throw new StorageException(e);
}
}
/**
* Get NoteDb draft refs for a change.
*
* <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
* comments. A zombie draft is one which has been published but the write to delete the draft ref
* from All-Users failed.
*
* @param changeId change ID.
* @return raw refs from All-Users repo.
*/
public Collection<Ref> getDraftRefs(Change.Id changeId) {
try (Repository repo = repoManager.openRepository(allUsers)) {
return getDraftRefs(repo, changeId);
} catch (IOException e) {
throw new StorageException(e);
}
}
private Collection<Ref> getDraftRefs(Repository repo, Change.Id virtualId) throws IOException {
return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(virtualId));
}
public static <T extends Comment> List<T> sort(List<T> comments) {
comments.sort(COMMENT_ORDER);
return comments;
}
private Change.Id getVirtualId(ChangeNotes notes) {
return virtualIdAlgorithm == null
? notes.getChangeId()
: virtualIdAlgorithm.apply(notes.getServerId(), notes.getChangeId());
}
}