Merge "Add class that updates all code owner configs in a branch atomically"
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
new file mode 100644
index 0000000..6222b7d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScanner.java
@@ -0,0 +1,209 @@
+// Copyright (C) 2020 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.plugins.codeowners.backend;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to scan a branch for code owner config files and update them.
+ *
+ * <p>Doesn't parse the code owner config files but provides the raw content to the callback.
+ *
+ * <p>All updates to the code owner config files are done atomically with a single commit.
+ */
+@Singleton
+public class CodeOwnerConfigFileUpdateScanner {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final GitRepositoryManager repoManager;
+ private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+ private final Provider<PersonIdent> serverIdentProvider;
+ private final Provider<IdentifiedUser> identifiedUser;
+
+ @Inject
+ CodeOwnerConfigFileUpdateScanner(
+ GitRepositoryManager repoManager,
+ CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+ @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+ Provider<IdentifiedUser> identifiedUser) {
+ this.repoManager = repoManager;
+ this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+ this.serverIdentProvider = serverIdentProvider;
+ this.identifiedUser = identifiedUser;
+ }
+
+ /**
+ * Visits and updates all code owner config files in the given project and branch.
+ *
+ * <p>All updates are done in a single commit. If none of the code owner config files is updated,
+ * no new commit is created.
+ *
+ * @param branchNameKey the project and branch for which the code owner config files should be
+ * updated
+ * @param commitMessage commit message for the new commit if an update is performed
+ * @param codeOwnerConfigFileUpdater the callback that is invoked for each code owner config file
+ * @return the commit that renamed the email if any update was performed
+ */
+ public Optional<RevCommit> update(
+ BranchNameKey branchNameKey,
+ String commitMessage,
+ CodeOwnerConfigFileUpdater codeOwnerConfigFileUpdater) {
+ requireNonNull(branchNameKey, "branchNameKey");
+ requireNonNull(commitMessage, "commitMessage");
+ requireNonNull(codeOwnerConfigFileUpdater, "codeOwnerConfigFileUpdater");
+
+ CodeOwnerBackend codeOwnerBackend = codeOwnersPluginConfiguration.getBackend(branchNameKey);
+ logger.atFine().log(
+ "updating code owner files in branch %s of project %s",
+ branchNameKey.branch(), branchNameKey.project());
+
+ try (Repository repository = repoManager.openRepository(branchNameKey.project());
+ RevWalk rw = new RevWalk(repository);
+ ObjectInserter oi = repository.newObjectInserter();
+ CodeOwnerConfigTreeWalk treeWalk =
+ new CodeOwnerConfigTreeWalk(
+ codeOwnerBackend,
+ branchNameKey,
+ repository,
+ rw,
+ /** pathGlob */
+ null)) {
+ RevCommit revision = treeWalk.getRevision();
+ DirCache newTree = DirCache.newInCore();
+ DirCacheEditor editor = newTree.editor();
+
+ boolean dirty = false;
+ while (treeWalk.next()) {
+ Optional<String> updatedContent =
+ codeOwnerConfigFileUpdater.update(treeWalk.getFilePath(), treeWalk.getFileContent());
+ if (updatedContent.isPresent()) {
+ dirty = true;
+
+ // insert blob with new file content
+ ObjectId blobId = oi.insert(Constants.OBJ_BLOB, updatedContent.get().getBytes(UTF_8));
+
+ // append edit command to set the new blob for the code owner config file
+ editor.add(createEditCommand(treeWalk.getPathString(), blobId));
+ }
+ }
+
+ if (!dirty) {
+ return Optional.empty();
+ }
+
+ editor.finish();
+ ObjectId treeId = newTree.writeTree(oi);
+ ObjectId commitId = createCommit(oi, commitMessage, revision, treeId);
+ updateBranch(branchNameKey.branch(), repository, revision, commitId);
+ return Optional.of(rw.parseCommit(commitId));
+ } catch (IOException e) {
+ throw new StorageException(
+ String.format(
+ "Failed to scan for code owner configs in branch %s of project %s",
+ branchNameKey.branch(), branchNameKey.project()),
+ e);
+ }
+ }
+
+ /**
+ * Creates an edit command that sets the given blob for the given path
+ *
+ * @param jgitFilePath path of the file for which the blob should be set, as jgit path (not
+ * starting with '/')
+ * @param blobId the ID of the blob that should be set for the file path
+ * @return the edit command
+ */
+ private PathEdit createEditCommand(String jgitFilePath, ObjectId blobId) {
+ return new PathEdit(jgitFilePath) {
+ @Override
+ public void apply(DirCacheEntry entry) {
+ entry.setFileMode(FileMode.REGULAR_FILE);
+ entry.setObjectId(blobId);
+ }
+ };
+ }
+
+ /**
+ * Creates a new commit.
+ *
+ * @param objectInserter object inserter that should be used to insert the new commit
+ * @param commitMessage the commit message that should be used for the new commit
+ * @param parentCommit the commit that should be set as parent commit of the new commit
+ * @param treeId the tree of the new commit
+ * @return the commit ID
+ */
+ private ObjectId createCommit(
+ ObjectInserter objectInserter, String commitMessage, ObjectId parentCommit, ObjectId treeId)
+ throws IOException {
+ PersonIdent serverIdent = serverIdentProvider.get();
+ CommitBuilder cb = new CommitBuilder();
+ cb.setParentId(parentCommit);
+ cb.setTreeId(treeId);
+ cb.setCommitter(serverIdent);
+ cb.setAuthor(
+ identifiedUser.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
+ cb.setMessage(commitMessage);
+ ObjectId id = objectInserter.insert(cb);
+ objectInserter.flush();
+ return id;
+ }
+
+ /**
+ * Update the given branch.
+ *
+ * @param branchName the name of the branch that should be updated
+ * @param repository the repository in which the branch should be updated
+ * @param oldObjectId the expected old object ID of the branch
+ * @param newObjectId the new object ID that should be set for the branch
+ */
+ private void updateBranch(
+ String branchName, Repository repository, ObjectId oldObjectId, ObjectId newObjectId)
+ throws IOException {
+ RefUpdate ru = repository.updateRef(branchName);
+ ru.setExpectedOldObjectId(oldObjectId);
+ ru.setNewObjectId(newObjectId);
+ ru.update();
+ RefUpdateUtil.checkResult(ru);
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdater.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdater.java
new file mode 100644
index 0000000..1c38737
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdater.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2020 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.plugins.codeowners.backend;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+/** Callback interface to update a code owner config file. */
+public interface CodeOwnerConfigFileUpdater {
+ /**
+ * Callback for a code owner config file.
+ *
+ * @param codeOwnerConfigFilePath absolute path of the code owner config file
+ * @param codeOwnerConfigFileContent the content of the code owner config, can be also the content
+ * of a non-parseable code owner config
+ * @return the updated content of the code owner config file, {@link Optional#empty()} if no
+ * update should be performed
+ */
+ Optional<String> update(Path codeOwnerConfigFilePath, String codeOwnerConfigFileContent);
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
index a872638..f1e8a3b 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigScanner.java
@@ -14,33 +14,23 @@
package com.google.gerrit.plugins.codeowners.backend;
-import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.plugins.codeowners.backend.CodeOwners.getInvalidConfigCause;
import static java.util.Objects.requireNonNull;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.plugins.codeowners.JgitPath;
import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.nio.file.FileSystems;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.treewalk.filter.TreeFilter;
/** Class to scan a branch for code owner config files. */
@Singleton
@@ -120,33 +110,13 @@
try (Repository repository = repoManager.openRepository(branchNameKey.project());
RevWalk rw = new RevWalk(repository);
- TreeWalk treeWalk = new TreeWalk(repository)) {
- Ref ref = repository.exactRef(branchNameKey.branch());
- checkState(
- ref != null,
- "branch %s of project %s not found",
- branchNameKey.branch(),
- branchNameKey.project());
-
- RevCommit revision = rw.parseCommit(ref.getObjectId());
- treeWalk.addTree(revision.getTree());
- treeWalk.setRecursive(true);
- treeWalk.setFilter(
- createCodeOwnerConfigFilter(codeOwnerBackend, branchNameKey.project(), pathGlob));
-
+ CodeOwnerConfigTreeWalk treeWalk =
+ new CodeOwnerConfigTreeWalk(
+ codeOwnerBackend, branchNameKey, repository, rw, pathGlob)) {
while (treeWalk.next()) {
- Path filePath = Paths.get(treeWalk.getPathString());
- Path folderPath =
- filePath.getParent() != null
- ? JgitPath.of(filePath.getParent()).getAsAbsolutePath()
- : Paths.get("/");
- String fileName = filePath.getFileName().toString();
- CodeOwnerConfig.Key codeOwnerConfigKey =
- CodeOwnerConfig.Key.create(branchNameKey, folderPath, fileName);
- Optional<CodeOwnerConfig> codeOwnerConfig;
-
+ CodeOwnerConfig codeOwnerConfig;
try {
- codeOwnerConfig = codeOwnerBackend.getCodeOwnerConfig(codeOwnerConfigKey, revision);
+ codeOwnerConfig = treeWalk.getCodeOwnerConfig();
} catch (StorageException storageException) {
Optional<ConfigInvalidException> configInvalidException =
getInvalidConfigCause(storageException);
@@ -157,14 +127,11 @@
// The code owner config is invalid and cannot be parsed.
invalidCodeOwnerConfigCallback.onInvalidCodeOwnerConfig(
- folderPath.resolve(fileName), configInvalidException.get());
+ treeWalk.getFilePath(), configInvalidException.get());
continue;
}
- checkState(
- codeOwnerConfig.isPresent(), "code owner config %s not found", codeOwnerConfigKey);
- boolean visitFurtherCodeOwnerConfigFiles =
- codeOwnerConfigVisitor.visit(codeOwnerConfig.get());
+ boolean visitFurtherCodeOwnerConfigFiles = codeOwnerConfigVisitor.visit(codeOwnerConfig);
if (!visitFurtherCodeOwnerConfigFiles) {
break;
}
@@ -179,47 +146,6 @@
}
/**
- * Creates a {@link TreeFilter} that matches code owner config files in the given project.
- *
- * @param codeOwnerBackend the code owner backend that is being used
- * @param project the name of the project in which code owner config files should be matched
- * @param pathGlob optional Java NIO glob that the paths of code owner config files must match
- * @return the created {@link TreeFilter}
- */
- private static TreeFilter createCodeOwnerConfigFilter(
- CodeOwnerBackend codeOwnerBackend, Project.NameKey project, @Nullable String pathGlob) {
- return new TreeFilter() {
- @Override
- public boolean shouldBeRecursive() {
- return true;
- }
-
- @Override
- public boolean include(TreeWalk walker) throws IOException {
- if (walker.isSubtree()) {
- walker.enterSubtree();
- return false;
- }
- if (pathGlob != null
- && !FileSystems.getDefault()
- .getPathMatcher("glob:" + pathGlob)
- .matches(JgitPath.of(walker.getPathString()).getAsAbsolutePath())) {
- logger.atFine().log(
- "%s filtered out because it doesn't match the path glob", walker.getPathString());
- return false;
- }
- String fileName = Paths.get(walker.getPathString()).getFileName().toString();
- return codeOwnerBackend.isCodeOwnerConfigFile(project, fileName);
- }
-
- @Override
- public TreeFilter clone() {
- return this;
- }
- };
- }
-
- /**
* Returns an {@link InvalidCodeOwnerConfigCallback} instance that ignores invalid code owner
* config files.
*/
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java
new file mode 100644
index 0000000..1ba8f74
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigTreeWalk.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2020 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.plugins.codeowners.backend;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import java.io.IOException;
+import java.nio.file.FileSystems;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/** {@link TreeWalk} that filters for code owner config files in the tree. */
+public class CodeOwnerConfigTreeWalk extends TreeWalk {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final CodeOwnerBackend codeOwnerBackend;
+ private final BranchNameKey branchNameKey;
+ private final RevCommit revision;
+
+ public CodeOwnerConfigTreeWalk(
+ CodeOwnerBackend codeOwnerBackend,
+ BranchNameKey branchNameKey,
+ Repository repository,
+ RevWalk revWalk,
+ @Nullable String pathGlob)
+ throws IOException {
+ super(repository);
+
+ this.codeOwnerBackend = requireNonNull(codeOwnerBackend, "codeOwnerBackend");
+ this.branchNameKey = requireNonNull(branchNameKey, "branchNameKey");
+ this.revision =
+ getRevision(
+ branchNameKey,
+ requireNonNull(repository, "repository"),
+ requireNonNull(revWalk, "revWalk"));
+
+ addTree(revision.getTree());
+ setRecursive(true);
+ setFilter(createCodeOwnerConfigFilter(codeOwnerBackend, branchNameKey.project(), pathGlob));
+ }
+
+ /**
+ * Returns the revision from which the tree was loaded.
+ *
+ * @return the revision ID
+ */
+ public RevCommit getRevision() {
+ return revision;
+ }
+
+ /** Returns the absolute file path of the current entry. */
+ public Path getFilePath() {
+ return JgitPath.of(getPathString()).getAsAbsolutePath();
+ }
+
+ /** Returns the file content of the current entry. */
+ public String getFileContent() throws IOException {
+ ObjectLoader obj = getObjectReader().open(getObjectId(0), Constants.OBJ_BLOB);
+ byte[] raw = obj.getCachedBytes(Integer.MAX_VALUE);
+ return raw.length != 0 ? RawParseUtils.decode(raw) : "";
+ }
+
+ /** Returns the code owner config key of the current entry. */
+ public CodeOwnerConfig.Key getCodeOwnerConfigKey() {
+ Path filePath = getFilePath();
+ Path folderPath =
+ filePath.getParent() != null
+ ? JgitPath.of(filePath.getParent()).getAsAbsolutePath()
+ : Paths.get("/");
+ String fileName = Paths.get(getPathString()).getFileName().toString();
+ return CodeOwnerConfig.Key.create(branchNameKey, folderPath, fileName);
+ }
+
+ /**
+ * Loads the code owner config file at the current entry's path.
+ *
+ * @return the loaded code owner config
+ */
+ public CodeOwnerConfig getCodeOwnerConfig() {
+ CodeOwnerConfig.Key codeOwnerConfigKey = getCodeOwnerConfigKey();
+ return codeOwnerBackend
+ .getCodeOwnerConfig(codeOwnerConfigKey, revision)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format("code owner config %s not found", codeOwnerConfigKey)));
+ }
+
+ /**
+ * Looks up the current revision of the branch.
+ *
+ * @param branchNameKey the project and branch for which the current revision should be loaded
+ * @param repository the repository from which the branch revision should be loaded
+ * @return the current revision of the branch
+ */
+ private static RevCommit getRevision(
+ BranchNameKey branchNameKey, Repository repository, RevWalk revWalk) throws IOException {
+ Ref ref = repository.exactRef(branchNameKey.branch());
+ checkState(
+ ref != null,
+ "branch %s of project %s not found",
+ branchNameKey.branch(),
+ branchNameKey.project());
+
+ return revWalk.parseCommit(ref.getObjectId());
+ }
+
+ /**
+ * Creates a {@link TreeFilter} that matches code owner config files in the given project.
+ *
+ * @param codeOwnerBackend the code owner backend that is being used
+ * @param project the name of the project in which code owner config files should be matched
+ * @param pathGlob optional Java NIO glob that the paths of code owner config files must match
+ * @return the created {@link TreeFilter}
+ */
+ private static TreeFilter createCodeOwnerConfigFilter(
+ CodeOwnerBackend codeOwnerBackend, Project.NameKey project, @Nullable String pathGlob) {
+ return new TreeFilter() {
+ @Override
+ public boolean shouldBeRecursive() {
+ return true;
+ }
+
+ @Override
+ public boolean include(TreeWalk walker) throws IOException {
+ if (walker.isSubtree()) {
+ walker.enterSubtree();
+ return false;
+ }
+ if (pathGlob != null
+ && !FileSystems.getDefault()
+ .getPathMatcher("glob:" + pathGlob)
+ .matches(JgitPath.of(walker.getPathString()).getAsAbsolutePath())) {
+ logger.atFine().log(
+ "%s filtered out because it doesn't match the path glob", walker.getPathString());
+ return false;
+ }
+ String fileName = Paths.get(walker.getPathString()).getFileName().toString();
+ return codeOwnerBackend.isCodeOwnerConfigFile(project, fileName);
+ }
+
+ @Override
+ public TreeFilter clone() {
+ return this;
+ }
+ };
+ }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
new file mode 100644
index 0000000..b00b8b1
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerConfigFileUpdateScannerTest.java
@@ -0,0 +1,287 @@
+// Copyright (C) 2020 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.plugins.codeowners.backend;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.plugins.codeowners.JgitPath;
+import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest;
+import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.quality.Strictness;
+
+/** Tests for {@link CodeOwnerConfigFileUpdateScanner}. */
+public class CodeOwnerConfigFileUpdateScannerTest extends AbstractCodeOwnersTest {
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
+
+ @Mock private CodeOwnerConfigFileUpdater updater;
+
+ @Inject private ProjectOperations projectOperations;
+
+ private CodeOwnerConfigOperations codeOwnerConfigOperations;
+ private CodeOwnerConfigFileUpdateScanner codeOwnerConfigFileUpdateScanner;
+
+ @Before
+ public void setUpCodeOwnersPlugin() throws Exception {
+ codeOwnerConfigOperations =
+ plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+ codeOwnerConfigFileUpdateScanner =
+ plugin.getSysInjector().getInstance(CodeOwnerConfigFileUpdateScanner.class);
+ }
+
+ @Test
+ public void cannotUpdateCodeOwnerConfigsForNullBranch() throws Exception {
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class,
+ () ->
+ codeOwnerConfigFileUpdateScanner.update(
+ null,
+ "Update code owner configs",
+ (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> Optional.empty()));
+ assertThat(npe).hasMessageThat().isEqualTo("branchNameKey");
+ }
+
+ @Test
+ public void cannotUpdateCodeOwnerConfigsWithNullCommitMessage() throws Exception {
+ BranchNameKey branchNameKey = BranchNameKey.create(project, "master");
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class,
+ () ->
+ codeOwnerConfigFileUpdateScanner.update(
+ branchNameKey,
+ null,
+ (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> Optional.empty()));
+ assertThat(npe).hasMessageThat().isEqualTo("commitMessage");
+ }
+
+ @Test
+ public void cannotUpdateCodeOwnerConfigsWithNullUpdater() throws Exception {
+ BranchNameKey branchNameKey = BranchNameKey.create(project, "master");
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class,
+ () ->
+ codeOwnerConfigFileUpdateScanner.update(
+ branchNameKey, "Update code owner configs", null));
+ assertThat(npe).hasMessageThat().isEqualTo("codeOwnerConfigFileUpdater");
+ }
+
+ @Test
+ public void cannotUpdateCodeOwnerConfigsForNonExistingBranch() throws Exception {
+ BranchNameKey branchNameKey = BranchNameKey.create(project, "non-existing");
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ codeOwnerConfigFileUpdateScanner.update(
+ branchNameKey,
+ "Update code owner configs",
+ (codeOwnerConfigFilePath, codeOwnerConfigFileContent) -> Optional.empty()));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "branch %s of project %s not found", branchNameKey.branch(), project.get()));
+ }
+
+ @Test
+ public void noUpdateIfNoCodeOwnerConfigFilesExists() throws Exception {
+ Optional<RevCommit> commit =
+ codeOwnerConfigFileUpdateScanner.update(
+ BranchNameKey.create(project, "master"), "Update code owner configs", updater);
+ assertThat(commit).isEmpty();
+ verifyZeroInteractions(updater);
+ }
+
+ @Test
+ public void noUpdateForNonCodeOwnerConfigFiles() throws Exception {
+ // Create some non code owner config files.
+ try (TestRepository<Repository> testRepo =
+ new TestRepository<>(repoManager.openRepository(project))) {
+ Ref ref = testRepo.getRepository().exactRef("refs/heads/master");
+ RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+ testRepo.update(
+ "refs/heads/master",
+ testRepo
+ .commit()
+ .parent(head)
+ .message("Add some non code owner config files")
+ .add("owners.txt", "some content")
+ .add("owners", "some content")
+ .add("foo/bar/owners.txt", "some content")
+ .add("foo/bar/owners", "some content"));
+ }
+
+ Optional<RevCommit> commit =
+ codeOwnerConfigFileUpdateScanner.update(
+ BranchNameKey.create(project, "master"), "Update code owner configs", updater);
+ assertThat(commit).isEmpty();
+ verifyZeroInteractions(updater);
+ }
+
+ @Test
+ public void noUpdateIfCallbackDoesntReturnNewFileContent() throws Exception {
+ CodeOwnerConfig.Key codeOwnerConfigKey =
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/")
+ .fileName("OWNERS")
+ .addCodeOwnerEmail(admin.email())
+ .create();
+ Path path =
+ Paths.get(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getFilePath());
+ String content = codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent();
+
+ RevCommit oldHead = projectOperations.project(project).getHead("master");
+
+ when(updater.update(path, content)).thenReturn(Optional.empty());
+ Optional<RevCommit> commit =
+ codeOwnerConfigFileUpdateScanner.update(
+ BranchNameKey.create(project, "master"), "Update code owner configs", updater);
+ assertThat(commit).isEmpty();
+
+ // Verify the code owner config file was not updated.
+ assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent())
+ .isEqualTo(content);
+
+ // Check that no commit was created.
+ RevCommit newHead = projectOperations.project(project).getHead("master");
+ assertThat(newHead).isEqualTo(oldHead);
+ }
+
+ @Test
+ public void updateCodeOwnerConfigFiles() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ CodeOwnerConfig.Key codeOwnerConfigKey1 =
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/")
+ .fileName("OWNERS")
+ .addCodeOwnerEmail(admin.email())
+ .create();
+ Path path1 =
+ Paths.get(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getFilePath());
+ String oldContent1 =
+ codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getContent();
+ String newContent1 = user.email() + "\n";
+ when(updater.update(path1, oldContent1)).thenReturn(Optional.of(newContent1));
+
+ CodeOwnerConfig.Key codeOwnerConfigKey2 =
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .fileName("OWNERS")
+ .addCodeOwnerEmail(user.email())
+ .create();
+ Path path2 =
+ Paths.get(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getFilePath());
+ String oldContent2 =
+ codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getContent();
+ String newContent2 = user2.email() + "\n";
+ when(updater.update(path2, oldContent2)).thenReturn(Optional.of(newContent2));
+
+ RevCommit oldHead = projectOperations.project(project).getHead("master");
+
+ String commitMessage = "Update code owner configs";
+ Optional<RevCommit> commit =
+ codeOwnerConfigFileUpdateScanner.update(
+ BranchNameKey.create(project, "master"), commitMessage, updater);
+ assertThat(commit).isPresent();
+
+ // Verify that we received the expected callbacks for the invalid code onwer config.
+ Mockito.verify(updater).update(path1, oldContent1);
+ Mockito.verify(updater).update(path2, oldContent2);
+ verifyNoMoreInteractions(updater);
+
+ // Verify the code owner config files were updated.
+ assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey1).getContent())
+ .isEqualTo(newContent1);
+ assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey2).getContent())
+ .isEqualTo(newContent2);
+
+ // Check that exactly 1 commit was created.
+ RevCommit newHead = projectOperations.project(project).getHead("master");
+ assertThat(commit.get()).isEqualTo(newHead);
+ assertThat(newHead).isNotEqualTo(oldHead);
+ assertThat(newHead.getShortMessage()).isEqualTo(commitMessage);
+ assertThat(newHead.getParent(0)).isEqualTo(oldHead);
+ }
+
+ @Test
+ public void updateInvalidCodeOwnerConfigFile() throws Exception {
+ CodeOwnerConfig.Key codeOwnerConfigKey = createInvalidCodeOwnerConfig("/OWNERS", "INVALID");
+
+ when(updater.update(any(Path.class), any(String.class)))
+ .thenReturn(Optional.of("STILL INVALID"));
+ Optional<RevCommit> update =
+ codeOwnerConfigFileUpdateScanner.update(
+ BranchNameKey.create(project, "master"), "Update code owner configs", updater);
+ assertThat(update).isPresent();
+
+ // Verify that we received the expected callbacks for the invalid code onwer config.
+ Mockito.verify(updater).update(Paths.get("/OWNERS"), "INVALID");
+ verifyNoMoreInteractions(updater);
+
+ // Verify the code owner config file was updated.
+ assertThat(codeOwnerConfigOperations.codeOwnerConfig(codeOwnerConfigKey).getContent())
+ .isEqualTo("STILL INVALID");
+ }
+
+ private CodeOwnerConfig.Key createInvalidCodeOwnerConfig(String filePath, String content)
+ throws Exception {
+ disableCodeOwnersForProject(project);
+ String changeId =
+ createChange("Add invalid code owners file", JgitPath.of(filePath).get(), content)
+ .getChangeId();
+ approve(changeId);
+ gApi.changes().id(changeId).current().submit();
+ enableCodeOwnersForProject(project);
+ Path path = Paths.get(filePath);
+ return CodeOwnerConfig.Key.create(
+ project, "master", path.getParent().toString(), path.getFileName().toString());
+ }
+}