| // 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()); |
| } |
| } |