LCOV - code coverage report
Current view: top level - server/patch - SubmitWithStickyApprovalDiff.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 124 130 95.4 %
Date: 2022-11-19 15:00:39 Functions: 9 9 100.0 %

          Line data    Source code
       1             : // Copyright (C) 2021 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.patch;
      16             : 
      17             : import static com.google.gerrit.server.project.ProjectCache.illegalState;
      18             : 
      19             : import com.google.gerrit.common.Nullable;
      20             : import com.google.gerrit.common.data.PatchScript;
      21             : import com.google.gerrit.entities.LabelId;
      22             : import com.google.gerrit.entities.LabelType;
      23             : import com.google.gerrit.entities.Patch;
      24             : import com.google.gerrit.entities.Patch.ChangeType;
      25             : import com.google.gerrit.entities.PatchSet;
      26             : import com.google.gerrit.entities.PatchSetApproval;
      27             : import com.google.gerrit.entities.Project;
      28             : import com.google.gerrit.exceptions.StorageException;
      29             : import com.google.gerrit.extensions.client.DiffPreferencesInfo;
      30             : import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
      31             : import com.google.gerrit.extensions.restapi.AuthException;
      32             : import com.google.gerrit.server.CurrentUser;
      33             : import com.google.gerrit.server.config.GerritServerConfig;
      34             : import com.google.gerrit.server.diff.DiffInfoCreator;
      35             : import com.google.gerrit.server.git.GitRepositoryManager;
      36             : import com.google.gerrit.server.git.LargeObjectException;
      37             : import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
      38             : import com.google.gerrit.server.notedb.ChangeNotes;
      39             : import com.google.gerrit.server.patch.filediff.FileDiffOutput;
      40             : import com.google.gerrit.server.permissions.PermissionBackendException;
      41             : import com.google.gerrit.server.project.InvalidChangeOperationException;
      42             : import com.google.gerrit.server.project.ProjectCache;
      43             : import com.google.gerrit.server.project.ProjectState;
      44             : import com.google.inject.Inject;
      45             : import java.io.IOException;
      46             : import java.util.ArrayList;
      47             : import java.util.Arrays;
      48             : import java.util.List;
      49             : import java.util.Map;
      50             : import java.util.Optional;
      51             : import java.util.stream.Collectors;
      52             : import org.eclipse.jgit.diff.DiffFormatter;
      53             : import org.eclipse.jgit.internal.JGitText;
      54             : import org.eclipse.jgit.lib.Config;
      55             : import org.eclipse.jgit.lib.Repository;
      56             : import org.eclipse.jgit.util.RawParseUtils;
      57             : import org.eclipse.jgit.util.TemporaryBuffer;
      58             : 
      59             : /**
      60             :  * This class is used on submit to compute the diff between the latest approved patch-set, and the
      61             :  * current submitted patch-set.
      62             :  *
      63             :  * <p>Latest approved patch-set is defined by the latest patch-set which has Code-Review label voted
      64             :  * with the maximum possible value.
      65             :  *
      66             :  * <p>If the latest approved patch-set is the same as the submitted patch-set, the diff will be
      67             :  * empty.
      68             :  *
      69             :  * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
      70             :  */
      71             : public class SubmitWithStickyApprovalDiff {
      72             :   private static final int HEAP_EST_SIZE = 32 * 1024;
      73             :   private static final int DEFAULT_POST_SUBMIT_SIZE_LIMIT = 300 * 1024; // 300 KiB
      74             : 
      75             :   private final DiffOperations diffOperations;
      76             :   private final ProjectCache projectCache;
      77             :   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
      78             :   private final GitRepositoryManager repositoryManager;
      79             :   private final int maxCumulativeSize;
      80             : 
      81             :   @Inject
      82             :   SubmitWithStickyApprovalDiff(
      83             :       DiffOperations diffOperations,
      84             :       ProjectCache projectCache,
      85             :       PatchScriptFactory.Factory patchScriptFactoryFactory,
      86             :       GitRepositoryManager repositoryManager,
      87          53 :       @GerritServerConfig Config serverConfig) {
      88          53 :     this.diffOperations = diffOperations;
      89          53 :     this.projectCache = projectCache;
      90          53 :     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
      91          53 :     this.repositoryManager = repositoryManager;
      92             :     // (November 2021) We define the max cumulative comment size to 300 KIB since it's a reasonable
      93             :     // size that is large enough for all purposes but not too large to choke the change index by
      94             :     // exceeding the cumulative comment size limit (new comments are not allowed once the limit
      95             :     // is reached). At Google, the change index limit is 5MB, while the cumulative size limit is
      96             :     // set at 3MB. In this example, we can reach at most 3.3MB hence we ensure not to exceed the
      97             :     // limit of 5MB.
      98             :     // The reason we exclude the post submit diff from the cumulative comment size limit is
      99             :     // just because change messages not currently being validated. Change messages are still
     100             :     // counted towards the limit, though.
     101          53 :     maxCumulativeSize =
     102          53 :         serverConfig.getInt(
     103             :             "change",
     104             :             "cumulativeCommentSizeLimit",
     105             :             CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
     106          53 :   }
     107             : 
     108             :   public String apply(ChangeNotes notes, CurrentUser currentUser)
     109             :       throws AuthException, IOException, PermissionBackendException,
     110             :           InvalidChangeOperationException {
     111          53 :     PatchSet currentPatchset = notes.getCurrentPatchSet();
     112             : 
     113          53 :     PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
     114          53 :     if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
     115             :       // If the latest approved patchset is the current patchset, no need to return anything.
     116          53 :       return "";
     117             :     }
     118           5 :     StringBuilder diff =
     119             :         new StringBuilder(
     120           5 :             String.format(
     121           5 :                 "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
     122           5 :     Map<String, FileDiffOutput> modifiedFiles =
     123           5 :         listModifiedFiles(
     124           5 :             notes.getProjectName(),
     125             :             currentPatchset,
     126           5 :             notes.getPatchSets().get(latestApprovedPatchsetId));
     127             : 
     128             :     // To make the message a bit more concise, we skip the magic files.
     129           5 :     List<FileDiffOutput> modifiedFilesList =
     130           5 :         modifiedFiles.values().stream()
     131           5 :             .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
     132           5 :             .collect(Collectors.toList());
     133             : 
     134           5 :     if (modifiedFilesList.isEmpty()) {
     135           2 :       diff.append(
     136             :           "No files were changed between the latest approved patch-set and the submitted one.\n");
     137           2 :       return diff.toString();
     138             :     }
     139             : 
     140           4 :     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
     141           4 :     TemporaryBuffer.Heap buffer =
     142             :         new TemporaryBuffer.Heap(
     143           4 :             Math.min(HEAP_EST_SIZE, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
     144             :             DEFAULT_POST_SUBMIT_SIZE_LIMIT);
     145           4 :     try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
     146           4 :         DiffFormatter formatter = new DiffFormatter(buffer)) {
     147           4 :       formatter.setRepository(repository);
     148           4 :       formatter.setDetectRenames(true);
     149           4 :       boolean isDiffTooLarge = false;
     150           4 :       List<String> formatterResult = null;
     151             :       try {
     152           4 :         formatter.format(
     153           4 :             modifiedFilesList.get(0).oldCommitId(), modifiedFilesList.get(0).newCommitId());
     154             :         // This returns the diff for all the files.
     155           4 :         formatterResult =
     156           4 :             Arrays.stream(RawParseUtils.decode(buffer.toByteArray()).split("\n"))
     157           4 :                 .collect(Collectors.toList());
     158           1 :       } catch (IOException e) {
     159           1 :         if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
     160           1 :           isDiffTooLarge = true;
     161             :         } else {
     162           0 :           throw e;
     163             :         }
     164           4 :       }
     165           4 :       if (formatterResult != null) {
     166           4 :         int addedBytes = formatterResult.stream().mapToInt(String::length).sum();
     167           4 :         if (!CommentCumulativeSizeValidator.isEnoughSpace(notes, addedBytes, maxCumulativeSize)) {
     168           1 :           isDiffTooLarge = true;
     169             :         }
     170             :       }
     171           4 :       for (FileDiffOutput fileDiff : modifiedFilesList) {
     172           4 :         diff.append(
     173           4 :             getDiffForFile(
     174             :                 notes,
     175           4 :                 currentPatchset.id(),
     176             :                 latestApprovedPatchsetId,
     177             :                 fileDiff,
     178             :                 currentUser,
     179             :                 formatterResult,
     180             :                 isDiffTooLarge));
     181           4 :       }
     182             :     }
     183           4 :     return diff.toString();
     184             :   }
     185             : 
     186             :   private String getDiffForFile(
     187             :       ChangeNotes notes,
     188             :       PatchSet.Id currentPatchsetId,
     189             :       PatchSet.Id latestApprovedPatchsetId,
     190             :       FileDiffOutput fileDiffOutput,
     191             :       CurrentUser currentUser,
     192             :       @Nullable List<String> formatterResult,
     193             :       boolean isDiffTooLarge)
     194             :       throws AuthException, InvalidChangeOperationException, IOException,
     195             :           PermissionBackendException {
     196           4 :     StringBuilder diff =
     197             :         new StringBuilder(
     198           4 :             String.format(
     199             :                 "```\nThe name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
     200           4 :                 fileDiffOutput.newPath().isPresent()
     201           4 :                     ? fileDiffOutput.newPath().get()
     202           4 :                     : fileDiffOutput.oldPath().get(),
     203           4 :                 fileDiffOutput.insertions(),
     204           4 :                 fileDiffOutput.deletions()));
     205           4 :     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     206           4 :     PatchScriptFactory patchScriptFactory =
     207           4 :         patchScriptFactoryFactory.create(
     208             :             notes,
     209           4 :             fileDiffOutput.newPath().isPresent()
     210           4 :                 ? fileDiffOutput.newPath().get()
     211           4 :                 : fileDiffOutput.oldPath().get(),
     212             :             latestApprovedPatchsetId,
     213             :             currentPatchsetId,
     214             :             diffPreferencesInfo,
     215             :             currentUser);
     216           4 :     PatchScript patchScript = null;
     217             :     try {
     218             :       // TODO(paiking): we can get rid of this call to optimize by checking the diff for renames.
     219           4 :       patchScript = patchScriptFactory.call();
     220           0 :     } catch (LargeObjectException exception) {
     221           0 :       diff.append("The file content is too large for showing the full diff. \n\n");
     222           0 :       return diff.toString();
     223           4 :     }
     224           4 :     if (patchScript.getChangeType() == ChangeType.RENAMED) {
     225           1 :       diff.append(
     226           1 :           String.format(
     227             :               "The file %s was renamed to %s\n",
     228           1 :               fileDiffOutput.oldPath().get(), fileDiffOutput.newPath().get()));
     229             :     }
     230           4 :     if (isDiffTooLarge) {
     231           1 :       diff.append("The diff is too large to show. Please review the diff.");
     232           1 :       diff.append("\n```\n");
     233           1 :       return diff.toString();
     234             :     }
     235             :     // This filters only the file we need.
     236             :     // TODO(paiking): we can make this more efficient by mapping the files to their respective
     237             :     //  diffs prior to this method, such that we need to go over the diff only once.
     238           4 :     diff.append(getDiffForFile(patchScript, formatterResult));
     239             :     // This line (and the ``` above) are useful for formatting in the web UI.
     240           4 :     diff.append("\n```\n");
     241           4 :     return diff.toString();
     242             :   }
     243             : 
     244             :   /**
     245             :    * Show patch set as unified difference for a specific file. We on purpose are not using {@link
     246             :    * DiffInfoCreator} since we'd like to get the original git/JGit style diff.
     247             :    */
     248             :   public String getDiffForFile(PatchScript patchScript, List<String> formatterResult) {
     249             :     // only return information about the current file, and not about files that are not
     250             :     // relevant. DiffFormatter returns other potential files because of rebases, which we can
     251             :     // ignore.
     252           4 :     List<String> modifiedFormatterResult = new ArrayList<>();
     253           4 :     int indexOfFormatterResult = 0;
     254           4 :     while (formatterResult.size() > indexOfFormatterResult
     255             :         && !formatterResult
     256           4 :             .get(indexOfFormatterResult)
     257           4 :             .equals(
     258           4 :                 String.format(
     259             :                     "diff --git a/%s b/%s",
     260           4 :                     patchScript.getOldName() != null
     261           1 :                         ? patchScript.getOldName()
     262           4 :                         : patchScript.getNewName(),
     263           4 :                     patchScript.getNewName()))) {
     264           1 :       indexOfFormatterResult++;
     265             :     }
     266             :     // remove non user friendly information.
     267           4 :     while (formatterResult.size() > indexOfFormatterResult
     268           4 :         && !formatterResult.get(indexOfFormatterResult).startsWith("@@")) {
     269           4 :       indexOfFormatterResult++;
     270             :     }
     271           4 :     for (; indexOfFormatterResult < formatterResult.size(); indexOfFormatterResult++) {
     272           4 :       if (formatterResult.get(indexOfFormatterResult).startsWith("diff --git")) {
     273           1 :         break;
     274             :       }
     275           4 :       modifiedFormatterResult.add(formatterResult.get(indexOfFormatterResult));
     276             :     }
     277           4 :     if (modifiedFormatterResult.size() == 0) {
     278             :       // This happens for diffs that are just renames, but we already account for renames.
     279           1 :       return "";
     280             :     }
     281           4 :     return modifiedFormatterResult.stream()
     282           4 :         .filter(s -> !s.equals("\\ No newline at end of file"))
     283           4 :         .collect(Collectors.joining("\n"));
     284             :   }
     285             : 
     286             :   private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
     287           4 :     DiffPreferencesInfo diffPreferencesInfo = new DiffPreferencesInfo();
     288           4 :     diffPreferencesInfo.ignoreWhitespace = Whitespace.IGNORE_NONE;
     289           4 :     diffPreferencesInfo.intralineDifference = true;
     290           4 :     return diffPreferencesInfo;
     291             :   }
     292             : 
     293             :   private PatchSet.Id getLatestApprovedPatchsetId(ChangeNotes notes) {
     294          53 :     ProjectState projectState =
     295          53 :         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     296          53 :     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
     297          53 :     for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
     298          46 :       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
     299          10 :         continue;
     300             :       }
     301          46 :       Optional<LabelType> lt =
     302          46 :           projectState.getLabelTypes(notes).byLabel(patchSetApproval.labelId());
     303          46 :       if (!lt.isPresent() || !lt.get().isMaxPositive(patchSetApproval)) {
     304           4 :         continue;
     305             :       }
     306          46 :       if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
     307          10 :         maxPatchSetId = patchSetApproval.patchSetId();
     308             :       }
     309          46 :     }
     310          53 :     return maxPatchSetId;
     311             :   }
     312             : 
     313             :   /**
     314             :    * Gets the list of modified files between the two latest patch-sets. Can be used to compute
     315             :    * difference in files between those two patch-sets.
     316             :    */
     317             :   private Map<String, FileDiffOutput> listModifiedFiles(
     318             :       Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     319             :     try {
     320           5 :       return diffOperations.listModifiedFiles(
     321           5 :           project, priorPatchSet.commitId(), ps.commitId(), DiffOptions.DEFAULTS);
     322           0 :     } catch (DiffNotAvailableException ex) {
     323           0 :       throw new StorageException(
     324             :           "failed to compute difference in files, so won't post diff messsage on submit although "
     325             :               + "the latest approved patch-set was not the same as the submitted patch-set.",
     326             :           ex);
     327             :     }
     328             :   }
     329             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750