blob: 24a9c83166e12196bb19cbe9d2ac36e84855a471 [file] [log] [blame]
// 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());
}
}
}