Merge changes Ibb1a58e6,I52a90932,If6124558,I6fa3e915,I482c3a43, ... * changes: CodeOwnersOnPostReviewIT: Fix spelling mistake in comment Clarify general override policy for config parameters Clarify the project config settings override config settings from parents Document that disabledBranch for project overrides the global setting Add FAQ how to setup code owner overrides Mention the REST endpoint to set config parameters in the config FAQs Allow to set required approval and override approval via REST Allow to set a file extension for code owner config files via REST Allow to enable/disable the code owners functionality via REST Add REST endpoint that lists the paths of a change that a given user owns Add change message with the owned files when code owner is added as reviewer
diff --git a/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java new file mode 100644 index 0000000..684d715 --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/api/CodeOwnerProjectConfigInput.java
@@ -0,0 +1,64 @@ +// 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; + +import java.util.List; + +/** + * Input for the {@code com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST + * endpoint + * + * <p>This class determines the JSON format of the input in the REST API. + * + * <p>If a field in this input entity is not set, the corresponding parameter in the {@code + * code-owners.config} file is not updated. + */ +public class CodeOwnerProjectConfigInput { + /** Whether the code owners functionality should be disabled/enabled for the project. */ + public Boolean disabled; + + /** + * Branches for which the code owners functionality is disabled. + * + * <p>Can be exact refs, ref patterns or regular expressions. + * + * <p>Overrides any existing disabled branch configuration. + */ + public List<String> disabledBranches; + + /** The file extension that should be used for code owner config files in this project. */ + public String fileExtension; + + /** + * The approval that is required from code owners. + * + * <p>The required approval must be specified in the format {@code <label-name>+<label-value>}. + * + * <p>If an empty string is provided the required approval configuration is unset. Unsetting the + * required approval means that the inherited required approval configuration or the default + * required approval ({@code Code-Review+1}) will apply. + * + * <p>In contrast to providing an empty string, providing {@code null} (or not setting the value) + * means that the required approval configuration is not updated. + */ + public String requiredApproval; + + /** + * The approvals that count as override for the code owners submit check. + * + * <p>The override approvals must be specified in the format {@code <label-name>+<label-value>}. + */ + public List<String> overrideApprovals; +}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java new file mode 100644 index 0000000..0fc1497 --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/api/OwnedPathsInfo.java
@@ -0,0 +1,34 @@ +// Copyright (C) 2021 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; + +import java.util.List; + +/** + * Representation of a list of owned paths in the REST API. + * + * <p>This class determines the JSON format for the response of the {@code + * com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST endpoint. + */ +public class OwnedPathsInfo { + /** + * List of the owned paths. + * + * <p>The paths are returned as absolute paths. + * + * <p>The paths are sorted alphabetically. + */ + public List<String> ownedPaths; +}
diff --git a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java index 89fbacf..5f99cd1 100644 --- a/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java +++ b/java/com/google/gerrit/plugins/codeowners/api/ProjectCodeOwners.java
@@ -31,6 +31,15 @@ /** Returns the code owner project configuration. */ CodeOwnerProjectConfigInfo getConfig() throws RestApiException; + /** + * Update the code owner project configuration. + * + * @param input the input specifying which parameters should be updated + * @return the update code owner project configuration + */ + CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input) + throws RestApiException; + /** Create a request to check the code owner config files in the project. */ CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException; @@ -147,6 +156,12 @@ } @Override + public CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input) + throws RestApiException { + throw new NotImplementedException(); + } + + @Override public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException { throw new NotImplementedException(); }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java index df9ed04..1b90340 100644 --- a/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java +++ b/java/com/google/gerrit/plugins/codeowners/api/RevisionCodeOwners.java
@@ -34,6 +34,11 @@ */ CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException; + /** + * Retrieves the paths of the revision that owned by the user that is specified in the request. + */ + OwnedPathsRequest getOwnedPaths() throws RestApiException; + /** Request to check code owner config files. */ abstract class CheckCodeOwnerConfigFilesRequest { private String path; @@ -88,6 +93,25 @@ public abstract Map<String, List<ConsistencyProblemInfo>> check() throws RestApiException; } + /** Request to check code owner config files. */ + abstract class OwnedPathsRequest { + private String user; + + /** Sets the user for which the owned paths should be retrieved. */ + public OwnedPathsRequest forUser(String user) { + this.user = user; + return this; + } + + /** Returns the user for which the owned paths should be retrieved. */ + public String getUser() { + return user; + } + + /** Executes the request and retrieves the paths that are owned by the user. */ + public abstract OwnedPathsInfo get() throws RestApiException; + } + /** * A default implementation which allows source compatibility when adding new methods to the * interface. @@ -97,5 +121,10 @@ public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException { throw new NotImplementedException(); } + + @Override + public OwnedPathsRequest getOwnedPaths() throws RestApiException { + throw new NotImplementedException(); + } } }
diff --git a/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java index 2085640..b8b0b17 100644 --- a/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java +++ b/java/com/google/gerrit/plugins/codeowners/api/impl/ProjectCodeOwnersImpl.java
@@ -22,9 +22,11 @@ import com.google.gerrit.plugins.codeowners.api.BranchCodeOwners; import com.google.gerrit.plugins.codeowners.api.CheckCodeOwnerConfigFilesInput; import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput; import com.google.gerrit.plugins.codeowners.api.ProjectCodeOwners; import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFiles; import com.google.gerrit.plugins.codeowners.restapi.GetCodeOwnerProjectConfig; +import com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig; import com.google.gerrit.server.project.BranchResource; import com.google.gerrit.server.project.ProjectResource; import com.google.gerrit.server.restapi.project.BranchesCollection; @@ -42,6 +44,7 @@ private final BranchesCollection branchesCollection; private final BranchCodeOwnersImpl.Factory branchCodeOwnersApi; private final GetCodeOwnerProjectConfig getCodeOwnerProjectConfig; + private final PutCodeOwnerProjectConfig putCodeOwnerProjectConfig; private final CheckCodeOwnerConfigFiles checkCodeOwnerConfigFiles; private final ProjectResource projectResource; @@ -50,11 +53,13 @@ BranchesCollection branchesCollection, BranchCodeOwnersImpl.Factory branchCodeOwnersApi, GetCodeOwnerProjectConfig getCodeOwnerProjectConfig, + PutCodeOwnerProjectConfig putCodeOwnerProjectConfig, CheckCodeOwnerConfigFiles checkCodeOwnerConfigFiles, @Assisted ProjectResource projectResource) { this.branchesCollection = branchesCollection; this.branchCodeOwnersApi = branchCodeOwnersApi; this.getCodeOwnerProjectConfig = getCodeOwnerProjectConfig; + this.putCodeOwnerProjectConfig = putCodeOwnerProjectConfig; this.checkCodeOwnerConfigFiles = checkCodeOwnerConfigFiles; this.projectResource = projectResource; } @@ -80,6 +85,16 @@ } @Override + public CodeOwnerProjectConfigInfo updateConfig(CodeOwnerProjectConfigInput input) + throws RestApiException { + try { + return putCodeOwnerProjectConfig.apply(projectResource, input).value(); + } catch (Exception e) { + throw asRestApiException("Cannot update code owner project config", e); + } + } + + @Override public CheckCodeOwnerConfigFilesRequest checkCodeOwnerConfigFiles() throws RestApiException { return new CheckCodeOwnerConfigFilesRequest() { @Override
diff --git a/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java index 11ab392..d42327c 100644 --- a/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java +++ b/java/com/google/gerrit/plugins/codeowners/api/impl/RevisionCodeOwnersImpl.java
@@ -19,10 +19,13 @@ import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.plugins.codeowners.api.CheckCodeOwnerConfigFilesInRevisionInput; +import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo; import com.google.gerrit.plugins.codeowners.api.RevisionCodeOwners; import com.google.gerrit.plugins.codeowners.restapi.CheckCodeOwnerConfigFilesInRevision; +import com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths; import com.google.gerrit.server.change.RevisionResource; import com.google.inject.Inject; +import com.google.inject.Provider; import com.google.inject.assistedinject.Assisted; import java.util.List; import java.util.Map; @@ -34,13 +37,16 @@ } private final CheckCodeOwnerConfigFilesInRevision checkCodeOwnerConfigFilesInRevision; + private final Provider<GetOwnedPaths> getOwnedPathsProvider; private final RevisionResource revisionResource; @Inject public RevisionCodeOwnersImpl( CheckCodeOwnerConfigFilesInRevision checkCodeOwnerConfigFilesInRevision, + Provider<GetOwnedPaths> getOwnedPathsProvider, @Assisted RevisionResource revisionResource) { this.checkCodeOwnerConfigFilesInRevision = checkCodeOwnerConfigFilesInRevision; + this.getOwnedPathsProvider = getOwnedPathsProvider; this.revisionResource = revisionResource; } @@ -61,4 +67,20 @@ } }; } + + @Override + public OwnedPathsRequest getOwnedPaths() throws RestApiException { + return new OwnedPathsRequest() { + @Override + public OwnedPathsInfo get() throws RestApiException { + try { + GetOwnedPaths getOwnedPaths = getOwnedPathsProvider.get(); + getOwnedPaths.setUser(getUser()); + return getOwnedPaths.apply(revisionResource).value(); + } catch (Exception e) { + throw asRestApiException("Cannot get owned paths", e); + } + } + }; + } }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java index a93c20d..50e5f68 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/BackendModule.java
@@ -16,6 +16,7 @@ import com.google.gerrit.extensions.annotations.Exports; import com.google.gerrit.extensions.config.FactoryModule; +import com.google.gerrit.extensions.events.ReviewerAddedListener; import com.google.gerrit.extensions.registration.DynamicMap; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.server.ExceptionHook; @@ -47,6 +48,7 @@ DynamicSet.bind(binder(), ExceptionHook.class).to(CodeOwnersExceptionHook.class); DynamicSet.bind(binder(), OnPostReview.class).to(CodeOwnersOnPostReview.class); + DynamicSet.bind(binder(), ReviewerAddedListener.class).to(CodeOwnersOnAddReviewer.class); } @Provides
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java index 69d6dea..49eddd7 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheck.java
@@ -15,15 +15,20 @@ package com.google.gerrit.plugins.codeowners.backend; import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static java.util.Comparator.comparing; import static java.util.Objects.requireNonNull; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.Project; import com.google.gerrit.exceptions.StorageException; @@ -108,6 +113,35 @@ } /** + * Returns the paths of the files in the given patch set that are owned by the specified account. + * + * @param changeNotes the change notes for which the owned files should be returned + * @param patchSet the patch set for which the owned files should be returned + * @param accountId account ID of the code owner for which the owned files should be returned + * @return the paths of the files in the given patch set that are owned by the specified account + * @throws ResourceConflictException if the destination branch of the change no longer exists + */ + public ImmutableList<Path> getOwnedPaths( + ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId) + throws ResourceConflictException { + try { + return getFileStatusesForAccount(changeNotes, patchSet, accountId) + .flatMap( + fileCodeOwnerStatus -> + Stream.of( + fileCodeOwnerStatus.newPathStatus(), fileCodeOwnerStatus.oldPathStatus()) + .filter(Optional::isPresent) + .map(Optional::get)) + .filter(pathCodeOwnerStatus -> pathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED) + .map(PathCodeOwnerStatus::path) + .sorted(comparing(Path::toString)) + .collect(toImmutableList()); + } catch (IOException | PatchListNotAvailableException e) { + throw new StorageException(e); + } + } + + /** * Whether the given change has sufficient code owner approvals to be submittable. * * @param changeNotes the change notes @@ -242,14 +276,16 @@ * <p>The purpose of this method is to find the files/paths in a change that are owned by the * given account. * - * @param changeNotes the notes of the change for which the current code owner statuses should be - * returned + * @param changeNotes the notes of the change for which the code owner statuses should be returned + * @param patchSet the patch set for which the code owner statuses should be returned * @param accountId the ID of the account for which an approval should be assumed */ + @VisibleForTesting public Stream<FileCodeOwnerStatus> getFileStatusesForAccount( - ChangeNotes changeNotes, Account.Id accountId) + ChangeNotes changeNotes, PatchSet patchSet, Account.Id accountId) throws ResourceConflictException, IOException, PatchListNotAvailableException { requireNonNull(changeNotes, "changeNotes"); + requireNonNull(patchSet, "patchSet"); requireNonNull(accountId, "accountId"); try (TraceTimer traceTimer = TraceContext.newTimer( @@ -257,6 +293,7 @@ Metadata.builder() .projectName(changeNotes.getProjectName().get()) .changeId(changeNotes.getChangeId().get()) + .patchSetId(patchSet.id().get()) .build())) { RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(changeNotes.getProjectName()); @@ -276,9 +313,7 @@ "isBootstrapping = %s (isProjectOwner = %s)", isBootstrapping, isProjectOwner); if (isBootstrapping && isProjectOwner) { // Return all paths as approved. - return changedFiles - .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId()) - .stream() + return changedFiles.compute(changeNotes.getProjectName(), patchSet.commitId()).stream() .map( changedFile -> FileCodeOwnerStatus.create( @@ -296,9 +331,7 @@ oldPath, CodeOwnerStatus.APPROVED)))); } - return changedFiles - .compute(changeNotes.getProjectName(), changeNotes.getCurrentPatchSet().commitId()) - .stream() + return changedFiles.compute(changeNotes.getProjectName(), patchSet.commitId()).stream() .map( changedFile -> getFileStatus(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java new file mode 100644 index 0000000..92db861 --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnAddReviewer.java
@@ -0,0 +1,188 @@ +// Copyright (C) 2021 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.util.stream.Collectors.joining; + +import com.google.common.collect.ImmutableList; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.ChangeMessage; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.events.ReviewerAddedListener; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration; +import com.google.gerrit.plugins.codeowners.util.JgitPath; +import com.google.gerrit.server.ChangeMessagesUtil; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.update.BatchUpdate; +import com.google.gerrit.server.update.BatchUpdateOp; +import com.google.gerrit.server.update.ChangeContext; +import com.google.gerrit.server.update.UpdateException; +import com.google.gerrit.server.util.time.TimeUtil; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Callback that is invoked when a user is added as a reviewer. + * + * <p>If a code owner was added as reviewer add a change message that lists the files that are owned + * by the reviewer. + */ +@Singleton +public class CodeOwnersOnAddReviewer implements ReviewerAddedListener { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private static final String TAG_ADD_REVIEWER = + ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addReviewer"; + + private final CodeOwnersPluginConfiguration codeOwnersPluginConfiguration; + private final CodeOwnerApprovalCheck codeOwnerApprovalCheck; + private final Provider<CurrentUser> userProvider; + private final BatchUpdate.Factory batchUpdateFactory; + private final ChangeNotes.Factory changeNotesFactory; + private final AccountCache accountCache; + private final ChangeMessagesUtil changeMessageUtil; + + @Inject + CodeOwnersOnAddReviewer( + CodeOwnersPluginConfiguration codeOwnersPluginConfiguration, + CodeOwnerApprovalCheck codeOwnerApprovalCheck, + Provider<CurrentUser> userProvider, + BatchUpdate.Factory batchUpdateFactory, + ChangeNotes.Factory changeNotesFactory, + AccountCache accountCache, + ChangeMessagesUtil changeMessageUtil) { + this.codeOwnersPluginConfiguration = codeOwnersPluginConfiguration; + this.codeOwnerApprovalCheck = codeOwnerApprovalCheck; + this.userProvider = userProvider; + this.batchUpdateFactory = batchUpdateFactory; + this.changeNotesFactory = changeNotesFactory; + this.accountCache = accountCache; + this.changeMessageUtil = changeMessageUtil; + } + + @Override + public void onReviewersAdded(Event event) { + Change.Id changeId = Change.id(event.getChange()._number); + Project.NameKey projectName = Project.nameKey(event.getChange().project); + BranchNameKey branchNameKey = BranchNameKey.create(projectName, event.getChange().branch); + + if (codeOwnersPluginConfiguration.isDisabled(branchNameKey) + || codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(projectName) <= 0) { + return; + } + + try (BatchUpdate batchUpdate = + batchUpdateFactory.create(projectName, userProvider.get(), TimeUtil.nowTs())) { + batchUpdate.addOp(changeId, new Op(event.getReviewers())); + batchUpdate.execute(); + } catch (RestApiException | UpdateException e) { + logger.atSevere().withCause(e).log( + String.format( + "Failed to post code-owners change message for reviewer on change %s in project %s.", + changeId, projectName)); + } + } + + private class Op implements BatchUpdateOp { + private final List<AccountInfo> reviewers; + + Op(List<AccountInfo> reviewers) { + this.reviewers = reviewers; + } + + @Override + public boolean updateChange(ChangeContext ctx) throws Exception { + String message = + reviewers.stream() + .map(accountInfo -> Account.id(accountInfo._accountId)) + .map( + reviewerAccountId -> + buildMessageForReviewer( + ctx.getProject(), ctx.getChange().getId(), reviewerAccountId)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(joining("\n")); + + if (message.isEmpty()) { + return false; + } + + ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(ctx, message, TAG_ADD_REVIEWER); + changeMessageUtil.addChangeMessage( + ctx.getUpdate(ctx.getChange().currentPatchSetId()), changeMessage); + return true; + } + + private Optional<String> buildMessageForReviewer( + Project.NameKey projectName, Change.Id changeId, Account.Id reviewerAccountId) { + ChangeNotes changeNotes = changeNotesFactory.create(projectName, changeId); + + ImmutableList<Path> ownedPaths; + try { + ownedPaths = + codeOwnerApprovalCheck.getOwnedPaths( + changeNotes, changeNotes.getCurrentPatchSet(), reviewerAccountId); + } catch (RestApiException e) { + logger.atFine().withCause(e).log( + "Couldn't compute owned paths of change %s for account %s", + changeNotes.getChangeId(), reviewerAccountId.get()); + return Optional.empty(); + } + + if (ownedPaths.isEmpty()) { + // this reviewer doesn't own any of the modified paths + return Optional.empty(); + } + + Account reviewerAccount = accountCache.getEvenIfMissing(reviewerAccountId).account(); + + StringBuilder message = new StringBuilder(); + message.append( + String.format( + "%s who was added as reviewer owns the following files:\n", + reviewerAccount.getName())); + + int maxPathsInChangeMessage = + codeOwnersPluginConfiguration.getMaxPathsInChangeMessages(projectName); + if (ownedPaths.size() <= maxPathsInChangeMessage) { + appendPaths(message, ownedPaths.stream()); + } else { + // -1 so that we never show "(1 more files)" + int limit = maxPathsInChangeMessage - 1; + appendPaths(message, ownedPaths.stream().limit(limit)); + message.append(String.format("(%s more files)\n", ownedPaths.size() - limit)); + } + + return Optional.of(message.toString()); + } + + private void appendPaths(StringBuilder message, Stream<Path> pathsToAppend) { + pathsToAppend.forEach( + path -> message.append(String.format("* %s\n", JgitPath.of(path).get()))); + } + } +}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java index 2877c93..68573a4 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/CodeOwnersOnPostReview.java
@@ -15,26 +15,20 @@ package com.google.gerrit.plugins.codeowners.backend; import static com.google.common.base.Preconditions.checkState; -import static com.google.common.collect.ImmutableList.toImmutableList; -import static java.util.Comparator.comparing; import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; -import com.google.gerrit.entities.Account; import com.google.gerrit.entities.PatchSet; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration; import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval; -import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus; import com.google.gerrit.plugins.codeowners.util.JgitPath; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.notedb.ChangeNotes; -import com.google.gerrit.server.patch.PatchListNotAvailableException; import com.google.gerrit.server.restapi.change.OnPostReview; import com.google.gerrit.server.util.LabelVote; import com.google.inject.Inject; import com.google.inject.Singleton; -import java.io.IOException; import java.nio.file.Path; import java.util.Map; import java.util.Optional; @@ -113,7 +107,18 @@ LabelVote newVote = getNewVote(requiredApproval, approvals); - ImmutableList<Path> ownedPaths = getOwnedPaths(changeNotes, user.getAccountId()); + ImmutableList<Path> ownedPaths; + try { + ownedPaths = + codeOwnerApprovalCheck.getOwnedPaths( + changeNotes, changeNotes.getCurrentPatchSet(), user.getAccountId()); + } catch (RestApiException e) { + logger.atFine().withCause(e).log( + "Couldn't compute owned paths of change %s for account %s", + changeNotes.getChangeId(), user.getAccountId().get()); + return Optional.empty(); + } + if (ownedPaths.isEmpty()) { // the user doesn't own any of the modified paths return Optional.empty(); @@ -246,31 +251,4 @@ approvals); return LabelVote.create(labelName, approvals.get(labelName)); } - - private ImmutableList<Path> getOwnedPaths(ChangeNotes changeNotes, Account.Id accountId) { - try { - return codeOwnerApprovalCheck - .getFileStatusesForAccount(changeNotes, accountId) - .flatMap( - fileCodeOwnerStatus -> - Stream.of( - fileCodeOwnerStatus.newPathStatus(), fileCodeOwnerStatus.oldPathStatus()) - .filter(Optional::isPresent) - .map(Optional::get)) - .filter(pathCodeOwnerStatus -> pathCodeOwnerStatus.status() == CodeOwnerStatus.APPROVED) - .map(PathCodeOwnerStatus::path) - .sorted(comparing(Path::toString)) - .collect(toImmutableList()); - } catch (RestApiException e) { - logger.atFine().withCause(e).log( - "Couldn't compute owned paths of change %s for account %s", - changeNotes.getChangeId(), accountId.get()); - return ImmutableList.of(); - } catch (IOException | PatchListNotAvailableException e) { - logger.atSevere().withCause(e).log( - "Failed to compute owned paths of change %s for account %s", - changeNotes.getChangeId(), accountId.get()); - return ImmutableList.of(); - } - } }
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java index 01d381b..b72c1f0 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfig.java
@@ -22,7 +22,6 @@ import com.google.gerrit.server.config.PluginConfigFactory; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import com.google.gerrit.server.project.ProjectState; import org.eclipse.jgit.lib.Config; @@ -115,15 +114,14 @@ * validation errors */ ImmutableList<CommitValidationMessage> validateProjectLevelConfig( - ProjectState projectState, String fileName, ProjectLevelConfig.Bare projectLevelConfig) { + ProjectState projectState, String fileName, Config projectLevelConfig) { requireNonNull(projectState, "projectState"); requireNonNull(fileName, "fileName"); requireNonNull(projectLevelConfig, "projectLevelConfig"); String[] requiredApprovals = - projectLevelConfig - .getConfig() - .getStringList(SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey()); + projectLevelConfig.getStringList( + SECTION_CODE_OWNERS, /* subsection= */ null, getConfigKey()); ImmutableList.Builder<CommitValidationMessage> validationMessages = ImmutableList.builder(); for (String requiredApproval : requiredApprovals) { try {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java index 9f163ce..cbeb83d 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/BackendConfig.java
@@ -29,7 +29,6 @@ import com.google.gerrit.server.config.PluginConfigFactory; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import com.google.inject.Inject; import com.google.inject.Singleton; import java.util.ArrayList; @@ -88,14 +87,13 @@ * validation errors */ ImmutableList<CommitValidationMessage> validateProjectLevelConfig( - String fileName, ProjectLevelConfig.Bare projectLevelConfig) { + String fileName, Config projectLevelConfig) { requireNonNull(fileName, "fileName"); requireNonNull(projectLevelConfig, "projectLevelConfig"); List<CommitValidationMessage> validationMessages = new ArrayList<>(); - String backendName = - projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, null, KEY_BACKEND); + String backendName = projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_BACKEND); if (backendName != null) { if (!lookupBackend(backendName).isPresent()) { validationMessages.add( @@ -107,9 +105,8 @@ } } - for (String subsection : projectLevelConfig.getConfig().getSubsections(SECTION_CODE_OWNERS)) { - backendName = - projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, subsection, KEY_BACKEND); + for (String subsection : projectLevelConfig.getSubsections(SECTION_CODE_OWNERS)) { + backendName = projectLevelConfig.getString(SECTION_CODE_OWNERS, subsection, KEY_BACKEND); if (backendName != null) { if (!lookupBackend(backendName).isPresent()) { validationMessages.add(
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java index 28e7a8d..ababe6f 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginConfigValidator.java
@@ -37,13 +37,14 @@ import java.util.ArrayList; import java.util.List; import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; /** Validates modifications to the {@code code-owners.config} file in {@code refs/meta/config}. */ @Singleton -class CodeOwnersPluginConfigValidator implements CommitValidationListener { +public class CodeOwnersPluginConfigValidator implements CommitValidationListener { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final String pluginName; @@ -97,7 +98,12 @@ ProjectState projectState = getProjectState(project, receiveEvent.commit); ProjectLevelConfig.Bare cfg = loadConfig(project, fileName, receiveEvent.commit); - validateConfig(projectState, fileName, cfg); + ImmutableList<CommitValidationMessage> validationMessages = + validateConfig(projectState, fileName, cfg.getConfig()); + if (!validationMessages.isEmpty()) { + throw new CommitValidationException( + exceptionMessage(fileName, cfg.getRevision()), validationMessages); + } return ImmutableList.of(); } catch (IOException | ConfigInvalidException | PatchListNotAvailableException e) { String errorMessage = @@ -160,11 +166,10 @@ * @param projectState the project state * @param fileName the name of the config file * @param cfg the project-level code-owners configuration that should be validated - * @throws CommitValidationException throw if there are any validation errors + * @return list of messages with validation issues, empty list if there are no issues */ - private void validateConfig( - ProjectState projectState, String fileName, ProjectLevelConfig.Bare cfg) - throws CommitValidationException { + public ImmutableList<CommitValidationMessage> validateConfig( + ProjectState projectState, String fileName, Config cfg) { List<CommitValidationMessage> validationMessages = new ArrayList<>(); validationMessages.addAll(backendConfig.validateProjectLevelConfig(fileName, cfg)); validationMessages.addAll(generalConfig.validateProjectLevelConfig(fileName, cfg)); @@ -173,10 +178,7 @@ requiredApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg)); validationMessages.addAll( overrideApprovalConfig.validateProjectLevelConfig(projectState, fileName, cfg)); - if (!validationMessages.isEmpty()) { - throw new CommitValidationException( - exceptionMessage(fileName, cfg.getRevision()), validationMessages); - } + return ImmutableList.copyOf(validationMessages); } /**
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java new file mode 100644 index 0000000..1f51e93 --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersProjectConfigFile.java
@@ -0,0 +1,71 @@ +// Copyright (C) 2021 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.config; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.server.git.meta.VersionedMetaData; +import java.io.IOException; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.Config; + +/** + * Reads/writes the code-owners project configuration from/to the {@code code-owners.config} file in + * the {@code refs/meta/config} branch. + */ +public class CodeOwnersProjectConfigFile extends VersionedMetaData { + public static final String FILE_NAME = "code-owners.config"; + + private boolean isLoaded = false; + private Config config; + + @Override + protected String getRefName() { + return RefNames.REFS_CONFIG; + } + + /** + * Returns the loaded code owners config. + * + * <p>Fails if loading was not done yet. + */ + public Config getConfig() { + checkLoaded(); + return config; + } + + @Override + protected void onLoad() throws IOException, ConfigInvalidException { + if (revision != null) { + config = readConfig(FILE_NAME); + } else { + config = new Config(); + } + isLoaded = true; + } + + @Override + protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException { + checkLoaded(); + saveConfig(FILE_NAME, config); + return true; + } + + private void checkLoaded() { + checkState(isLoaded, "%s not loaded yet", FILE_NAME); + } +}
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java index e14ece9..8348893 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -33,7 +33,6 @@ import com.google.gerrit.server.config.PluginConfigFactory; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import com.google.inject.Inject; import com.google.inject.Singleton; import java.util.ArrayList; @@ -107,28 +106,25 @@ * validation errors */ ImmutableList<CommitValidationMessage> validateProjectLevelConfig( - String fileName, ProjectLevelConfig.Bare projectLevelConfig) { + String fileName, Config projectLevelConfig) { requireNonNull(fileName, "fileName"); requireNonNull(projectLevelConfig, "projectLevelConfig"); List<CommitValidationMessage> validationMessages = new ArrayList<>(); try { - projectLevelConfig - .getConfig() - .getEnum( - SECTION_CODE_OWNERS, - null, - KEY_MERGE_COMMIT_STRATEGY, - MergeCommitStrategy.ALL_CHANGED_FILES); + projectLevelConfig.getEnum( + SECTION_CODE_OWNERS, + null, + KEY_MERGE_COMMIT_STRATEGY, + MergeCommitStrategy.ALL_CHANGED_FILES); } catch (IllegalArgumentException e) { validationMessages.add( new CommitValidationMessage( String.format( "Merge commit strategy '%s' that is configured in %s (parameter %s.%s) is invalid.", - projectLevelConfig - .getConfig() - .getString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY), + projectLevelConfig.getString( + SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY), fileName, SECTION_CODE_OWNERS, KEY_MERGE_COMMIT_STRATEGY), @@ -136,17 +132,14 @@ } try { - projectLevelConfig - .getConfig() - .getEnum(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE); + projectLevelConfig.getEnum( + SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS, FallbackCodeOwners.NONE); } catch (IllegalArgumentException e) { validationMessages.add( new CommitValidationMessage( String.format( "The value for fallback code owners '%s' that is configured in %s (parameter %s.%s) is invalid.", - projectLevelConfig - .getConfig() - .getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS), + projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_FALLBACK_CODE_OWNERS), fileName, SECTION_CODE_OWNERS, KEY_FALLBACK_CODE_OWNERS), @@ -154,22 +147,19 @@ } try { - projectLevelConfig - .getConfig() - .getInt( - SECTION_CODE_OWNERS, - null, - KEY_MAX_PATHS_IN_CHANGE_MESSAGES, - DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES); + projectLevelConfig.getInt( + SECTION_CODE_OWNERS, + null, + KEY_MAX_PATHS_IN_CHANGE_MESSAGES, + DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES); } catch (IllegalArgumentException e) { validationMessages.add( new CommitValidationMessage( String.format( "The value for max paths in change messages '%s' that is configured in %s" + " (parameter %s.%s) is invalid.", - projectLevelConfig - .getConfig() - .getString(SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES), + projectLevelConfig.getString( + SECTION_CODE_OWNERS, null, KEY_MAX_PATHS_IN_CHANGE_MESSAGES), fileName, SECTION_CODE_OWNERS, KEY_MAX_PATHS_IN_CHANGE_MESSAGES),
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java index 720df22..62ae72f 100644 --- a/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java +++ b/java/com/google/gerrit/plugins/codeowners/backend/config/StatusConfig.java
@@ -28,7 +28,6 @@ import com.google.gerrit.server.config.PluginConfigFactory; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import com.google.gerrit.server.project.RefPatternMatcher; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -78,21 +77,21 @@ * validation errors */ ImmutableList<CommitValidationMessage> validateProjectLevelConfig( - String fileName, ProjectLevelConfig.Bare projectLevelConfig) { + String fileName, Config projectLevelConfig) { requireNonNull(fileName, "fileName"); requireNonNull(projectLevelConfig, "projectLevelConfig"); List<CommitValidationMessage> validationMessages = new ArrayList<>(); try { - projectLevelConfig.getConfig().getBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, false); + projectLevelConfig.getBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, false); } catch (IllegalArgumentException e) { validationMessages.add( new CommitValidationMessage( String.format( "Disabled value '%s' that is configured in %s.config (parameter %s.%s) is" + " invalid.", - projectLevelConfig.getConfig().getString(SECTION_CODE_OWNERS, null, KEY_DISABLED), + projectLevelConfig.getString(SECTION_CODE_OWNERS, null, KEY_DISABLED), pluginName, SECTION_CODE_OWNERS, KEY_DISABLED), @@ -100,9 +99,7 @@ } for (String refPattern : - projectLevelConfig - .getConfig() - .getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH)) { + projectLevelConfig.getStringList(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH)) { try { RefPatternMatcher.getMatcher(refPattern).match("refs/heads/master", null); } catch (PatternSyntaxException e) {
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java new file mode 100644 index 0000000..305b31a --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/restapi/GetOwnedPaths.java
@@ -0,0 +1,85 @@ +// Copyright (C) 2021 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.restapi; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.gerrit.entities.Account; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestReadView; +import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo; +import com.google.gerrit.plugins.codeowners.backend.CodeOwnerApprovalCheck; +import com.google.gerrit.server.account.AccountResolver; +import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException; +import com.google.gerrit.server.change.RevisionResource; +import com.google.inject.Inject; +import java.io.IOException; +import java.nio.file.Path; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.kohsuke.args4j.Option; + +/** + * REST endpoint that lists the files of a revision that are owned by a specified user. + * + * <p>This REST endpoint handles {@code GET + * /changes/<change-id>/revisions/<revision-id>/owned_paths} requests. + */ +public class GetOwnedPaths implements RestReadView<RevisionResource> { + private final AccountResolver accountResolver; + private final CodeOwnerApprovalCheck codeOwnerApprovalCheck; + + private String user; + + @Option(name = "--user", usage = "user for which the owned paths should be returned") + public void setUser(String user) { + this.user = user; + } + + @Inject + public GetOwnedPaths( + AccountResolver accountResolver, CodeOwnerApprovalCheck codeOwnerApprovalCheck) { + this.accountResolver = accountResolver; + this.codeOwnerApprovalCheck = codeOwnerApprovalCheck; + } + + @Override + public Response<OwnedPathsInfo> apply(RevisionResource revisionResource) + throws BadRequestException, ResourceConflictException, UnresolvableAccountException, + ConfigInvalidException, IOException { + Account.Id accountId = resolveAccount(); + + ImmutableList<Path> ownedPaths = + codeOwnerApprovalCheck.getOwnedPaths( + revisionResource.getNotes(), revisionResource.getPatchSet(), accountId); + + OwnedPathsInfo ownedPathsInfo = new OwnedPathsInfo(); + ownedPathsInfo.ownedPaths = ownedPaths.stream().map(Path::toString).collect(toImmutableList()); + return Response.ok(ownedPathsInfo); + } + + private Account.Id resolveAccount() + throws BadRequestException, UnresolvableAccountException, ConfigInvalidException, + IOException { + if (Strings.isNullOrEmpty(user)) { + throw new BadRequestException("--user required"); + } + + return accountResolver.resolve(user).asUnique().account().id(); + } +}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java new file mode 100644 index 0000000..1f2733a --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/restapi/PutCodeOwnerProjectConfig.java
@@ -0,0 +1,169 @@ +// Copyright (C) 2021 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.restapi; + +import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS; +import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_FILE_EXTENSION; +import static com.google.gerrit.plugins.codeowners.backend.config.OverrideApprovalConfig.KEY_OVERRIDE_APPROVAL; +import static com.google.gerrit.plugins.codeowners.backend.config.RequiredApprovalConfig.KEY_REQUIRED_APPROVAL; +import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED; +import static com.google.gerrit.plugins.codeowners.backend.config.StatusConfig.KEY_DISABLED_BRANCH; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.extensions.annotations.PluginName; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput; +import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfigValidator; +import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersProjectConfigFile; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.meta.MetaDataUpdate; +import com.google.gerrit.server.git.validators.CommitValidationMessage; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.permissions.ProjectPermission; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectResource; +import com.google.gerrit.server.project.ProjectState; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Repository; + +/** + * REST endpoint that updates the code owner project configuration. + * + * <p>This REST endpoint handles {@code PUT /projects/<project-name>/code_owners.project_config} + * requests. + */ +@Singleton +public class PutCodeOwnerProjectConfig + implements RestModifyView<ProjectResource, CodeOwnerProjectConfigInput> { + private final String pluginName; + private final PermissionBackend permissionBackend; + private final GitRepositoryManager repoManager; + private final Provider<MetaDataUpdate.User> metaDataUpdateFactory; + private final ProjectCache projectCache; + private final CodeOwnersPluginConfigValidator codeOwnersPluginConfigValidator; + private final CodeOwnerProjectConfigJson codeOwnerProjectConfigJson; + + @Inject + public PutCodeOwnerProjectConfig( + @PluginName String pluginName, + PermissionBackend permissionBackend, + GitRepositoryManager repoManager, + Provider<MetaDataUpdate.User> metaDataUpdateFactory, + ProjectCache projectCache, + CodeOwnersPluginConfigValidator codeOwnersPluginConfigValidator, + CodeOwnerProjectConfigJson codeOwnerProjectConfigJson) { + this.pluginName = pluginName; + this.permissionBackend = permissionBackend; + this.repoManager = repoManager; + this.metaDataUpdateFactory = metaDataUpdateFactory; + this.projectCache = projectCache; + this.codeOwnersPluginConfigValidator = codeOwnersPluginConfigValidator; + this.codeOwnerProjectConfigJson = codeOwnerProjectConfigJson; + } + + @Override + public Response<CodeOwnerProjectConfigInfo> apply( + ProjectResource projectResource, CodeOwnerProjectConfigInput input) + throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException { + // This REST endpoint requires the caller to be a project owner. + permissionBackend + .currentUser() + .project(projectResource.getNameKey()) + .check(ProjectPermission.WRITE_CONFIG); + + try (Repository repo = repoManager.openRepository(projectResource.getNameKey()); + MetaDataUpdate metaDataUpdate = + metaDataUpdateFactory.get().create(projectResource.getNameKey())) { + metaDataUpdate.setMessage(String.format("Update %s configuration", pluginName)); + + CodeOwnersProjectConfigFile codeOwnersProjectConfigFile = new CodeOwnersProjectConfigFile(); + codeOwnersProjectConfigFile.load(projectResource.getNameKey(), repo); + Config codeOwnersConfig = codeOwnersProjectConfigFile.getConfig(); + + if (input.disabled != null) { + codeOwnersConfig.setBoolean( + SECTION_CODE_OWNERS, /* subsection= */ null, KEY_DISABLED, input.disabled); + } + + if (input.disabledBranches != null) { + codeOwnersConfig.setStringList( + SECTION_CODE_OWNERS, + /* subsection= */ null, + KEY_DISABLED_BRANCH, + input.disabledBranches); + } + + if (input.fileExtension != null) { + codeOwnersConfig.setString( + SECTION_CODE_OWNERS, /* subsection= */ null, KEY_FILE_EXTENSION, input.fileExtension); + } + + if (input.requiredApproval != null) { + if (input.requiredApproval.isEmpty()) { + codeOwnersConfig.unset( + SECTION_CODE_OWNERS, /* subsection= */ null, KEY_REQUIRED_APPROVAL); + } else { + codeOwnersConfig.setString( + SECTION_CODE_OWNERS, + /* subsection= */ null, + KEY_REQUIRED_APPROVAL, + input.requiredApproval); + } + } + + if (input.overrideApprovals != null) { + codeOwnersConfig.setStringList( + SECTION_CODE_OWNERS, + /* subsection= */ null, + KEY_OVERRIDE_APPROVAL, + input.overrideApprovals); + } + + validateConfig(projectResource.getProjectState(), codeOwnersConfig); + + codeOwnersProjectConfigFile.commit(metaDataUpdate); + projectCache.evict(projectResource.getNameKey()); + } + + CodeOwnerProjectConfigInfo updatedCodeOwnerProjectConfigInfo = + codeOwnerProjectConfigJson.format(projectResource); + return Response.created(updatedCodeOwnerProjectConfigInfo); + } + + private void validateConfig(ProjectState projectState, Config codeOwnersConfig) + throws BadRequestException { + ImmutableList<CommitValidationMessage> validationMessages = + codeOwnersPluginConfigValidator.validateConfig( + projectState, CodeOwnersProjectConfigFile.FILE_NAME, codeOwnersConfig); + if (!validationMessages.isEmpty()) { + StringBuilder exceptionMessage = new StringBuilder(); + exceptionMessage.append("invalid config:\n"); + validationMessages.forEach( + validationMessage -> + exceptionMessage.append(String.format("* %s\n", validationMessage.getMessage()))); + throw new BadRequestException(exceptionMessage.toString()); + } + } +}
diff --git a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java index df4e6f7..37b3823 100644 --- a/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java +++ b/java/com/google/gerrit/plugins/codeowners/restapi/RestApiModule.java
@@ -51,9 +51,11 @@ get(CHANGE_KIND, "code_owners.status").to(GetCodeOwnerStatus.class); + get(REVISION_KIND, "owned_paths").to(GetOwnedPaths.class); post(REVISION_KIND, "code_owners.check_config").to(CheckCodeOwnerConfigFilesInRevision.class); get(PROJECT_KIND, "code_owners.project_config").to(GetCodeOwnerProjectConfig.class); + put(PROJECT_KIND, "code_owners.project_config").to(PutCodeOwnerProjectConfig.class); post(PROJECT_KIND, "code_owners.check_config").to(CheckCodeOwnerConfigFiles.class); } }
diff --git a/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java new file mode 100644 index 0000000..df6b1d0 --- /dev/null +++ b/java/com/google/gerrit/plugins/codeowners/testing/OwnedPathsInfoSubject.java
@@ -0,0 +1,55 @@ +// Copyright (C) 2021 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.Truth.assertAbout; + +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.IterableSubject; +import com.google.common.truth.Subject; +import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo; + +/** {@link Subject} for doing assertions on {@link OwnedPathsInfo}s. */ +public class OwnedPathsInfoSubject extends Subject { + /** + * Starts fluent chain to do assertions on a {@link OwnedPathsInfo}. + * + * @param ownedPathsInfo the owned paths info on which assertions should be done + * @return the created {@link OwnedPathsInfoSubject} + */ + public static OwnedPathsInfoSubject assertThat(OwnedPathsInfo ownedPathsInfo) { + return assertAbout(ownedPathsInfos()).that(ownedPathsInfo); + } + + private static Factory<OwnedPathsInfoSubject, OwnedPathsInfo> ownedPathsInfos() { + return OwnedPathsInfoSubject::new; + } + + private final OwnedPathsInfo ownedPathsInfo; + + private OwnedPathsInfoSubject(FailureMetadata metadata, OwnedPathsInfo ownedPathsInfo) { + super(metadata, ownedPathsInfo); + this.ownedPathsInfo = ownedPathsInfo; + } + + public IterableSubject hasOwnedPathsThat() { + return check("ownedPaths()").that(ownedPathsInfo().ownedPaths); + } + + private OwnedPathsInfo ownedPathsInfo() { + isNotNull(); + return ownedPathsInfo; + } +}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java new file mode 100644 index 0000000..576d2fe --- /dev/null +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnAddReviewerIT.java
@@ -0,0 +1,358 @@ +// Copyright (C) 2021 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.acceptance.api; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.api.projects.DeleteBranchesInput; +import com.google.gerrit.extensions.common.ChangeMessageInfo; +import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT; +import java.util.Collection; +import org.junit.Test; + +/** + * Acceptance test for {@code com.google.gerrit.plugins.codeowners.backend.CodeOwnersOnAddReviewer}. + */ +public class CodeOwnersOnAddReviewerIT extends AbstractCodeOwnersIT { + @Test + @GerritConfig(name = "plugin.code-owners.disabled", value = "true") + public void noChangeMessageAddedIfCodeOwnersFuctionalityIsDisabled() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1."); + } + + @Test + public void noChangeMessageAddedIfReviewerIsNotACodeOwner() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(admin.email()) + .create(); + + String changeId = createChange("Test Change", "foo/bar.baz", "file content").getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1."); + } + + @Test + public void changeMessageListsOwnedPaths() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path = "foo/bar.baz"; + String changeId = createChange("Test Change", path, "file content").getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n* %s\n", + user.fullName(), path)); + } + + @Test + public void changeMessageListsOnlyOwnedPaths() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path1 = "foo/bar.baz"; + String path2 = "foo/baz.bar"; + String changeId = + createChange( + "Test Change", + ImmutableMap.of( + path1, + "file content", + path2, + "file content", + "bar/foo.baz", + "file content", + "bar/baz.foo", + "file content")) + .getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n* %s\n* %s\n", + user.fullName(), path1, path2)); + } + + @Test + public void noChangeMessageAddedIfSameCodeOwnerIsAddedAsReviewerAgain() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path = "foo/bar.baz"; + String changeId = createChange("Test Change", path, "file content").getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + int messageCount = gApi.changes().id(changeId).get().messages.size(); + + // Add the same code owner as reviewer again. + gApi.changes().id(changeId).addReviewer(user.email()); + + // Check that no new change message was added. + assertThat(gApi.changes().id(changeId).get().messages.size()).isEqualTo(messageCount); + } + + @Test + @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "4") + public void pathsInChangeMessageAreLimited_limitNotReached() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path1 = "foo/bar.baz"; + String path2 = "foo/baz.bar"; + String path3 = "bar/foo.baz"; + String path4 = "bar/baz.foo"; + String changeId = + createChange( + "Test Change", + ImmutableMap.of( + path1, + "file content", + path2, + "file content", + path3, + "file content", + path4, + "file content")) + .getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n" + + "* %s\n" + + "* %s\n" + + "* %s\n" + + "* %s\n", + user.fullName(), path4, path3, path1, path2)); + } + + @Test + @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "3") + public void pathsInChangeMessageAreLimited_limitReached() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path1 = "foo/bar.baz"; + String path2 = "foo/baz.bar"; + String path3 = "bar/foo.baz"; + String path4 = "bar/baz.foo"; + String changeId = + createChange( + "Test Change", + ImmutableMap.of( + path1, + "file content", + path2, + "file content", + path3, + "file content", + path4, + "file content")) + .getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n" + + "* %s\n" + + "* %s\n" + + "(2 more files)\n", + user.fullName(), path4, path3)); + } + + @Test + @GerritConfig(name = "plugin.code-owners.maxPathsInChangeMessages", value = "0") + public void pathsInChangeMessagesDisabled() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/") + .addCodeOwnerEmail(admin.email()) + .create(); + + String changeId = + createChange( + "Test Change", + ImmutableMap.of( + "foo/bar.baz", + "file content", + "foo/baz.bar", + "file content", + "bar/foo.baz", + "file content", + "bar/baz.foo", + "file content")) + .getChangeId(); + + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1."); + } + + @Test + public void noChangeMessageAddedIfDestinationBranchWasDeleted() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/") + .addCodeOwnerEmail(user.email()) + .create(); + + String branchName = "tempBranch"; + createBranch(BranchNameKey.create(project, branchName)); + + String changeId = createChange("refs/for/" + branchName).getChangeId(); + + DeleteBranchesInput input = new DeleteBranchesInput(); + input.branches = ImmutableList.of(branchName); + gApi.projects().name(project.get()).deleteBranches(input); + + gApi.changes().id(changeId).addReviewer(user.email()); + + // If the destination branch of the change no longer exits, the owned paths cannot be computed. + // Hence no change message is added in this case. + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 1."); + } + + @Test + public void changeMessageListsOwnedPathsIfReviewerIsAddedViaPostReview() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + String path = "foo/bar.baz"; + String changeId = createChange("Test Change", path, "file content").getChangeId(); + + // Add reviewer via PostReview. + gApi.changes().id(changeId).current().review(ReviewInput.create().reviewer(user.email())); + gApi.changes().id(changeId).addReviewer(user.email()); + + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n* %s\n", + user.fullName(), path)); + } + + @Test + public void reviewerAndCodeOwnerApprovalAddedAtTheSameTime() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(admin.email()) + .addCodeOwnerEmail(user.email()) + .create(); + + String path = "foo/bar.baz"; + String changeId = createChange("Test Change", path, "file content").getChangeId(); + + // 'admin' grants a code owner approval (Code-Review+1) and adds 'user' as reviewer. + gApi.changes().id(changeId).current().review(ReviewInput.recommend().reviewer(user.email())); + + // We expect that 2 changes messages are added: + // 1. change message listing the paths that were approved by voting Code-Review+1 + // 2. change message listing the paths owned by the new reviewer + Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages; + assertThat(Iterables.get(messages, messages.size() - 2).message) + .isEqualTo( + String.format( + "Patch Set 1: Code-Review+1\n\n" + + "By voting Code-Review+1 the following files are now code-owner approved by" + + " %s:\n" + + "* %s\n", + admin.fullName(), path)); + assertThat(Iterables.getLast(messages).message) + .isEqualTo( + String.format( + "%s who was added as reviewer owns the following files:\n* %s\n", + user.fullName(), path)); + } +}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java index 178b16b..761472b 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnersOnPostReviewIT.java
@@ -110,7 +110,7 @@ recommend(changeId); // Check that no new change message was added. - // Gerrit code omits the change message if no vote was changed. + // Gerrit core omits the change message if no vote was changed. assertThat(gApi.changes().id(changeId).get().messages.size()).isEqualTo(messageCount); }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java new file mode 100644 index 0000000..6040196 --- /dev/null +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/GetOwnedPathsIT.java
@@ -0,0 +1,182 @@ +// Copyright (C) 2021 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.acceptance.api; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.plugins.codeowners.testing.OwnedPathsInfoSubject.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT; +import com.google.gerrit.plugins.codeowners.api.OwnedPathsInfo; +import com.google.gerrit.plugins.codeowners.util.JgitPath; +import com.google.inject.Inject; +import org.junit.Test; + +/** + * Acceptance test for the {@link com.google.gerrit.plugins.codeowners.restapi.GetOwnedPaths} REST + * endpoint. + */ +public class GetOwnedPathsIT extends AbstractCodeOwnersIT { + @Inject private RequestScopeOperations requestScopeOperations; + + @Test + public void getOwnedPathRequiresUser() throws Exception { + String changeId = createChange().getChangeId(); + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> + changeCodeOwnersApiFactory + .change(changeId) + .current() + .getOwnedPaths() + .forUser(/* user= */ null) + .get()); + assertThat(exception).hasMessageThat().isEqualTo("--user required"); + } + + @Test + public void cannotGetOwnedPathForNonExistingUser() throws Exception { + String nonExistingUser = "non-existing"; + String changeId = createChange().getChangeId(); + UnprocessableEntityException exception = + assertThrows( + UnprocessableEntityException.class, + () -> + changeCodeOwnersApiFactory + .change(changeId) + .current() + .getOwnedPaths() + .forUser(nonExistingUser) + .get()); + assertThat(exception) + .hasMessageThat() + .isEqualTo(String.format("Account '%s' not found", nonExistingUser)); + } + + @Test + @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP") + public void cannotGetOwnedPathForNonVisibleUser() throws Exception { + TestAccount nonVisibleUser = + accountCreator.create( + "nonVisibleUser", + "nonVisibleUser@example.com", + "Non-Visible User", + /* displayName= */ null); + String changeId = createChange().getChangeId(); + requestScopeOperations.setApiUser(user.id()); + UnprocessableEntityException exception = + assertThrows( + UnprocessableEntityException.class, + () -> + changeCodeOwnersApiFactory + .change(changeId) + .current() + .getOwnedPaths() + .forUser(nonVisibleUser.email()) + .get()); + assertThat(exception) + .hasMessageThat() + .isEqualTo(String.format("Account '%s' not found", nonVisibleUser.email())); + } + + @Test + public void getOwnedPaths() throws Exception { + setAsCodeOwners("/foo/", user); + + String path1 = "/foo/bar/baz.md"; + String path2 = "/foo/baz/bar.md"; + String path3 = "/bar/foo.md"; + + String changeId = + createChange( + "test change", + ImmutableMap.of( + JgitPath.of(path1).get(), + "file content", + JgitPath.of(path2).get(), + "file content", + JgitPath.of(path3).get(), + "file content")) + .getChangeId(); + + OwnedPathsInfo ownedPathsInfo = + changeCodeOwnersApiFactory + .change(changeId) + .current() + .getOwnedPaths() + .forUser(user.email()) + .get(); + assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder(); + } + + @Test + public void getOwnedPathForOwnUser() throws Exception { + setAsRootCodeOwners(admin); + + String path1 = "/foo/bar/baz.md"; + String path2 = "/foo/baz/bar.md"; + + String changeId = + createChange( + "test change", + ImmutableMap.of( + JgitPath.of(path1).get(), + "file content", + JgitPath.of(path2).get(), + "file content")) + .getChangeId(); + + OwnedPathsInfo ownedPathsInfo = + changeCodeOwnersApiFactory.change(changeId).current().getOwnedPaths().forUser("self").get(); + assertThat(ownedPathsInfo).hasOwnedPathsThat().containsExactly(path1, path2).inOrder(); + } + + @Test + public void getOwnedPathsForNonCodeOwner() throws Exception { + setAsCodeOwners("/foo/", admin); + + String path1 = "/foo/bar/baz.md"; + String path2 = "/foo/baz/bar.md"; + String path3 = "/bar/foo.md"; + + String changeId = + createChange( + "test change", + ImmutableMap.of( + JgitPath.of(path1).get(), + "file content", + JgitPath.of(path2).get(), + "file content", + JgitPath.of(path3).get(), + "file content")) + .getChangeId(); + + OwnedPathsInfo ownedPathsInfo = + changeCodeOwnersApiFactory + .change(changeId) + .current() + .getOwnedPaths() + .forUser(user.email()) + .get(); + assertThat(ownedPathsInfo).hasOwnedPathsThat().isEmpty(); + } +}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java new file mode 100644 index 0000000..2da6c3d --- /dev/null +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/PutCodeOwnerProjectConfigIT.java
@@ -0,0 +1,337 @@ +// Copyright (C) 2021 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.acceptance.api; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.plugins.codeowners.testing.RequiredApprovalSubject.assertThat; +import static com.google.gerrit.server.project.ProjectCache.illegalState; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; +import static com.google.gerrit.truth.OptionalSubject.assertThat; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.acceptance.UseClockStep; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.common.LabelDefinitionInput; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInfo; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput; +import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration; +import com.google.gerrit.plugins.codeowners.backend.config.RequiredApproval; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.restapi.project.DeleteRef; +import com.google.inject.Inject; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Before; +import org.junit.Test; + +/** + * Acceptance test for the {@link + * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint. + * + * <p>Further tests for the {@link + * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that + * require using the REST API are implemented in {@link + * com.google.gerrit.plugins.codeowners.acceptance.restapi.PutCodeOwnerProjectConfigRestIT}. + */ +public class PutCodeOwnerProjectConfigIT extends AbstractCodeOwnersIT { + @Inject private RequestScopeOperations requestScopeOperations; + @Inject private ProjectOperations projectOperations; + @Inject private DeleteRef deleteRef; + + private CodeOwnersPluginConfiguration codeOwnersPluginConfiguration; + + @Before + public void setup() throws Exception { + codeOwnersPluginConfiguration = + plugin.getSysInjector().getInstance(CodeOwnersPluginConfiguration.class); + } + + @Test + public void requiresCallerToBeProjectOwner() throws Exception { + requestScopeOperations.setApiUser(user.id()); + AuthException authException = + assertThrows( + AuthException.class, + () -> + projectCodeOwnersApiFactory + .project(project) + .updateConfig(new CodeOwnerProjectConfigInput())); + assertThat(authException).hasMessageThat().isEqualTo("write refs/meta/config not permitted"); + } + + @Test + public void disableAndReenableCodeOwnersFunctionality() throws Exception { + assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabled = true; + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.status.disabled).isTrue(); + assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue(); + + input.disabled = false; + updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.status.disabled).isNull(); + assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse(); + } + + @Test + public void setDisabledBranches() throws Exception { + BranchNameKey masterBranch = BranchNameKey.create(project, "master"); + BranchNameKey fooBranch = BranchNameKey.create(project, "foo"); + + createBranch(fooBranch); + assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabledBranches = ImmutableList.of("refs/heads/master", "refs/heads/foo"); + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.status.disabledBranches) + .containsExactly("refs/heads/master", "refs/heads/foo"); + assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isTrue(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue(); + + input = new CodeOwnerProjectConfigInput(); + input.disabledBranches = ImmutableList.of(); + updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.status.disabledBranches).isNull(); + assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse(); + } + + @Test + public void setDisabledBranchesRegEx() throws Exception { + BranchNameKey masterBranch = BranchNameKey.create(project, "master"); + BranchNameKey fooBranch = BranchNameKey.create(project, "foo"); + + createBranch(fooBranch); + assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isFalse(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabledBranches = ImmutableList.of("refs/heads/*"); + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.status.disabledBranches) + .containsExactly("refs/heads/master", "refs/heads/foo"); + assertThat(codeOwnersPluginConfiguration.isDisabled(masterBranch)).isTrue(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue(); + } + + @Test + public void setDisabledBranchThatDoesntExist() throws Exception { + BranchNameKey fooBranch = BranchNameKey.create(project, "foo"); + + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isFalse(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabledBranches = ImmutableList.of("refs/heads/foo"); + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + // status.disabledBranches does only contain existing branches + assertThat(updatedConfig.status.disabledBranches).isNull(); + assertThat(codeOwnersPluginConfiguration.isDisabled(fooBranch)).isTrue(); + + createBranch(fooBranch); + assertThat(projectCodeOwnersApiFactory.project(project).getConfig().status.disabledBranches) + .containsExactly("refs/heads/foo"); + } + + @Test + public void cannotSetInvalidDisabledBranch() throws Exception { + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabledBranches = ImmutableList.of("^refs/heads/["); + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> projectCodeOwnersApiFactory.project(project).updateConfig(input)); + + assertThat(exception) + .hasMessageThat() + .contains( + "invalid config:\n" + + "* Disabled branch '^refs/heads/[' that is configured in code-owners.config" + + " (parameter codeOwners.disabledBranch) is invalid: Unclosed character class"); + } + + @Test + public void setFileExtension() throws Exception { + assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.fileExtension = "foo"; + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.general.fileExtension).isEqualTo("foo"); + assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).value().isEqualTo("foo"); + + input.fileExtension = ""; + updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.general.fileExtension).isNull(); + assertThat(codeOwnersPluginConfiguration.getFileExtension(project)).isEmpty(); + } + + @Test + public void setRequiredApproval() throws Exception { + RequiredApproval requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project); + assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review"); + assertThat(requiredApproval).hasValueThat().isEqualTo(1); + + String otherLabel = "Other"; + LabelDefinitionInput labelInput = new LabelDefinitionInput(); + labelInput.values = ImmutableMap.of("+2", "Approval", "+1", "LGTM", " 0", "No Vote"); + gApi.projects().name(project.get()).label(otherLabel).create(labelInput).get(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.requiredApproval = otherLabel + "+2"; + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.requiredApproval.label).isEqualTo(otherLabel); + assertThat(updatedConfig.requiredApproval.value).isEqualTo(2); + requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project); + assertThat(requiredApproval).hasLabelNameThat().isEqualTo(otherLabel); + assertThat(requiredApproval).hasValueThat().isEqualTo(2); + + input.requiredApproval = ""; + updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.requiredApproval.label).isEqualTo("Code-Review"); + assertThat(updatedConfig.requiredApproval.value).isEqualTo(1); + requiredApproval = codeOwnersPluginConfiguration.getRequiredApproval(project); + assertThat(requiredApproval).hasLabelNameThat().isEqualTo("Code-Review"); + assertThat(requiredApproval).hasValueThat().isEqualTo(1); + } + + @Test + public void setInvalidRequiredApproval() throws Exception { + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.requiredApproval = "Non-Existing-Label+2"; + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> projectCodeOwnersApiFactory.project(project).updateConfig(input)); + assertThat(exception) + .hasMessageThat() + .contains( + String.format( + "invalid config:\n" + + "* Required approval 'Non-Existing-Label+2' that is configured in" + + " code-owners.config (parameter codeOwners.requiredApproval) is invalid:" + + " Label Non-Existing-Label doesn't exist for project %s.", + project)); + } + + @Test + public void setOverrideApproval() throws Exception { + assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty(); + + String overrideLabel1 = "Bypass-Owners"; + String overrideLabel2 = "Owners-Override"; + createOwnersOverrideLabel(overrideLabel1); + createOwnersOverrideLabel(overrideLabel2); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.overrideApprovals = ImmutableList.of(overrideLabel1 + "+1", overrideLabel2 + "+1"); + CodeOwnerProjectConfigInfo updatedConfig = + projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.overrideApproval).hasSize(2); + assertThat(updatedConfig.overrideApproval.get(0).label).isEqualTo(overrideLabel1); + assertThat(updatedConfig.overrideApproval.get(0).value).isEqualTo(1); + assertThat(updatedConfig.overrideApproval.get(1).label).isEqualTo(overrideLabel2); + assertThat(updatedConfig.overrideApproval.get(1).value).isEqualTo(1); + assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).hasSize(2); + + input.overrideApprovals = ImmutableList.of(); + updatedConfig = projectCodeOwnersApiFactory.project(project).updateConfig(input); + assertThat(updatedConfig.overrideApproval).isNull(); + assertThat(codeOwnersPluginConfiguration.getOverrideApproval(project)).isEmpty(); + } + + @Test + public void setInvalidOverrideApproval() throws Exception { + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.overrideApprovals = ImmutableList.of("Non-Existing-Label+2"); + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> projectCodeOwnersApiFactory.project(project).updateConfig(input)); + assertThat(exception) + .hasMessageThat() + .contains( + String.format( + "invalid config:\n" + + "* Required approval 'Non-Existing-Label+2' that is configured in" + + " code-owners.config (parameter codeOwners.overrideApproval) is invalid:" + + " Label Non-Existing-Label doesn't exist for project %s.", + project)); + } + + @Test + @UseClockStep + public void checkCommitData() throws Exception { + RevCommit head1 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabled = true; + projectCodeOwnersApiFactory.project(project).updateConfig(input); + + // Check message, author and committer. + RevCommit head2 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG); + assertThat(head2).isNotEqualTo(head1); + assertThat(head2.getFullMessage()).isEqualTo("Update code-owners configuration"); + assertThat(head2.getAuthorIdent().getEmailAddress()).isEqualTo(admin.email()); + assertThat(head2.getCommitterIdent().getName()).isEqualTo("Gerrit Code Review"); + + input.disabled = false; + projectCodeOwnersApiFactory.project(project).updateConfig(input); + + // Check that timestamps differ for each commit. + RevCommit head3 = projectOperations.project(project).getHead(RefNames.REFS_CONFIG); + assertThat(head3).isNotEqualTo(head2); + assertThat(head3.getAuthorIdent().getWhen()).isGreaterThan(head2.getAuthorIdent().getWhen()); + assertThat(head3.getCommitterIdent().getWhen()) + .isGreaterThan(head2.getCommitterIdent().getWhen()); + } + + @Test + public void noOpUpdate() throws Exception { + RevCommit oldHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG); + projectCodeOwnersApiFactory.project(project).updateConfig(new CodeOwnerProjectConfigInput()); + RevCommit newHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG); + assertThat(newHead).isEqualTo(oldHead); + } + + @Test + public void updateConfigWhenRefsMetaConfigIsMissing() throws Exception { + ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); + deleteRef.deleteSingleRef(projectState, RefNames.REFS_CONFIG); + + assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isFalse(); + + CodeOwnerProjectConfigInput input = new CodeOwnerProjectConfigInput(); + input.disabled = true; + projectCodeOwnersApiFactory.project(project).updateConfig(input); + + assertThat(codeOwnersPluginConfiguration.isDisabled(project)).isTrue(); + } +}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java index 7d2bd66..8149903 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/CodeOwnersRestApiBindingsIT.java
@@ -42,11 +42,13 @@ private static final ImmutableList<RestCall> REVISION_ENDPOINTS = ImmutableList.of( - RestCall.post("/changes/%s/revisions/current/code-owners~code_owners.check_config")); + RestCall.post("/changes/%s/revisions/current/code-owners~code_owners.check_config"), + RestCall.get("/changes/%s/revisions/current/code-owners~owned_paths")); private static final ImmutableList<RestCall> PROJECT_ENDPOINTS = ImmutableList.of( RestCall.get("/projects/%s/code-owners~code_owners.project_config"), + RestCall.put("/projects/%s/code-owners~code_owners.project_config"), RestCall.post("/projects/%s/code_owners.check_config")); private static final ImmutableList<RestCall> BRANCH_ENDPOINTS =
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java new file mode 100644 index 0000000..f1ed3e6 --- /dev/null +++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/restapi/PutCodeOwnerProjectConfigRestIT.java
@@ -0,0 +1,67 @@ +// Copyright (C) 2021 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.acceptance.restapi; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.RestResponse; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.extensions.restapi.IdString; +import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersIT; +import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest; +import com.google.gerrit.plugins.codeowners.api.CodeOwnerProjectConfigInput; +import org.junit.Test; + +/** + * Acceptance test for the {@link + * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that + * require using REST. + * + * <p>Acceptance test for the {@link + * com.google.gerrit.plugins.codeowners.restapi.PutCodeOwnerProjectConfig} REST endpoint that can + * use the Java API are implemented in {@link + * com.google.gerrit.plugins.codeowners.acceptance.api.PutCodeOwnerProjectConfigIT}. + * + * <p>The tests in this class do not depend on the used code owner backend, hence we do not need to + * extend {@link AbstractCodeOwnersIT}. + */ +public class PutCodeOwnerProjectConfigRestIT extends AbstractCodeOwnersTest { + @Test + public void cannotUpdateConfigAnonymously() throws Exception { + RestResponse r = + anonymousRestSession.put( + String.format( + "/projects/%s/code_owners.project_config", IdString.fromDecoded(project.get())), + new CodeOwnerProjectConfigInput()); + r.assertForbidden(); + assertThat(r.getEntityContent()).contains("Authentication required"); + } + + @Test + @GerritConfig(name = "plugin.code-owners.backend", value = "non-existing-backend") + public void cannotUpdateConfigIfPluginConfigurationIsInvalid() throws Exception { + RestResponse r = + adminRestSession.put( + String.format( + "/projects/%s/code_owners.project_config", IdString.fromDecoded(project.get())), + new CodeOwnerProjectConfigInput()); + r.assertConflict(); + assertThat(r.getEntityContent()) + .contains( + "Invalid configuration of the code-owners plugin. Code owner backend" + + " 'non-existing-backend' that is configured in gerrit.config (parameter" + + " plugin.code-owners.backend) not found."); + } +}
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java index 59930d1..f957516 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckForAccountTest.java
@@ -16,17 +16,21 @@ import static com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject.assertThatStream; +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.PatchSet; import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest; import com.google.gerrit.plugins.codeowners.acceptance.testsuite.CodeOwnerConfigOperations; import com.google.gerrit.plugins.codeowners.common.CodeOwnerStatus; import com.google.gerrit.plugins.codeowners.testing.FileCodeOwnerStatusSubject; import com.google.gerrit.plugins.codeowners.util.JgitPath; import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.truth.ListSubject; import com.google.inject.Inject; import java.nio.file.Path; import java.nio.file.Paths; @@ -34,7 +38,10 @@ import org.junit.Before; import org.junit.Test; -/** Tests for {@link CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, Account.Id)}. */ +/** + * Tests for {@link CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, PatchSet, + * Account.Id)}. + */ public class CodeOwnerApprovalCheckForAccountTest extends AbstractCodeOwnersTest { @Inject private ChangeNotes.Factory changeNotesFactory; @Inject private RequestScopeOperations requestScopeOperations; @@ -58,10 +65,12 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would not be approved by the user. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -89,6 +98,7 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Add a Code-Review+1 (= code owner approval) from the code owner. requestScopeOperations.setApiUser(codeOwner.id()); @@ -96,7 +106,8 @@ // Verify that the file would not be approved by the user. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -120,10 +131,12 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would be approved by the user. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -135,6 +148,75 @@ } @Test + public void approvedByUser_forPatchSets() throws Exception { + codeOwnerConfigOperations + .newCodeOwnerConfig() + .project(project) + .branch("master") + .folderPath("/foo/") + .addCodeOwnerEmail(user.email()) + .create(); + + Path path1 = Paths.get("/foo/bar.baz"); + String changeId = + createChange("Change Adding A File", JgitPath.of(path1).get(), "file content") + .getChangeId(); + + // amend change and add another file + Path path2 = Paths.get("/foo/baz.bar"); + PushOneCommit push = + pushFactory.create( + admin.newIdent(), + testRepo, + "subject", + ImmutableMap.of( + JgitPath.of(path1).get(), "file content", JgitPath.of(path2).get(), "file content"), + changeId); + push.to("refs/for/master").assertOkStatus(); + + ChangeNotes changeNotes = getChangeNotes(changeId); + + // Verify that the file in patch set 1 would be approved by the user. + Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, + changeNotes.getPatchSets().get(PatchSet.id(changeNotes.getChangeId(), 1)), + user.id()); + FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = + assertThatStream(fileCodeOwnerStatuses).onlyElement(); + fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path1); + fileCodeOwnerStatusSubject + .hasNewPathStatus() + .value() + .hasStatusThat() + .isEqualTo(CodeOwnerStatus.APPROVED); + + // Verify that both files in patch set 2 would be approved by the user. + fileCodeOwnerStatuses = + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, + changeNotes.getPatchSets().get(PatchSet.id(changeNotes.getChangeId(), 2)), + user.id()); + ListSubject<FileCodeOwnerStatusSubject, FileCodeOwnerStatus> fileCodeOwnerStatusListSubject = + assertThatStream(fileCodeOwnerStatuses); + fileCodeOwnerStatusListSubject.hasSize(2); + fileCodeOwnerStatusSubject = fileCodeOwnerStatusListSubject.element(0); + fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path1); + fileCodeOwnerStatusSubject + .hasNewPathStatus() + .value() + .hasStatusThat() + .isEqualTo(CodeOwnerStatus.APPROVED); + fileCodeOwnerStatusSubject = fileCodeOwnerStatusListSubject.element(1); + fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path2); + fileCodeOwnerStatusSubject + .hasNewPathStatus() + .value() + .hasStatusThat() + .isEqualTo(CodeOwnerStatus.APPROVED); + } + + @Test public void notApprovedByUser_bootstrapping() throws Exception { // since no code owner config exists we are entering the bootstrapping code path in // CodeOwnerApprovalCheck @@ -142,10 +224,12 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would not be approved by the user since the user is not a project owner. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -164,11 +248,13 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would be approved by the 'admin' user since the 'admin' user is a // project owner. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), admin.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), admin.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -189,10 +275,12 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would be approved by the user since the user is a fallback code owner. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -221,11 +309,13 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would not be approved by the user since fallback code owners do not // apply. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path); @@ -245,10 +335,12 @@ Path path = Paths.get("/foo/bar.baz"); String changeId = createChange("Change Adding A File", JgitPath.of(path).get(), "file content").getChangeId(); + ChangeNotes changeNotes = getChangeNotes(changeId); // Verify that the file would be approved by the user since the user is a fallback code owner. Stream<FileCodeOwnerStatus> fileCodeOwnerStatuses = - codeOwnerApprovalCheck.getFileStatusesForAccount(getChangeNotes(changeId), user.id()); + codeOwnerApprovalCheck.getFileStatusesForAccount( + changeNotes, changeNotes.getCurrentPatchSet(), user.id()); FileCodeOwnerStatusSubject fileCodeOwnerStatusSubject = assertThatStream(fileCodeOwnerStatuses).onlyElement(); fileCodeOwnerStatusSubject.hasNewPathStatus().value().hasPathThat().isEqualTo(path);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java index 59a6e64..10727fb 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/CodeOwnerApprovalCheckTest.java
@@ -56,7 +56,7 @@ * <p>Further tests with fallback code owners are implemented in {@link * CodeOwnerApprovalCheckWithAllUsersAsFallbackCodeOwnersTest} and the functionality of {@link * CodeOwnerApprovalCheck#getFileStatusesForAccount(ChangeNotes, - * com.google.gerrit.entities.Account.Id)} is covered by {@link + * com.google.gerrit.entities.PatchSet, com.google.gerrit.entities.Account.Id)} is covered by {@link * CodeOwnerApprovalCheckForAccountTest}. */ public class CodeOwnerApprovalCheckTest extends AbstractCodeOwnersTest {
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java index 079f3b4..a8b6f0b 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/AbstractRequiredApprovalConfigTest.java
@@ -24,7 +24,6 @@ import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import com.google.gerrit.server.project.ProjectState; import org.eclipse.jgit.lib.Config; import org.junit.Test; @@ -129,9 +128,7 @@ () -> getRequiredApprovalConfig() .validateProjectLevelConfig( - /* projectState= */ null, - "code-owners.config", - new ProjectLevelConfig.Bare("code-owners.config"))); + /* projectState= */ null, "code-owners.config", new Config())); assertThat(npe).hasMessageThat().isEqualTo("projectState"); } @@ -143,10 +140,7 @@ NullPointerException.class, () -> getRequiredApprovalConfig() - .validateProjectLevelConfig( - projectState, - /* fileName= */ null, - new ProjectLevelConfig.Bare("code-owners.config"))); + .validateProjectLevelConfig(projectState, /* fileName= */ null, new Config())); assertThat(npe).hasMessageThat().isEqualTo("fileName"); } @@ -168,23 +162,19 @@ ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); ImmutableList<CommitValidationMessage> commitValidationMessage = getRequiredApprovalConfig() - .validateProjectLevelConfig( - projectState, - "code-owners.config", - new ProjectLevelConfig.Bare("code-owners.config")); + .validateProjectLevelConfig(projectState, "code-owners.config", new Config()); assertThat(commitValidationMessage).isEmpty(); } @Test public void validateValidProjectLevelConfig() throws Exception { ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig() - .setString( - SECTION_CODE_OWNERS, - /* subsection= */ null, - getRequiredApprovalConfig().getConfigKey(), - "Code-Review+2"); + Config cfg = new Config(); + cfg.setString( + SECTION_CODE_OWNERS, + /* subsection= */ null, + getRequiredApprovalConfig().getConfigKey(), + "Code-Review+2"); ImmutableList<CommitValidationMessage> commitValidationMessage = getRequiredApprovalConfig() .validateProjectLevelConfig(projectState, "code-owners.config", cfg); @@ -194,13 +184,12 @@ @Test public void validateInvalidProjectLevelConfig() throws Exception { ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig() - .setString( - SECTION_CODE_OWNERS, - /* subsection= */ null, - getRequiredApprovalConfig().getConfigKey(), - "INVALID"); + Config cfg = new Config(); + cfg.setString( + SECTION_CODE_OWNERS, + /* subsection= */ null, + getRequiredApprovalConfig().getConfigKey(), + "INVALID"); ImmutableList<CommitValidationMessage> commitValidationMessage = getRequiredApprovalConfig() .validateProjectLevelConfig(projectState, "code-owners.config", cfg);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java index 9852676..10ef51c 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/BackendConfigTest.java
@@ -30,7 +30,6 @@ import com.google.gerrit.plugins.codeowners.backend.proto.ProtoBackend; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import org.eclipse.jgit.lib.Config; import org.junit.Before; import org.junit.Test; @@ -191,9 +190,7 @@ NullPointerException npe = assertThrows( NullPointerException.class, - () -> - backendConfig.validateProjectLevelConfig( - null, new ProjectLevelConfig.Bare("code-owners.config"))); + () -> backendConfig.validateProjectLevelConfig(null, new Config())); assertThat(npe).hasMessageThat().isEqualTo("fileName"); } @@ -209,17 +206,15 @@ @Test public void validateEmptyProjectLevelConfig() throws Exception { ImmutableList<CommitValidationMessage> commitValidationMessage = - backendConfig.validateProjectLevelConfig( - "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config")); + backendConfig.validateProjectLevelConfig("code-owners.config", new Config()); assertThat(commitValidationMessage).isEmpty(); } @Test public void validateValidProjectLevelConfig() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig() - .setString( - SECTION_CODE_OWNERS, null, KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId()); + Config cfg = new Config(); + cfg.setString( + SECTION_CODE_OWNERS, null, KEY_BACKEND, CodeOwnerBackendId.FIND_OWNERS.getBackendId()); ImmutableList<CommitValidationMessage> commitValidationMessage = backendConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessage).isEmpty(); @@ -227,8 +222,8 @@ @Test public void validateInvalidProjectLevelConfig_invalidProjectConfiguration() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_BACKEND, "INVALID"); + Config cfg = new Config(); + cfg.setString(SECTION_CODE_OWNERS, null, KEY_BACKEND, "INVALID"); ImmutableList<CommitValidationMessage> commitValidationMessages = backendConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessages).hasSize(1); @@ -243,8 +238,8 @@ @Test public void validateInvalidProjectLevelConfig_invalidBranchConfiguration() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setString(SECTION_CODE_OWNERS, "someBranch", KEY_BACKEND, "INVALID"); + Config cfg = new Config(); + cfg.setString(SECTION_CODE_OWNERS, "someBranch", KEY_BACKEND, "INVALID"); ImmutableList<CommitValidationMessage> commitValidationMessages = backendConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessages).hasSize(1);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java index ff2785c..5a78e50 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -42,7 +42,6 @@ import com.google.gerrit.plugins.codeowners.common.MergeCommitStrategy; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import org.eclipse.jgit.lib.Config; import org.junit.Before; import org.junit.Test; @@ -400,9 +399,7 @@ NullPointerException npe = assertThrows( NullPointerException.class, - () -> - generalConfig.validateProjectLevelConfig( - null, new ProjectLevelConfig.Bare("code-owners.config"))); + () -> generalConfig.validateProjectLevelConfig(null, new Config())); assertThat(npe).hasMessageThat().isEqualTo("fileName"); } @@ -418,20 +415,18 @@ @Test public void validateEmptyProjectLevelConfig() throws Exception { ImmutableList<CommitValidationMessage> commitValidationMessage = - generalConfig.validateProjectLevelConfig( - "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config")); + generalConfig.validateProjectLevelConfig("code-owners.config", new Config()); assertThat(commitValidationMessage).isEmpty(); } @Test public void validateValidProjectLevelConfig() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig() - .setString( - SECTION_CODE_OWNERS, - null, - KEY_MERGE_COMMIT_STRATEGY, - MergeCommitStrategy.ALL_CHANGED_FILES.name()); + Config cfg = new Config(); + cfg.setString( + SECTION_CODE_OWNERS, + null, + KEY_MERGE_COMMIT_STRATEGY, + MergeCommitStrategy.ALL_CHANGED_FILES.name()); ImmutableList<CommitValidationMessage> commitValidationMessage = generalConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessage).isEmpty(); @@ -439,8 +434,8 @@ @Test public void validateInvalidProjectLevelConfig_invalidMergeCommitStrategy() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY, "INVALID"); + Config cfg = new Config(); + cfg.setString(SECTION_CODE_OWNERS, null, KEY_MERGE_COMMIT_STRATEGY, "INVALID"); ImmutableList<CommitValidationMessage> commitValidationMessages = generalConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessages).hasSize(1);
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java index 4163db3..2d892e4 100644 --- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java +++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/StatusConfigTest.java
@@ -28,7 +28,6 @@ import com.google.gerrit.plugins.codeowners.acceptance.AbstractCodeOwnersTest; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.ValidationMessage; -import com.google.gerrit.server.project.ProjectLevelConfig; import org.eclipse.jgit.lib.Config; import org.junit.Before; import org.junit.Test; @@ -266,9 +265,7 @@ NullPointerException npe = assertThrows( NullPointerException.class, - () -> - statusConfig.validateProjectLevelConfig( - null, new ProjectLevelConfig.Bare("code-owners.config"))); + () -> statusConfig.validateProjectLevelConfig(null, new Config())); assertThat(npe).hasMessageThat().isEqualTo("fileName"); } @@ -284,16 +281,15 @@ @Test public void validateEmptyProjectLevelConfig() throws Exception { ImmutableList<CommitValidationMessage> commitValidationMessage = - statusConfig.validateProjectLevelConfig( - "code-owners.config", new ProjectLevelConfig.Bare("code-owners.config")); + statusConfig.validateProjectLevelConfig("code-owners.config", new Config()); assertThat(commitValidationMessage).isEmpty(); } @Test public void validateValidProjectLevelConfig() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, true); - cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "refs/heads/master"); + Config cfg = new Config(); + cfg.setBoolean(SECTION_CODE_OWNERS, null, KEY_DISABLED, true); + cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "refs/heads/master"); ImmutableList<CommitValidationMessage> commitValidationMessage = statusConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessage).isEmpty(); @@ -301,8 +297,8 @@ @Test public void validateInvalidProjectLevelConfig_invalidDisabledValue() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED, "INVALID"); + Config cfg = new Config(); + cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED, "INVALID"); ImmutableList<CommitValidationMessage> commitValidationMessages = statusConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessages).hasSize(1); @@ -317,8 +313,8 @@ @Test public void validateInvalidProjectLevelConfig_invalidDisabledBranch() throws Exception { - ProjectLevelConfig.Bare cfg = new ProjectLevelConfig.Bare("code-owners.config"); - cfg.getConfig().setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "^refs/heads/["); + Config cfg = new Config(); + cfg.setString(SECTION_CODE_OWNERS, null, KEY_DISABLED_BRANCH, "^refs/heads/["); ImmutableList<CommitValidationMessage> commitValidationMessages = statusConfig.validateProjectLevelConfig("code-owners.config", cfg); assertThat(commitValidationMessages).hasSize(1);
diff --git a/resources/Documentation/config-faqs.md b/resources/Documentation/config-faqs.md index 3ac6230..4a86c2e 100644 --- a/resources/Documentation/config-faqs.md +++ b/resources/Documentation/config-faqs.md
@@ -4,6 +4,7 @@ * [How to check if the code owners functionality is enabled for a project or branch](#checkIfEnabled) * [How to avoid issues with code owner config files](#avoidIssuesWithCodeOwnerConfigs) * [How to investigate issues with code owner config files](#investigateIssuesWithCodeOwnerConfigs) +* [How to setup code owner overrides](#setupOverrides) ## <a id="updateCodeOwnersConfig">How to update the code-owners.config file for a project @@ -25,6 +26,9 @@ * push the newly created commit back to the `refs/meta/config` branch (e.g. `git push origin HEAD:refs/meta/config`) +Some of the configuration parameters can also be set via the [Update Code Owner +Project Config REST endpoint](rest-api.html#update-code-owner-project-config). + ## <a id="checkIfEnabled">How to check if the code owners functionality is enabled for a project or branch To check if the code owners functionality is enabled for a single branch, use @@ -93,6 +97,41 @@ Also see [above](#avoidIssuesWithCodeOwnerConfigs) how to avoid issues with code owner config files in the first place. +## <a id="setupOverrides">How to setup code owner overrides + +To setup code owner overrides do: + +### 1. Define a label that should count as code owner override: + +Create a [review label](../../../Documentation/config-labels.html) +via the [Create Label REST +endpoint](../../../Documentation/rest-api-projects.html#create-label): + +``` + curl -X PUT -d '{"commit_message": "Create Owners-Override Label", "values": {" 0": "No Override", "+1": "Override"}}' --header "Content-Type: application/json" https://<gerrit-host>/a/projects/<project-name>/labels/Owners-Override +``` + +### 2. Configure this label as override approval: + +Configure the override label via the [Update Code Owner Project Config REST +endpoint](rest-api.html#update-code-owner-project-config): + +``` + curl -X PUT -d '{"override_approvals": ["Owners-Override+1"]}' --header "Content-Type: application/json" https://<gerrit-host>/a/projects/<project-name>/code_owners.project_config +``` +\ +Also see the description of the +[override_approval](config.html#codeOwnersOverrideApproval) configuration +parameter. + +### 3. Assign permissions to vote on the override approval: + +Go to the access screen of your project in the Gerrit web UI and assign +permissions to vote on the override label. + +Alternatively the permissions can also be assigned via the [Set Access REST +endpoint](../../../Documentation/rest-api-projects.html#set-access). + --- Back to [@PLUGIN@ documentation index](index.html)
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md index e3f7485..2753850 100644 --- a/resources/Documentation/config.md +++ b/resources/Documentation/config.md
@@ -11,7 +11,17 @@ `@PLUGIN@.config` files that are stored in the `refs/meta/config` branches of the projects. -Parameters that are not set for a project are inherited from the parent project. +Parameters that are not set for a project are inherited from the parent project +or the global configuration in `gerrit.config`. + +A config setting on project level overrides the corresponsing setting that is +inherited from parent projects and the global configuration in `gerrit.config`. + +**NOTE:** Some configuration parameters have a list of values and can be +specified multiple times (e.g. `disabledBranch`). If such a value is set on +project level it means that the complete inherited list is overridden. It's +*not* possible to just add a value to the inherited list, but if this is wanted +the complete list with the additional value has to be set on project level. # <a id="globalConfiguration">Global configuration in gerrit.config</a> @@ -315,14 +325,21 @@ By default `NONE`. <a id="pluginCodeOwnersMaxPathsInChangeMessages">plugin.@PLUGIN@.maxPathsInChangeMessages</a> -: When a user votes on the [code owners - label](#pluginCodeOwnersRequiredApproval) the paths that are affected by - the vote are listed in the change message that is posted when the vote - is applied.\ +: The @PLUGIN@ plugin lists owned paths in change messages when: + \ + 1. A code owner votes on the [code owners + label](#pluginCodeOwnersRequiredApproval):\ + The paths that are affected by the vote are listed in the change message + that is posted when the vote is applied.\ + \ + 2. A code owner is added as reviewer:\ + The paths that are owned by the reviewer are posted as a change + message.\ + \ This configuration parameter controls the maximum number of paths that are included in change messages. This is to prevent that the change messages become too big for large changes that touch many files.\ - Setting the value to `0` disables including affected paths into change + Setting the value to `0` disables including owned paths into change messages.\ Can be overridden per project by setting [codeOwners.maxPathsInChangeMessages](#codeOwnersMaxPathsInChangeMessages) @@ -337,7 +354,8 @@ This allows projects to opt-out of the code owners functionality.\ Overrides the global setting [plugin.@PLUGIN@.disabled](#pluginCodeOwnersDisabled) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.disabled` setting from parent + projects.\ By default `false`. <a id="codeOwnersDisabledBranch">codeOwners.disabledBranch</a> @@ -351,12 +369,17 @@ approvals.\ This allows branches to opt-out of the code owners functionality.\ Can be set multiple times.\ + Overrides the global setting + [plugin.@PLUGIN@.disabledBranch](#pluginCodeOwnersDisabledBranch) in + `gerrit.config` and the `codeOwners.disabledBranch` setting from parent + projects.\ By default unset. <a id="codeOwnersBackend">codeOwners.backend</a> : The code owners backend that should be used for the project.\ Overrides the global setting - [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config`.\ + [plugin.@PLUGIN@.backend](#pluginCodeOwnersBackend) in `gerrit.config` + and the `codeOwners.backend` setting from parent projects.\ Can be overridden per branch by setting [codeOwners.\<branch\>.backend](#codeOwnersBranchBackend).\ The supported code owner backends are listed at the @@ -377,7 +400,8 @@ The branch can be the short or full name. If both configurations exist the one for the full name takes precedence.\ Overrides the per repository setting - [codeOwners.backend](#codeOwnersBackend).\ + [codeOwners.backend](#codeOwnersBackend) and the + `codeOwners.\<branch\>.backend` setting from parent projects.\ The supported code owner backends are listed at the [Backends](backends.html) page.\ If not set, the project level configuration @@ -400,7 +424,8 @@ files are ignored.\ Overrides the global setting [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.fileExtension` setting from parent + projects.\ If not set, the global setting [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in `gerrit.config` is used. @@ -412,7 +437,8 @@ users can discover the override instructions easily.\ Overrides the global setting [plugin.@PLUGIN@.overrideInfoUrl](#pluginCodeOwnersOverrideInfoUrl) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.overrideInfoUrl` setting from parent + projects.\ If not set, the global setting [plugin.@PLUGIN@.overrideInfoUrl](#pluginCodeOwnersOverrideInfoUrl) in `gerrit.config` is used. @@ -434,7 +460,8 @@ self-approve their own changes by voting on the change.\ Overrides the global setting [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.enableImplicitApprovals` setting + from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.enableImplicitApprovals](#pluginCodeOwnersenableImplicitApprovals) in `gerrit.config` is used. @@ -449,7 +476,8 @@ Can be specified multiple time to set multiple global code owners.\ Overrides the global setting [plugin.@PLUGIN@.globalCodeOwner](#pluginCodeOwnersGlobalCodeOwner) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.globalCodeOwner` setting from parent + projects.\ If not set, the global setting [plugin.@PLUGIN@.globalCodeOwner](#pluginCodeOwnersGlobalCodeOwner) in `gerrit.config` is used. @@ -458,7 +486,8 @@ : Whether code owner config files are read-only.\ Overrides the global setting [plugin.@PLUGIN@.readOnly](#pluginCodeOwnersReadOnly) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.readOnly` setting from parent + projects.\ If not set, the global setting [plugin.@PLUGIN@.readOnly](#pluginCodeOwnersReadOnly) in `gerrit.config` is used. @@ -475,7 +504,8 @@ config files in open changes as part of a pre-submit validation.\ Overrides the global setting [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.enableValidationOnCommitReceived` + setting from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.enableValidationOnCommitReceived](#pluginCodeOwnersEnableValidationOnCommitReceived) in `gerrit.config` is used. @@ -491,7 +521,8 @@ Disabling the submit validation is not recommended.\ Overrides the global setting [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.enableValidationOnSubmit` setting + from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.enableValidationOnSubmit](#pluginCodeOwnersEnableValidationOnSubmit) in `gerrit.config` is used. @@ -509,7 +540,8 @@ or [enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit). Overrides the global setting [plugin.@PLUGIN@.rejectNonResolvableCodeOwners](#pluginCodeOwnersRejectNonResolvableCodeOwners) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.rejectNonResolvableCodeOwners` + setting from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.rejectNonResolvableCodeOwners](#pluginCodeOwnersRejectNonResolvableCodeOwners) in `gerrit.config` is used. @@ -526,7 +558,8 @@ or [enableValidationOnSubmit](#codeOwnersEnableValidationOnSubmit). Overrides the global setting [plugin.@PLUGIN@.rejectNonResolvableImports](#pluginCodeOwnersRejectNonResolvableImports) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.rejectNonResolvableImports` + setting from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.rejectNonResolvableImports](#pluginCodeOwnersRejectNonResolvableImports) in `gerrit.config` is used. @@ -551,7 +584,8 @@ owners check.\ Overrides the global setting [plugin.@PLUGIN@.requiredApproval](#pluginCodeOwnersRequiredApproval) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.requiredApproval` setting from + parent projects.\ If not set, the global setting [plugin.@PLUGIN@.requiredApproval](#pluginCodeOwnersRequiredApproval) in `gerrit.config` is used. @@ -578,7 +612,8 @@ ignored for the code owners check.\ Overrides the global setting [plugin.@PLUGIN@.overrideApproval](#pluginCodeOwnersOverrideApproval) in - `gerrit.config`.\ + `gerrit.config` and the `codeOwners.overrideApproval` setting from + parent projects.\ If not set, the global setting [plugin.@PLUGIN@.overrideApproval](#pluginCodeOwnersOverrideApproval) in `gerrit.config` is used. @@ -591,7 +626,8 @@ for an explanation of these values).\ Overrides the global setting [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.mergeCommitStrategy` setting from + parent projects.\ If not set, the global setting [plugin.@PLUGIN@.mergeCommitStrategy](#pluginCodeOwnersMergeCommitStrategy) in `gerrit.config` is used. @@ -606,24 +642,33 @@ for an explanation of these values).\ Overrides the global setting [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.fallbackCodeOwners` setting from + parent projects.\ If not set, the global setting [plugin.@PLUGIN@.fallbackCodeOwners](#pluginCodeOwnersFallbackCodeOwners) in `gerrit.config` is used. <a id="codeOwnersMaxPathsInChangeMessages">codeOwners.maxPathsInChangeMessages</a> -: When a user votes on the [code owners - label](#codeOwnersRequiredApproval) the paths that are affected by the - vote are listed in the change message that is posted when the vote is - applied.\ +: The @PLUGIN@ plugin lists owned paths in change messages when: + \ + 1. A code owner votes on the [code owners + label](#pluginCodeOwnersRequiredApproval):\ + The paths that are affected by the vote are listed in the change message + that is posted when the vote is applied.\ + \ + 2. A code owner is added as reviewer:\ + The paths that are owned by the reviewer are posted as a change + message.\ + \ This configuration parameter controls the maximum number of paths that are included in change messages. This is to prevent that the change messages become too big for large changes that touch many files.\ - Setting the value to `0` disables including affected paths into change + Setting the value to `0` disables including owned paths into change messages.\ Overrides the global setting [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages) - in `gerrit.config`.\ + in `gerrit.config` and the `codeOwners.maxPathsInChangeMessages` setting + from parent projects.\ If not set, the global setting [plugin.@PLUGIN@.maxPathsInChangeMessages](#pluginCodeOwnersMaxPathsInChangeMessages) in `gerrit.config` is used.\
diff --git a/resources/Documentation/rest-api.md b/resources/Documentation/rest-api.md index dd9dbcf..bde20bd 100644 --- a/resources/Documentation/rest-api.md +++ b/resources/Documentation/rest-api.md
@@ -63,6 +63,44 @@ } ``` +### <a id="update-code-owner-project-config">Update Code Owner Project Config +_'PUT /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/code_owners.project_config'_ + +Updates the code owner project configuration. + +The configuration parameters that should be updated must be specified in the +request body in a [CodeOwnerProjectConfigInput](#code-owner-project-config-info) +entity. + +#### Request + +``` + PUT /projects/foo%2Fbar/code_owners.project_config HTTP/1.0 + Content-Type: application/json; charset=UTF-8 + + { + "disabled": "true" + } +``` + +#### Response + +As a response the updated code owner project config is returned as +[CodeOwnerProjectConfigInfo](#code-owner-project-config-info) entity. + +``` + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "status": { + "disabled": "true" + } + } +``` + ### <a id="check-code-owner-config-files">Check Code Owner Config Files _'POST /projects/[\{project-name\}](../../../Documentation/rest-api-projects.html#project-name)/code_owners.check_config'_ @@ -577,6 +615,42 @@ This way the sort order on a change is always the same for files that have the exact same code owners (requires that the limit is the same on all requests). +### <a id="get-owned-files">Get Owned Files +_'GET /changes/[\{change-id}](../../../Documentation/rest-api-changes.html#change-id)/revisions/[\{revison-id\}](../../../Documentation/rest-api-changes.html#revision-id)/owned_paths'_ + +Lists the files of the revision that are owned by the specified user (see `user` +request parameter below). + +The following request parameters can be specified: + +| Field Name | | Description | +| ----------- | --------- | ----------- | +| `user` | mandatory | user for which the owned paths should be returned + +#### Request + +``` + GET /changes/20187/revisions/current/owned_paths?user=foo.bar@example.com HTTP/1.0 +``` + +#### Response + +As a response a [OwnedPathsInfo](#owned-paths-info) entity is returned. + +``` + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "owned_paths": [ + "/foo/bar/baz.md", + "/foo/baz/bar.md", + ] + } +``` + ### <a id="check-code-owner-config-files-in-revision">Check Code Owner Config Files In Revision _'POST /changes/[\{change-id}](../../../Documentation/rest-api-changes.html#change-id)/revisions/[\{revison-id\}](../../../Documentation/rest-api-changes.html#revision-id)/code_owners.check_config'_ @@ -751,6 +825,23 @@ --- +### <a id="code-owner-project-config-input"> CodeOwnerProjectConfigInput +The `CodeOwnerProjectConfigInput` entity specifies which parameters in the +`code-owner.project` file in `refs/meta/config` should be updated. + +If a field in this input is not set, the corresponding parameter in the +`code-owners.config` file is not updated. + +| Field Name | | Description | +| ---------- | -------- | ----------- | +| `disabled` | optional | Whether the code owners functionality should be disabled/enabled for the project. +| `disabled_branch` | optional | List of branches for which the code owners functionality is disabled. Can be exact refs, ref patterns or regular expressions. Overrides any existing disabled branch configuration. +| `file_extension` | optional | The file extension that should be used for code owner config files in this project. +| `required_approval` | optional | The approval that is required from code owners. Must be specified in the format "\<label-name\>+\<label-value\>". If an empty string is provided the required approval configuration is unset. Unsetting the required approval means that the inherited required approval configuration or the default required approval (`Code-Review+1`) will apply. In contrast to providing an empty string, providing `null` (or not setting the value) means that the required approval configuration is not updated. +| `override_approvals` | optional | The approvals that count as override for the code owners submit check. Must be specified in the format "\<label-name\>+\<label-value\>". + +--- + ### <a id="code-owner-reference-info"> CodeOwnerReferenceInfo The `CodeOwnerReferenceInfo` entity contains information about a code owner reference in a code owner config. @@ -815,6 +906,14 @@ | `override_info_url` | optional | Optional URL for a page that provides project/host-specific information about how to request a code owner override. |`fallback_code_owners` || Policy that controls who should own paths that have no code owners defined. Possible values are: `NONE`: Paths for which no code owners are defined are owned by no one. `ALL_USER`: Paths for which no code owners are defined are owned by all users. +### <a id="owned-paths-info"> OwnedPathsInfo +The `OwnedPathsInfo` entity contains paths that are owned by a user. + + +| Field Name | Description | +| ------------- | ----------- | +| `owned_paths` | The owned paths as absolute paths, sorted alphabetically. + ### <a id="path-code-owner-status-info"> PathCodeOwnerStatusInfo The `PathCodeOwnerStatusInfo` entity describes the code owner status for a path in a change.