Get commentlinks per-project in UI code

Remove commentlinks from GerritConfig and instead provide them in JSON
form from GET /projects/X/config. On the client side, create new
CommentLinkProcessor instances when loading a change or patch.
Fortunately, even though this requires another RPC to get the project
config, in all existing screen instances, there is already an RPC we
can parallelize it with.

In the long term we may want to enable server-side HTML rendering, but
that requires enabling this option on a variety of RPC endpoints, not
all of which are converted to the new REST API. In addition, there is
the problem of defining a stable HTML fragment style. For example, the
commit message template is currently implemented using a
not-quite-trivial template in GWT's UiBinder, so some of that
formatting and styling would need to be hoisted out into the server
side; doable, but we're not there yet.

Change-Id: Iaecbeff939c8fcbc1c6f500e0b04ce03f35e1fd3
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index a33f4c4..f207d43 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -439,7 +439,8 @@
     "use_contributor_agreements": false,
     "use_content_merge": true,
     "use_signed_off_by": false,
-    "require_change_id": true
+    "require_change_id": true,
+    "commentlinks": {}
   }
 ----
 
@@ -958,6 +959,10 @@
 If set, require a valid link:user-changeid.html[Change-Id] footer in any
 commit uploaded for review. This does not apply to commits pushed
 directly to a branch or tag.
+|`commentlinks`|
+Comment link configuration for the project. Has the same format as the
+link:config-gerrit.html#_a_id_commentlink_a_section_commentlink[commentlink section]
+of `gerrit.config`.
 |======================================
 
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GerritConfig.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GerritConfig.java
index 1abf485..7660a80 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GerritConfig.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GerritConfig.java
@@ -20,13 +20,7 @@
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadScheme;
 import com.google.gerrit.reviewdb.client.AuthType;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gwtexpui.safehtml.client.FindReplace;
-import com.google.gwtexpui.safehtml.client.LinkFindReplace;
-import com.google.gwtexpui.safehtml.client.RawFindReplace;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Set;
 
 public class GerritConfig implements Cloneable {
@@ -53,41 +47,6 @@
   protected String anonymousCowardName;
   protected int suggestFrom;
 
-  // Hack to pass FindReplace across the JSON serialization boundary, which
-  // doesn't work with interfaces.
-  public static class CommentLink {
-    public static CommentLink newCommentLink(String find, String link) {
-      return new CommentLink(find, link, true);
-    }
-
-    public static CommentLink newRawCommentLink(String find, String repl) {
-      return new CommentLink(find, repl, false);
-    }
-
-    protected String find;
-    protected String repl;
-    protected boolean isLink;
-
-    protected CommentLink() {
-    }
-
-    private CommentLink(String find, String repl, boolean isLink) {
-      this.find = find;
-      this.repl = repl;
-      this.isLink = isLink;
-    }
-
-    public FindReplace asFindReplace() {
-      if (isLink) {
-        return new LinkFindReplace(find, repl);
-      } else {
-        return new RawFindReplace(find, repl);
-      }
-    }
-  }
-  protected List<CommentLink> commentLinks;
-  private transient List<FindReplace> findReplaceLinks;
-
   public String getRegisterUrl() {
     return registerUrl;
   }
@@ -226,28 +185,6 @@
     editableAccountFields = af;
   }
 
-  public List<FindReplace> getCommentLinks() {
-    if (findReplaceLinks == null) {
-      if (commentLinks != null) {
-        findReplaceLinks = new ArrayList<FindReplace>(commentLinks.size());
-        for (CommentLink cl : commentLinks) {
-          findReplaceLinks.add(cl.asFindReplace());
-        }
-        findReplaceLinks = Collections.unmodifiableList(findReplaceLinks);
-      }
-    }
-    return findReplaceLinks;
-  }
-
-  public List<CommentLink> getSerializableCommentLinks() {
-    return commentLinks;
-  }
-
-  public void setSerializableCommentLinks(List<CommentLink> commentLinks) {
-    findReplaceLinks = null;
-    this.commentLinks = Collections.unmodifiableList(commentLinks);
-  }
-
   public boolean isDocumentationAvailable() {
     return documentationAvailable;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java
index a2debf2..39f5cb0 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchSetDetail.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
 
 import java.util.List;
 
@@ -24,6 +25,7 @@
   protected PatchSet patchSet;
   protected PatchSetInfo info;
   protected List<Patch> patches;
+  protected Project.NameKey project;
 
   public PatchSetDetail() {
   }
@@ -51,4 +53,12 @@
   public void setPatches(final List<Patch> p) {
     patches = p;
   }
+
+  public Project.NameKey getProject() {
+    return project;
+  }
+
+  public void setProject(final Project.NameKey p) {
+    project = p;
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDescriptionBlock.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDescriptionBlock.java
index 5cd6cdb..e5c8dcf 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDescriptionBlock.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDescriptionBlock.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.changes;
 
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.common.data.AccountInfoCache;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.reviewdb.client.Change;
@@ -37,10 +38,11 @@
   }
 
   public void display(Change chg, Boolean starred, Boolean canEditCommitMessage,
-      PatchSetInfo info,
-      final AccountInfoCache acc, SubmitTypeRecord submitTypeRecord) {
+      PatchSetInfo info, AccountInfoCache acc,
+      SubmitTypeRecord submitTypeRecord,
+      CommentLinkProcessor commentLinkProcessor) {
     infoBlock.display(chg, acc, submitTypeRecord);
     messageBlock.display(chg.currentPatchSetId(), starred,
-      canEditCommitMessage,  info.getMessage());
+        canEditCommitMessage, info.getMessage(), commentLinkProcessor);
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java
index c219fa3..18a3494 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java
@@ -18,7 +18,11 @@
 import com.google.gerrit.client.FormatUtil;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.AccountInfo;
+import com.google.gerrit.client.projects.ConfigInfo;
+import com.google.gerrit.client.projects.ProjectApi;
+import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.CommentPanel;
 import com.google.gerrit.client.ui.ComplexDisclosurePanel;
 import com.google.gerrit.client.ui.ExpandAllCommand;
@@ -79,6 +83,7 @@
   private PatchSetsBlock patchSetsBlock;
 
   private Panel comments;
+  private CommentLinkProcessor commentLinkProcessor;
 
   private KeyCommandSet keysNavigation;
   private KeyCommandSet keysAction;
@@ -260,10 +265,25 @@
   @Override
   public void onValueChange(final ValueChangeEvent<ChangeDetail> event) {
     if (isAttached()) {
-      // Until this screen is fully migrated to the new API, this call must be
-      // sequential, because we can't start an async get at the source of every
-      // call that might trigger a value change.
-      ChangeApi.detail(event.getValue().getChange().getId().get(),
+      // Until this screen is fully migrated to the new API, these calls must
+      // happen sequentially after the ChangeDetail lookup, because we can't
+      // start an async get at the source of every call that might trigger a
+      // value change.
+      CallbackGroup cbs = new CallbackGroup();
+      ProjectApi.config(event.getValue().getChange().getProject())
+          .get(cbs.add(new GerritCallback<ConfigInfo>() {
+            @Override
+            public void onSuccess(ConfigInfo result) {
+              commentLinkProcessor =
+                  new CommentLinkProcessor(result.commentlinks());
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+              // Handled by last callback's onFailure.
+            }
+          }));
+      ChangeApi.detail(event.getValue().getChange().getId().get(), cbs.add(
           new GerritCallback<com.google.gerrit.client.changes.ChangeInfo>() {
             @Override
             public void onSuccess(
@@ -271,7 +291,7 @@
               changeInfo = result;
               display(event.getValue());
             }
-          });
+          }));
     }
   }
 
@@ -292,7 +312,8 @@
         detail.isStarred(),
         detail.canEditCommitMessage(),
         detail.getCurrentPatchSetDetail().getInfo(),
-        detail.getAccounts(), detail.getSubmitTypeRecord());
+        detail.getAccounts(), detail.getSubmitTypeRecord(),
+        commentLinkProcessor);
     dependsOn.display(detail.getDependsOn());
     neededBy.display(detail.getNeededBy());
     approvals.display(changeInfo);
@@ -411,8 +432,8 @@
         isRecent = msg.getWrittenOn().after(aged);
       }
 
-      final CommentPanel cp =
-          new CommentPanel(author, msg.getWrittenOn(), msg.getMessage());
+      final CommentPanel cp = new CommentPanel(author, msg.getWrittenOn(),
+          msg.getMessage(), commentLinkProcessor);
       cp.setRecent(isRecent);
       cp.addStyleName(Gerrit.RESOURCES.css().commentPanelBorder());
       if (i == msgList.size() - 1) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
index ea184df..198480e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
@@ -68,8 +68,9 @@
     initWidget(uiBinder.createAndBindUi(this));
   }
 
-  public void display(final String commitMessage) {
-    display(null, null, false, commitMessage);
+  public void display(String commitMessage,
+      CommentLinkProcessor commentLinkProcessor) {
+    display(null, null, false, commitMessage, commentLinkProcessor);
   }
 
   private abstract class CommitMessageEditDialog extends CommentedActionDialog<ChangeDetail> {
@@ -103,7 +104,8 @@
   }
 
   public void display(final PatchSet.Id patchSetId,
-      Boolean starred, Boolean canEditCommitMessage, final String commitMessage) {
+      Boolean starred, Boolean canEditCommitMessage, final String commitMessage,
+      CommentLinkProcessor commentLinkProcessor) {
     starPanel.clear();
     if (patchSetId != null && starred != null && Gerrit.isSignedIn()) {
       Change.Id changeId = patchSetId.getParentKey();
@@ -170,7 +172,7 @@
     // Linkify commit summary
     SafeHtml commitSummaryLinkified = new SafeHtmlBuilder().append(commitSummary);
     commitSummaryLinkified = commitSummaryLinkified.linkify();
-    commitSummaryLinkified = CommentLinkProcessor.apply(commitSummaryLinkified);
+    commitSummaryLinkified = commentLinkProcessor.apply(commitSummaryLinkified);
     commitSummaryPre.setInnerHTML(commitSummaryLinkified.asString());
 
     // Hide commit body if there is no body
@@ -180,7 +182,7 @@
       // Linkify commit body
       SafeHtml commitBodyLinkified = new SafeHtmlBuilder().append(commitBody);
       commitBodyLinkified = commitBodyLinkified.linkify();
-      commitBodyLinkified = CommentLinkProcessor.apply(commitBodyLinkified);
+      commitBodyLinkified = commentLinkProcessor.apply(commitBodyLinkified);
       commitBodyLinkified = commitBodyLinkified.replaceAll("\n\n", "<p></p>");
       commitBodyLinkified = commitBodyLinkified.replaceAll("\n", "<br />");
       commitBodyPre.setInnerHTML(commitBodyLinkified.asString());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PublishCommentScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PublishCommentScreen.java
index 97949e3..cfc75c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PublishCommentScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PublishCommentScreen.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.client.patches.AbstractPatchContentTable;
 import com.google.gerrit.client.patches.CommentEditorContainer;
 import com.google.gerrit.client.patches.CommentEditorPanel;
+import com.google.gerrit.client.projects.ConfigInfo;
+import com.google.gerrit.client.projects.ProjectApi;
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
@@ -78,6 +80,7 @@
   private boolean saveStateOnUnload = true;
   private List<CommentEditorPanel> commentEditors;
   private ChangeInfo change;
+  private CommentLinkProcessor commentLinkProcessor;
 
   public PublishCommentScreen(final PatchSet.Id psi) {
     patchSetId = psi;
@@ -148,8 +151,8 @@
     super.onLoad();
 
     CallbackGroup cbs = new CallbackGroup();
-    ChangeApi.revision(patchSetId).view("review").get(cbs.add(
-        new AsyncCallback<ChangeInfo>() {
+    ChangeApi.revision(patchSetId).view("review")
+        .get(cbs.add(new AsyncCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
             result.init();
@@ -166,7 +169,7 @@
           @Override
           protected void preDisplay(final PatchSetPublishDetail result) {
             send.setEnabled(true);
-            display(result);
+            PublishCommentScreen.this.preDisplay(result, this);
           }
 
           @Override
@@ -176,6 +179,24 @@
         }));
   }
 
+  private void preDisplay(final PatchSetPublishDetail pubDetail,
+      final ScreenLoadCallback<PatchSetPublishDetail> origCb) {
+    ProjectApi.config(pubDetail.getChange().getProject())
+        .get(new AsyncCallback<ConfigInfo>() {
+          @Override
+          public void onSuccess(ConfigInfo result) {
+            commentLinkProcessor =
+                new CommentLinkProcessor(result.commentlinks());
+            display(pubDetail);
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+            origCb.onFailure(caught);
+          }
+        });
+  }
+
   @Override
   protected void onUnload() {
     super.onUnload();
@@ -281,7 +302,7 @@
     for (String value : values) {
       ValueRadioButton b = new ValueRadioButton(label, value);
       SafeHtml buf = new SafeHtmlBuilder().append(b.format());
-      buf = CommentLinkProcessor.apply(buf);
+      buf = commentLinkProcessor.apply(buf);
       SafeHtml.set(b, buf);
 
       if (lastState != null && patchSetId.equals(lastState.patchSetId)
@@ -301,7 +322,7 @@
     setPageTitle(Util.M.publishComments(r.getChange().getKey().abbreviate(),
         patchSetId.get()));
     descBlock.display(r.getChange(), null, false, r.getPatchSetInfo(), r.getAccounts(),
-        r.getSubmitTypeRecord());
+       r.getSubmitTypeRecord(), commentLinkProcessor);
 
     if (r.getChange().getStatus().isOpen()) {
       initApprovals(approvalPanel);
@@ -337,7 +358,8 @@
           priorFile = fn;
         }
 
-        final CommentEditorPanel editor = new CommentEditorPanel(c);
+        final CommentEditorPanel editor =
+            new CommentEditorPanel(c, commentLinkProcessor);
         if (c.getLine() == AbstractPatchContentTable.R_HEAD) {
           editor.setAuthorNameText(Util.C.fileCommentHeader());
         } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java
index 20cc2c1..a46946b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/AbstractPatchContentTable.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.changes.PatchTable;
 import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.CommentPanel;
 import com.google.gerrit.client.ui.NavigationTable;
 import com.google.gerrit.client.ui.NeedsSignInKeyCommand;
@@ -86,6 +87,7 @@
   private HandlerRegistration regComment;
   private final KeyCommandSet keysOpenByEnter;
   private HandlerRegistration regOpenByEnter;
+  private CommentLinkProcessor commentLinkProcessor;
   boolean isDisplayBinary;
 
   protected AbstractPatchContentTable() {
@@ -241,6 +243,10 @@
     render(s, d);
   }
 
+  void setCommentLinkProcessor(CommentLinkProcessor commentLinkProcessor) {
+    this.commentLinkProcessor = commentLinkProcessor;
+  }
+
   protected boolean hasDifferences(PatchScript script) {
     return hasEdits(script) || hasMeta(script);
   }
@@ -553,7 +559,8 @@
       return null;
     }
 
-    final CommentEditorPanel ed = new CommentEditorPanel(newComment);
+    final CommentEditorPanel ed =
+        new CommentEditorPanel(newComment, commentLinkProcessor);
     ed.addFocusHandler(this);
     ed.addBlurHandler(this);
     boolean isCommentRow = false;
@@ -690,7 +697,8 @@
   protected void bindComment(final int row, final int col,
       final PatchLineComment line, final boolean isLast, boolean expandComment) {
     if (line.getStatus() == PatchLineComment.Status.DRAFT) {
-      final CommentEditorPanel plc = new CommentEditorPanel(line);
+      final CommentEditorPanel plc =
+          new CommentEditorPanel(line, commentLinkProcessor);
       plc.addFocusHandler(this);
       plc.addBlurHandler(this);
       table.setWidget(row, col, plc);
@@ -864,7 +872,7 @@
     final Button replyDone;
 
     PublishedCommentPanel(final AccountInfo author, final PatchLineComment c) {
-      super(author, c.getWrittenOn(), c.getMessage());
+      super(author, c.getWrittenOn(), c.getMessage(), commentLinkProcessor);
       this.comment = c;
 
       reply = new Button(PatchUtil.C.buttonReply());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
index ad022b5..63d7d8e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/CommentEditorPanel.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.CommentPanel;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -25,10 +26,10 @@
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
 import com.google.gwt.user.client.Timer;
-import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.globalkey.client.NpTextArea;
+import com.google.gwtjsonrpc.common.AsyncCallback;
 import com.google.gwtjsonrpc.common.VoidResult;
 
 import java.sql.Timestamp;
@@ -58,7 +59,9 @@
   private final Button discard;
   private final Timer expandTimer;
 
-  public CommentEditorPanel(final PatchLineComment plc) {
+  public CommentEditorPanel(final PatchLineComment plc,
+      final CommentLinkProcessor commentLinkProcessor) {
+    super(commentLinkProcessor);
     comment = plc;
 
     addStyleName(Gerrit.RESOURCES.css().commentEditorPanel());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScreen.java
index 297be6b..59de8b4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/PatchScreen.java
@@ -21,8 +21,12 @@
 import com.google.gerrit.client.changes.CommitMessageBlock;
 import com.google.gerrit.client.changes.PatchTable;
 import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.projects.ConfigInfo;
+import com.google.gerrit.client.projects.ProjectApi;
+import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.ScreenLoadCallback;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.ListenableAccountDiffPreference;
 import com.google.gerrit.client.ui.Screen;
 import com.google.gerrit.common.data.PatchScript;
@@ -38,6 +42,7 @@
 import com.google.gwt.event.logical.shared.ValueChangeEvent;
 import com.google.gwt.event.logical.shared.ValueChangeHandler;
 import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
@@ -97,6 +102,7 @@
   protected PatchSet.Id idSideB;
   protected PatchScriptSettingsPanel settingsPanel;
   protected TopView topView;
+  protected CommentLinkProcessor commentLinkProcessor;
 
   private ReviewedPanels reviewedPanels;
   private HistoryTable historyTable;
@@ -365,8 +371,9 @@
     if (isFirst && fileList != null) {
       fileList.movePointerTo(patchKey);
     }
-    PatchUtil.DETAIL_SVC.patchScript(patchKey, idSideA, idSideB, //
-        settingsPanel.getValue(), new ScreenLoadCallback<PatchScript>(this) {
+
+    com.google.gwtjsonrpc.common.AsyncCallback<PatchScript> pscb =
+        new ScreenLoadCallback<PatchScript>(this) {
           @Override
           protected void preDisplay(final PatchScript result) {
             if (rpcSequence == rpcseq) {
@@ -381,7 +388,29 @@
               super.onFailure(caught);
             }
           }
-        });
+        };
+    if (commentLinkProcessor == null) {
+      // Fetch config in parallel if we haven't previously.
+      CallbackGroup cb = new CallbackGroup();
+      ProjectApi.config(patchSetDetail.getProject())
+          .get(cb.add(new AsyncCallback<ConfigInfo>() {
+            @Override
+            public void onSuccess(ConfigInfo result) {
+              commentLinkProcessor =
+                  new CommentLinkProcessor(result.commentlinks());
+              contentTable.setCommentLinkProcessor(commentLinkProcessor);
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+              // Handled by ScreenLoadCallback.onFailure.
+            }
+          }));
+      pscb = cb.addGwtjsonrpc(pscb);
+    }
+
+    PatchUtil.DETAIL_SVC.patchScript(patchKey, idSideA, idSideB, //
+        settingsPanel.getValue(), pscb);
   }
 
   private void onResult(final PatchScript script, final boolean isFirst) {
@@ -397,7 +426,8 @@
 
     if (idSideB.equals(patchSetDetail.getPatchSet().getId())) {
       commitMessageBlock.setVisible(true);
-      commitMessageBlock.display(patchSetDetail.getInfo().getMessage());
+      commitMessageBlock.display(patchSetDetail.getInfo().getMessage(),
+          commentLinkProcessor);
     } else {
       commitMessageBlock.setVisible(false);
       Util.DETAIL_SVC.patchSetDetail(idSideB,
@@ -405,7 +435,8 @@
             @Override
             public void onSuccess(PatchSetDetail result) {
               commitMessageBlock.setVisible(true);
-              commitMessageBlock.display(result.getInfo().getMessage());
+              commitMessageBlock.display(result.getInfo().getMessage(),
+                  commentLinkProcessor);
             }
           });
     }
@@ -432,6 +463,7 @@
       contentTable.removeFromParent();
       contentTable = new UnifiedDiffTable();
       contentTable.fileList = fileList;
+      contentTable.setCommentLinkProcessor(commentLinkProcessor);
       contentPanel.add(contentTable);
       setToken(Dispatcher.toPatchUnified(idSideA, patchKey));
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SideBySideTable.java
index 15ab951..a7ba18b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/SideBySideTable.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.client.patches.PatchLine.Type.REPLACE;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.FileMode;
@@ -39,6 +40,7 @@
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
+
 import org.eclipse.jgit.diff.Edit;
 
 import java.util.ArrayList;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java
index 82df54a..2efe00e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/patches/UnifiedDiffTable.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.client.patches.PatchLine.Type.INSERT;
 
 import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
@@ -32,8 +33,8 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.Element;
-import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
+import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtexpui.safehtml.client.SafeHtmlBuilder;
 import com.google.gwtorm.client.KeyUtil;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
new file mode 100644
index 0000000..c1a05df
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2013 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.client.projects;
+
+import com.google.gerrit.client.rpc.NativeMap;
+import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwtexpui.safehtml.client.FindReplace;
+import com.google.gwtexpui.safehtml.client.LinkFindReplace;
+import com.google.gwtexpui.safehtml.client.RawFindReplace;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ConfigInfo extends JavaScriptObject {
+  public final native JavaScriptObject has_require_change_id()
+  /*-{ return this.hasOwnProperty('require_change_id'); }-*/;
+  public final native boolean require_change_id()
+  /*-{ return this.require_change_id; }-*/;
+
+  public final native JavaScriptObject has_use_content_merge()
+  /*-{ return this.hasOwnProperty('use_content_merge'); }-*/;
+  public final native boolean use_content_merge()
+  /*-{ return this.use_content_merge; }-*/;
+
+  public final native JavaScriptObject has_use_contributor_agreements()
+  /*-{ return this.hasOwnProperty('use_contributor_agreements'); }-*/;
+  public final native boolean use_contributor_agreements()
+  /*-{ return this.use_contributor_agreements; }-*/;
+
+  public final native JavaScriptObject has_use_signed_off_by()
+  /*-{ return this.hasOwnProperty('use_signed_off_by'); }-*/;
+  public final native boolean use_signed_off_by()
+  /*-{ return this.use_signed_off_by; }-*/;
+
+  private final native NativeMap<CommentLinkInfo> commentlinks0()
+  /*-{ return this.commentlinks; }-*/;
+  public final List<FindReplace> commentlinks() {
+    JsArray<CommentLinkInfo> cls = commentlinks0().values();
+    List<FindReplace> commentLinks = new ArrayList<FindReplace>(cls.length());
+    for (int i = 0; i < cls.length(); i++) {
+      CommentLinkInfo cl = cls.get(i);
+      if (cl.link() != null) {
+        commentLinks.add(new LinkFindReplace(cl.match(), cl.link()));
+      } else {
+        commentLinks.add(new RawFindReplace(cl.match(), cl.html()));
+      }
+    }
+    return commentLinks;
+  }
+
+  protected ConfigInfo() {
+  }
+
+  static class CommentLinkInfo extends JavaScriptObject {
+    final native String match() /*-{ return this.match; }-*/;
+    final native String link() /*-{ return this.link; }-*/;
+    final native String html() /*-{ return this.html; }-*/;
+
+    protected CommentLinkInfo() {
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index a6676dc..a17602f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -15,6 +15,7 @@
 
 import com.google.gerrit.client.VoidResult;
 import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
@@ -32,6 +33,10 @@
         .put(input, asyncCallback);
   }
 
+  public static RestApi config(Project.NameKey name) {
+    return new RestApi("/projects/").id(name.get()).view("config");
+  }
+
   private static class ProjectInput extends JavaScriptObject {
     static ProjectInput create() {
       return (ProjectInput) createObject();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
index 2be8959..10cd1f0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentLinkProcessor.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.ui;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.common.data.GerritConfig.CommentLink;
 import com.google.gwtexpui.safehtml.client.FindReplace;
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 import com.google.gwtjsonrpc.common.AsyncCallback;
@@ -26,27 +25,26 @@
 import java.util.List;
 
 public class CommentLinkProcessor {
-  public static SafeHtml apply(SafeHtml buf) {
-    try {
-      return buf.replaceAll(Gerrit.getConfig().getCommentLinks());
+  private List<FindReplace> commentLinks;
 
+  public CommentLinkProcessor(List<FindReplace> commentLinks) {
+    this.commentLinks = commentLinks;
+  }
+
+  public SafeHtml apply(SafeHtml buf) {
+    try {
+      return buf.replaceAll(commentLinks);
     } catch (RuntimeException err) {
       // One or more of the patterns isn't valid on this browser.
       // Try to filter the list down and remove the invalid ones.
 
-      List<FindReplace> all = Gerrit.getConfig().getCommentLinks();
-      List<CommentLink> ser = Gerrit.getConfig().getSerializableCommentLinks();
-
-      List<FindReplace> safe = new ArrayList<FindReplace>(all.size());
-      List<CommentLink> safeSer = new ArrayList<CommentLink>(safe.size());
+      List<FindReplace> safe = new ArrayList<FindReplace>(commentLinks.size());
 
       List<PatternError> bad = new ArrayList<PatternError>();
-      for (int i = 0; i < all.size(); i++) {
-        FindReplace r = all.get(i);
+      for (FindReplace r : commentLinks) {
         try {
           buf.replaceAll(Collections.singletonList(r));
           safe.add(r);
-          safeSer.add(ser.get(i));
         } catch (RuntimeException why) {
           bad.add(new PatternError(r, why.getMessage()));
         }
@@ -75,13 +73,13 @@
       }
 
       try {
-        Gerrit.getConfig().setSerializableCommentLinks(safeSer);
+        commentLinks = safe;
         return buf.replaceAll(safe);
       } catch (RuntimeException err2) {
         // To heck with it. The patterns passed individually above but
         // failed as a group? Just render without.
         //
-        Gerrit.getConfig().setSerializableCommentLinks(null);
+        commentLinks = null;
         return buf;
       }
     }
@@ -96,7 +94,4 @@
       errorMessage = w;
     }
   }
-
-  private CommentLinkProcessor() {
-  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java
index 8f8c7ea..cb4dc7d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommentPanel.java
@@ -53,11 +53,13 @@
   private final InlineLabel messageSummary;
   private final FlowPanel content;
   private final DoubleClickHTML messageText;
+  private CommentLinkProcessor commentLinkProcessor;
   private FlowPanel buttons;
   private boolean recent;
 
-  public CommentPanel(final AccountInfo author, final Date when, String message) {
-    this();
+  public CommentPanel(final AccountInfo author, final Date when, String message,
+      CommentLinkProcessor commentLinkProcessor) {
+    this(commentLinkProcessor);
 
     setMessageText(message);
     setAuthorNameText(FormatUtil.name(author));
@@ -68,7 +70,8 @@
     fmt.getElement(0, 2).setTitle(FormatUtil.mediumFormat(when));
   }
 
-  protected CommentPanel() {
+  protected CommentPanel(CommentLinkProcessor commentLinkProcessor) {
+    this.commentLinkProcessor = commentLinkProcessor;
     final FlowPanel body = new FlowPanel();
     initWidget(body);
     setStyleName(Gerrit.RESOURCES.css().commentPanel());
@@ -118,7 +121,7 @@
 
     messageSummary.setText(summarize(message));
     SafeHtml buf = new SafeHtmlBuilder().append(message).wikify();
-    buf = CommentLinkProcessor.apply(buf);
+    buf = commentLinkProcessor.apply(buf);
     SafeHtml.set(messageText, buf);
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritConfigProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritConfigProvider.java
index a8a3b754..7241624 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritConfigProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritConfigProvider.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.httpd;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GerritConfig;
-import com.google.gerrit.common.data.GerritConfig.CommentLink;
 import com.google.gerrit.common.data.GitwebConfig;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.Realm;
@@ -37,10 +34,7 @@
 
 import java.net.MalformedURLException;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
 
 import javax.servlet.ServletContext;
 
@@ -149,45 +143,9 @@
       config.setSshdAddress(sshInfo.getHostKeys().get(0).getHost());
     }
 
-    config.setSerializableCommentLinks(buildCommentLinks(cfg));
     return config;
   }
 
-  private static List<CommentLink> buildCommentLinks(Config cfg) {
-    Set<String> sections = cfg.getSubsections("commentlink");
-    List<CommentLink> links = Lists.newArrayListWithCapacity(sections.size());
-
-    for (String name : cfg.getSubsections("commentlink")) {
-      String match = cfg.getString("commentlink", name, "match");
-
-      // Unfortunately this validation isn't entirely complete. Clients
-      // can have exceptions trying to evaluate the pattern if they don't
-      // support a token used, even if the server does support the token.
-      //
-      // At the minimum, we can trap problems related to unmatched groups.
-      try {
-        Pattern.compile(match);
-      } catch (PatternSyntaxException e) {
-        throw new ProvisionException("Invalid pattern \"" + match
-            + "\" in commentlink." + name + ".match: " + e.getMessage());
-      }
-
-      String link = cfg.getString("commentlink", name, "link");
-      int hasLink = Strings.isNullOrEmpty(link) ? 0 : 1;
-      String html = cfg.getString("commentlink", name, "html");
-      int hasHtml = Strings.isNullOrEmpty(html) ? 0 : 1;
-      if (hasLink + hasHtml != 1) {
-        throw new ProvisionException(
-            "commentlink." + name + " must have either link or html");
-      } else if (hasLink == 1) {
-        links.add(CommentLink.newCommentLink(match, link));
-      } else if (hasHtml == 1) {
-        links.add(CommentLink.newRawCommentLink(match, html));
-      }
-    }
-    return links;
-  }
-
   @Override
   public GerritConfig get() {
     try {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java
index 95a8e26..8e81dd3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/PatchSetDetailFactory.java
@@ -106,7 +106,7 @@
         throw new NoSuchEntityException();
       }
     }
-
+    projectKey = control.getProject().getNameKey();
     final PatchList list;
 
     try {
@@ -114,8 +114,6 @@
         oldId = toObjectId(psIdBase);
         newId = toObjectId(psIdNew);
 
-        projectKey = control.getProject().getNameKey();
-
         list = listFor(keyFor(diffPrefs.getIgnoreWhitespace()));
       } else { // OK, means use base to compare
         list = patchListCache.get(control.getChange(), patchSet);
@@ -139,6 +137,7 @@
 
     detail = new PatchSetDetail();
     detail.setPatchSet(patchSet);
+    detail.setProject(projectKey);
 
     detail.setInfo(infoFactory.get(db, psIdNew));
     detail.setPatches(patches);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index 0985f51..f9698fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.Maps;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.GitRepositoryManager;
 
+import java.util.Map;
+
 public class GetConfig implements RestReadView<ProjectResource> {
   public static class ConfigInfo {
     public final String kind = "gerritcodereview#project_config";
@@ -25,6 +28,8 @@
     public Boolean useContentMerge;
     public Boolean useSignedOffBy;
     public Boolean requireChangeId;
+
+    public Map<String, CommentLinkInfo> commentlinks;
   }
 
   @Override
@@ -39,6 +44,13 @@
       result.useSignedOffBy = project.isUseSignedOffBy();
       result.requireChangeId = project.isRequireChangeID();
     }
+
+    // commentlinks are visible to anyone, as they are used for linkification
+    // on the client side.
+    result.commentlinks = Maps.newLinkedHashMap();
+    for (CommentLinkInfo cl : project.getCommentLinks()) {
+      result.commentlinks.put(cl.name, cl);
+    }
     return result;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 6150a3f..ec00e3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -69,6 +69,7 @@
   private final PrologEnvironment.Factory envFactory;
   private final GitRepositoryManager gitMgr;
   private final RulesCache rulesCache;
+  private final List<CommentLinkInfo> commentLinks;
 
   private final ProjectConfig config;
   private final Set<AccountGroup.UUID> localOwners;
@@ -93,6 +94,7 @@
       final PrologEnvironment.Factory envFactory,
       final GitRepositoryManager gitMgr,
       final RulesCache rulesCache,
+      final List<CommentLinkInfo> commentLinks,
       @Assisted final ProjectConfig config) {
     this.projectCache = projectCache;
     this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
@@ -101,6 +103,7 @@
     this.envFactory = envFactory;
     this.gitMgr = gitMgr;
     this.rulesCache = rulesCache;
+    this.commentLinks = commentLinks;
     this.config = config;
     this.capabilities = isAllProjects
       ? new CapabilityCollection(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
@@ -363,6 +366,10 @@
     return new LabelTypes(Collections.unmodifiableList(all));
   }
 
+  public List<CommentLinkInfo> getCommentLinks() {
+    return commentLinks;
+  }
+
   private boolean getInheritableBoolean(Function<Project, InheritableBoolean> func) {
     for (ProjectState s : tree()) {
       switch (func.apply(s.getProject())) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index 98b0b4a..a6fc23e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -80,8 +80,8 @@
       for (LabelType label : labelTypes.getLabelTypes()) {
         config.getLabelSections().put(label.getName(), label);
       }
-      allProjects = new ProjectState(this, allProjectsName, null,
-          null, null, null, config);
+      allProjects = new ProjectState(this, allProjectsName, null, null, null,
+          null, null, config);
     }
 
     @Override
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 51ea5a2..f818746 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -541,10 +541,10 @@
     RulesCache rulesCache = null;
     all.put(local.getProject().getNameKey(), new ProjectState(
         projectCache, allProjectsName, projectControlFactory,
-        envFactory, mgr, rulesCache, local));
+        envFactory, mgr, rulesCache, null, local));
     all.put(parent.getProject().getNameKey(), new ProjectState(
         projectCache, allProjectsName, projectControlFactory,
-        envFactory, mgr, rulesCache, parent));
+        envFactory, mgr, rulesCache, null, parent));
     return all.get(local.getProject().getNameKey());
   }