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.checkState;
18 : import static java.util.stream.Collectors.toList;
19 :
20 : import com.google.auto.value.AutoValue;
21 : import com.google.common.collect.HashBasedTable;
22 : import com.google.common.collect.ImmutableMap;
23 : import com.google.common.collect.Iterables;
24 : import com.google.common.collect.LinkedHashMultimap;
25 : import com.google.common.collect.Lists;
26 : import com.google.common.collect.Maps;
27 : import com.google.common.collect.MultimapBuilder;
28 : import com.google.common.collect.SetMultimap;
29 : import com.google.common.collect.Table;
30 : import com.google.common.flogger.FluentLogger;
31 : import com.google.common.primitives.Ints;
32 : import com.google.gerrit.common.Nullable;
33 : import com.google.gerrit.entities.Account;
34 : import com.google.gerrit.entities.LabelType;
35 : import com.google.gerrit.entities.LabelTypes;
36 : import com.google.gerrit.entities.LabelValue;
37 : import com.google.gerrit.entities.PatchSetApproval;
38 : import com.google.gerrit.entities.SubmitRecord;
39 : import com.google.gerrit.extensions.common.ApprovalInfo;
40 : import com.google.gerrit.extensions.common.LabelInfo;
41 : import com.google.gerrit.extensions.common.VotingRangeInfo;
42 : import com.google.gerrit.server.ChangeUtil;
43 : import com.google.gerrit.server.account.AccountLoader;
44 : import com.google.gerrit.server.notedb.ReviewerStateInternal;
45 : import com.google.gerrit.server.permissions.LabelPermission;
46 : import com.google.gerrit.server.permissions.PermissionBackend;
47 : import com.google.gerrit.server.permissions.PermissionBackendException;
48 : import com.google.gerrit.server.query.change.ChangeData;
49 : import com.google.inject.Inject;
50 : import com.google.inject.Singleton;
51 : import java.time.Instant;
52 : import java.util.ArrayList;
53 : import java.util.Collection;
54 : import java.util.Collections;
55 : import java.util.HashMap;
56 : import java.util.HashSet;
57 : import java.util.LinkedHashMap;
58 : import java.util.List;
59 : import java.util.Map;
60 : import java.util.Optional;
61 : import java.util.Set;
62 : import java.util.TreeMap;
63 :
64 : /**
65 : * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
66 : */
67 : @Singleton
68 : public class LabelsJson {
69 146 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
70 :
71 : private final PermissionBackend permissionBackend;
72 :
73 : @Inject
74 146 : LabelsJson(PermissionBackend permissionBackend) {
75 146 : this.permissionBackend = permissionBackend;
76 146 : }
77 :
78 : /**
79 : * Returns all {@link LabelInfo}s for a single change. Uses the provided {@link AccountLoader} to
80 : * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
81 : * populate all accounts in the returned {@link LabelInfo}s.
82 : */
83 : @Nullable
84 : Map<String, LabelInfo> labelsFor(
85 : AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
86 : throws PermissionBackendException {
87 103 : if (!standard && !detailed) {
88 71 : return null;
89 : }
90 :
91 103 : LabelTypes labelTypes = cd.getLabelTypes();
92 : Map<String, LabelWithStatus> withStatus =
93 103 : cd.change().isMerged()
94 55 : ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
95 103 : : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
96 103 : return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
97 : }
98 :
99 : /**
100 : * Returns A map of all label names and the values that the provided user has permission to vote
101 : * on.
102 : *
103 : * @param filterApprovalsBy a Gerrit user ID.
104 : * @param cd {@link ChangeData} corresponding to a specific gerrit change.
105 : * @return A Map where the key contain a label name, and the value is a list of the permissible
106 : * vote values that the user can vote on.
107 : */
108 : Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
109 : throws PermissionBackendException {
110 103 : SetMultimap<String, String> permitted = LinkedHashMultimap.create();
111 103 : boolean isMerged = cd.change().isMerged();
112 103 : Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
113 103 : for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
114 103 : if (isMerged && !labelType.isAllowPostSubmit()) {
115 1 : continue;
116 : }
117 103 : Set<LabelPermission.WithValue> can =
118 103 : permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
119 103 : for (LabelValue v : labelType.getValues()) {
120 103 : boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
121 103 : if (isMerged) {
122 : // Votes cannot be decreased if the change is merged. Only accept the label value if it's
123 : // greater or equal than the user's latest vote.
124 55 : short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
125 55 : ok &= v.getValue() >= prev;
126 : }
127 103 : if (ok) {
128 103 : permitted.put(labelType.getName(), v.formatValue());
129 : }
130 103 : }
131 103 : }
132 103 : clearOnlyZerosEntries(permitted);
133 103 : return permitted.asMap();
134 : }
135 :
136 : private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
137 103 : List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
138 103 : for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
139 103 : if (isOnlyZero(e.getValue())) {
140 9 : toClear.add(e.getKey());
141 : }
142 103 : }
143 103 : for (String label : toClear) {
144 9 : permitted.removeAll(label);
145 9 : }
146 103 : }
147 :
148 : private static boolean isOnlyZero(Collection<String> values) {
149 103 : return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
150 : }
151 :
152 : private static void addApproval(LabelInfo label, ApprovalInfo approval) {
153 71 : if (label.all == null) {
154 71 : label.all = new ArrayList<>();
155 : }
156 71 : label.all.add(approval);
157 71 : }
158 :
159 : private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
160 : AccountLoader accountLoader,
161 : ChangeData cd,
162 : LabelTypes labelTypes,
163 : boolean standard,
164 : boolean detailed)
165 : throws PermissionBackendException {
166 103 : Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
167 103 : setAllApprovals(accountLoader, cd, labels, detailed);
168 :
169 103 : for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
170 103 : Optional<LabelType> type = labelTypes.byLabel(e.getKey());
171 103 : if (!type.isPresent()) {
172 5 : continue;
173 : }
174 103 : if (standard) {
175 103 : for (PatchSetApproval psa : cd.currentApprovals()) {
176 58 : if (type.get().matches(psa)) {
177 58 : short val = psa.value();
178 58 : Account.Id accountId = psa.accountId();
179 58 : setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId);
180 : }
181 58 : }
182 : }
183 103 : setLabelValues(type.get(), e.getValue());
184 103 : }
185 103 : return labels;
186 : }
187 :
188 : private Integer parseRangeValue(String value) {
189 71 : if (value.startsWith("+")) {
190 71 : value = value.substring(1);
191 71 : } else if (value.startsWith(" ")) {
192 71 : value = value.trim();
193 : }
194 71 : return Ints.tryParse(value);
195 : }
196 :
197 : private ApprovalInfo approvalInfo(
198 : AccountLoader accountLoader,
199 : Account.Id id,
200 : @Nullable Integer value,
201 : @Nullable VotingRangeInfo permittedVotingRange,
202 : @Nullable String tag,
203 : @Nullable Instant date) {
204 71 : ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
205 71 : accountLoader.put(ai);
206 71 : return ai;
207 : }
208 :
209 : private void setLabelValues(LabelType type, LabelWithStatus l) {
210 103 : l.label().defaultValue = type.getDefaultValue();
211 103 : l.label().values = new LinkedHashMap<>();
212 103 : for (LabelValue v : type.getValues()) {
213 103 : l.label().values.put(v.formatValue(), v.getText());
214 103 : }
215 103 : if (isOnlyZero(l.label().values.keySet())) {
216 0 : l.label().values = null;
217 : }
218 103 : }
219 :
220 : private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
221 103 : Map<String, Short> result = new HashMap<>();
222 103 : for (PatchSetApproval psa : cd.currentApprovals()) {
223 67 : if (psa.accountId().equals(accountId)) {
224 67 : result.put(psa.label(), psa.value());
225 : }
226 67 : }
227 103 : return result;
228 : }
229 :
230 : private Map<String, LabelWithStatus> labelsForSubmittedChange(
231 : AccountLoader accountLoader,
232 : ChangeData cd,
233 : LabelTypes labelTypes,
234 : boolean standard,
235 : boolean detailed)
236 : throws PermissionBackendException {
237 55 : Set<Account.Id> allUsers = new HashSet<>();
238 55 : if (detailed) {
239 : // Users expect to see all reviewers on closed changes, even if they
240 : // didn't vote on the latest patch set. If we don't need detailed labels,
241 : // we aren't including 0 votes for all users below, so we can just look at
242 : // the latest patch set (in the next loop).
243 55 : for (PatchSetApproval psa : cd.approvals().values()) {
244 55 : allUsers.add(psa.accountId());
245 55 : }
246 : }
247 :
248 55 : Set<String> labelNames = new HashSet<>();
249 : SetMultimap<Account.Id, PatchSetApproval> current =
250 55 : MultimapBuilder.hashKeys().hashSetValues().build();
251 55 : for (PatchSetApproval a : cd.currentApprovals()) {
252 55 : allUsers.add(a.accountId());
253 55 : Optional<LabelType> type = labelTypes.byLabel(a.labelId());
254 55 : if (type.isPresent()) {
255 46 : labelNames.add(type.get().getName());
256 : // Not worth the effort to distinguish between votable/non-votable for 0
257 : // values on closed changes, since they can't vote anyway.
258 46 : current.put(a.accountId(), a);
259 : }
260 55 : }
261 :
262 : // Since voting on merged changes is allowed all labels which apply to
263 : // the change must be returned. All applying labels can be retrieved from
264 : // the submit records, which is what initLabels does.
265 : // It's not possible to only compute the labels based on the approvals
266 : // since merged changes may not have approvals for all labels (e.g. if not
267 : // all labels are required for submit or if the change was auto-closed due
268 : // to direct push or if new labels were defined after the change was
269 : // merged).
270 : Map<String, LabelWithStatus> labels;
271 55 : labels = initLabels(accountLoader, cd, labelTypes, standard);
272 :
273 : // Also include all labels for which approvals exists. E.g. there can be
274 : // approvals for labels that are ignored by a Prolog submit rule and hence
275 : // it wouldn't be included in the submit records.
276 55 : for (String name : labelNames) {
277 46 : if (!labels.containsKey(name)) {
278 2 : labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
279 : }
280 46 : }
281 :
282 55 : labels.entrySet().stream()
283 55 : .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
284 55 : .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
285 :
286 55 : for (Account.Id accountId : allUsers) {
287 55 : Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
288 55 : Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
289 55 : if (detailed) {
290 55 : pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
291 : }
292 55 : for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
293 55 : ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
294 55 : byLabel.put(entry.getKey(), ai);
295 55 : addApproval(entry.getValue().label(), ai);
296 55 : }
297 55 : for (PatchSetApproval psa : current.get(accountId)) {
298 46 : Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
299 46 : if (!type.isPresent()) {
300 0 : continue;
301 : }
302 :
303 46 : short val = psa.value();
304 46 : ApprovalInfo info = byLabel.get(type.get().getName());
305 46 : if (info != null) {
306 46 : info.value = Integer.valueOf(val);
307 46 : info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
308 46 : info.setDate(psa.granted());
309 46 : info.tag = psa.tag().orElse(null);
310 46 : if (psa.postSubmit()) {
311 3 : info.postSubmit = true;
312 : }
313 : }
314 46 : if (!standard) {
315 6 : continue;
316 : }
317 :
318 46 : setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId);
319 46 : }
320 55 : }
321 55 : return labels;
322 : }
323 :
324 : private Map<String, LabelWithStatus> initLabels(
325 : AccountLoader accountLoader, ChangeData cd, LabelTypes labelTypes, boolean standard) {
326 103 : Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
327 103 : for (SubmitRecord rec : submitRecords(cd)) {
328 103 : if (rec.labels == null) {
329 5 : continue;
330 : }
331 103 : for (SubmitRecord.Label r : rec.labels) {
332 103 : LabelWithStatus p = labels.get(r.label);
333 103 : if (p == null || p.status().compareTo(r.status) < 0) {
334 103 : LabelInfo n = new LabelInfo();
335 103 : if (standard) {
336 103 : switch (r.status) {
337 : case OK:
338 56 : n.approved = accountLoader.get(r.appliedBy);
339 56 : break;
340 : case REJECT:
341 19 : n.rejected = accountLoader.get(r.appliedBy);
342 19 : n.blocking = true;
343 19 : break;
344 : case IMPOSSIBLE:
345 : case MAY:
346 : case NEED:
347 : default:
348 : break;
349 : }
350 : }
351 :
352 103 : n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null;
353 103 : labels.put(r.label, LabelWithStatus.create(n, r.status));
354 : }
355 103 : }
356 103 : }
357 103 : setLabelsDescription(labels, labelTypes);
358 103 : return labels;
359 : }
360 :
361 : private void setLabelsDescription(
362 : Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) {
363 103 : for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
364 103 : String labelName = entry.getKey();
365 103 : Optional<LabelType> type = labelTypes.byLabel(labelName);
366 103 : if (!type.isPresent()) {
367 5 : continue;
368 : }
369 103 : LabelWithStatus labelWithStatus = entry.getValue();
370 103 : labelWithStatus.label().description = type.get().getDescription().orElse(null);
371 103 : }
372 103 : }
373 :
374 : private void setLabelScores(
375 : AccountLoader accountLoader,
376 : LabelType type,
377 : LabelWithStatus l,
378 : short score,
379 : Account.Id accountId) {
380 58 : if (l.label().approved != null || l.label().rejected != null) {
381 56 : return;
382 : }
383 :
384 27 : if (type.getMin() == null || type.getMax() == null) {
385 : // Can't set score for unknown or misconfigured type.
386 0 : return;
387 : }
388 :
389 27 : if (score != 0) {
390 25 : if (score == type.getMin().getValue()) {
391 3 : l.label().rejected = accountLoader.get(accountId);
392 25 : } else if (score == type.getMax().getValue()) {
393 10 : l.label().approved = accountLoader.get(accountId);
394 22 : } else if (score < 0) {
395 9 : l.label().disliked = accountLoader.get(accountId);
396 9 : l.label().value = score;
397 22 : } else if (score > 0 && l.label().disliked == null) {
398 22 : l.label().recommended = accountLoader.get(accountId);
399 22 : l.label().value = score;
400 : }
401 : }
402 27 : }
403 :
404 : private void setAllApprovals(
405 : AccountLoader accountLoader,
406 : ChangeData cd,
407 : Map<String, LabelWithStatus> labels,
408 : boolean detailed)
409 : throws PermissionBackendException {
410 103 : checkState(
411 103 : !cd.change().isMerged(),
412 : "should not call setAllApprovals on %s change",
413 103 : ChangeUtil.status(cd.change()));
414 :
415 : // Include a user in the output for this label if either:
416 : // - They are an explicit reviewer.
417 : // - They ever voted on this change.
418 103 : Set<Account.Id> allUsers = new HashSet<>();
419 103 : allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER));
420 103 : for (PatchSetApproval psa : cd.approvals().values()) {
421 58 : allUsers.add(psa.accountId());
422 58 : }
423 :
424 103 : Table<Account.Id, String, PatchSetApproval> current =
425 103 : HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
426 103 : for (PatchSetApproval psa : cd.currentApprovals()) {
427 58 : current.put(psa.accountId(), psa.label(), psa);
428 58 : }
429 :
430 103 : LabelTypes labelTypes = cd.getLabelTypes();
431 103 : for (Account.Id accountId : allUsers) {
432 66 : Map<String, VotingRangeInfo> pvr = null;
433 66 : PermissionBackend.ForChange perm = null;
434 66 : if (detailed) {
435 66 : perm = permissionBackend.absentUser(accountId).change(cd);
436 66 : pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
437 : }
438 66 : for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
439 66 : Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
440 66 : if (!lt.isPresent()) {
441 : // Ignore submit record for undefined label; likely the submit rule
442 : // author didn't intend for the label to show up in the table.
443 3 : continue;
444 : }
445 : Integer value;
446 : VotingRangeInfo permittedVotingRange =
447 66 : pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null);
448 66 : String tag = null;
449 66 : Instant date = null;
450 66 : PatchSetApproval psa = current.get(accountId, lt.get().getName());
451 66 : if (psa != null) {
452 58 : value = Integer.valueOf(psa.value());
453 58 : if (value == 0) {
454 : // This may be a dummy approval that was inserted when the reviewer
455 : // was added. Explicitly check whether the user can vote on this
456 : // label.
457 12 : value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
458 : }
459 58 : tag = psa.tag().orElse(null);
460 58 : date = psa.granted();
461 58 : if (psa.postSubmit()) {
462 0 : logger.atWarning().log("unexpected post-submit approval on open change: %s", psa);
463 : }
464 : } else {
465 : // Either the user cannot vote on this label, or they were added as a
466 : // reviewer but have not responded yet. Explicitly check whether the
467 : // user can vote on this label.
468 37 : value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
469 : }
470 66 : addApproval(
471 66 : e.getValue().label(),
472 66 : approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date));
473 66 : }
474 66 : }
475 103 : }
476 :
477 : private List<SubmitRecord> submitRecords(ChangeData cd) {
478 103 : return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
479 : }
480 :
481 : private Map<String, VotingRangeInfo> getPermittedVotingRanges(
482 : Map<String, Collection<String>> permittedLabels) {
483 71 : Map<String, VotingRangeInfo> permittedVotingRanges =
484 71 : Maps.newHashMapWithExpectedSize(permittedLabels.size());
485 71 : for (String label : permittedLabels.keySet()) {
486 71 : List<Integer> permittedVotingRange =
487 71 : permittedLabels.get(label).stream()
488 71 : .map(this::parseRangeValue)
489 71 : .filter(java.util.Objects::nonNull)
490 71 : .sorted()
491 71 : .collect(toList());
492 :
493 71 : if (permittedVotingRange.isEmpty()) {
494 0 : permittedVotingRanges.put(label, null);
495 : } else {
496 71 : int minPermittedValue = permittedVotingRange.get(0);
497 71 : int maxPermittedValue = Iterables.getLast(permittedVotingRange);
498 71 : permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue));
499 : }
500 71 : }
501 71 : return permittedVotingRanges;
502 : }
503 :
504 : @AutoValue
505 103 : abstract static class LabelWithStatus {
506 : private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
507 103 : return new AutoValue_LabelsJson_LabelWithStatus(label, status);
508 : }
509 :
510 : abstract LabelInfo label();
511 :
512 : @Nullable
513 : abstract SubmitRecord.Label.Status status();
514 : }
515 : }
|