Merge branch 'stable-3.6' into stable-3.7

* stable-3.6:
  Update jgit to c6b0ee04e49c96e0beec4154196c416abcf2bcc9
  config-mail.txt: note about necessary restart
  Disallow javascript: protocol in comment links
  Reintroduce the Change-Id footer in change screen
  Limit the number of changes that can be submitted together
  Update git submodules
  Register a default kex handler with the SshDaemon
  Fix HTTP 404 when browsing tags on Gitweb
  Update git submodules
  fixup! Add an option to periodically warm the project_list cache
  Set version to 3.6.3-SNAPSHOT
  GitwebServlet: Retrieve git path from FileRepository
  Limit index query results in ChangeNotes#createCheckedUsingIndexLookup
  Make delegate() method public
  Support per-project enableSignedPush in computePushCertificateValidation
  Internally handle refs updated in a BatchRefUpdate by a single event

Also adapt I4e7d9d35c to the new GrChangeView Lit component and
convert to Typescript, converting @property to @state and dropping
the leading '_' for consistency and improved readability.

Release-Notes: skip
Change-Id: I20501dca6ee94c1f7a3a1ab43aee405737a0a3c4
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index be6e140..661af0c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1507,6 +1507,13 @@
 
 Default is true.
 
+[[change.maxSubmittableAtOnce]]change.maxSubmittableAtOnce::
++
+Maximum number of changes that can be chained together in the same repository
+to be submitted at once.
++
+Default is 32767.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index bc45956..8bd5dc7 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -20,6 +20,11 @@
 example template to an equivalently named file without the `.example` extension
 and modifying it will allow an administrator to customize the template.
 
+[NOTE]
+The content of the templates at `'$site_path'/etc/mail/.*\.soy` are cached at
+startup by Gerrit. If they are modified Gerrit needs to be restarted before the
+changes takes effect.
+
 == Supported Mail Templates
 
 Each mail that Gerrit sends out is controlled by at least one template.  These
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index ba4d5f0..8b0023b 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -43,12 +43,13 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.GitwebConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.DelegateRepository;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -85,6 +86,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -101,7 +103,7 @@
   private final Set<String> deniedActions;
   private final Path gitwebCgi;
   private final URI gitwebUrl;
-  private final LocalDiskRepositoryManager repoManager;
+  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
@@ -119,12 +121,10 @@
       SshInfo sshInfo,
       Provider<AnonymousUser> anonymousUserProvider,
       GitwebConfig gitwebConfig,
-      GitwebCgiConfig gitwebCgiConfig)
+      GitwebCgiConfig gitwebCgiConfig,
+      AllProjectsName allProjects)
       throws IOException {
-    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
-      throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
-    }
-    this.repoManager = (LocalDiskRepositoryManager) repoManager;
+    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
@@ -132,6 +132,9 @@
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
     this.deniedActions = new HashSet<>();
 
+    // ensure that Gitweb works on supported repository type by checking All-Projects project
+    getProjectRoot(allProjects);
+
     final String url = gitwebConfig.getUrl();
     if (url != null && !url.equals("gitweb")) {
       URI uri = null;
@@ -537,7 +540,8 @@
     }
   }
 
-  private String[] makeEnv(HttpServletRequest req, ProjectState projectState) {
+  private String[] makeEnv(HttpServletRequest req, ProjectState projectState)
+      throws RepositoryNotFoundException, IOException {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -577,7 +581,7 @@
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
     env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", getProjectRoot(nameKey));
 
     if (projectState.statePermitsRead()
         && permissionBackend
@@ -634,6 +638,25 @@
     return env.getEnvArray();
   }
 
+  private String getProjectRoot(Project.NameKey nameKey)
+      throws RepositoryNotFoundException, IOException {
+    try (Repository repo = repoManager.openRepository(nameKey)) {
+      return getProjectRoot(repo);
+    }
+  }
+
+  private String getProjectRoot(Repository repo) {
+    if (repo instanceof DelegateRepository) {
+      return getProjectRoot(((DelegateRepository) repo).delegate());
+    }
+
+    if (repo instanceof FileRepository) {
+      return repo.getDirectory().getAbsolutePath();
+    }
+
+    throw new ProvisionException("Gitweb can only be used with FileRepository");
+  }
+
   private void copyContentToCGI(HttpServletRequest req, OutputStream dst) throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index c477bb5..99bd62d 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -144,7 +144,7 @@
         type.setProject("?p=${project}.git;a=summary");
         type.setRevision("?p=${project}.git;a=commit;h=${commit}");
         type.setBranch("?p=${project}.git;a=shortlog;h=${branch}");
-        type.setTag("?p=${project}.git;a=tag;h=${tag}");
+        type.setTag("?p=${project}.git;a=shortlog;h=${tag}");
         type.setRootTree("?p=${project}.git;a=tree;hb=${commit}");
         type.setFile("?p=${project}.git;hb=${commit};f=${file}");
         type.setFileHistory("?p=${project}.git;a=history;hb=${branch};f=${file}");
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 31934d5..a342686 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -188,7 +188,7 @@
      * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
-      InternalChangeQuery query = queryProvider.get().noFields();
+      InternalChangeQuery query = queryProvider.get().setLimit(2).noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
         throw new NoSuchChangeException(changeId);
diff --git a/java/com/google/gerrit/server/project/NullProjectCache.java b/java/com/google/gerrit/server/project/NullProjectCache.java
index d19a726..57976d3 100644
--- a/java/com/google/gerrit/server/project/NullProjectCache.java
+++ b/java/com/google/gerrit/server/project/NullProjectCache.java
@@ -62,6 +62,11 @@
   }
 
   @Override
+  public void refreshProjectList() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public Set<UUID> guessRelevantGroupUUIDs() {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
index caffb45..df2e1cf 100644
--- a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
@@ -94,7 +94,7 @@
   @Override
   public void run() {
     logger.atFine().log("Loading project_list cache");
-    cache.all();
+    cache.refreshProjectList();
     logger.atFine().log("Finished loading project_list cache");
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index fb0a4ec..e0569b9 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -94,6 +94,9 @@
   /** Returns sorted iteration of projects. */
   ImmutableSortedSet<Project.NameKey> all();
 
+  /** Refreshes project list cache */
+  void refreshProjectList();
+
   /**
    * Returns estimated set of relevant groups extracted from hot project access rules. If the cache
    * is cold or too small for the entire project set of the server, this set may be incomplete.
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 52a524f..67c031e 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -286,6 +286,11 @@
   }
 
   @Override
+  public void refreshProjectList() {
+    list.refresh(ListKey.ALL);
+  }
+
+  @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
       return all().stream()
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 2a260e41..86d6c674 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
@@ -48,6 +49,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -59,6 +61,8 @@
 public class LocalMergeSuperSetComputation implements MergeSuperSetComputation {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public static final int MAX_SUBMITTABLE_CHANGES_AT_ONCE_DEFAULT = 1024;
+
   public static class LocalMergeSuperSetComputationModule extends AbstractModule {
     @Override
     protected void configure() {
@@ -83,15 +87,20 @@
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<BranchNameKey, Optional<RevCommit>> heads;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
+  private final int maxSubmittableChangesAtOnce;
 
   @Inject
   LocalMergeSuperSetComputation(
       Provider<InternalChangeQuery> queryProvider,
-      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+      @GerritServerConfig Config gerritConfig) {
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
+    this.maxSubmittableChangesAtOnce =
+        gerritConfig.getInt(
+            "change", "maxSubmittableAtOnce", MAX_SUBMITTABLE_CHANGES_AT_ONCE_DEFAULT);
   }
 
   @Override
@@ -130,9 +139,15 @@
       }
 
       Set<String> visibleHashes =
-          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, branchNameKey);
+          walkChangesByHashes(
+              visibleCommits,
+              Collections.emptySet(),
+              or,
+              branchNameKey,
+              maxSubmittableChangesAtOnce);
       Set<String> nonVisibleHashes =
-          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, branchNameKey);
+          walkChangesByHashes(
+              nonVisibleCommits, visibleHashes, or, branchNameKey, maxSubmittableChangesAtOnce);
 
       ChangeSet partialSet =
           byCommitsOnBranchNotMerged(or, branchNameKey, visibleHashes, nonVisibleHashes, user);
@@ -216,7 +231,11 @@
 
   @UsedAt(UsedAt.Project.GOOGLE)
   public Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
+      Collection<RevCommit> sourceCommits,
+      Set<String> ignoreHashes,
+      OpenRepo or,
+      BranchNameKey b,
+      int limit)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
     or.rw.reset();
@@ -226,7 +245,11 @@
       if (ignoreHashes.contains(name)) {
         continue;
       }
-      destHashes.add(name);
+      if (destHashes.size() < limit) {
+        destHashes.add(name);
+      } else {
+        break;
+      }
       or.rw.markStart(c);
     }
     for (RevCommit c : or.rw) {
@@ -234,7 +257,11 @@
       if (ignoreHashes.contains(name)) {
         continue;
       }
-      destHashes.add(name);
+      if (destHashes.size() < limit) {
+        destHashes.add(name);
+      } else {
+        break;
+      }
     }
 
     return destHashes;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 7e0bce9..5a4f073 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -20,7 +20,9 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Project;
@@ -191,6 +193,26 @@
   }
 
   @Test
+  @Sandboxed
+  @GerritConfig(name = "change.maxSubmittableAtOnce", value = "2")
+  public void submittedTogetherWithMaxChangesLimit() throws Exception {
+    String targetRef = "refs/for/master";
+
+    commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    pushHead(testRepo, targetRef, false);
+
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, targetRef, false);
+
+    RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
+    String id3 = getChangeId(c3_1);
+    pushHead(testRepo, targetRef, false);
+
+    assertSubmittedTogether(id3, id3, id2);
+  }
+
+  @Test
   public void respectTopicsOnAncestors() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index d6e1958..aaaf404 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -182,6 +182,13 @@
 import {rootUrl} from '../../../utils/url-util';
 import {createEditUrl} from '../../../models/views/edit';
 
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN =
+  /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
@@ -385,6 +392,14 @@
   @state()
   selectedRevision?: RevisionInfo | EditRevisionInfo;
 
+  @state()
+  get changeIdCommitMessageError() {
+    return this.computeChangeIdCommitMessageError(
+      this.latestCommitMessage,
+      this.change
+    );
+  }
+
   /**
    * <gr-change-actions> populates this via two-way data binding.
    * Private but used in tests.
@@ -946,6 +961,11 @@
           background-color: var(--background-color-secondary);
           padding-right: var(--spacing-m);
         }
+        .changeId {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          margin-top: var(--spacing-l);
+        }
         section {
           background-color: var(--view-background-color);
           box-shadow: var(--elevation-level-1);
@@ -1469,6 +1489,19 @@
                   .markdown=${false}
                 ></gr-formatted-text>
               </gr-editable-content>
+              <div class="changeId" ?hidden=${!this.changeIdCommitMessageError}>
+                <hr />
+                Change-Id:
+                <span
+                  class=${this.computeChangeIdClass(
+                    this.changeIdCommitMessageError
+                  )}
+                  title=${this.computeTitleAttributeWarning(
+                    this.changeIdCommitMessageError
+                  )}
+                  >${this.change?.change_id}</span
+                >
+              </div>
             </div>
             <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
             <gr-change-summary></gr-change-summary>
@@ -2425,6 +2458,60 @@
     });
   }
 
+  // private but used in test
+  computeChangeIdClass(displayChangeId?: string | null) {
+    if (displayChangeId) {
+      return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+    }
+    return '';
+  }
+
+  computeTitleAttributeWarning(displayChangeId?: string | null) {
+    if (!displayChangeId) {
+      return undefined;
+    }
+    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+    return undefined;
+  }
+
+  computeChangeIdCommitMessageError(
+    commitMessage: string | null,
+    change?: ParsedChangeInfo
+  ) {
+    if (change === undefined) {
+      return undefined;
+    }
+
+    if (!commitMessage) {
+      return CHANGE_ID_ERROR.MISSING;
+    }
+
+    // Find the last match in the commit message:
+    let changeId;
+    let changeIdArr;
+
+    while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
+      changeId = changeIdArr[2];
+    }
+
+    if (changeId) {
+      // A change-id is detected in the commit message.
+
+      if (changeId === change.change_id) {
+        // The change-id found matches the real change-id.
+        return null;
+      }
+      // The change-id found does not match the change-id.
+      return CHANGE_ID_ERROR.MISMATCH;
+    }
+    // There is no change-id in the commit message.
+    return CHANGE_ID_ERROR.MISSING;
+  }
+
   // Private but used in tests.
   computeReplyButtonLabel() {
     if (this.diffDrafts === undefined) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index f3017f8..d86007a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -435,6 +435,11 @@
                       >
                         <gr-formatted-text></gr-formatted-text>
                       </gr-editable-content>
+                      <div class="changeId" hidden="">
+                        <hr />
+                        Change-Id:
+                        <span class="" title=""></span>
+                      </div>
                     </div>
                     <h3 class="assistive-tech-only">
                       Comments and Checks Summary
@@ -1773,6 +1778,103 @@
     element.handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
+  test('computeChangeIdCommitMessageError', () => {
+    let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change: ParsedChangeInfo = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+
+    change = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      'missing'
+    );
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change: ParsedChangeInfo = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change: ParsedChangeInfo = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChangeViewChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element.computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+  });
+
+  test('computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+      element.computeTitleAttributeWarning(changeIdCommitMessageError),
+      'No Change-Id in commit message'
+    );
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+      element.computeTitleAttributeWarning(changeIdCommitMessageError),
+      'Change-Id mismatch'
+    );
+  });
+
+  test('computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(element.computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+      element.computeChangeIdClass(changeIdCommitMessageError),
+      'warning'
+    );
+  });
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, 'changeChanged');