Parse and record remote review host in .gitattributes

This change enhances RepoCommand to read the "review" attribute from
<remote> and <default> elements in a repo manifest. The parsed review
URL is then used to add a "remote-review=<host-identifier>" entry to
the .gitattributes file for each project, where the host identifier is
extracted from the review URL. This allows tools to associate submodules
with their respective Gerrit review hosts.

Change-Id: I40406cdf4a5bbe08aa7b551136b75009f4c5b0bf
Signed-off-by: Alexander Hendrich <hendrich@google.com>
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
index 0949d04..d8ac28c 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/ManifestParserTest.java
@@ -254,4 +254,26 @@ public void testXXE() throws Exception {
 		assertTrue(e.getCause().getMessage().contains("DOCTYPE"));
 	}
 
+	@Test
+	public void testManifestParserWithReview() throws Exception {
+		String baseUrl = "https://git.google.com/";
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append("<remote name=\"remote1\" fetch=\".\" review=\"https://review-1.com\" />")
+				.append("<remote name=\"remote2\" fetch=\".\" review=\"https://review-2.com\" />")
+				.append("<default revision=\"master\" remote=\"remote1\" />")
+				.append("<project path=\"foo\" name=\"foo\" />")
+				.append("<project path=\"bar\" name=\"bar\" remote=\"remote2\" />")
+				.append("</manifest>");
+
+		ManifestParser parser = new ManifestParser(null, null, "master", baseUrl, null, null);
+		parser.read(new ByteArrayInputStream(xmlContent.toString().getBytes(UTF_8)));
+
+		Map<String, RepoProject> map = parser.getProjects().stream()
+				.collect(Collectors.toMap(RepoProject::getPath, Function.identity()));
+		assertEquals("https://review-1.com", map.get("foo").getReview());
+		assertEquals("https://review-2.com", map.get("bar").getReview());
+	}
+
 }
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
index 3162e79..ceb18e6 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/gitrepo/RepoCommandTest.java
@@ -1302,6 +1302,104 @@ public void testRecordSubmoduleLabels() throws Exception {
 	}
 
 	@Test
+	public void testRecordSubmoduleLabelsWithRemoteReview() throws Exception {
+		Repository remoteDb = createBareRepository();
+		Repository tempDb = createWorkRepository();
+
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent
+				.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append(
+						"<remote name=\"remote1\" fetch=\".\""
+								+ " review=\"https://googleplex-android-review.git.corp.google.com\" />")
+				.append("<default revision=\"master\" remote=\"remote1\" />")
+				.append("<project path=\"test\" ")
+				.append("revision=\"master\" ")
+				.append("name=\"")
+				.append(notDefaultUri)
+				.append("\" ")
+				.append("groups=\"a1,a2\" />")
+				.append("</manifest>");
+		JGitTestUtil.writeTrashFile(tempDb, "manifest.xml", xmlContent.toString());
+
+		RepoCommand command = new RepoCommand(remoteDb);
+		command
+				.setPath(tempDb.getWorkTree().getAbsolutePath() + "/manifest.xml")
+				.setURI(rootUri)
+				.setRecordSubmoduleLabels(true)
+				.call();
+		// Clone it
+		File directory = createTempDirectory("testBareRepo");
+		try (Repository localDb =
+				Git.cloneRepository()
+						.setDirectory(directory)
+						.setURI(remoteDb.getDirectory().toURI().toString())
+						.call()
+						.getRepository(); ) {
+			// The .gitattributes file should exist
+			File gitattributes = new File(localDb.getWorkTree(), ".gitattributes");
+			assertTrue("The .gitattributes file should exist", gitattributes.exists());
+			try (BufferedReader reader = Files.newBufferedReader(gitattributes.toPath(), UTF_8)) {
+				String content = reader.readLine();
+				assertEquals(
+						".gitattributes content should be as expected",
+						"/test a1 a2 remote-review=googleplex-android",
+						content);
+			}
+		}
+	}
+
+	@Test
+	public void testRecordSubmoduleLabelsWithDefaultReview() throws Exception {
+		Repository remoteDb = createBareRepository();
+		Repository tempDb = createWorkRepository();
+
+		StringBuilder xmlContent = new StringBuilder();
+		xmlContent
+				.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
+				.append("<manifest>")
+				.append("<remote name=\"remote1\" fetch=\".\" />")
+				.append(
+						"<default revision=\"master\" remote=\"remote1\""
+								+ " review=\"https://googleplex-android-review.git.corp.google.com\" />")
+				.append("<project path=\"test\" ")
+				.append("revision=\"master\" ")
+				.append("name=\"")
+				.append(notDefaultUri)
+				.append("\" ")
+				.append("groups=\"a1,a2\" />")
+				.append("</manifest>");
+		JGitTestUtil.writeTrashFile(tempDb, "manifest.xml", xmlContent.toString());
+
+		RepoCommand command = new RepoCommand(remoteDb);
+		command
+				.setPath(tempDb.getWorkTree().getAbsolutePath() + "/manifest.xml")
+				.setURI(rootUri)
+				.setRecordSubmoduleLabels(true)
+				.call();
+		// Clone it
+		File directory = createTempDirectory("testBareRepo");
+		try (Repository localDb =
+				Git.cloneRepository()
+						.setDirectory(directory)
+						.setURI(remoteDb.getDirectory().toURI().toString())
+						.call()
+						.getRepository(); ) {
+			// The .gitattributes file should exist
+			File gitattributes = new File(localDb.getWorkTree(), ".gitattributes");
+			assertTrue("The .gitattributes file should exist", gitattributes.exists());
+			try (BufferedReader reader = Files.newBufferedReader(gitattributes.toPath(), UTF_8)) {
+				String content = reader.readLine();
+				assertEquals(
+						".gitattributes content should be as expected",
+						"/test a1 a2 remote-review=googleplex-android",
+						content);
+			}
+		}
+	}
+
+	@Test
 	public void testRecordShallowRecommendation() throws Exception {
 		Repository remoteDb = createBareRepository();
 		Repository tempDb = createWorkRepository();
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
index e511a68..bfc7c9c 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/BareSuperprojectWriter.java
@@ -192,6 +192,11 @@ private void prepareIndex(List<RepoProject> projects, DirCache index,
 					rec.append(" "); //$NON-NLS-1$
 					rec.append(group);
 				}
+				String reviewHost = getPlainHost(proj.getReview());
+				if (reviewHost != null && !reviewHost.isEmpty()) {
+					rec.append(" remote-review="); //$NON-NLS-1$
+					rec.append(reviewHost);
+				}
 				rec.append("\n"); //$NON-NLS-1$
 				attributes.append(rec.toString());
 			}
@@ -320,4 +325,37 @@ private RevCommit commitTreeOnCurrentTip(ObjectInserter inserter,
 
 		return rw.parseCommit(commitId);
 	}
+
+	private static String getReviewHost(String reviewUrl) {
+		if (reviewUrl == null) {
+			return null;
+		}
+		try {
+			if (!reviewUrl.contains("://")) { //$NON-NLS-1$
+				reviewUrl = "https://" + reviewUrl; //$NON-NLS-1$
+			}
+			return URI.create(reviewUrl).getHost();
+		} catch (IllegalArgumentException e) {
+			return null;
+		}
+	}
+
+	/**
+	 * Return the host name without "-review" suffix.
+	 *
+	 * @param reviewUrl
+	 *            the review URL
+	 * @return host name without "-review" suffix
+	 */
+	private static String getPlainHost(String reviewUrl) {
+		String host = getReviewHost(reviewUrl);
+		if (host == null) {
+			return null;
+		}
+		int reviewIdx = host.indexOf("-review"); //$NON-NLS-1$
+		if (reviewIdx != -1) {
+			return host.substring(0, reviewIdx);
+		}
+		return host;
+	}
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
index 12ba893..32234a0 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/ManifestParser.java
@@ -61,6 +61,7 @@ public class ManifestParser extends DefaultHandler {
 
 	private String defaultRemote;
 	private String defaultRevision;
+	private String defaultReview;
 	private int xmlInRead;
 	private RepoProject currentProject;
 
@@ -195,7 +196,8 @@ public void startElement(
 			String alias = attributes.getValue("alias");
 			String fetch = attributes.getValue("fetch");
 			String revision = attributes.getValue("revision");
-			Remote remote = new Remote(fetch, revision);
+			String review = attributes.getValue("review");
+			Remote remote = new Remote(fetch, revision, review);
 			remotes.put(attributes.getValue("name"), remote);
 			if (alias != null) {
 				remotes.put(alias, remote);
@@ -204,6 +206,7 @@ public void startElement(
 		case "default":
 			defaultRemote = attributes.getValue("remote");
 			defaultRevision = attributes.getValue("revision");
+			defaultReview = attributes.getValue("review");
 			break;
 		case "copyfile":
 			if (currentProject == null) {
@@ -314,6 +317,12 @@ public void endDocument() throws SAXException {
 			}
 			proj.setUrl(remoteUrl.resolve(proj.getName()).toString())
 				.setDefaultRevision(revision);
+			Remote r = remotes.get(remote);
+			if (r != null && r.review != null) {
+				proj.setReview(r.review);
+			} else if (defaultReview != null) {
+				proj.setReview(defaultReview);
+			}
 		}
 
 		filteredProjects.addAll(projects);
@@ -440,10 +449,12 @@ private boolean isNestedReferencefile(ReferenceFile referencefile) {
 	private static class Remote {
 		final String fetch;
 		final String revision;
+		final String review;
 
-		Remote(String fetch, String revision) {
+		Remote(String fetch, String revision, String review) {
 			this.fetch = fetch;
 			this.revision = revision;
+			this.review = review;
 		}
 	}
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
index 392ca92..32f2ad1 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoCommand.java
@@ -591,6 +591,7 @@ private List<RepoProject> renameProjects(List<RepoProject> projects) {
 				p.addCopyFiles(proj.getCopyFiles());
 				p.addLinkFiles(proj.getLinkFiles());
 				p.setUpstream(proj.getUpstream());
+				p.setReview(proj.getReview());
 				ret.add(p);
 			}
 		}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
index 07eb12d..97f461c 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/gitrepo/RepoProject.java
@@ -41,6 +41,7 @@ public class RepoProject implements Comparable<RepoProject> {
 	private String upstream;
 	private String destBranch;
 	private String recommendShallow;
+	private String review;
 	private String url;
 	private String defaultRevision;
 
@@ -333,6 +334,27 @@ public void setRecommendShallow(String recommendShallow) {
 	}
 
 	/**
+	 * Return the Gerrit review server URL defined by the project's remote.
+	 *
+	 * @return the review server URL
+	 * @since 7.8
+	 */
+	public String getReview() {
+		return review;
+	}
+
+	/**
+	 * Sets the Gerrit review server URL.
+	 *
+	 * @param review the review server URL
+	 * @since 7.8
+	 */
+	public void setReview(String review) {
+		this.review = review;
+	}
+
+
+	/**
 	 * Add a copy file configuration.
 	 *
 	 * @param copyfile a {@link org.eclipse.jgit.gitrepo.RepoProject.CopyFile} object.