blob: b5e01810a547f88320b055c2671e5037c248806a [file] [log] [blame]
// Copyright (C) 2018 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.server.change;
import static com.google.common.base.Preconditions.checkArgument;
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 com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Ordering;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.api.changes.ReviewerInput;
import com.google.gerrit.extensions.api.changes.ReviewerResult;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.GroupMembers;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.group.GroupResolver;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
public class ReviewerModifier {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
public static final int DEFAULT_MAX_REVIEWERS = 20;
/**
* Controls which failures should be ignored.
*
* <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
* ignored a failure means that the operation fails.
*/
public enum FailureBehavior {
// All failures cause the operation to fail.
FAIL,
// Only not found failures cause the operation to fail, all other failures are ignored.
IGNORE_EXCEPT_NOT_FOUND,
// All failures are ignored.
IGNORE_ALL;
}
private enum FailureType {
NOT_FOUND,
OTHER;
}
// TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
// type in the public interfaces of ReviewerModifier, rather than passing around the REST API type
// internally.
public static class InternalReviewerInput extends ReviewerInput {
/**
* Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
* resolving to an account/group/email.
*/
public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
/** Whether the visibility check for the reviewer account should be skipped. */
public boolean skipVisibilityCheck = false;
}
public static InternalReviewerInput newReviewerInput(
String reviewer, ReviewerState state, NotifyHandling notify) {
InternalReviewerInput in = new InternalReviewerInput();
in.reviewer = reviewer;
in.state = state;
in.notify = notify;
return in;
}
public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
Change change,
ObjectId commitId,
@Nullable Account.Id accountId,
NotifyHandling notify,
Account.Id mostRecentUploader) {
if (accountId == null || accountId.equals(mostRecentUploader)) {
// If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
return Optional.empty();
}
logger.atFine().log(
"Adding account %d from author/committer identity of commit %s as cc to change %d",
accountId.get(), commitId.name(), change.getChangeId());
InternalReviewerInput in = new InternalReviewerInput();
in.reviewer = accountId.toString();
in.state = CC;
in.notify = notify;
in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
return Optional.of(in);
}
private final AccountResolver accountResolver;
private final PermissionBackend permissionBackend;
private final GroupResolver groupResolver;
private final GroupMembers groupMembers;
private final AccountLoader.Factory accountLoaderFactory;
private final Config cfg;
private final ReviewerJson json;
private final ProjectCache projectCache;
private final Provider<AnonymousUser> anonymousProvider;
private final AddReviewersOp.Factory addReviewersOpFactory;
private final OutgoingEmailValidator validator;
private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
@Inject
ReviewerModifier(
AccountResolver accountResolver,
PermissionBackend permissionBackend,
GroupResolver groupResolver,
GroupMembers groupMembers,
AccountLoader.Factory accountLoaderFactory,
@GerritServerConfig Config cfg,
ReviewerJson json,
ProjectCache projectCache,
Provider<AnonymousUser> anonymousProvider,
AddReviewersOp.Factory addReviewersOpFactory,
OutgoingEmailValidator validator,
DeleteReviewerOp.Factory deleteReviewerOpFactory,
DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
this.accountResolver = accountResolver;
this.permissionBackend = permissionBackend;
this.groupResolver = groupResolver;
this.groupMembers = groupMembers;
this.accountLoaderFactory = accountLoaderFactory;
this.cfg = cfg;
this.json = json;
this.projectCache = projectCache;
this.anonymousProvider = anonymousProvider;
this.addReviewersOpFactory = addReviewersOpFactory;
this.validator = validator;
this.deleteReviewerOpFactory = deleteReviewerOpFactory;
this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
}
/**
* Prepare application of a single {@link ReviewerInput}.
*
* @param notes change notes.
* @param user user performing the reviewer addition.
* @param input input describing user or group to add as a reviewer.
* @param allowGroup whether to allow
* @return handle describing the addition operation. If the {@code op} field is present, this
* operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
* contains information about an error that occurred
*/
public ReviewerModification prepare(
ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup)
throws IOException, PermissionBackendException, ConfigInvalidException {
try (TraceContext.TraceTimer ignored =
TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
requireNonNull(input.reviewer);
boolean confirmed = input.confirmed();
boolean allowByEmail =
projectCache
.get(notes.getProjectName())
.orElseThrow(illegalState(notes.getProjectName()))
.is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
ReviewerModification byAccountId = byAccountId(input, notes, user);
ReviewerModification wholeGroup = null;
if (!byAccountId.exactMatchFound) {
wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
if (wholeGroup != null && wholeGroup.exactMatchFound) {
return wholeGroup;
}
}
if (wholeGroup != null
&& byAccountId.failureType == FailureType.NOT_FOUND
&& wholeGroup.failureType == FailureType.NOT_FOUND) {
return fail(
byAccountId.input,
FailureType.NOT_FOUND,
byAccountId.result.error + "\n" + wholeGroup.result.error);
}
if (byAccountId.failureType != FailureType.NOT_FOUND) {
return byAccountId;
}
if (wholeGroup != null) {
return wholeGroup;
}
return addByEmail(input, notes, user);
}
}
public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) {
return new ReviewerModification(
newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
revision.getNotes(),
revision.getUser(),
ImmutableSet.of(user.asIdentifiedUser().getAccount()),
null,
true,
false);
}
@Nullable
private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user)
throws PermissionBackendException, IOException, ConfigInvalidException {
IdentifiedUser reviewerUser;
boolean exactMatchFound = false;
try {
if (ReviewerState.REMOVED.equals(input.state)
|| (input instanceof InternalReviewerInput
&& ((InternalReviewerInput) input).skipVisibilityCheck)) {
reviewerUser =
accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
} else {
reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
}
if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
|| input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
exactMatchFound = true;
}
} catch (UnprocessableEntityException e) {
// Caller might choose to ignore this NOT_FOUND result if they find another result e.g. by
// group, but if not, the error message will be useful.
return fail(input, FailureType.NOT_FOUND, e.getMessage());
}
if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
return new ReviewerModification(
input,
notes,
user,
ImmutableSet.of(reviewerUser.getAccount()),
null,
exactMatchFound,
false);
}
return fail(
input,
FailureType.OTHER,
MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
}
@Nullable
private ReviewerModification addWholeGroup(
ReviewerInput input,
ChangeNotes notes,
CurrentUser user,
boolean confirmed,
boolean allowGroup,
boolean allowByEmail)
throws IOException, PermissionBackendException {
if (!allowGroup) {
return null;
}
GroupDescription.Basic group;
try {
// TODO(dborowitz): This currently doesn't work in the push path because InternalGroupBackend
// depends on the Provider<CurrentUser> which returns anonymous in that path.
group = groupResolver.parseInternal(input.reviewer);
} catch (UnprocessableEntityException e) {
if (!allowByEmail) {
return fail(
input,
FailureType.NOT_FOUND,
MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
}
return null;
}
if (!isLegalReviewerGroup(group.getGroupUUID())) {
return fail(
input,
FailureType.OTHER,
MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
}
if (input.state().equals(REMOVED)) {
return fail(
input,
FailureType.OTHER,
MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
}
Set<Account> reviewers = new HashSet<>();
Set<Account> members;
try {
members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
} catch (NoSuchProjectException e) {
return fail(input, FailureType.OTHER, e.getMessage());
}
// if maxAllowed is set to 0, it is allowed to add any number of
// reviewers
int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
if (maxAllowed > 0 && members.size() > maxAllowed) {
logger.atFine().log(
"Adding %d group members is not allowed (maxAllowed = %d)", members.size(), maxAllowed);
return fail(
input,
FailureType.OTHER,
MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
}
// if maxWithoutCheck is set to 0, we never ask for confirmation
int maxWithoutConfirmation =
cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
logger.atFine().log(
"Adding %d group members as reviewer requires confirmation (maxWithoutConfirmation = %d)",
members.size(), maxWithoutConfirmation);
return fail(
input,
FailureType.OTHER,
true,
MessageFormat.format(
ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
}
for (Account member : members) {
if (isValidReviewer(notes.getChange().getDest(), member)) {
reviewers.add(member);
}
}
return new ReviewerModification(input, notes, user, reviewers, null, true, true);
}
@Nullable
private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
throws PermissionBackendException {
if (!permissionBackend
.user(anonymousProvider.get())
.change(notes)
.test(ChangePermission.READ)) {
return fail(
input,
FailureType.OTHER,
MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
}
Address adr = Address.tryParse(input.reviewer);
if (adr == null || !validator.isValid(adr.email())) {
return fail(
input,
FailureType.NOT_FOUND,
MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
}
return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
}
private boolean isValidReviewer(BranchNameKey branch, Account member)
throws PermissionBackendException {
// Check ref permission instead of change permission, since change permissions take into
// account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
// see private changes.
return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
}
private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
return fail(input, failureType, false, error);
}
private ReviewerModification fail(
ReviewerInput input, FailureType failureType, boolean confirm, String error) {
ReviewerModification addition = new ReviewerModification(input, failureType);
addition.result.confirm = confirm ? true : null;
addition.result.error = error;
return addition;
}
public class ReviewerModification {
public final ReviewerResult result;
@Nullable public final ReviewerOp op;
public final ImmutableSet<Account> reviewers;
public final ImmutableSet<Address> reviewersByEmail;
@Nullable final IdentifiedUser caller;
final boolean exactMatchFound;
private final ReviewerInput input;
@Nullable private final FailureType failureType;
private ReviewerModification(ReviewerInput input, FailureType failureType) {
this.input = input;
this.failureType = requireNonNull(failureType);
result = new ReviewerResult(input.reviewer);
op = null;
reviewers = ImmutableSet.of();
reviewersByEmail = ImmutableSet.of();
caller = null;
exactMatchFound = false;
}
private ReviewerModification(
ReviewerInput input,
ChangeNotes notes,
CurrentUser caller,
@Nullable Iterable<Account> reviewers,
@Nullable Iterable<Address> reviewersByEmail,
boolean exactMatchFound,
boolean forGroup) {
checkArgument(
reviewers != null || reviewersByEmail != null,
"must have either reviewers or reviewersByEmail");
this.input = input;
this.failureType = null;
result = new ReviewerResult(input.reviewer);
if (!state().equals(REMOVED)) {
// Always silently ignore adding the owner as any type of reviewer on their own change. They
// may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
} else {
this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
}
this.reviewersByEmail =
reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
this.caller = caller.asIdentifiedUser();
if (state().equals(REMOVED)) {
// only one is set.
checkState(
(this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty())
|| (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1));
if (this.reviewers.size() >= 1) {
checkState(this.reviewers.size() == 1);
DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
deleteReviewerInput.notify = input.notify;
deleteReviewerInput.notifyDetails = input.notifyDetails;
op =
deleteReviewerOpFactory.create(
Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput);
} else {
checkState(this.reviewersByEmail.size() == 1);
op =
deleteReviewerByEmailOpFactory.create(
Iterables.getOnlyElement(this.reviewersByEmail.asList()));
}
} else {
op =
addReviewersOpFactory.create(
this.reviewers.stream().map(Account::id).collect(toImmutableSet()),
this.reviewersByEmail,
state(),
forGroup);
}
this.exactMatchFound = exactMatchFound;
}
private ImmutableSet<Account> reviewersAsList(
ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
if (reviewers == null) {
return ImmutableSet.of();
}
Stream<Account> reviewerStream = Streams.stream(reviewers);
if (omitChangeOwner) {
reviewerStream =
reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
}
return reviewerStream.collect(toImmutableSet());
}
public void gatherResults(ChangeData cd) throws PermissionBackendException {
checkState(op != null, "addition did not result in an update op");
checkState(op.getResult() != null, "op did not return a result");
// Generate result details and fill AccountLoader. This occurs outside
// the Op because the accounts are in a different table.
ReviewerOp.Result opResult = op.getResult();
switch (state()) {
case CC:
result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
for (Account.Id accountId : opResult.addedCCs()) {
result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
}
accountLoaderFactory.create(true).fill(result.ccs);
for (Address a : opResult.addedCCsByEmail()) {
result.ccs.add(new AccountInfo(a.name(), a.email()));
}
break;
case REVIEWER:
result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
for (PatchSetApproval psa : opResult.addedReviewers()) {
// New reviewers have value 0, don't bother normalizing.
result.reviewers.add(
json.format(
new ReviewerInfo(psa.accountId().get()),
psa.accountId(),
cd,
ImmutableList.of(psa)));
}
accountLoaderFactory.create(true).fill(result.reviewers);
for (Address a : opResult.addedReviewersByEmail()) {
result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
}
break;
case REMOVED:
if (opResult.deletedReviewer().isPresent()) {
result.removed =
json.format(
new ReviewerInfo(opResult.deletedReviewer().get().get()),
opResult.deletedReviewer().get(),
cd);
accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed));
} else if (opResult.deletedReviewerByEmail().isPresent()) {
result.removed =
new AccountInfo(
opResult.deletedReviewerByEmail().get().name(),
opResult.deletedReviewerByEmail().get().email());
}
break;
default:
throw new IllegalStateException(
String.format("Illegal ReviewerState argument is %s", state().name()));
}
}
public ReviewerState state() {
return input.state();
}
public boolean isFailure() {
return failureType != null;
}
public boolean isIgnorableFailure() {
checkState(failureType != null);
FailureBehavior behavior =
(input instanceof InternalReviewerInput)
? ((InternalReviewerInput) input).otherFailureBehavior
: FailureBehavior.FAIL;
return behavior == FailureBehavior.IGNORE_ALL
|| (failureType == FailureType.OTHER
&& behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
}
}
public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
return !SystemGroupBackend.isSystemGroup(groupUUID);
}
public ReviewerModificationList prepare(
ChangeNotes notes,
CurrentUser user,
Iterable<? extends ReviewerInput> inputs,
boolean allowGroup)
throws IOException, PermissionBackendException, ConfigInvalidException {
// Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
// reviewer; the last call to ChangeUpdate#putReviewer wins. This can happen if the caller
// specifies the same string twice, or less obviously if they specify multiple groups with
// overlapping members.
// TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
// previously processed, to proactively prevent overlap so we don't have to rely on this subtle
// behavior.
ImmutableList<ReviewerInput> sorted =
Streams.stream(inputs)
.sorted(
comparing(
ReviewerInput::state,
Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
.collect(toImmutableList());
List<ReviewerModification> additions = new ArrayList<>();
for (ReviewerInput input : sorted) {
ReviewerModification addition = prepare(notes, user, input, allowGroup);
if (addition.op != null) {
// Assume any callers preparing a list of batch insertions are handling their own email.
addition.op.suppressEmail();
}
additions.add(addition);
}
return new ReviewerModificationList(additions);
}
// TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
// really an op, it's a collection of ops, and it's only called from the body of other ops. We
// could make this class an op, but we would still have AddReviewersOp. Better would probably be
// to design a single op that supports combining multiple ReviewerInputs together. That would
// probably also subsume the Addition class itself, which would be a good thing.
public static class ReviewerModificationList {
private final ImmutableList<ReviewerModification> modifications;
private ReviewerModificationList(List<ReviewerModification> modifications) {
this.modifications = ImmutableList.copyOf(modifications);
}
public ImmutableList<ReviewerModification> getFailures() {
return modifications.stream()
.filter(a -> a.isFailure() && !a.isIgnorableFailure())
.collect(toImmutableList());
}
// We never call updateRepo on the addition ops, which is only ok because it's a no-op.
public void updateChange(ChangeContext ctx, PatchSet patchSet)
throws RestApiException, IOException, PermissionBackendException {
for (ReviewerModification addition : modifications()) {
addition.op.setPatchSet(patchSet);
addition.op.updateChange(ctx);
}
}
public void postUpdate(PostUpdateContext ctx) throws Exception {
for (ReviewerModification addition : modifications()) {
if (addition.op != null) {
addition.op.postUpdate(ctx);
}
}
}
public <T> ImmutableSet<T> flattenResults(
Function<ReviewerOp.Result, ? extends Collection<T>> func) {
modifications()
.forEach(
a ->
checkArgument(
a.op != null && a.op.getResult() != null, "missing result on %s", a));
return modifications().stream()
.map(a -> a.op.getResult())
.map(func)
.flatMap(Collection::stream)
.collect(toImmutableSet());
}
private ImmutableList<ReviewerModification> modifications() {
return modifications.stream()
.filter(
a -> {
if (a.isFailure()) {
if (a.isIgnorableFailure()) {
return false;
}
// Shouldn't happen, caller should have checked that there were no errors.
throw new IllegalStateException("error in addition: " + a.result.error);
}
return true;
})
.collect(toImmutableList());
}
}
}