Merge "Markdown highlighting - remove emphasis css rules"
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 85006dc..bc45956 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -221,6 +221,10 @@
 The original subject limited to 72 characters, with an ellipsis if it exceeds
 that.
 
+$change.sizeBucket::
++
+Human-readable size bucket of the current change.
+
 $change.ownerEmail::
 +
 The email address of the owner of the change.
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 6c9a2b1..da033c1 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -98,6 +98,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
+    private String emailTitle;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -145,6 +146,10 @@
                     ? ((StringEmailHeader) header).getString()
                     : header));
       }
+      EmailHeader titleHeader = message.headers().get("Subject");
+      if (titleHeader instanceof StringEmailHeader) {
+        emailTitle = ((StringEmailHeader) titleHeader).getString();
+      }
 
       return this;
     }
@@ -190,6 +195,15 @@
       return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
     }
 
+    public FakeEmailSenderSubject title(String expectedEmailTitle) {
+      if (!emailTitle.equals(expectedEmailTitle)) {
+        failWithoutActual(
+            fact("Expected email title", expectedEmailTitle),
+            fact("but actual title is", emailTitle));
+      }
+      return this;
+    }
+
     private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
       for (String email : emails) {
         rcpt(type, email);
diff --git a/java/com/google/gerrit/entities/ChangeSizeBucket.java b/java/com/google/gerrit/entities/ChangeSizeBucket.java
new file mode 100644
index 0000000..4d01ebd
--- /dev/null
+++ b/java/com/google/gerrit/entities/ChangeSizeBucket.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 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.entities;
+
+/**
+ * A human-readable change size bucket.
+ *
+ * <p>Should be kept in sync with
+ * polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+ */
+public class ChangeSizeBucket {
+
+  /**
+   * The upper bounds for the different size buckets.
+   *
+   * <p>Same as gr-change-list-item.ts::ChangeSize.
+   */
+  private enum BucketThresholds {
+    XS(10),
+    SMALL(50),
+    MEDIUM(250),
+    LARGE(1000);
+
+    public final long delta;
+
+    BucketThresholds(long delta) {
+      this.delta = delta;
+    }
+  }
+
+  /**
+   * Gets the correlative size bucket for the given change delta.
+   *
+   * <p>Same as gr-change-list-item.ts::computeChangeSize().
+   *
+   * @param delta the total number of changed lines (additions+deletions) of the change.
+   * @return a short human-readable size bucket.
+   */
+  public static String getChangeSizeBucket(long delta) {
+    if (delta == 0) {
+      return "NoOp";
+    } else if (delta < BucketThresholds.XS.delta) {
+      return "XS";
+    } else if (delta < BucketThresholds.SMALL.delta) {
+      return "S";
+    } else if (delta < BucketThresholds.MEDIUM.delta) {
+      return "M";
+    } else if (delta < BucketThresholds.LARGE.delta) {
+      return "L";
+    }
+    return "XL";
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 63b9c70..cba6e47 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -74,6 +75,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class ChangeEmail extends NotificationEmail {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected static ChangeData newChangeData(
@@ -231,6 +233,18 @@
     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
+  private int getInsertionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::insertions)
+        .reduce(0, Integer::sum);
+  }
+
+  private int getDeletionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::deletions)
+        .reduce(0, Integer::sum);
+  }
+
   /** Get a link to the change; null if the server doesn't know its own address. */
   @Nullable
   public String getChangeUrl() {
@@ -285,10 +299,6 @@
                       fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
-        Integer insertions =
-            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
-        Integer deletions =
-            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -297,8 +307,8 @@
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
                 modifiedFiles.size() - 1, //
-                insertions, //
-                deletions));
+                getInsertionsCount(), //
+                getDeletionsCount()));
         detail.append("\n");
       }
       return detail.toString();
@@ -309,29 +319,35 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
-      throws DiffNotAvailableException {
-    PatchSet ps;
-    if (patchSetId == patchSet.number()) {
-      ps = patchSet;
-    } else {
-      try {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+    try {
+      PatchSet ps;
+      if (patchSetId == patchSet.number()) {
+        ps = patchSet;
+      } else {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
-      } catch (StorageException e) {
-        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+    } catch (StorageException | DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Failed to get modified files");
+      return new HashMap<>();
     }
-    return args.diffOperations.listModifiedFilesAgainstParent(
-        change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
-      return args.diffOperations.listModifiedFilesAgainstParent(
-          change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      try {
+        return args.diffOperations.listModifiedFilesAgainstParent(
+            change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      } catch (DiffNotAvailableException e) {
+        logger.atSevere().withCause(e).log("Failed to get modified files");
+      }
+    } else {
+      logger.atSevere().log("no patchSet specified");
     }
-    throw new DiffNotAvailableException("no patchSet specified");
+    return new HashMap<>();
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -497,6 +513,9 @@
     changeData.put("ownerName", getNameFor(change.getOwner()));
     changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    changeData.put(
+        "sizeBucket",
+        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
@@ -579,16 +598,11 @@
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
     Map<String, FileDiffOutput> modifiedFiles;
-    try {
-      modifiedFiles = listModifiedFiles();
-      if (modifiedFiles.isEmpty()) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot format patch");
-      return "";
+    modifiedFiles = listModifiedFiles();
+    if (modifiedFiles.isEmpty()) {
+      // Octopus merges are not well supported for diff output by Gerrit.
+      // Currently these always have a null oldId in the PatchList.
+      return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
     }
 
     int maxSize = args.settings.maximumDiffSize;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 81e4f3e..ce5438b 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -37,7 +37,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
@@ -60,13 +59,16 @@
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
 public class CommentSender extends ReplyToChangeSender {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
+
     CommentSender create(Project.NameKey project, Change.Id changeId);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
@@ -199,12 +201,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = null;
-        try {
-          modifiedFiles = listModifiedFiles(c.key.patchSetId);
-        } catch (DiffNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get modified files");
-        }
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index c6103f4..6013862 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -64,6 +64,7 @@
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -599,6 +600,7 @@
   }
 
   private interface Adder {
+
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
@@ -1000,6 +1002,15 @@
   }
 
   @Test
+  public void verifyTitle() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .title(String.format("[S] Change in %s[master]: test commit", project));
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
   public void createWipChange() throws Exception {
     stagePreChange("refs/for/master%wip");
     assertThat(sender).didNotSend();
@@ -1291,6 +1302,7 @@
   }
 
   private interface Stager {
+
     StagedChange stage() throws Exception;
   }
 
diff --git a/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
new file mode 100644
index 0000000..a0fff22
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2022 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.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ChangeSizeBucketTest {
+
+  @Test
+  public void getChangeSizeBucket() {
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(0)).isEqualTo("NoOp");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1)).isEqualTo("XS");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(10)).isEqualTo("S");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(50)).isEqualTo("M");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(250)).isEqualTo("L");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1000)).isEqualTo("XL");
+  }
+}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 28d04d0..ba422a4 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -25,10 +25,13 @@
   {@param change: ?}
   {@param shortProjectName: ?}
   {@param instanceAndProjectName: ?}
-  {@param addInstanceNameInSubject: ?}  /** boolean */
+  {@param addInstanceNameInSubject: ?}    /** boolean */
+
   {if not $addInstanceNameInSubject}
-    Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$shortProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {else}
-    Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {/if}
 {/template}