Line data Source code
1 : // Copyright (C) 2009 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.approval;
16 :
17 : import static com.google.common.base.Preconditions.checkArgument;
18 : import static com.google.common.collect.ImmutableList.toImmutableList;
19 : import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
20 : import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
21 : import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
22 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
23 : import static java.util.Comparator.comparing;
24 : import static java.util.Objects.requireNonNull;
25 : import static java.util.stream.Collectors.joining;
26 :
27 : import com.google.common.annotations.VisibleForTesting;
28 : import com.google.common.collect.ArrayListMultimap;
29 : import com.google.common.collect.ImmutableList;
30 : import com.google.common.collect.ImmutableListMultimap;
31 : import com.google.common.collect.ImmutableSet;
32 : import com.google.common.collect.Iterables;
33 : import com.google.common.collect.ListMultimap;
34 : import com.google.common.collect.Lists;
35 : import com.google.common.collect.Multimap;
36 : import com.google.common.collect.Multimaps;
37 : import com.google.common.collect.Sets;
38 : import com.google.common.flogger.FluentLogger;
39 : import com.google.gerrit.common.Nullable;
40 : import com.google.gerrit.entities.Account;
41 : import com.google.gerrit.entities.AttentionSetUpdate;
42 : import com.google.gerrit.entities.Change;
43 : import com.google.gerrit.entities.LabelId;
44 : import com.google.gerrit.entities.LabelType;
45 : import com.google.gerrit.entities.LabelTypes;
46 : import com.google.gerrit.entities.PatchSet;
47 : import com.google.gerrit.entities.PatchSetApproval;
48 : import com.google.gerrit.entities.PatchSetInfo;
49 : import com.google.gerrit.exceptions.StorageException;
50 : import com.google.gerrit.extensions.restapi.AuthException;
51 : import com.google.gerrit.extensions.restapi.BadRequestException;
52 : import com.google.gerrit.extensions.restapi.RestApiException;
53 : import com.google.gerrit.index.query.QueryParseException;
54 : import com.google.gerrit.server.CurrentUser;
55 : import com.google.gerrit.server.ReviewerSet;
56 : import com.google.gerrit.server.ReviewerStatusUpdate;
57 : import com.google.gerrit.server.account.AccountCache;
58 : import com.google.gerrit.server.change.LabelNormalizer;
59 : import com.google.gerrit.server.config.AnonymousCowardName;
60 : import com.google.gerrit.server.notedb.ChangeNotes;
61 : import com.google.gerrit.server.notedb.ChangeUpdate;
62 : import com.google.gerrit.server.notedb.ReviewerStateInternal;
63 : import com.google.gerrit.server.permissions.ChangePermission;
64 : import com.google.gerrit.server.permissions.LabelPermission;
65 : import com.google.gerrit.server.permissions.PermissionBackend;
66 : import com.google.gerrit.server.permissions.PermissionBackendException;
67 : import com.google.gerrit.server.project.ProjectCache;
68 : import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
69 : import com.google.gerrit.server.query.approval.UserInPredicate;
70 : import com.google.gerrit.server.util.AccountTemplateUtil;
71 : import com.google.gerrit.server.util.LabelVote;
72 : import com.google.gerrit.server.util.ManualRequestContext;
73 : import com.google.gerrit.server.util.OneOffRequestContext;
74 : import com.google.inject.Inject;
75 : import com.google.inject.Provider;
76 : import com.google.inject.Singleton;
77 : import java.time.Instant;
78 : import java.util.ArrayList;
79 : import java.util.Collection;
80 : import java.util.Collections;
81 : import java.util.HashSet;
82 : import java.util.LinkedHashSet;
83 : import java.util.List;
84 : import java.util.Map;
85 : import java.util.Objects;
86 : import java.util.Optional;
87 : import java.util.Set;
88 : import org.eclipse.jgit.lib.Config;
89 : import org.eclipse.jgit.revwalk.RevWalk;
90 :
91 : /**
92 : * Utility functions to manipulate patchset approvals.
93 : *
94 : * <p>Approvals are overloaded, they represent both approvals and reviewers which should be CCed on
95 : * a change. To ensure that reviewers are not lost there must always be an approval on each patchset
96 : * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
97 : * "no score" case, a dummy approval, which may live in any of the available categories, with a
98 : * score of 0 is used.
99 : */
100 : @Singleton
101 : public class ApprovalsUtil {
102 146 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
103 :
104 : public static PatchSetApproval.Builder newApproval(
105 : PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) {
106 : PatchSetApproval.Builder b =
107 65 : PatchSetApproval.builder()
108 65 : .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
109 65 : .value(value)
110 65 : .granted(when);
111 65 : user.updateRealAccountId(b::realAccountId);
112 65 : return b;
113 : }
114 :
115 : private static Iterable<PatchSetApproval> filterApprovals(
116 : Iterable<PatchSetApproval> psas, Account.Id accountId) {
117 67 : return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
118 : }
119 :
120 : private final AccountCache accountCache;
121 : private final String anonymousCowardName;
122 : private final ApprovalCopier approvalCopier;
123 : private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
124 : private final PermissionBackend permissionBackend;
125 : private final ProjectCache projectCache;
126 : private final LabelNormalizer labelNormalizer;
127 : private final OneOffRequestContext requestContext;
128 :
129 : @VisibleForTesting
130 : @Inject
131 : public ApprovalsUtil(
132 : AccountCache accountCache,
133 : @AnonymousCowardName String anonymousCowardName,
134 : ApprovalCopier approvalCopier,
135 : Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
136 : PermissionBackend permissionBackend,
137 : ProjectCache projectCache,
138 : LabelNormalizer labelNormalizer,
139 146 : OneOffRequestContext requestContext) {
140 146 : this.accountCache = accountCache;
141 146 : this.anonymousCowardName = anonymousCowardName;
142 146 : this.approvalCopier = approvalCopier;
143 146 : this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
144 146 : this.permissionBackend = permissionBackend;
145 146 : this.projectCache = projectCache;
146 146 : this.labelNormalizer = labelNormalizer;
147 146 : this.requestContext = requestContext;
148 146 : }
149 :
150 : /**
151 : * Get all reviewers for a change.
152 : *
153 : * @param notes change notes.
154 : * @return reviewers for the change.
155 : */
156 : public ReviewerSet getReviewers(ChangeNotes notes) {
157 103 : return notes.load().getReviewers();
158 : }
159 :
160 : /**
161 : * Get updates to reviewer set.
162 : *
163 : * @param notes change notes.
164 : * @return reviewer updates for the change.
165 : */
166 : public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) {
167 103 : return notes.load().getReviewerUpdates();
168 : }
169 :
170 : public List<PatchSetApproval> addReviewers(
171 : ChangeUpdate update,
172 : LabelTypes labelTypes,
173 : Change change,
174 : PatchSet ps,
175 : PatchSetInfo info,
176 : Iterable<Account.Id> wantReviewers,
177 : Collection<Account.Id> existingReviewers) {
178 0 : return addReviewers(
179 : update,
180 : labelTypes,
181 : change,
182 0 : ps.id(),
183 0 : info.getAuthor().getAccount(),
184 0 : info.getCommitter().getAccount(),
185 : wantReviewers,
186 : existingReviewers);
187 : }
188 :
189 : public List<PatchSetApproval> addReviewers(
190 : ChangeNotes notes,
191 : ChangeUpdate update,
192 : LabelTypes labelTypes,
193 : Change change,
194 : Iterable<Account.Id> wantReviewers) {
195 33 : PatchSet.Id psId = change.currentPatchSetId();
196 : Collection<Account.Id> existingReviewers;
197 33 : existingReviewers = notes.load().getReviewers().byState(REVIEWER);
198 : // Existing reviewers should include pending additions in the REVIEWER
199 : // state, taken from ChangeUpdate.
200 33 : existingReviewers = Lists.newArrayList(existingReviewers);
201 33 : for (Map.Entry<Account.Id, ReviewerStateInternal> entry : update.getReviewers().entrySet()) {
202 11 : if (entry.getValue() == REVIEWER) {
203 9 : existingReviewers.add(entry.getKey());
204 : }
205 11 : }
206 33 : return addReviewers(
207 : update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
208 : }
209 :
210 : private List<PatchSetApproval> addReviewers(
211 : ChangeUpdate update,
212 : LabelTypes labelTypes,
213 : Change change,
214 : PatchSet.Id psId,
215 : Account.Id authorId,
216 : Account.Id committerId,
217 : Iterable<Account.Id> wantReviewers,
218 : Collection<Account.Id> existingReviewers) {
219 33 : List<LabelType> allTypes = labelTypes.getLabelTypes();
220 33 : if (allTypes.isEmpty()) {
221 0 : return ImmutableList.of();
222 : }
223 :
224 33 : Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
225 33 : if (authorId != null && canSee(update.getNotes(), authorId)) {
226 0 : need.add(authorId);
227 : }
228 :
229 33 : if (committerId != null && canSee(update.getNotes(), committerId)) {
230 0 : need.add(committerId);
231 : }
232 33 : need.remove(change.getOwner());
233 33 : need.removeAll(existingReviewers);
234 33 : if (need.isEmpty()) {
235 3 : return ImmutableList.of();
236 : }
237 :
238 33 : List<PatchSetApproval> cells = Lists.newArrayListWithCapacity(need.size());
239 33 : LabelId labelId = Iterables.getLast(allTypes).getLabelId();
240 33 : for (Account.Id account : need) {
241 33 : cells.add(
242 33 : PatchSetApproval.builder()
243 33 : .key(PatchSetApproval.key(psId, account, labelId))
244 33 : .value(0)
245 33 : .granted(update.getWhen())
246 33 : .build());
247 33 : update.putReviewer(account, REVIEWER);
248 33 : }
249 33 : return Collections.unmodifiableList(cells);
250 : }
251 :
252 : private boolean canSee(ChangeNotes notes, Account.Id accountId) {
253 : try {
254 0 : if (!projectCache
255 0 : .get(notes.getProjectName())
256 0 : .orElseThrow(illegalState(notes.getProjectName()))
257 0 : .statePermitsRead()) {
258 0 : return false;
259 : }
260 0 : return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ);
261 0 : } catch (PermissionBackendException e) {
262 0 : logger.atWarning().withCause(e).log(
263 : "Failed to check if account %d can see change %d",
264 0 : accountId.get(), notes.getChangeId().get());
265 0 : return false;
266 : }
267 : }
268 :
269 : /**
270 : * Adds accounts to a change as reviewers in the CC state.
271 : *
272 : * @param notes change notes.
273 : * @param update change update.
274 : * @param wantCCs accounts to CC.
275 : * @param keepExistingReviewers whether provided accounts that are already reviewer should be kept
276 : * as reviewer or be downgraded to CC
277 : * @return whether a change was made.
278 : */
279 : public Collection<Account.Id> addCcs(
280 : ChangeNotes notes,
281 : ChangeUpdate update,
282 : Collection<Account.Id> wantCCs,
283 : boolean keepExistingReviewers) {
284 29 : return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers);
285 : }
286 :
287 : private Collection<Account.Id> addCcs(
288 : ChangeUpdate update,
289 : Collection<Account.Id> wantCCs,
290 : ReviewerSet existingReviewers,
291 : boolean keepExistingReviewers) {
292 29 : Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
293 29 : need.removeAll(existingReviewers.byState(CC));
294 29 : if (keepExistingReviewers) {
295 4 : need.removeAll(existingReviewers.byState(REVIEWER));
296 : }
297 29 : need.removeAll(update.getReviewers().keySet());
298 29 : for (Account.Id account : need) {
299 29 : update.putReviewer(account, CC);
300 29 : }
301 29 : return need;
302 : }
303 :
304 : /**
305 : * Adds approvals to ChangeUpdate for a new patch set, and writes to NoteDb.
306 : *
307 : * @param update change update.
308 : * @param labelTypes label types for the containing project.
309 : * @param ps patch set being approved.
310 : * @param user user adding approvals.
311 : * @param approvals approvals to add.
312 : */
313 : public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
314 : ChangeUpdate update,
315 : LabelTypes labelTypes,
316 : PatchSet ps,
317 : CurrentUser user,
318 : Map<String, Short> approvals)
319 : throws RestApiException, PermissionBackendException {
320 103 : Account.Id accountId = user.getAccountId();
321 103 : checkArgument(
322 103 : accountId.equals(ps.uploader()),
323 : "expected user %s to match patch set uploader %s",
324 : accountId,
325 103 : ps.uploader());
326 103 : if (approvals.isEmpty()) {
327 103 : return ImmutableList.of();
328 : }
329 5 : checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
330 5 : List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
331 5 : Instant ts = update.getWhen();
332 5 : for (Map.Entry<String, Short> vote : approvals.entrySet()) {
333 5 : Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
334 5 : if (!lt.isPresent()) {
335 0 : throw new BadRequestException(
336 0 : String.format("label \"%s\" is not a configured label", vote.getKey()));
337 : }
338 5 : cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build());
339 5 : }
340 5 : for (PatchSetApproval psa : cells) {
341 5 : update.putApproval(psa.label(), psa.value());
342 5 : }
343 5 : return cells;
344 : }
345 :
346 : public static void checkLabel(LabelTypes labelTypes, String name, Short value)
347 : throws BadRequestException {
348 4 : Optional<LabelType> label = labelTypes.byLabel(name);
349 4 : if (!label.isPresent()) {
350 3 : throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
351 : }
352 4 : if (label.get().getValue(value) == null) {
353 3 : throw new BadRequestException(
354 3 : String.format("label \"%s\": %d is not a valid value", name, value));
355 : }
356 4 : }
357 :
358 : private static void checkApprovals(
359 : Map<String, Short> approvals, PermissionBackend.ForChange forChange)
360 : throws AuthException, PermissionBackendException {
361 5 : for (Map.Entry<String, Short> vote : approvals.entrySet()) {
362 5 : String name = vote.getKey();
363 5 : Short value = vote.getValue();
364 5 : if (!forChange.test(new LabelPermission.WithValue(name, value))) {
365 0 : throw new AuthException(
366 0 : String.format("applying label \"%s\": %d is restricted", name, value));
367 : }
368 5 : }
369 5 : }
370 :
371 : public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
372 : ChangeNotes notes) {
373 103 : return notes.load().getApprovals().onlyNonCopied();
374 : }
375 :
376 : /**
377 : * Copies approvals to a new patch set.
378 : *
379 : * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
380 : * stores them in NoteDb.
381 : *
382 : * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
383 : * set and hence not copied) the approvers are added to the attention set since they need to
384 : * re-review the change and renew their approvals.
385 : *
386 : * @param notes the change notes
387 : * @param patchSet the newly created patch set
388 : * @param revWalk {@link RevWalk} that can see the new patch set revision
389 : * @param repoConfig the repo config
390 : * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
391 : * attention set
392 : * @return the result of the approval copying
393 : */
394 : public ApprovalCopier.Result copyApprovalsToNewPatchSet(
395 : ChangeNotes notes,
396 : PatchSet patchSet,
397 : RevWalk revWalk,
398 : Config repoConfig,
399 : ChangeUpdate changeUpdate) {
400 51 : ApprovalCopier.Result approvalCopierResult =
401 51 : approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
402 51 : approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
403 :
404 51 : if (!notes.getChange().isWorkInProgress()) {
405 : // The attention set should not be updated when the change is work-in-progress.
406 51 : addAttentionSetUpdatesForOutdatedApprovals(
407 51 : changeUpdate, approvalCopierResult.outdatedApprovals());
408 : }
409 :
410 51 : return approvalCopierResult;
411 : }
412 :
413 : private void addAttentionSetUpdatesForOutdatedApprovals(
414 : ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
415 51 : Set<AttentionSetUpdate> updates = new HashSet<>();
416 :
417 51 : Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
418 51 : outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
419 : for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
420 51 : outdatedApprovalsByUser.asMap().entrySet()) {
421 11 : Account.Id approverId = e.getKey();
422 11 : Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
423 :
424 : String message;
425 11 : if (outdatedUserApprovals.size() == 1) {
426 11 : PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
427 11 : message =
428 11 : String.format(
429 : "Vote got outdated and was removed: %s",
430 11 : LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
431 11 : .format());
432 11 : } else {
433 3 : message =
434 3 : String.format(
435 : "Votes got outdated and were removed: %s",
436 3 : outdatedUserApprovals.stream()
437 3 : .map(
438 : outdatedUserApproval ->
439 3 : LabelVote.create(
440 3 : outdatedUserApproval.label(), outdatedUserApproval.value())
441 3 : .format())
442 3 : .sorted()
443 3 : .collect(joining(", ")));
444 : }
445 :
446 11 : updates.add(
447 11 : AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
448 11 : }
449 51 : changeUpdate.addToPlannedAttentionSetUpdates(updates);
450 51 : }
451 :
452 : public Optional<String> formatApprovalCopierResult(
453 : ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
454 51 : requireNonNull(approvalCopierResult, "approvalCopierResult");
455 51 : requireNonNull(labelTypes, "labelTypes");
456 :
457 51 : if (approvalCopierResult.copiedApprovals().isEmpty()
458 51 : && approvalCopierResult.outdatedApprovals().isEmpty()) {
459 50 : return Optional.empty();
460 : }
461 :
462 15 : StringBuilder message = new StringBuilder();
463 :
464 15 : if (!approvalCopierResult.copiedApprovals().isEmpty()) {
465 12 : message.append("Copied Votes:\n");
466 12 : message.append(
467 12 : formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
468 : }
469 15 : if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
470 11 : if (!approvalCopierResult.copiedApprovals().isEmpty()) {
471 4 : message.append("\n");
472 : }
473 11 : message.append("Outdated Votes:\n");
474 11 : message.append(
475 11 : formatApprovalListWithCopyCondition(
476 11 : approvalCopierResult.outdatedApprovals(), labelTypes));
477 : }
478 :
479 15 : return Optional.of(message.toString());
480 : }
481 :
482 : /**
483 : * Formats the given approvals as a bullet list, each approval with the corresponding copy
484 : * condition if available.
485 : *
486 : * <p>E.g.:
487 : *
488 : * <pre>
489 : * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
490 : * * Verified+1 (copy condition: "is:MIN")
491 : * </pre>
492 : *
493 : * <p>Entries in the list can have the following formats:
494 : *
495 : * <ul>
496 : * <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
497 : * "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
498 : * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
499 : * <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
500 : * "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
501 : * present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
502 : * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
503 : * <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
504 : * present), e.g.: {@code Code-Review+1, Code-Review+2}
505 : * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
506 : * the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
507 : * missing)}
508 : * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
509 : * condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
510 : * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
511 : * "is:FOO")}
512 : * </ul>
513 : *
514 : * @param approvals the approvals that should be formatted
515 : * @param labelTypes the label types
516 : * @return bullet list with the formatted approvals
517 : */
518 : private String formatApprovalListWithCopyCondition(
519 : ImmutableSet<PatchSetApproval> approvals, LabelTypes labelTypes) {
520 15 : StringBuilder message = new StringBuilder();
521 :
522 : // sort approvals by label vote so that we list them in a deterministic order
523 15 : ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
524 15 : approvals.stream()
525 15 : .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
526 15 : .collect(toImmutableList());
527 :
528 15 : ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
529 15 : Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
530 :
531 : for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
532 15 : approvalsByLabel.asMap().entrySet()) {
533 15 : String label = approvalsByLabelEntry.getKey();
534 15 : Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
535 :
536 15 : message.append("* ");
537 15 : if (!labelTypes.byLabel(label).isPresent()) {
538 3 : message
539 3 : .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
540 3 : .append(" (label type is missing)\n");
541 3 : continue;
542 : }
543 :
544 14 : LabelType labelType = labelTypes.byLabel(label).get();
545 14 : if (!labelType.getCopyCondition().isPresent()) {
546 4 : message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
547 4 : continue;
548 : }
549 :
550 14 : message
551 14 : .append(
552 14 : formatApprovalsWithCopyCondition(
553 14 : approvalsForSameLabel, labelType.getCopyCondition().get()))
554 14 : .append("\n");
555 14 : }
556 :
557 15 : return message.toString();
558 : }
559 :
560 : /**
561 : * Formats the given approvals of the same label with the given copy condition.
562 : *
563 : * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
564 : *
565 : * <p>The following format may be returned:
566 : *
567 : * <ul>
568 : * <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
569 : * "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
570 : * is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
571 : * <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
572 : * "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
573 : * present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
574 : * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
575 : * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
576 : * condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
577 : * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
578 : * "is:FOO")}
579 : * </ul>
580 : *
581 : * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
582 : * @param copyCondition the copy condition of the label
583 : * @return the formatted approvals
584 : */
585 : private String formatApprovalsWithCopyCondition(
586 : Collection<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
587 14 : StringBuilder message = new StringBuilder();
588 :
589 : boolean containsUserInPredicate;
590 : try {
591 14 : containsUserInPredicate = containsUserInPredicate(copyCondition);
592 1 : } catch (QueryParseException e) {
593 1 : logger.atWarning().withCause(e).log("Non-parsable query condition");
594 1 : message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
595 1 : message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
596 1 : return message.toString();
597 14 : }
598 :
599 14 : if (containsUserInPredicate) {
600 : // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
601 : // include the approvers into the change message since they are relevant for the matching. For
602 : // example it can happen that the same approval of different users is copied for the one user
603 : // but not for the other user (since the one user is a member of the approverin group and the
604 : // other user isn't).
605 : //
606 : // Example:
607 : // * label Foo has the copy condition 'is:ANY approverin:123'
608 : // * group 123 contains UserA as member, but not UserB
609 : // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
610 : //
611 : // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
612 : // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
613 : // group 123 and the copy condition doesn't match.
614 : //
615 : // So it can happen that the same approval Foo+1, but by different users, is copied and
616 : // outdated at the same time. To allow users to understand that the copying depends on who did
617 : // the approval, the approvers must be included into the change message.
618 :
619 : // sort the approvals by their approvers name-email so that the approvers always appear in a
620 : // deterministic order
621 2 : ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
622 2 : approvalsForSameLabel.stream()
623 2 : .sorted(
624 2 : comparing(
625 : (PatchSetApproval psa) ->
626 2 : LabelVote.create(psa.label(), psa.value()).format())
627 2 : .thenComparing(
628 : psa ->
629 1 : accountCache
630 1 : .getEvenIfMissing(psa.accountId())
631 1 : .account()
632 1 : .getNameEmail(anonymousCowardName)))
633 2 : .collect(toImmutableList());
634 :
635 2 : ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
636 2 : Multimaps.index(
637 : approvalsSortedByLabelVoteAndApprover,
638 2 : psa -> LabelVote.create(psa.label(), psa.value()))
639 2 : .entries().stream()
640 2 : .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
641 2 : message.append(
642 2 : approversByLabelVote.asMap().entrySet().stream()
643 2 : .map(
644 : approversByLabelVoteEntry ->
645 2 : formatLabelVoteWithApprovers(
646 2 : approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
647 2 : .collect(joining(", ")));
648 2 : } else {
649 : // copy condition doesn't contain a UserInPredicate
650 14 : message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
651 : }
652 14 : message.append(String.format(" (copy condition: \"%s\")", copyCondition));
653 14 : return message.toString();
654 : }
655 :
656 : private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
657 : // Use a request context to run checks as an internal user with expanded visibility. This is
658 : // so that the output of the copy condition does not depend on who is running the current
659 : // request (e.g. a group used in this query might not be visible to the person sending this
660 : // request).
661 14 : try (ManualRequestContext ignored = requestContext.open()) {
662 14 : return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
663 14 : .stream()
664 14 : .anyMatch(UserInPredicate.class::isInstance);
665 : }
666 : }
667 :
668 : /**
669 : * Formats the given approvals as a comma-separated list of label votes.
670 : *
671 : * <p>E.g.: {@code Code-Review+1, CodeReview+2}
672 : *
673 : * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
674 : * list of label votes, must be sorted
675 : * @return the given approvals as a comma-separated list of label votes
676 : */
677 : private String formatApprovalsAsLabelVotesList(
678 : Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
679 15 : return sortedApprovalsForSameLabel.stream()
680 15 : .map(psa -> LabelVote.create(psa.label(), psa.value()))
681 15 : .distinct()
682 15 : .map(LabelVote::format)
683 15 : .collect(joining(", "));
684 : }
685 :
686 : /**
687 : * Formats the given label vote with a comma-separated list of the given approvers.
688 : *
689 : * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
690 : *
691 : * @param labelVote the label vote that should be formatted with a comma-separated list of the
692 : * given approver
693 : * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
694 : * given label vote
695 : * @return the given label vote with a comma-separated list of the given approvers
696 : */
697 : private String formatLabelVoteWithApprovers(
698 : LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
699 2 : return new StringBuilder()
700 2 : .append(labelVote.format())
701 2 : .append(" by ")
702 2 : .append(
703 2 : sortedApprovers.stream()
704 2 : .map(AccountTemplateUtil::getAccountTemplate)
705 2 : .collect(joining(", ")))
706 2 : .toString();
707 : }
708 :
709 : /**
710 : * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but
711 : * does not include deleted labels.
712 : *
713 : * @param notes changenotes of the change.
714 : * @param psId patch-set id for the change and patch-set we want to get approvals.
715 : * @return all approvals for the specified patch-set, including copied votes, not including
716 : * deleted labels.
717 : */
718 : public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
719 103 : List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
720 103 : return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
721 : }
722 :
723 : public Iterable<PatchSetApproval> byPatchSetUser(
724 : ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) {
725 67 : return filterApprovals(byPatchSet(notes, psId), accountId);
726 : }
727 :
728 : @Nullable
729 : public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
730 9 : if (c == null) {
731 0 : return null;
732 : }
733 : try {
734 : // Submit approval is never copied.
735 9 : return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c));
736 0 : } catch (StorageException e) {
737 0 : return null;
738 : }
739 : }
740 :
741 : @Nullable
742 : public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
743 9 : if (c == null) {
744 0 : return null;
745 : }
746 9 : PatchSetApproval submitter = null;
747 9 : for (PatchSetApproval a : approvals) {
748 9 : if (a.patchSetId().equals(c) && a.value() > 0 && a.isLegacySubmit()) {
749 9 : if (submitter == null || a.granted().compareTo(submitter.granted()) > 0) {
750 9 : submitter = a;
751 : }
752 : }
753 9 : }
754 9 : return submitter;
755 : }
756 :
757 : public static String renderMessageWithApprovals(
758 : int patchSetId, Map<String, Short> n, Map<String, PatchSetApproval> c) {
759 88 : StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
760 88 : if (!n.isEmpty()) {
761 4 : boolean first = true;
762 4 : for (Map.Entry<String, Short> e : n.entrySet()) {
763 4 : if (c.containsKey(e.getKey()) && c.get(e.getKey()).value() == e.getValue()) {
764 3 : continue;
765 : }
766 4 : if (first) {
767 4 : msgs.append(":");
768 4 : first = false;
769 : }
770 4 : msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format());
771 4 : }
772 : }
773 88 : return msgs.toString();
774 : }
775 : }
|