| // Copyright (C) 2020 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.restapi.change; |
| |
| import static com.google.common.collect.ImmutableList.toImmutableList; |
| import static com.google.common.collect.ImmutableSet.toImmutableSet; |
| import static java.util.stream.Collectors.groupingBy; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.Comment.Range; |
| 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.metrics.Counter0; |
| import com.google.gerrit.metrics.Description; |
| import com.google.gerrit.metrics.MetricMaker; |
| import com.google.gerrit.server.CommentsUtil; |
| import com.google.gerrit.server.logging.Metadata; |
| import com.google.gerrit.server.logging.TraceContext; |
| import com.google.gerrit.server.logging.TraceContext.TraceTimer; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.patch.DiffMappings; |
| 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.GitPositionTransformer; |
| import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict; |
| import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping; |
| import com.google.gerrit.server.patch.GitPositionTransformer.Mapping; |
| import com.google.gerrit.server.patch.GitPositionTransformer.Position; |
| import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity; |
| import com.google.gerrit.server.patch.filediff.FileDiffOutput; |
| import com.google.gerrit.server.patch.filediff.FileEdits; |
| import com.google.gerrit.server.patch.filediff.TaggedEdit; |
| import com.google.inject.Inject; |
| import com.google.inject.Singleton; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.function.Function; |
| import java.util.stream.Stream; |
| import org.eclipse.jgit.lib.ObjectId; |
| |
| /** |
| * Container for all logic necessary to port comments to a target patchset. |
| * |
| * <p>A ported comment is a comment which was left on an earlier patchset and is shown on a later |
| * patchset. If a comment eligible for porting (e.g. before target patchset) can't be matched to its |
| * exact position in the target patchset, we'll map it to its next best location. This can also |
| * include a transformation of a line comment into a file comment. |
| */ |
| @Singleton |
| public class CommentPorter { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| @VisibleForTesting |
| @Singleton |
| static class Metrics { |
| final Counter0 portedAsPatchsetLevel; |
| final Counter0 portedAsFileLevel; |
| final Counter0 portedAsRangeComments; |
| |
| @Inject |
| Metrics(MetricMaker metricMaker) { |
| portedAsPatchsetLevel = |
| metricMaker.newCounter( |
| "ported_comments/as_patchset_level", |
| new Description("Total number of comments ported as patchset-level comments.") |
| .setRate() |
| .setUnit("count")); |
| portedAsFileLevel = |
| metricMaker.newCounter( |
| "ported_comments/as_file_level", |
| new Description("Total number of comments ported as file-level comments.") |
| .setRate() |
| .setUnit("count")); |
| portedAsRangeComments = |
| metricMaker.newCounter( |
| "ported_comments/as_range_comments", |
| new Description( |
| "Total number of comments having line/range values in the ported patchset.") |
| .setRate() |
| .setUnit("count")); |
| } |
| } |
| |
| private final DiffOperations diffOperations; |
| private final GitPositionTransformer positionTransformer = |
| new GitPositionTransformer(BestPositionOnConflict.INSTANCE); |
| private final CommentsUtil commentsUtil; |
| private final Metrics metrics; |
| |
| @Inject |
| public CommentPorter(DiffOperations diffOperations, CommentsUtil commentsUtil, Metrics metrics) { |
| this.diffOperations = diffOperations; |
| this.commentsUtil = commentsUtil; |
| this.metrics = metrics; |
| } |
| |
| /** |
| * Ports the given comments to the target patchset. |
| * |
| * <p>Not all given comments are ported. Only those fulfilling some criteria (e.g. before target |
| * patchset) are considered eligible for porting. |
| * |
| * <p>The returned comments represent the ported version. They don't bear any indication to which |
| * patchset they were ported. This is intentional as the target patchset should be obvious from |
| * the API or the used REST resources. The returned comments still have the patchset field filled. |
| * It contains the reference to the patchset on which the comment was originally left. That |
| * patchset number can vary among the returned comments as all comments before the target patchset |
| * are potentially eligible for porting. |
| * |
| * <p>The number of returned comments can be smaller (-> only eligible ones are ported!) or larger |
| * compared to the provided comments. The latter happens when files appear as copied in the target |
| * patchset. In such a situation, the same comment UUID will occur more than once in the returned |
| * comments. |
| * |
| * @param changeNotes the {@link ChangeNotes} of the change to which the comments belong |
| * @param targetPatchset the patchset to which the comments should be ported |
| * @param comments the original comments |
| * @param filters additional filters to apply to the comments before porting. Only the remaining |
| * comments will be ported. |
| * @return the ported comments, in no particular order |
| */ |
| public ImmutableList<HumanComment> portComments( |
| ChangeNotes changeNotes, |
| PatchSet targetPatchset, |
| List<HumanComment> comments, |
| List<HumanCommentFilter> filters) { |
| try (TraceTimer ignored = |
| TraceContext.newTimer( |
| "Porting comments", Metadata.builder().patchSetId(targetPatchset.number()).build())) { |
| ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset); |
| ImmutableList<HumanComment> relevantComments = filter(comments, allFilters); |
| return port(changeNotes, targetPatchset, relevantComments); |
| } |
| } |
| |
| private ImmutableList<HumanCommentFilter> addDefaultFilters( |
| List<HumanCommentFilter> filters, PatchSet targetPatchset) { |
| // Apply the EarlierPatchsetCommentFilter first as it reduces the amount of comments before |
| // more expensive filters are applied. |
| HumanCommentFilter earlierPatchsetFilter = |
| new EarlierPatchsetCommentFilter(targetPatchset.id()); |
| return Stream.concat(Stream.of(earlierPatchsetFilter), filters.stream()) |
| .collect(toImmutableList()); |
| } |
| |
| private ImmutableList<HumanComment> filter( |
| List<HumanComment> allComments, ImmutableList<HumanCommentFilter> filters) { |
| ImmutableList<HumanComment> filteredComments = ImmutableList.copyOf(allComments); |
| for (HumanCommentFilter filter : filters) { |
| filteredComments = filter.filter(filteredComments); |
| } |
| return filteredComments; |
| } |
| |
| private ImmutableList<HumanComment> port( |
| ChangeNotes notes, PatchSet targetPatchset, List<HumanComment> comments) { |
| Map<Integer, ImmutableList<HumanComment>> commentsPerPatchset = |
| comments.stream().collect(groupingBy(comment -> comment.key.patchSetId, toImmutableList())); |
| |
| ImmutableList.Builder<HumanComment> portedComments = |
| ImmutableList.builderWithExpectedSize(comments.size()); |
| for (Integer originalPatchsetId : commentsPerPatchset.keySet()) { |
| ImmutableList<HumanComment> patchsetComments = commentsPerPatchset.get(originalPatchsetId); |
| PatchSet originalPatchset = |
| notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId)); |
| if (originalPatchset != null) { |
| portedComments.addAll( |
| portSamePatchset( |
| notes.getProjectName(), |
| notes.getChange(), |
| originalPatchset, |
| targetPatchset, |
| patchsetComments)); |
| } else { |
| logger.atWarning().log( |
| "Some comments which should be ported refer to the non-existent patchset %s of" |
| + " change %d. Omitting %d affected comments.", |
| originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()); |
| } |
| } |
| return portedComments.build(); |
| } |
| |
| private ImmutableList<HumanComment> portSamePatchset( |
| Project.NameKey project, |
| Change change, |
| PatchSet originalPatchset, |
| PatchSet targetPatchset, |
| ImmutableList<HumanComment> comments) { |
| try (TraceTimer ignored = |
| TraceContext.newTimer( |
| "Porting comments same patchset", |
| Metadata.builder() |
| .projectName(project.get()) |
| .changeId(change.getChangeId()) |
| .patchSetId(originalPatchset.number()) |
| .build())) { |
| Map<Short, List<HumanComment>> commentsPerSide = |
| comments.stream().collect(groupingBy(comment -> comment.side)); |
| ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder(); |
| for (Map.Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) { |
| portedComments.addAll( |
| portSamePatchsetAndSide( |
| project, |
| change, |
| originalPatchset, |
| targetPatchset, |
| sideAndComments.getValue(), |
| sideAndComments.getKey())); |
| } |
| return portedComments.build(); |
| } |
| } |
| |
| private ImmutableList<HumanComment> portSamePatchsetAndSide( |
| Project.NameKey project, |
| Change change, |
| PatchSet originalPatchset, |
| PatchSet targetPatchset, |
| List<HumanComment> comments, |
| short side) { |
| try (TraceTimer ignored = |
| TraceContext.newTimer( |
| "Porting comments same patchset and side", |
| Metadata.builder() |
| .projectName(project.get()) |
| .changeId(change.getChangeId()) |
| .patchSetId(originalPatchset.number()) |
| .commentSide(side) |
| .build())) { |
| ImmutableSet<Mapping> mappings; |
| try { |
| mappings = loadMappings(project, change, originalPatchset, targetPatchset, side); |
| } catch (Exception e) { |
| logger.atWarning().withCause(e).log( |
| "Could not determine some necessary diff mappings for porting comments on change %s" |
| + " from patchset %s to patchset %s. Mapping %d affected comments to the fallback" |
| + " destination.", |
| change.getChangeId(), |
| originalPatchset.id().getId(), |
| targetPatchset.id().getId(), |
| comments.size()); |
| mappings = getFallbackMappings(comments); |
| } |
| |
| ImmutableList<PositionedEntity<HumanComment>> positionedComments = |
| comments.stream().map(this::toPositionedEntity).collect(toImmutableList()); |
| ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap = |
| positionTransformer.transform(positionedComments, mappings).stream() |
| .collect( |
| ImmutableMap.toImmutableMap( |
| Function.identity(), PositionedEntity::getEntityAtUpdatedPosition)); |
| collectMetrics(origToPortedMap); |
| return ImmutableList.copyOf(origToPortedMap.values()); |
| } |
| } |
| |
| private ImmutableSet<Mapping> loadMappings( |
| Project.NameKey project, |
| Change change, |
| PatchSet originalPatchset, |
| PatchSet targetPatchset, |
| short side) |
| throws DiffNotAvailableException { |
| try (TraceTimer ignored = |
| TraceContext.newTimer( |
| "Loading commit mappings", |
| Metadata.builder() |
| .projectName(project.get()) |
| .changeId(change.getChangeId()) |
| .patchSetId(originalPatchset.number()) |
| .build())) { |
| ObjectId originalCommit = determineCommitId(change, originalPatchset, side); |
| ObjectId targetCommit = determineCommitId(change, targetPatchset, side); |
| return loadCommitMappings(project, originalCommit, targetCommit); |
| } |
| } |
| |
| private ObjectId determineCommitId(Change change, PatchSet patchset, short side) { |
| return commentsUtil |
| .determineCommitId(change, patchset, side) |
| .orElseThrow( |
| () -> |
| new IllegalStateException( |
| String.format( |
| "Commit indicated by change %d, patchset %d, side %d doesn't exist.", |
| change.getId().get(), patchset.id().get(), side))); |
| } |
| |
| private ImmutableSet<Mapping> loadCommitMappings( |
| Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit) |
| throws DiffNotAvailableException { |
| try (TraceTimer ignored = |
| TraceContext.newTimer( |
| "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) { |
| Map<String, FileDiffOutput> modifiedFiles = |
| diffOperations.listModifiedFiles( |
| project, |
| originalCommit, |
| targetCommit, |
| DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build()); |
| return modifiedFiles.values().stream() |
| .map(CommentPorter::getFileEdits) |
| .map(DiffMappings::toMapping) |
| .collect(toImmutableSet()); |
| } |
| } |
| |
| private static FileEdits getFileEdits(FileDiffOutput fileDiffOutput) { |
| return FileEdits.create( |
| fileDiffOutput.edits().stream().map(TaggedEdit::edit).collect(toImmutableList()), |
| fileDiffOutput.oldPath(), |
| fileDiffOutput.newPath()); |
| } |
| |
| private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) { |
| // Consider all files as deleted. -> Comments will be ported to the fallback destination, which |
| // currently are patchset-level comments. |
| return comments.stream() |
| .map(comment -> comment.key.filename) |
| .distinct() |
| .map(FileMapping::forDeletedFile) |
| .map(fileMapping -> Mapping.create(fileMapping, ImmutableSet.of())) |
| .collect(toImmutableSet()); |
| } |
| |
| private PositionedEntity<HumanComment> toPositionedEntity(HumanComment comment) { |
| return PositionedEntity.create( |
| comment, CommentPorter::extractPosition, CommentPorter::createCommentAtNewPosition); |
| } |
| |
| private static Position extractPosition(HumanComment comment) { |
| Position.Builder positionBuilder = Position.builder(); |
| // Patchset-level comments don't have a file path. The transformation logic still works when |
| // using the magic file path but it doesn't hurt to use the actual representation for "no file" |
| // internally. |
| if (!Patch.PATCHSET_LEVEL.equals(comment.key.filename)) { |
| positionBuilder.filePath(comment.key.filename); |
| } |
| return positionBuilder.lineRange(extractLineRange(comment)).build(); |
| } |
| |
| /** |
| * Returns {@link Optional#empty()} if the {@code comment} parameter is a file comment, or the |
| * comment range {start_line, end_line} otherwise. |
| */ |
| private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) { |
| // Line specifications in comment are 1-based. Line specifications in Position are 0-based. |
| if (comment.range != null) { |
| // The combination of (line, charOffset) is exclusive and must be mapped to an exclusive line. |
| int exclusiveEndLine = |
| comment.range.endChar > 0 ? comment.range.endLine : comment.range.endLine - 1; |
| return Optional.of( |
| GitPositionTransformer.Range.create(comment.range.startLine - 1, exclusiveEndLine)); |
| } |
| if (comment.lineNbr > 0) { |
| return Optional.of(GitPositionTransformer.Range.create(comment.lineNbr - 1, comment.lineNbr)); |
| } |
| // File comment -> no range. |
| return Optional.empty(); |
| } |
| |
| private static HumanComment createCommentAtNewPosition( |
| HumanComment originalComment, Position newPosition) { |
| HumanComment portedComment = new HumanComment(originalComment); |
| portedComment.key.filename = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL); |
| if (portedComment.range != null && newPosition.lineRange().isPresent()) { |
| // Comment was a range comment and also stayed one. |
| portedComment.range = |
| toRange( |
| newPosition.lineRange().get(), |
| portedComment.range.startChar, |
| portedComment.range.endChar); |
| portedComment.lineNbr = portedComment.range.endLine; |
| } else { |
| portedComment.range = null; |
| // No line -> use 0 = file comment or any other comment type without an explicit line. |
| portedComment.lineNbr = newPosition.lineRange().map(range -> range.start() + 1).orElse(0); |
| } |
| if (Patch.PATCHSET_LEVEL.equals(portedComment.key.filename)) { |
| // Correct the side of the comment to Side.REVISION (= 1) if the comment was changed to |
| // patchset level. |
| portedComment.side = 1; |
| } |
| return portedComment; |
| } |
| |
| private static Range toRange( |
| GitPositionTransformer.Range lineRange, int originalStartChar, int originalEndChar) { |
| int adjustedEndLine = originalEndChar > 0 ? lineRange.end() : lineRange.end() + 1; |
| return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar); |
| } |
| |
| /** |
| * Collect metrics from the original and ported comments. |
| * |
| * @param portMap map of the ported comments. The keys contain a {@link PositionedEntity} of the |
| * original comment, and the values contain the transformed comments. |
| */ |
| private void collectMetrics(ImmutableMap<PositionedEntity<HumanComment>, HumanComment> portMap) { |
| for (Map.Entry<PositionedEntity<HumanComment>, HumanComment> entry : portMap.entrySet()) { |
| HumanComment original = entry.getKey().getEntity(); |
| HumanComment transformed = entry.getValue(); |
| |
| if (!Patch.isMagic(original.key.filename)) { |
| if (Patch.PATCHSET_LEVEL.equals(transformed.key.filename)) { |
| metrics.portedAsPatchsetLevel.increment(); |
| } else if (extractLineRange(original).isPresent()) { |
| if (extractLineRange(transformed).isPresent()) { |
| metrics.portedAsRangeComments.increment(); |
| } else { |
| // line range was present in the original comment, but the ported comment is a file |
| // level comment. |
| metrics.portedAsFileLevel.increment(); |
| } |
| } |
| } |
| } |
| } |
| |
| /** A filter which just keeps those comments which are before the given patchset. */ |
| private static class EarlierPatchsetCommentFilter implements HumanCommentFilter { |
| |
| private final PatchSet.Id patchsetId; |
| |
| public EarlierPatchsetCommentFilter(PatchSet.Id patchsetId) { |
| this.patchsetId = patchsetId; |
| } |
| |
| @Override |
| public ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments) { |
| return comments.stream() |
| .filter(comment -> comment.key.patchSetId < patchsetId.get()) |
| .collect(toImmutableList()); |
| } |
| } |
| } |