Add class to compute the code owner statuses for the files in a change
We need the code owner statuses for the files in a change for 2
purposes:
1. to implement a REST endpoint that returns them to the UI so that they
can be shown to the user
2. to check whether a change is submittable
In order for a change to be submittable every file, or rather touched
path, in a change needs to be approved by a code owner:
* New file:
requires approval on the new path
* Modified file:
requires approval on the path (old path == new path)
* Deleted file:
requires approval on the old path
* Renamed file:
requires approval on the old and new path (equivalent to delete old
path + add new path)
* Copied file:
requires approval on the new path (an approval on the old path is not
needed since the file at this path was not touched)
For every touched path the following code owner statuses are possible:
* INSUFFICIENT_REVIEWERS:
The path needs an owner approval, but none of its code owners is
currently a reviewer of the change.
* PENDING:
A code owner of this path has been added as reviewer, but no code
owner approval for this path has been given yet.
* APPROVED:
The path has been approved by a code owner or a code owners override
is present (code owner overrides will be implemented only later).
The new CodeOwnerApprovalCheck computes the code owner statuses for all
files/paths that were changed in the current revision of a change. For
this computation it iterates over the touched paths to look up the code
owners. The code owners are then matched against the reviewers/approvers
on the change.
Computing the code owner statuses for non-current revisions is not
supported since the semantics are unclear, e.g.:
* non-current revisions are never submittable, hence asking which code
owner approvals are still missing in order to make the revision
submittable makes no sense
* the code owner status PENDING doesn't make sense for an old
revision, from the perspective of the change owner this status looks
like the change is waiting for the code owner to approve, but since
voting on old revisions is not possible the code-owner is not able to
provide this approval
* the code owner statuses are computed from the approvals that were
given by code owners, the UI always shows the current approval even
when looking at an old revision, showing code owner statuses that
mismatch the shown approvals (because they are computed from approvals
that were present on an old revision) would only confuse users
In order for a path to be approved we require an explicit vote on the
required label. Later we will also consider an implicit approval from
the patch set uploader, if the patch set uploader is a code owner (but
this will only be implemented in a follow-up change, for now we have a
TODO for this in CodeOwnerApprovalCheck).
Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: I95ab49f4cd04633046c9072c4083b797ba6b59ee
diff --git a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
index bfbec88..645eaff 100644
--- a/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
+++ b/java/com/google/gerrit/plugins/codeowners/acceptance/AbstractCodeOwnersTest.java
@@ -21,6 +21,7 @@
import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.plugins.codeowners.JgitPath;
+import java.nio.file.Path;
/**
* Base class for code owner integration tests.
@@ -35,6 +36,10 @@
name = "code-owners",
sysModule = "com.google.gerrit.plugins.codeowners.acceptance.TestModule")
public class AbstractCodeOwnersTest extends LightweightPluginDaemonTest {
+ protected String createChangeWithFileDeletion(Path filePath) throws Exception {
+ return createChangeWithFileDeletion(filePath.toString());
+ }
+
protected String createChangeWithFileDeletion(String filePath) throws Exception {
createChange("Change Adding A File", JgitPath.of(filePath).get(), "file content").getChangeId();
@@ -50,6 +55,10 @@
return r.getChangeId();
}
+ protected String createChangeWithFileRename(Path oldFilePath, Path newFilePath) throws Exception {
+ return createChangeWithFileRename(oldFilePath.toString(), newFilePath.toString());
+ }
+
protected String createChangeWithFileRename(String oldFilePath, String newFilePath)
throws Exception {
String changeId1 =
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java
new file mode 100644
index 0000000..1873b23
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerStatus.java
@@ -0,0 +1,33 @@
+// 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.api;
+
+/** Code owner status for a path in a change. */
+public enum CodeOwnerStatus {
+ /**
+ * The path needs a code owner approval, but none of its code owners is currently a reviewer of
+ * the change.
+ */
+ INSUFFICIENT_REVIEWERS,
+
+ /**
+ * A code owner of this path has been added as reviewer, but no code owner approval for this path
+ * has been given yet.
+ */
+ PENDING,
+
+ /** The path has been approved by a code owner or a code owners override is present. */
+ APPROVED;
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
new file mode 100644
index 0000000..a317efa
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -0,0 +1,269 @@
+// 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.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.config.RequiredApproval;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to check code owner approvals on a change.
+ *
+ * <p>Every file, or rather touched path, in a change needs to be approved by a code owner:
+ *
+ * <ul>
+ * <li>New file: requires approval on the new path
+ * <li>Modified file: requires approval on the old/new path (old path == new path)
+ * <li>Deleted file: requires approval on the old path
+ * <li>Renamed file: requires approval on the old and new path (equivalent to delete old path +
+ * add new path)
+ * <li>Copied file: requires approval on the new path (an approval on the old path is not needed
+ * since the file at this path was not touched)
+ * </ul>
+ */
+@Singleton
+public class CodeOwnerApprovalCheck {
+ private final GitRepositoryManager repoManager;
+ private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration;
+ private final ChangedFiles changedFiles;
+ private final CodeOwnerConfigHierarchy codeOwnerConfigHierarchy;
+ private final Provider<CodeOwnerResolver> codeOwnerResolver;
+
+ @Inject
+ CodeOwnerApprovalCheck(
+ GitRepositoryManager repoManager,
+ CodeOwnersPluginConfiguration codeOwnersPluginConfiguration,
+ ChangedFiles changedFiles,
+ CodeOwnerConfigHierarchy codeOwnerConfigHierarchy,
+ Provider<CodeOwnerResolver> codeOwnerResolver) {
+ this.repoManager = repoManager;
+ this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration;
+ this.changedFiles = changedFiles;
+ this.codeOwnerConfigHierarchy = codeOwnerConfigHierarchy;
+ this.codeOwnerResolver = codeOwnerResolver;
+ }
+
+ /**
+ * Gets the code owner statuses for all files/paths that were changed in the current revision of
+ * the given change.
+ *
+ * <p>The code owner statuses tell the user which code owner approvals are still missing in order
+ * to make the change submittable.
+ *
+ * <p>Computing the code owner statuses for non-current revisions is not supported since the
+ * semantics are unclear, e.g.:
+ *
+ * <ul>
+ * <li>non-current revisions are never submittable, hence asking which code owner approvals are
+ * still missing in order to make the revision submittable makes no sense
+ * <li>the code owner status {@link CodeOwnerStatus#PENDING} doesn't make sense for an old
+ * revision, from the perspective of the change owner this status looks like the change is
+ * waiting for the code owner to approve, but since voting on old revisions is not possible
+ * the code-owner is not able to provide this approval
+ * <li>the code owner statuses are computed from the approvals that were given by code owners,
+ * the UI always shows the current approval even when looking at an old revision, showing
+ * code owner statuses that mismatch the shown approvals (because they are computed from
+ * approvals that were present on an old revision) would only confuse users
+ * </ul>
+ *
+ * @param changeNotes the notes of the change for which the current code owner statuses should be
+ * returned
+ */
+ public ImmutableSet<FileCodeOwnerStatus> getFileStatuses(ChangeNotes changeNotes)
+ throws IOException {
+ requireNonNull(changeNotes, "changeNotes");
+
+ RequiredApproval requiredApproval =
+ codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getChange().getDest());
+
+ BranchNameKey branch = changeNotes.getChange().getDest();
+ ObjectId revision = getDestBranchRevision(changeNotes.getChange());
+
+ ImmutableSet<Account.Id> reviewerAccountIds = getReviewerAccountIds(changeNotes);
+ ImmutableSet<Account.Id> approverAccountIds =
+ getApproverAccountIds(requiredApproval, changeNotes);
+
+ ImmutableSet.Builder<FileCodeOwnerStatus> fileCodeOwnerStatusBuilder = ImmutableSet.builder();
+
+ // Iterate over all files that have been changed in the revision.
+ for (ChangedFile changedFile :
+ changedFiles.compute(
+ changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId())) {
+ // Compute the code owner status for the new path, if there is a new path.
+ Optional<PathCodeOwnerStatus> newPathStatus = Optional.empty();
+ if (changedFile.newPath().isPresent()) {
+ newPathStatus =
+ Optional.of(
+ getCodeOwnerStatus(
+ branch,
+ revision,
+ reviewerAccountIds,
+ approverAccountIds,
+ changedFile.newPath().get()));
+ }
+
+ // Compute the code owner status for the old path, if the file was deleted or renamed.
+ Optional<PathCodeOwnerStatus> oldPathStatus = Optional.empty();
+ if (changedFile.isDeletion() || changedFile.isRename()) {
+ checkState(
+ changedFile.oldPath().isPresent(), "old path must be present for deletion/rename");
+ oldPathStatus =
+ Optional.of(
+ getCodeOwnerStatus(
+ branch,
+ revision,
+ reviewerAccountIds,
+ approverAccountIds,
+ changedFile.oldPath().get()));
+ }
+
+ fileCodeOwnerStatusBuilder.add(
+ FileCodeOwnerStatus.create(changedFile, newPathStatus, oldPathStatus));
+ }
+
+ return fileCodeOwnerStatusBuilder.build();
+ }
+
+ private PathCodeOwnerStatus getCodeOwnerStatus(
+ BranchNameKey branch,
+ ObjectId revision,
+ ImmutableSet<Account.Id> reviewerAccountIds,
+ ImmutableSet<Account.Id> approverAccountIds,
+ Path absolutePath) {
+ // TODO(ekempin): Check if the path is owned by the uploader of the patch set since paths that
+ // are owned by the patch set uploader should be considered as approved.
+ if (reviewerAccountIds.isEmpty()) {
+ // Short-cut, if there are no reviewers, any path has the INSUFFICIENT_REVIEWERS code owner
+ // status.
+ return PathCodeOwnerStatus.create(absolutePath, CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ }
+
+ AtomicReference<CodeOwnerStatus> codeOwnerStatus =
+ new AtomicReference<>(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ codeOwnerConfigHierarchy.visit(
+ branch,
+ revision,
+ absolutePath,
+ codeOwnerConfig -> {
+ ImmutableSet<Account.Id> codeOwnerAccountIds =
+ getCodeOwnerAccountIds(codeOwnerConfig, absolutePath);
+
+ if (Collections.disjoint(codeOwnerAccountIds, reviewerAccountIds)) {
+ // We need to continue to check if any of the higher-level code owners is a reviewer.
+ return true;
+ }
+
+ if (Collections.disjoint(codeOwnerAccountIds, approverAccountIds)) {
+ // At least one of the code owners is a reviewer on the change.
+ codeOwnerStatus.set(CodeOwnerStatus.PENDING);
+
+ // We need to continue to check if any of the higher-level code owners has approved the
+ // change.
+ return true;
+ }
+
+ // At least one of the code owners approved the change.
+ codeOwnerStatus.set(CodeOwnerStatus.APPROVED);
+
+ // We can abort since we already found that the path was approved.
+ return false;
+ });
+
+ return PathCodeOwnerStatus.create(absolutePath, codeOwnerStatus.get());
+ }
+
+ /**
+ * Gets the IDs of the accounts that own the given path according to the given code owner config.
+ *
+ * @param codeOwnerConfig the code owner config from which the code owners should be retrieved
+ * @param absolutePath the path for which the code owners should be retrieved
+ */
+ private ImmutableSet<Account.Id> getCodeOwnerAccountIds(
+ CodeOwnerConfig codeOwnerConfig, Path absolutePath) {
+ return codeOwnerResolver.get().enforceVisibility(false)
+ .resolveLocalCodeOwners(codeOwnerConfig, absolutePath).stream()
+ .map(CodeOwner::accountId)
+ .collect(toImmutableSet());
+ }
+
+ /**
+ * Gets the IDs of the accounts that are reviewer on the given change.
+ *
+ * @param changeNotes the change notes
+ */
+ private ImmutableSet<Account.Id> getReviewerAccountIds(ChangeNotes changeNotes) {
+ return changeNotes.getReviewers().byState(ReviewerStateInternal.REVIEWER);
+ }
+
+ /**
+ * Gets the IDs of the accounts that posted a patch set approval on the given revisions that
+ * counts as code owner approval.
+ *
+ * @param requiredApproval approval that is required from code owners to approve the files in a
+ * change
+ * @param changeNotes the change notes
+ */
+ private ImmutableSet<Account.Id> getApproverAccountIds(
+ RequiredApproval requiredApproval, ChangeNotes changeNotes) {
+ return changeNotes.getApprovals().get(changeNotes.getCurrentPatchSet().id()).stream()
+ .filter(requiredApproval::isCodeOwnerApproval)
+ .map(PatchSetApproval::accountId)
+ .collect(toImmutableSet());
+ }
+
+ /**
+ * Gets the current revision of the destination branch of the given change.
+ *
+ * <p>This is the revision from which the code owner configs should be read when computing code
+ * owners for the files that are touched in the change.
+ */
+ private ObjectId getDestBranchRevision(Change change) throws IOException {
+ try (Repository repository = repoManager.openRepository(change.getProject());
+ RevWalk rw = new RevWalk(repository)) {
+ Ref ref = repository.exactRef(change.getDest().branch());
+ checkNotNull(
+ ref,
+ "branch %s in repository %s not found",
+ change.getDest().branch(),
+ change.getProject().get());
+ return rw.parseCommit(ref.getObjectId());
+ }
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
new file mode 100644
index 0000000..3d084db
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/FileCodeOwnerStatus.java
@@ -0,0 +1,58 @@
+// 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 com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/** Code owner status for a particular file that was changed in a change. */
+@AutoValue
+public abstract class FileCodeOwnerStatus {
+ /** The changed file to which the code owner statuses belong. */
+ public abstract ChangedFile changedFile();
+
+ /**
+ * The code owner status for the new path.
+ *
+ * <p>Not set if the file was deleted.
+ */
+ public abstract Optional<PathCodeOwnerStatus> newPathStatus();
+
+ /**
+ * The code owner status for the old path.
+ *
+ * <p>Only set if the file was deleted or renamed.
+ *
+ * <p>{@link #changedFile()} also has an old path if the file was copied, but in case of copy the
+ * old path didn't change and hence we do not need any code owner approval for it.
+ */
+ public abstract Optional<PathCodeOwnerStatus> oldPathStatus();
+
+ /**
+ * Creates a {@link FileCodeOwnerStatus} instance.
+ *
+ * @param changedFile the changed file to which the code owner statuses belong
+ * @param newPathCodeOwnerStatus the code owner status of the new path
+ * @param oldPathCodeOwnerStatus the code owner status of the old path
+ * @return the created {@link FileCodeOwnerStatus} instance
+ */
+ public static FileCodeOwnerStatus create(
+ ChangedFile changedFile,
+ Optional<PathCodeOwnerStatus> newPathCodeOwnerStatus,
+ Optional<PathCodeOwnerStatus> oldPathCodeOwnerStatus) {
+ return new AutoValue_FileCodeOwnerStatus(
+ changedFile, newPathCodeOwnerStatus, oldPathCodeOwnerStatus);
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
new file mode 100644
index 0000000..9285a52
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnerStatus.java
@@ -0,0 +1,44 @@
+// 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 com.google.auto.value.AutoValue;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import java.nio.file.Path;
+
+/** Code owner status for a particular path that has been modified in a change. */
+@AutoValue
+public abstract class PathCodeOwnerStatus {
+ /**
+ * Path to which the {@link #status()} belongs.
+ *
+ * <p>Always an absolute path.
+ */
+ public abstract Path path();
+
+ /** The code owner status of the {@link #path()}. */
+ public abstract CodeOwnerStatus status();
+
+ /**
+ * Creates a {@link PathCodeOwnerStatus} instance.
+ *
+ * @param path the path to which the code owner status belongs
+ * @param codeOwnerStatus the code owner status
+ * @return the created {@link PathCodeOwnerStatus} instance
+ */
+ public static PathCodeOwnerStatus create(Path path, CodeOwnerStatus codeOwnerStatus) {
+ return new AutoValue_PathCodeOwnerStatus(path, codeOwnerStatus);
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/BUILD b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
index 8293637..8baf177 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/BUILD
+++ b/java/com/google/gerrit/plugins/codeowners/testing/BUILD
@@ -13,6 +13,7 @@
"//java/com/google/gerrit/truth",
"//lib:guava",
"//lib/truth",
+ "//lib/truth:truth-java8-extension",
"//plugins/code-owners:code-owners__plugin",
],
)
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
index ce4e586..b83bde2 100644
--- a/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
+++ b/java/com/google/gerrit/plugins/codeowners/testing/ChangedFileSubject.java
@@ -35,7 +35,7 @@
}
/** Creates subject factory for mapping {@link ChangedFile}s to {@link ChangedFileSubject}s. */
- private static Subject.Factory<ChangedFileSubject, ChangedFile> changedFiles() {
+ public static Subject.Factory<ChangedFileSubject, ChangedFile> changedFiles() {
return ChangedFileSubject::new;
}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java
new file mode 100644
index 0000000..6f2453f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/FileCodeOwnerStatusSubject.java
@@ -0,0 +1,72 @@
+// 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.testing;
+
+import static com.google.gerrit.plugins.codeowners.testing.ChangedFileSubject.changedFiles;
+import static com.google.gerrit.plugins.codeowners.testing.PathCodeOwnerStatusSubject.pathCodeOwnerStatuses;
+import static com.google.gerrit.truth.OptionalSubject.optionals;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.backend.FileCodeOwnerStatus;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.OptionalSubject;
+import java.util.Set;
+
+/** {@link Subject} for doing assertions on {@link FileCodeOwnerStatus}es. */
+public class FileCodeOwnerStatusSubject extends Subject {
+ public static ListSubject<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> assertThatSet(
+ Set<FileCodeOwnerStatus> fileCodeOwnerStatuses) {
+ return ListSubject.assertThat(
+ ImmutableList.copyOf(fileCodeOwnerStatuses), fileCodeOwnerStatuses());
+ }
+
+ private static Factory<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> fileCodeOwnerStatuses() {
+ return FileCodeOwnerStatusSubject::new;
+ }
+
+ private final FileCodeOwnerStatus fileCodeOwnerStatus;
+
+ private FileCodeOwnerStatusSubject(
+ FailureMetadata metadata, FileCodeOwnerStatus fileCodeOwnerStatus) {
+ super(metadata, fileCodeOwnerStatus);
+ this.fileCodeOwnerStatus = fileCodeOwnerStatus;
+ }
+
+ /** Returns a subject for the changed file. */
+ public ChangedFileSubject hasChangedFile() {
+ return check("changedFile()").about(changedFiles()).that(fileCodeOwnerStatus().changedFile());
+ }
+
+ /** Returns an {@link OptionalSubject} for the code owners status of the new path. */
+ public OptionalSubject<PathCodeOwnerStatusSubject, ?> hasNewPathStatus() {
+ return check("newPathStatus()")
+ .about(optionals())
+ .thatCustom(fileCodeOwnerStatus().newPathStatus(), pathCodeOwnerStatuses());
+ }
+
+ /** Returns an {@link OptionalSubject} for the code owners status of the old path. */
+ public OptionalSubject<PathCodeOwnerStatusSubject, ?> hasOldPathStatus() {
+ return check("oldPathStatus()")
+ .about(optionals())
+ .thatCustom(fileCodeOwnerStatus().oldPathStatus(), pathCodeOwnerStatuses());
+ }
+
+ private FileCodeOwnerStatus fileCodeOwnerStatus() {
+ isNotNull();
+ return fileCodeOwnerStatus;
+ }
+}
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java
new file mode 100644
index 0000000..47bea0e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/codeowners/testing/PathCodeOwnerStatusSubject.java
@@ -0,0 +1,70 @@
+// 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.testing;
+
+import static com.google.common.truth.PathSubject.paths;
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.PathSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.backend.PathCodeOwnerStatus;
+
+/** {@link Subject} for doing assertions on {@link PathCodeOwnerStatus}s. */
+public class PathCodeOwnerStatusSubject extends Subject {
+ /**
+ * Starts fluent chain to do assertions on a {@link PathCodeOwnerStatus}.
+ *
+ * @param pathCodeOwnerStatus the {@link PathCodeOwnerStatus} on which assertions should be done
+ * @return the created {@link PathCodeOwnerStatusSubject}
+ */
+ public static PathCodeOwnerStatusSubject assertThat(PathCodeOwnerStatus pathCodeOwnerStatus) {
+ return assertAbout(pathCodeOwnerStatuses()).that(pathCodeOwnerStatus);
+ }
+
+ /**
+ * Creates subject factory for mapping {@link PathCodeOwnerStatus}es to {@link
+ * PathCodeOwnerStatusSubject}s.
+ */
+ public static Subject.Factory<PathCodeOwnerStatusSubject, PathCodeOwnerStatus>
+ pathCodeOwnerStatuses() {
+ return PathCodeOwnerStatusSubject::new;
+ }
+
+ private final PathCodeOwnerStatus pathCodeOwnerStatus;
+
+ private PathCodeOwnerStatusSubject(
+ FailureMetadata metadata, PathCodeOwnerStatus pathCodeOwnerStatus) {
+ super(metadata, pathCodeOwnerStatus);
+ this.pathCodeOwnerStatus = pathCodeOwnerStatus;
+ }
+
+ /** Returns a {@link ComparableSubject} for the path. */
+ public PathSubject hasPathThat() {
+ return check("path()").about(paths()).that(pathCodeOwnerStatus().path());
+ }
+
+ /** Returns a {@link ComparableSubject} for the code owner status. */
+ public ComparableSubject<CodeOwnerStatus> hasStatusThat() {
+ return check("status()").that(pathCodeOwnerStatus().status());
+ }
+
+ private PathCodeOwnerStatus pathCodeOwnerStatus() {
+ isNotNull();
+ return pathCodeOwnerStatus;
+ }
+}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
new file mode 100644
index 0000000..ec84d84
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -0,0 +1,622 @@
+// 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.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatSet;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+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.gerrit.plugins.codeowners.api.CodeOwnerStatus;
+import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CodeOwnerApprovalCheck}. */
+public class CodeOwnerApprovalCheckTest extends AbstractCodeOwnersTest {
+ @Inject private ChangeNotes.Factory changeNotesFactory;
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ private CodeOwnerApprovalCheck codeOwnerApprovalCheck;
+ private CodeOwnerConfigOperations codeOwnerConfigOperations;
+
+ @Before
+ public void setUpCodeOwnersPlugin() throws Exception {
+ codeOwnerApprovalCheck = plugin.getSysInjector().getInstance(CodeOwnerApprovalCheck.class);
+ codeOwnerConfigOperations =
+ plugin.getSysInjector().getInstance(CodeOwnerConfigOperations.class);
+ }
+
+ @Test
+ public void cannotGetStatusesForNullChangeNotes() throws Exception {
+ NullPointerException npe =
+ assertThrows(
+ NullPointerException.class, () -> codeOwnerApprovalCheck.getFileStatuses(null));
+ assertThat(npe).hasMessageThat().isEqualTo("changeNotes");
+ }
+
+ @Test
+ public void getStatusForFileAddition_insufficientReviewers() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // Add a reviewer that is not a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileModification_insufficientReviewers() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ Path path = Paths.get("/foo/bar.baz");
+ createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
+ String changeId =
+ createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
+ .getChangeId();
+
+ // Add a reviewer that is not a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileDeletion_insufficientReviewers() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId = createChangeWithFileDeletion(path);
+
+ // Add a reviewer that is not a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+ }
+
+ @Test
+ public void getStatusForFileRename_insufficientReviewers() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ Path oldPath = Paths.get("/foo/old.bar");
+ Path newPath = Paths.get("/foo/new.bar");
+ String changeId = createChangeWithFileRename(oldPath, newPath);
+
+ // Add a reviewer that is not a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasChangedFile().isRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileAddition_pending() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // Add a reviewer that is a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.PENDING);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileModification_pending() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
+ String changeId =
+ createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
+ .getChangeId();
+
+ // Add a reviewer that is a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.PENDING);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileDeletion_pending() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId = createChangeWithFileDeletion(path);
+
+ // Add a reviewer that is a code owner.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.PENDING);
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+ }
+
+ @Test
+ public void getStatusForFileRename_pendingOldPath() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/bar/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path oldPath = Paths.get("/foo/bar/abc.txt");
+ Path newPath = Paths.get("/foo/baz/abc.txt");
+ String changeId = createChangeWithFileRename(oldPath, newPath);
+
+ // Add a reviewer that is a code owner old path.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.PENDING);
+ fileCodeOwnerStatusSubject.hasChangedFile().isRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileRename_pendingNewPath() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/baz/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path oldPath = Paths.get("/foo/bar/abc.txt");
+ Path newPath = Paths.get("/foo/baz/abc.txt");
+ String changeId = createChangeWithFileRename(oldPath, newPath);
+
+ // Add a reviewer that is a code owner of the new path.
+ gApi.changes().id(changeId).addReviewer(user.email());
+
+ // Add a Code-Review+1 (= code owner approval) from a user that is not a code owner.
+ requestScopeOperations.setApiUser(user2.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.PENDING);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasChangedFile().isRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileAddition_approved() throws Exception {
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileModification_approved() throws Exception {
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ createChange("Test Change", JgitPath.of(path).get(), "file content").getChangeId();
+ String changeId =
+ createChange("Change Modifying A File", JgitPath.of(path).get(), "new file content")
+ .getChangeId();
+
+ // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileDeletion_approved() throws Exception {
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar.baz");
+ String changeId = createChangeWithFileDeletion(path);
+
+ // Add a Code-Review+1 from a code owner (by default this counts as code owner approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().isEmpty();
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isDeletion();
+ }
+
+ @Test
+ public void getStatusForFileRename_approvedOldPath() throws Exception {
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/bar/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path oldPath = Paths.get("/foo/bar/abc.txt");
+ Path newPath = Paths.get("/foo/baz/abc.txt");
+ String changeId = createChangeWithFileRename(oldPath, newPath);
+
+ // Add a Code-Review+1 from a code owner of the old path (by default this counts as code owner
+ // approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ fileCodeOwnerStatusSubject.hasChangedFile().isRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void getStatusForFileRename_approvedNewPath() throws Exception {
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/baz/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+
+ Path oldPath = Paths.get("/foo/bar/abc.txt");
+ Path newPath = Paths.get("/foo/baz/abc.txt");
+ String changeId = createChangeWithFileRename(oldPath, newPath);
+
+ // Add a Code-Review+1 from a code owner of the new path (by default this counts as code owner
+ // approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(newPath);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ fileCodeOwnerStatusSubject.hasOldPathStatus().value().hasPathThat().isEqualTo(oldPath);
+ fileCodeOwnerStatusSubject
+ .hasOldPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.INSUFFICIENT_REVIEWERS);
+ fileCodeOwnerStatusSubject.hasChangedFile().isRename();
+ fileCodeOwnerStatusSubject.hasChangedFile().isNoDeletion();
+ }
+
+ @Test
+ public void parentCodeOwnerConfigsAreConsidered() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+ TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/")
+ .addCodeOwnerEmail(user.email())
+ .create();
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/")
+ .addCodeOwnerEmail(user2.email())
+ .create();
+
+ codeOwnerConfigOperations
+ .newCodeOwnerConfig()
+ .project(project)
+ .branch("master")
+ .folderPath("/foo/bar/")
+ .addCodeOwnerEmail(user3.email())
+ .create();
+
+ Path path = Paths.get("/foo/bar/baz.txt");
+ String changeId =
+ createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId();
+
+ // Add a Code-Review+1 from a code owner on root level (by default this counts as code owner
+ // approval).
+ requestScopeOperations.setApiUser(user.id());
+ recommend(changeId);
+
+ // Add code owner from a lower level as reviewer.
+ gApi.changes().id(changeId).addReviewer(user2.email());
+
+ ImmutableSet<FileCodeOwnerStatus> fileCodeOwnerStatuses =
+ codeOwnerApprovalCheck.getFileStatuses(getChangeNotes(changeId));
+ // The expected status is APPROVED since 'user' which is configured as code owner on the root
+ // level approved the change.
+ FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject =
+ assertThatSet(fileCodeOwnerStatuses).onlyElement();
+ fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
+ fileCodeOwnerStatusSubject
+ .hasNewPathStatus()
+ .value()
+ .hasStatusThat()
+ .isEqualTo(CodeOwnerStatus.APPROVED);
+ }
+
+ private ChangeNotes getChangeNotes(String changeId) throws Exception {
+ return changeNotesFactory.create(project, Change.id(gApi.changes().id(changeId).get()._number));
+ }
+}