Line data Source code
1 : // Copyright (C) 2018 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.change;
16 :
17 : import static com.google.common.base.Preconditions.checkArgument;
18 : import static com.google.common.base.Preconditions.checkState;
19 : import static com.google.common.collect.ImmutableList.toImmutableList;
20 : import static com.google.common.collect.ImmutableSet.toImmutableSet;
21 : import static com.google.gerrit.extensions.client.ReviewerState.CC;
22 : import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
23 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
24 : import static java.util.Comparator.comparing;
25 : import static java.util.Objects.requireNonNull;
26 :
27 : import com.google.common.collect.ImmutableList;
28 : import com.google.common.collect.ImmutableSet;
29 : import com.google.common.collect.Iterables;
30 : import com.google.common.collect.Lists;
31 : import com.google.common.collect.Ordering;
32 : import com.google.common.collect.Streams;
33 : import com.google.common.flogger.FluentLogger;
34 : import com.google.gerrit.common.Nullable;
35 : import com.google.gerrit.entities.Account;
36 : import com.google.gerrit.entities.AccountGroup;
37 : import com.google.gerrit.entities.Address;
38 : import com.google.gerrit.entities.BooleanProjectConfig;
39 : import com.google.gerrit.entities.BranchNameKey;
40 : import com.google.gerrit.entities.Change;
41 : import com.google.gerrit.entities.GroupDescription;
42 : import com.google.gerrit.entities.PatchSet;
43 : import com.google.gerrit.entities.PatchSetApproval;
44 : import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
45 : import com.google.gerrit.extensions.api.changes.NotifyHandling;
46 : import com.google.gerrit.extensions.api.changes.ReviewerInfo;
47 : import com.google.gerrit.extensions.api.changes.ReviewerInput;
48 : import com.google.gerrit.extensions.api.changes.ReviewerResult;
49 : import com.google.gerrit.extensions.client.ReviewerState;
50 : import com.google.gerrit.extensions.common.AccountInfo;
51 : import com.google.gerrit.extensions.restapi.RestApiException;
52 : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
53 : import com.google.gerrit.server.AnonymousUser;
54 : import com.google.gerrit.server.CurrentUser;
55 : import com.google.gerrit.server.IdentifiedUser;
56 : import com.google.gerrit.server.account.AccountLoader;
57 : import com.google.gerrit.server.account.AccountResolver;
58 : import com.google.gerrit.server.account.GroupMembers;
59 : import com.google.gerrit.server.config.GerritServerConfig;
60 : import com.google.gerrit.server.group.GroupResolver;
61 : import com.google.gerrit.server.group.SystemGroupBackend;
62 : import com.google.gerrit.server.logging.Metadata;
63 : import com.google.gerrit.server.logging.TraceContext;
64 : import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
65 : import com.google.gerrit.server.notedb.ChangeNotes;
66 : import com.google.gerrit.server.permissions.ChangePermission;
67 : import com.google.gerrit.server.permissions.PermissionBackend;
68 : import com.google.gerrit.server.permissions.PermissionBackendException;
69 : import com.google.gerrit.server.permissions.RefPermission;
70 : import com.google.gerrit.server.project.NoSuchProjectException;
71 : import com.google.gerrit.server.project.ProjectCache;
72 : import com.google.gerrit.server.query.change.ChangeData;
73 : import com.google.gerrit.server.update.ChangeContext;
74 : import com.google.gerrit.server.update.PostUpdateContext;
75 : import com.google.inject.Inject;
76 : import com.google.inject.Provider;
77 : import java.io.IOException;
78 : import java.text.MessageFormat;
79 : import java.util.ArrayList;
80 : import java.util.Collection;
81 : import java.util.HashSet;
82 : import java.util.List;
83 : import java.util.Optional;
84 : import java.util.Set;
85 : import java.util.function.Function;
86 : import java.util.stream.Stream;
87 : import org.eclipse.jgit.errors.ConfigInvalidException;
88 : import org.eclipse.jgit.lib.Config;
89 : import org.eclipse.jgit.lib.ObjectId;
90 :
91 : public class ReviewerModifier {
92 146 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
93 :
94 : public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
95 : public static final int DEFAULT_MAX_REVIEWERS = 20;
96 :
97 : /**
98 : * Controls which failures should be ignored.
99 : *
100 : * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
101 : * ignored a failure means that the operation fails.
102 : */
103 37 : public enum FailureBehavior {
104 : // All failures cause the operation to fail.
105 37 : FAIL,
106 :
107 : // Only not found failures cause the operation to fail, all other failures are ignored.
108 37 : IGNORE_EXCEPT_NOT_FOUND,
109 :
110 : // All failures are ignored.
111 37 : IGNORE_ALL;
112 : }
113 :
114 39 : private enum FailureType {
115 39 : NOT_FOUND,
116 39 : OTHER;
117 : }
118 :
119 : // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
120 : // type in the public interfaces of ReviewerModifier, rather than passing around the REST API type
121 : // internally.
122 37 : public static class InternalReviewerInput extends ReviewerInput {
123 : /**
124 : * Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
125 : * resolving to an account/group/email.
126 : */
127 37 : public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
128 :
129 : /** Whether the visibility check for the reviewer account should be skipped. */
130 37 : public boolean skipVisibilityCheck = false;
131 : }
132 :
133 : public static InternalReviewerInput newReviewerInput(
134 : String reviewer, ReviewerState state, NotifyHandling notify) {
135 29 : InternalReviewerInput in = new InternalReviewerInput();
136 29 : in.reviewer = reviewer;
137 29 : in.state = state;
138 29 : in.notify = notify;
139 29 : return in;
140 : }
141 :
142 : public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
143 : Change change,
144 : ObjectId commitId,
145 : @Nullable Account.Id accountId,
146 : NotifyHandling notify,
147 : Account.Id mostRecentUploader) {
148 103 : if (accountId == null || accountId.equals(mostRecentUploader)) {
149 : // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
150 103 : return Optional.empty();
151 : }
152 :
153 16 : logger.atFine().log(
154 : "Adding account %d from author/committer identity of commit %s as cc to change %d",
155 16 : accountId.get(), commitId.name(), change.getChangeId());
156 :
157 16 : InternalReviewerInput in = new InternalReviewerInput();
158 16 : in.reviewer = accountId.toString();
159 16 : in.state = CC;
160 16 : in.notify = notify;
161 16 : in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
162 16 : return Optional.of(in);
163 : }
164 :
165 : private final AccountResolver accountResolver;
166 : private final PermissionBackend permissionBackend;
167 : private final GroupResolver groupResolver;
168 : private final GroupMembers groupMembers;
169 : private final AccountLoader.Factory accountLoaderFactory;
170 : private final Config cfg;
171 : private final ReviewerJson json;
172 : private final ProjectCache projectCache;
173 : private final Provider<AnonymousUser> anonymousProvider;
174 : private final AddReviewersOp.Factory addReviewersOpFactory;
175 : private final OutgoingEmailValidator validator;
176 : private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
177 : private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
178 :
179 : @Inject
180 : ReviewerModifier(
181 : AccountResolver accountResolver,
182 : PermissionBackend permissionBackend,
183 : GroupResolver groupResolver,
184 : GroupMembers groupMembers,
185 : AccountLoader.Factory accountLoaderFactory,
186 : @GerritServerConfig Config cfg,
187 : ReviewerJson json,
188 : ProjectCache projectCache,
189 : Provider<AnonymousUser> anonymousProvider,
190 : AddReviewersOp.Factory addReviewersOpFactory,
191 : OutgoingEmailValidator validator,
192 : DeleteReviewerOp.Factory deleteReviewerOpFactory,
193 146 : DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
194 146 : this.accountResolver = accountResolver;
195 146 : this.permissionBackend = permissionBackend;
196 146 : this.groupResolver = groupResolver;
197 146 : this.groupMembers = groupMembers;
198 146 : this.accountLoaderFactory = accountLoaderFactory;
199 146 : this.cfg = cfg;
200 146 : this.json = json;
201 146 : this.projectCache = projectCache;
202 146 : this.anonymousProvider = anonymousProvider;
203 146 : this.addReviewersOpFactory = addReviewersOpFactory;
204 146 : this.validator = validator;
205 146 : this.deleteReviewerOpFactory = deleteReviewerOpFactory;
206 146 : this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
207 146 : }
208 :
209 : /**
210 : * Prepare application of a single {@link ReviewerInput}.
211 : *
212 : * @param notes change notes.
213 : * @param user user performing the reviewer addition.
214 : * @param input input describing user or group to add as a reviewer.
215 : * @param allowGroup whether to allow
216 : * @return handle describing the addition operation. If the {@code op} field is present, this
217 : * operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
218 : * contains information about an error that occurred
219 : */
220 : public ReviewerModification prepare(
221 : ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup)
222 : throws IOException, PermissionBackendException, ConfigInvalidException {
223 39 : try (TraceContext.TraceTimer ignored =
224 39 : TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
225 39 : requireNonNull(input.reviewer);
226 39 : boolean confirmed = input.confirmed();
227 39 : boolean allowByEmail =
228 : projectCache
229 39 : .get(notes.getProjectName())
230 39 : .orElseThrow(illegalState(notes.getProjectName()))
231 39 : .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
232 :
233 39 : ReviewerModification byAccountId = byAccountId(input, notes, user);
234 :
235 39 : ReviewerModification wholeGroup = null;
236 39 : if (!byAccountId.exactMatchFound) {
237 27 : wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
238 27 : if (wholeGroup != null && wholeGroup.exactMatchFound) {
239 5 : return wholeGroup;
240 : }
241 : }
242 :
243 39 : if (wholeGroup != null
244 : && byAccountId.failureType == FailureType.NOT_FOUND
245 : && wholeGroup.failureType == FailureType.NOT_FOUND) {
246 5 : return fail(
247 : byAccountId.input,
248 : FailureType.NOT_FOUND,
249 : byAccountId.result.error + "\n" + wholeGroup.result.error);
250 : }
251 :
252 39 : if (byAccountId.failureType != FailureType.NOT_FOUND) {
253 39 : return byAccountId;
254 : }
255 10 : if (wholeGroup != null) {
256 2 : return wholeGroup;
257 : }
258 :
259 10 : return addByEmail(input, notes, user);
260 39 : }
261 : }
262 :
263 : public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) {
264 23 : return new ReviewerModification(
265 23 : newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
266 23 : revision.getNotes(),
267 23 : revision.getUser(),
268 23 : ImmutableSet.of(user.asIdentifiedUser().getAccount()),
269 : null,
270 : true,
271 : false);
272 : }
273 :
274 : @Nullable
275 : private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user)
276 : throws PermissionBackendException, IOException, ConfigInvalidException {
277 : IdentifiedUser reviewerUser;
278 39 : boolean exactMatchFound = false;
279 : try {
280 39 : if (input instanceof InternalReviewerInput
281 : && ((InternalReviewerInput) input).skipVisibilityCheck) {
282 4 : reviewerUser =
283 4 : accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
284 : } else {
285 39 : reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
286 : }
287 39 : if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
288 37 : || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
289 26 : exactMatchFound = true;
290 : }
291 11 : } catch (UnprocessableEntityException e) {
292 : // Caller might choose to ignore this NOT_FOUND result if they find another result e.g. by
293 : // group, but if not, the error message will be useful.
294 11 : return fail(input, FailureType.NOT_FOUND, e.getMessage());
295 39 : }
296 :
297 39 : if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
298 39 : return new ReviewerModification(
299 : input,
300 : notes,
301 : user,
302 39 : ImmutableSet.of(reviewerUser.getAccount()),
303 : null,
304 : exactMatchFound,
305 : false);
306 : }
307 1 : return fail(
308 : input,
309 : FailureType.OTHER,
310 1 : MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
311 : }
312 :
313 : @Nullable
314 : private ReviewerModification addWholeGroup(
315 : ReviewerInput input,
316 : ChangeNotes notes,
317 : CurrentUser user,
318 : boolean confirmed,
319 : boolean allowGroup,
320 : boolean allowByEmail)
321 : throws IOException, PermissionBackendException {
322 27 : if (!allowGroup) {
323 6 : return null;
324 : }
325 :
326 : GroupDescription.Basic group;
327 : try {
328 : // TODO(dborowitz): This currently doesn't work in the push path because InternalGroupBackend
329 : // depends on the Provider<CurrentUser> which returns anonymous in that path.
330 5 : group = groupResolver.parseInternal(input.reviewer);
331 27 : } catch (UnprocessableEntityException e) {
332 27 : if (!allowByEmail) {
333 22 : return fail(
334 : input,
335 : FailureType.NOT_FOUND,
336 22 : MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
337 : }
338 10 : return null;
339 5 : }
340 :
341 5 : if (!isLegalReviewerGroup(group.getGroupUUID())) {
342 0 : return fail(
343 : input,
344 : FailureType.OTHER,
345 0 : MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
346 : }
347 :
348 5 : if (input.state().equals(REMOVED)) {
349 1 : return fail(
350 : input,
351 : FailureType.OTHER,
352 1 : MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
353 : }
354 :
355 5 : Set<Account> reviewers = new HashSet<>();
356 : Set<Account> members;
357 : try {
358 5 : members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
359 0 : } catch (NoSuchProjectException e) {
360 0 : return fail(input, FailureType.OTHER, e.getMessage());
361 5 : }
362 :
363 : // if maxAllowed is set to 0, it is allowed to add any number of
364 : // reviewers
365 5 : int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
366 5 : if (maxAllowed > 0 && members.size() > maxAllowed) {
367 1 : logger.atFine().log(
368 1 : "Adding %d group members is not allowed (maxAllowed = %d)", members.size(), maxAllowed);
369 1 : return fail(
370 : input,
371 : FailureType.OTHER,
372 1 : MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
373 : }
374 :
375 : // if maxWithoutCheck is set to 0, we never ask for confirmation
376 5 : int maxWithoutConfirmation =
377 5 : cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
378 5 : if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
379 1 : logger.atFine().log(
380 : "Adding %d group members as reviewer requires confirmation (maxWithoutConfirmation = %d)",
381 1 : members.size(), maxWithoutConfirmation);
382 1 : return fail(
383 : input,
384 : FailureType.OTHER,
385 : true,
386 1 : MessageFormat.format(
387 1 : ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
388 : }
389 :
390 5 : for (Account member : members) {
391 5 : if (isValidReviewer(notes.getChange().getDest(), member)) {
392 5 : reviewers.add(member);
393 : }
394 5 : }
395 :
396 5 : return new ReviewerModification(input, notes, user, reviewers, null, true, true);
397 : }
398 :
399 : @Nullable
400 : private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
401 : throws PermissionBackendException {
402 10 : if (!permissionBackend
403 10 : .user(anonymousProvider.get())
404 10 : .change(notes)
405 10 : .test(ChangePermission.READ)) {
406 0 : return fail(
407 : input,
408 : FailureType.OTHER,
409 0 : MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, input.reviewer));
410 : }
411 :
412 10 : Address adr = Address.tryParse(input.reviewer);
413 10 : if (adr == null || !validator.isValid(adr.email())) {
414 1 : return fail(
415 : input,
416 : FailureType.NOT_FOUND,
417 1 : MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
418 : }
419 10 : return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
420 : }
421 :
422 : private boolean isValidReviewer(BranchNameKey branch, Account member)
423 : throws PermissionBackendException {
424 : // Check ref permission instead of change permission, since change permissions take into
425 : // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
426 : // see private changes.
427 39 : return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
428 : }
429 :
430 : private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
431 27 : return fail(input, failureType, false, error);
432 : }
433 :
434 : private ReviewerModification fail(
435 : ReviewerInput input, FailureType failureType, boolean confirm, String error) {
436 27 : ReviewerModification addition = new ReviewerModification(input, failureType);
437 27 : addition.result.confirm = confirm ? true : null;
438 27 : addition.result.error = error;
439 27 : return addition;
440 : }
441 :
442 : public class ReviewerModification {
443 : public final ReviewerResult result;
444 : @Nullable public final ReviewerOp op;
445 : public final ImmutableSet<Account> reviewers;
446 : public final ImmutableSet<Address> reviewersByEmail;
447 : @Nullable final IdentifiedUser caller;
448 : final boolean exactMatchFound;
449 : private final ReviewerInput input;
450 : @Nullable private final FailureType failureType;
451 :
452 27 : private ReviewerModification(ReviewerInput input, FailureType failureType) {
453 27 : this.input = input;
454 27 : this.failureType = requireNonNull(failureType);
455 27 : result = new ReviewerResult(input.reviewer);
456 27 : op = null;
457 27 : reviewers = ImmutableSet.of();
458 27 : reviewersByEmail = ImmutableSet.of();
459 27 : caller = null;
460 27 : exactMatchFound = false;
461 27 : }
462 :
463 : private ReviewerModification(
464 : ReviewerInput input,
465 : ChangeNotes notes,
466 : CurrentUser caller,
467 : @Nullable Iterable<Account> reviewers,
468 : @Nullable Iterable<Address> reviewersByEmail,
469 : boolean exactMatchFound,
470 46 : boolean forGroup) {
471 46 : checkArgument(
472 : reviewers != null || reviewersByEmail != null,
473 : "must have either reviewers or reviewersByEmail");
474 :
475 46 : this.input = input;
476 46 : this.failureType = null;
477 46 : result = new ReviewerResult(input.reviewer);
478 46 : if (!state().equals(REMOVED)) {
479 : // Always silently ignore adding the owner as any type of reviewer on their own change. They
480 : // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
481 46 : this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
482 : } else {
483 3 : this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
484 : }
485 46 : this.reviewersByEmail =
486 46 : reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
487 46 : this.caller = caller.asIdentifiedUser();
488 46 : if (state().equals(REMOVED)) {
489 : // only one is set.
490 3 : checkState(
491 3 : (this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty())
492 3 : || (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1));
493 3 : if (this.reviewers.size() >= 1) {
494 3 : checkState(this.reviewers.size() == 1);
495 3 : DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
496 3 : deleteReviewerInput.notify = input.notify;
497 3 : deleteReviewerInput.notifyDetails = input.notifyDetails;
498 3 : op =
499 3 : deleteReviewerOpFactory.create(
500 3 : Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput);
501 3 : } else {
502 1 : checkState(this.reviewersByEmail.size() == 1);
503 1 : op =
504 1 : deleteReviewerByEmailOpFactory.create(
505 1 : Iterables.getOnlyElement(this.reviewersByEmail.asList()));
506 : }
507 : } else {
508 46 : op =
509 46 : addReviewersOpFactory.create(
510 46 : this.reviewers.stream().map(Account::id).collect(toImmutableSet()),
511 : this.reviewersByEmail,
512 46 : state(),
513 : forGroup);
514 : }
515 46 : this.exactMatchFound = exactMatchFound;
516 46 : }
517 :
518 : private ImmutableSet<Account> reviewersAsList(
519 : ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
520 46 : if (reviewers == null) {
521 10 : return ImmutableSet.of();
522 : }
523 :
524 46 : Stream<Account> reviewerStream = Streams.stream(reviewers);
525 46 : if (omitChangeOwner) {
526 46 : reviewerStream =
527 46 : reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
528 : }
529 46 : return reviewerStream.collect(toImmutableSet());
530 : }
531 :
532 : public void gatherResults(ChangeData cd) throws PermissionBackendException {
533 30 : checkState(op != null, "addition did not result in an update op");
534 30 : checkState(op.getResult() != null, "op did not return a result");
535 :
536 : // Generate result details and fill AccountLoader. This occurs outside
537 : // the Op because the accounts are in a different table.
538 30 : ReviewerOp.Result opResult = op.getResult();
539 30 : switch (state()) {
540 : case CC:
541 12 : result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
542 12 : for (Account.Id accountId : opResult.addedCCs()) {
543 12 : result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
544 12 : }
545 12 : accountLoaderFactory.create(true).fill(result.ccs);
546 12 : for (Address a : opResult.addedCCsByEmail()) {
547 7 : result.ccs.add(new AccountInfo(a.name(), a.email()));
548 7 : }
549 12 : break;
550 : case REVIEWER:
551 30 : result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
552 30 : for (PatchSetApproval psa : opResult.addedReviewers()) {
553 : // New reviewers have value 0, don't bother normalizing.
554 30 : result.reviewers.add(
555 30 : json.format(
556 30 : new ReviewerInfo(psa.accountId().get()),
557 30 : psa.accountId(),
558 : cd,
559 30 : ImmutableList.of(psa)));
560 30 : }
561 30 : accountLoaderFactory.create(true).fill(result.reviewers);
562 30 : for (Address a : opResult.addedReviewersByEmail()) {
563 7 : result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
564 7 : }
565 30 : break;
566 : case REMOVED:
567 3 : if (opResult.deletedReviewer().isPresent()) {
568 3 : result.removed =
569 3 : json.format(
570 3 : new ReviewerInfo(opResult.deletedReviewer().get().get()),
571 3 : opResult.deletedReviewer().get(),
572 : cd);
573 3 : accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed));
574 1 : } else if (opResult.deletedReviewerByEmail().isPresent()) {
575 1 : result.removed =
576 : new AccountInfo(
577 1 : opResult.deletedReviewerByEmail().get().name(),
578 1 : opResult.deletedReviewerByEmail().get().email());
579 : }
580 : break;
581 : default:
582 0 : throw new IllegalStateException(
583 0 : String.format("Illegal ReviewerState argument is %s", state().name()));
584 : }
585 30 : }
586 :
587 : public ReviewerState state() {
588 46 : return input.state();
589 : }
590 :
591 : public boolean isFailure() {
592 19 : return failureType != null;
593 : }
594 :
595 : public boolean isIgnorableFailure() {
596 5 : checkState(failureType != null);
597 : FailureBehavior behavior =
598 5 : (input instanceof InternalReviewerInput)
599 5 : ? ((InternalReviewerInput) input).otherFailureBehavior
600 5 : : FailureBehavior.FAIL;
601 5 : return behavior == FailureBehavior.IGNORE_ALL
602 : || (failureType == FailureType.OTHER
603 : && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
604 : }
605 : }
606 :
607 : public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
608 5 : return !SystemGroupBackend.isSystemGroup(groupUUID);
609 : }
610 :
611 : public ReviewerModificationList prepare(
612 : ChangeNotes notes,
613 : CurrentUser user,
614 : Iterable<? extends ReviewerInput> inputs,
615 : boolean allowGroup)
616 : throws IOException, PermissionBackendException, ConfigInvalidException {
617 : // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
618 : // reviewer; the last call to ChangeUpdate#putReviewer wins. This can happen if the caller
619 : // specifies the same string twice, or less obviously if they specify multiple groups with
620 : // overlapping members.
621 : // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
622 : // previously processed, to proactively prevent overlap so we don't have to rely on this subtle
623 : // behavior.
624 103 : ImmutableList<ReviewerInput> sorted =
625 103 : Streams.stream(inputs)
626 103 : .sorted(
627 103 : comparing(
628 : ReviewerInput::state,
629 103 : Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
630 103 : .collect(toImmutableList());
631 103 : List<ReviewerModification> additions = new ArrayList<>();
632 103 : for (ReviewerInput input : sorted) {
633 19 : ReviewerModification addition = prepare(notes, user, input, allowGroup);
634 19 : if (addition.op != null) {
635 : // Assume any callers preparing a list of batch insertions are handling their own email.
636 19 : addition.op.suppressEmail();
637 : }
638 19 : additions.add(addition);
639 19 : }
640 103 : return new ReviewerModificationList(additions);
641 : }
642 :
643 : // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
644 : // really an op, it's a collection of ops, and it's only called from the body of other ops. We
645 : // could make this class an op, but we would still have AddReviewersOp. Better would probably be
646 : // to design a single op that supports combining multiple ReviewerInputs together. That would
647 : // probably also subsume the Addition class itself, which would be a good thing.
648 : public static class ReviewerModificationList {
649 : private final ImmutableList<ReviewerModification> modifications;
650 :
651 103 : private ReviewerModificationList(List<ReviewerModification> modifications) {
652 103 : this.modifications = ImmutableList.copyOf(modifications);
653 103 : }
654 :
655 : public ImmutableList<ReviewerModification> getFailures() {
656 103 : return modifications.stream()
657 103 : .filter(a -> a.isFailure() && !a.isIgnorableFailure())
658 103 : .collect(toImmutableList());
659 : }
660 :
661 : // We never call updateRepo on the addition ops, which is only ok because it's a no-op.
662 :
663 : public void updateChange(ChangeContext ctx, PatchSet patchSet)
664 : throws RestApiException, IOException, PermissionBackendException {
665 103 : for (ReviewerModification addition : modifications()) {
666 19 : addition.op.setPatchSet(patchSet);
667 19 : addition.op.updateChange(ctx);
668 19 : }
669 103 : }
670 :
671 : public void postUpdate(PostUpdateContext ctx) throws Exception {
672 103 : for (ReviewerModification addition : modifications()) {
673 19 : if (addition.op != null) {
674 19 : addition.op.postUpdate(ctx);
675 : }
676 19 : }
677 103 : }
678 :
679 : public <T> ImmutableSet<T> flattenResults(
680 : Function<ReviewerOp.Result, ? extends Collection<T>> func) {
681 103 : modifications()
682 103 : .forEach(
683 : a ->
684 19 : checkArgument(
685 19 : a.op != null && a.op.getResult() != null, "missing result on %s", a));
686 103 : return modifications().stream()
687 103 : .map(a -> a.op.getResult())
688 103 : .map(func)
689 103 : .flatMap(Collection::stream)
690 103 : .collect(toImmutableSet());
691 : }
692 :
693 : private ImmutableList<ReviewerModification> modifications() {
694 103 : return modifications.stream()
695 103 : .filter(
696 : a -> {
697 19 : if (a.isFailure()) {
698 5 : if (a.isIgnorableFailure()) {
699 5 : return false;
700 : }
701 : // Shouldn't happen, caller should have checked that there were no errors.
702 0 : throw new IllegalStateException("error in addition: " + a.result.error);
703 : }
704 19 : return true;
705 : })
706 103 : .collect(toImmutableList());
707 : }
708 : }
709 : }
|