| // 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.checkState; |
| import static java.util.stream.Collectors.toList; |
| |
| import com.google.auto.value.AutoValue; |
| import com.google.common.collect.HashBasedTable; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.LinkedHashMultimap; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.MultimapBuilder; |
| import com.google.common.collect.SetMultimap; |
| import com.google.common.collect.Table; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.common.primitives.Ints; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.LabelType; |
| import com.google.gerrit.entities.LabelTypes; |
| import com.google.gerrit.entities.LabelValue; |
| import com.google.gerrit.entities.PatchSetApproval; |
| import com.google.gerrit.entities.SubmitRecord; |
| import com.google.gerrit.extensions.common.AccountInfo; |
| import com.google.gerrit.extensions.common.ApprovalInfo; |
| import com.google.gerrit.extensions.common.LabelInfo; |
| import com.google.gerrit.extensions.common.VotingRangeInfo; |
| import com.google.gerrit.server.ChangeUtil; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.account.AccountLoader; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.permissions.LabelPermission; |
| import com.google.gerrit.server.permissions.PermissionBackend; |
| import com.google.gerrit.server.permissions.PermissionBackendException; |
| import com.google.gerrit.server.project.DeleteVoteControl; |
| import com.google.gerrit.server.project.RemoveReviewerControl; |
| import com.google.gerrit.server.query.change.ChangeData; |
| import com.google.inject.Inject; |
| import com.google.inject.Singleton; |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.Set; |
| import java.util.TreeMap; |
| |
| /** |
| * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards. |
| */ |
| @Singleton |
| public class LabelsJson { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private final PermissionBackend permissionBackend; |
| private final DeleteVoteControl deleteVoteControl; |
| private final RemoveReviewerControl removeReviewerControl; |
| |
| @Inject |
| LabelsJson( |
| PermissionBackend permissionBackend, |
| DeleteVoteControl deleteVoteControl, |
| RemoveReviewerControl removeReviewerControl) { |
| this.permissionBackend = permissionBackend; |
| this.deleteVoteControl = deleteVoteControl; |
| this.removeReviewerControl = removeReviewerControl; |
| } |
| |
| /** |
| * Returns all {@link LabelInfo}s for a single change. Uses the provided {@link AccountLoader} to |
| * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to |
| * populate all accounts in the returned {@link LabelInfo}s. |
| */ |
| @Nullable |
| Map<String, LabelInfo> labelsFor( |
| AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed) |
| throws PermissionBackendException { |
| if (!standard && !detailed) { |
| return null; |
| } |
| |
| LabelTypes labelTypes = cd.getLabelTypes(); |
| Map<String, LabelWithStatus> withStatus = |
| cd.change().isMerged() |
| ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed) |
| : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed); |
| return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label)); |
| } |
| |
| /** |
| * Returns A map of all label names and the values that the provided user has permission to vote |
| * on. |
| * |
| * @param filterApprovalsBy a Gerrit user ID. |
| * @param cd {@link ChangeData} corresponding to a specific gerrit change. |
| * @return A Map where the key contain a label name, and the value is a list of the permissible |
| * vote values that the user can vote on. |
| */ |
| Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd) |
| throws PermissionBackendException { |
| SetMultimap<String, String> permitted = LinkedHashMultimap.create(); |
| boolean isMerged = cd.change().isMerged(); |
| Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd); |
| for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) { |
| if (isMerged && !labelType.isAllowPostSubmit()) { |
| continue; |
| } |
| Set<LabelPermission.WithValue> can = |
| permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType); |
| for (LabelValue v : labelType.getValues()) { |
| boolean ok = can.contains(new LabelPermission.WithValue(labelType, v)); |
| if (isMerged) { |
| // Votes cannot be decreased if the change is merged. Only accept the label value if it's |
| // greater or equal than the user's latest vote. |
| short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0); |
| ok &= v.getValue() >= prev; |
| } |
| if (ok) { |
| permitted.put(labelType.getName(), v.formatValue()); |
| } |
| } |
| } |
| clearOnlyZerosEntries(permitted); |
| return permitted.asMap(); |
| } |
| |
| /** |
| * Returns A map of all labels that the provided user has permission to remove. |
| * |
| * @param accountLoader to load the reviewers' data with. |
| * @param user a Gerrit user. |
| * @param cd {@link ChangeData} corresponding to a specific gerrit change. |
| * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}} |
| * that the user can remove votes from. |
| */ |
| Map<String, Map<String, List<AccountInfo>>> removableLabels( |
| AccountLoader accountLoader, CurrentUser user, ChangeData cd) |
| throws PermissionBackendException { |
| if (cd.change().isMerged()) { |
| return new HashMap<>(); |
| } |
| |
| Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>(); |
| LabelTypes labelTypes = cd.getLabelTypes(); |
| for (PatchSetApproval approval : cd.currentApprovals()) { |
| Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId()); |
| if (!labelType.isPresent()) { |
| continue; |
| } |
| if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get()) |
| || removeReviewerControl.testRemoveReviewer( |
| cd, user, approval.accountId(), approval.value()))) { |
| continue; |
| } |
| if (!res.containsKey(approval.label())) { |
| res.put(approval.label(), new HashMap<>()); |
| } |
| String labelValue = LabelValue.formatValue(approval.value()); |
| if (!res.get(approval.label()).containsKey(labelValue)) { |
| res.get(approval.label()).put(labelValue, new ArrayList<>()); |
| } |
| res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId())); |
| } |
| return res; |
| } |
| |
| private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) { |
| List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size()); |
| for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) { |
| if (isOnlyZero(e.getValue())) { |
| toClear.add(e.getKey()); |
| } |
| } |
| for (String label : toClear) { |
| permitted.removeAll(label); |
| } |
| } |
| |
| private static boolean isOnlyZero(Collection<String> values) { |
| return values.isEmpty() || (values.size() == 1 && values.contains(" 0")); |
| } |
| |
| private static void addApproval(LabelInfo label, ApprovalInfo approval) { |
| if (label.all == null) { |
| label.all = new ArrayList<>(); |
| } |
| label.all.add(approval); |
| } |
| |
| private Map<String, LabelWithStatus> labelsForUnsubmittedChange( |
| AccountLoader accountLoader, |
| ChangeData cd, |
| LabelTypes labelTypes, |
| boolean standard, |
| boolean detailed) |
| throws PermissionBackendException { |
| Map<String, LabelWithStatus> labels = |
| initLabels(accountLoader, cd, labelTypes, /*includeAccountInfo=*/ standard || detailed); |
| setAllApprovals(accountLoader, cd, labels, detailed); |
| |
| for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) { |
| Optional<LabelType> type = labelTypes.byLabel(e.getKey()); |
| if (!type.isPresent()) { |
| continue; |
| } |
| if (standard || detailed) { |
| for (PatchSetApproval psa : cd.currentApprovals()) { |
| if (type.get().matches(psa)) { |
| short val = psa.value(); |
| Account.Id accountId = psa.accountId(); |
| setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId); |
| } |
| } |
| } |
| setLabelValues(type.get(), e.getValue()); |
| } |
| return labels; |
| } |
| |
| private Integer parseRangeValue(String value) { |
| if (value.startsWith("+")) { |
| value = value.substring(1); |
| } else if (value.startsWith(" ")) { |
| value = value.trim(); |
| } |
| return Ints.tryParse(value); |
| } |
| |
| private ApprovalInfo approvalInfo( |
| AccountLoader accountLoader, |
| Account.Id id, |
| @Nullable Integer value, |
| @Nullable VotingRangeInfo permittedVotingRange, |
| @Nullable String tag, |
| @Nullable Instant date) { |
| ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date); |
| accountLoader.put(ai); |
| return ai; |
| } |
| |
| private void setLabelValues(LabelType type, LabelWithStatus l) { |
| l.label().defaultValue = type.getDefaultValue(); |
| l.label().values = new LinkedHashMap<>(); |
| for (LabelValue v : type.getValues()) { |
| l.label().values.put(v.formatValue(), v.getText()); |
| } |
| if (isOnlyZero(l.label().values.keySet())) { |
| l.label().values = null; |
| } |
| } |
| |
| private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) { |
| Map<String, Short> result = new HashMap<>(); |
| for (PatchSetApproval psa : cd.currentApprovals()) { |
| if (accountId == null || psa.accountId().equals(accountId)) { |
| result.put(psa.label(), psa.value()); |
| } |
| } |
| return result; |
| } |
| |
| private Map<String, LabelWithStatus> labelsForSubmittedChange( |
| AccountLoader accountLoader, |
| ChangeData cd, |
| LabelTypes labelTypes, |
| boolean standard, |
| boolean detailed) |
| throws PermissionBackendException { |
| Set<Account.Id> allUsers = new HashSet<>(); |
| if (detailed) { |
| // Users expect to see all reviewers on closed changes, even if they |
| // didn't vote on the latest patch set. If we don't need detailed labels, |
| // we aren't including 0 votes for all users below, so we can just look at |
| // the latest patch set (in the next loop). |
| for (PatchSetApproval psa : cd.approvals().values()) { |
| allUsers.add(psa.accountId()); |
| } |
| } |
| |
| Set<String> labelNames = new HashSet<>(); |
| SetMultimap<Account.Id, PatchSetApproval> current = |
| MultimapBuilder.hashKeys().hashSetValues().build(); |
| for (PatchSetApproval a : cd.currentApprovals()) { |
| allUsers.add(a.accountId()); |
| Optional<LabelType> type = labelTypes.byLabel(a.labelId()); |
| if (type.isPresent()) { |
| labelNames.add(type.get().getName()); |
| // Not worth the effort to distinguish between votable/non-votable for 0 |
| // values on closed changes, since they can't vote anyway. |
| current.put(a.accountId(), a); |
| } |
| } |
| |
| // Since voting on merged changes is allowed all labels which apply to |
| // the change must be returned. All applying labels can be retrieved from |
| // the submit records, which is what initLabels does. |
| // It's not possible to only compute the labels based on the approvals |
| // since merged changes may not have approvals for all labels (e.g. if not |
| // all labels are required for submit or if the change was auto-closed due |
| // to direct push or if new labels were defined after the change was |
| // merged). |
| Map<String, LabelWithStatus> labels; |
| labels = initLabels(accountLoader, cd, labelTypes, standard); |
| |
| // Also include all labels for which approvals exists. E.g. there can be |
| // approvals for labels that are ignored by a Prolog submit rule and hence |
| // it wouldn't be included in the submit records. |
| for (String name : labelNames) { |
| if (!labels.containsKey(name)) { |
| labels.put(name, LabelWithStatus.create(new LabelInfo(), null)); |
| } |
| } |
| |
| labels.entrySet().stream() |
| .filter(e -> labelTypes.byLabel(e.getKey()).isPresent()) |
| .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue())); |
| |
| for (Account.Id accountId : allUsers) { |
| Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size()); |
| Map<String, VotingRangeInfo> pvr = Collections.emptyMap(); |
| if (detailed) { |
| pvr = getPermittedVotingRanges(permittedLabels(accountId, cd)); |
| } |
| for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) { |
| ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null); |
| byLabel.put(entry.getKey(), ai); |
| addApproval(entry.getValue().label(), ai); |
| } |
| for (PatchSetApproval psa : current.get(accountId)) { |
| Optional<LabelType> type = labelTypes.byLabel(psa.labelId()); |
| if (!type.isPresent()) { |
| continue; |
| } |
| |
| short val = psa.value(); |
| ApprovalInfo info = byLabel.get(type.get().getName()); |
| if (info != null) { |
| info.value = Integer.valueOf(val); |
| info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null); |
| info.setDate(psa.granted()); |
| info.tag = psa.tag().orElse(null); |
| if (psa.postSubmit()) { |
| info.postSubmit = true; |
| } |
| } |
| if (!standard) { |
| continue; |
| } |
| |
| setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId); |
| } |
| } |
| return labels; |
| } |
| |
| private Map<String, LabelWithStatus> initLabels( |
| AccountLoader accountLoader, |
| ChangeData cd, |
| LabelTypes labelTypes, |
| boolean includeAccountInfo) { |
| Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator()); |
| for (SubmitRecord rec : submitRecords(cd)) { |
| if (rec.labels == null) { |
| continue; |
| } |
| for (SubmitRecord.Label r : rec.labels) { |
| LabelWithStatus p = labels.get(r.label); |
| if (p == null || p.status().compareTo(r.status) < 0) { |
| LabelInfo n = new LabelInfo(); |
| if (includeAccountInfo) { |
| switch (r.status) { |
| case OK: |
| n.approved = accountLoader.get(r.appliedBy); |
| break; |
| case REJECT: |
| n.rejected = accountLoader.get(r.appliedBy); |
| n.blocking = true; |
| break; |
| case IMPOSSIBLE: |
| case MAY: |
| case NEED: |
| default: |
| break; |
| } |
| } |
| |
| n.optional = r.status == SubmitRecord.Label.Status.MAY ? true : null; |
| labels.put(r.label, LabelWithStatus.create(n, r.status)); |
| } |
| } |
| } |
| setLabelsDescription(labels, labelTypes); |
| return labels; |
| } |
| |
| private void setLabelsDescription( |
| Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) { |
| for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) { |
| String labelName = entry.getKey(); |
| Optional<LabelType> type = labelTypes.byLabel(labelName); |
| if (!type.isPresent()) { |
| continue; |
| } |
| LabelWithStatus labelWithStatus = entry.getValue(); |
| labelWithStatus.label().description = type.get().getDescription().orElse(null); |
| } |
| } |
| |
| private void setLabelScores( |
| AccountLoader accountLoader, |
| LabelType type, |
| LabelWithStatus l, |
| short score, |
| Account.Id accountId) { |
| if (l.label().approved != null || l.label().rejected != null) { |
| return; |
| } |
| |
| if (type.getMin() == null || type.getMax() == null) { |
| // Can't set score for unknown or misconfigured type. |
| return; |
| } |
| |
| if (score != 0) { |
| if (score == type.getMin().getValue()) { |
| l.label().rejected = accountLoader.get(accountId); |
| } else if (score == type.getMax().getValue()) { |
| l.label().approved = accountLoader.get(accountId); |
| } else if (score < 0) { |
| l.label().disliked = accountLoader.get(accountId); |
| l.label().value = score; |
| } else if (score > 0 && l.label().disliked == null) { |
| l.label().recommended = accountLoader.get(accountId); |
| l.label().value = score; |
| } |
| } |
| } |
| |
| private void setAllApprovals( |
| AccountLoader accountLoader, |
| ChangeData cd, |
| Map<String, LabelWithStatus> labels, |
| boolean detailed) |
| throws PermissionBackendException { |
| checkState( |
| !cd.change().isMerged(), |
| "should not call setAllApprovals on %s change", |
| ChangeUtil.status(cd.change())); |
| |
| // Include a user in the output for this label if either: |
| // - They are an explicit reviewer. |
| // - They ever voted on this change. |
| Set<Account.Id> allUsers = new HashSet<>(); |
| allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER)); |
| for (PatchSetApproval psa : cd.approvals().values()) { |
| allUsers.add(psa.accountId()); |
| } |
| |
| Table<Account.Id, String, PatchSetApproval> current = |
| HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size()); |
| for (PatchSetApproval psa : cd.currentApprovals()) { |
| current.put(psa.accountId(), psa.label(), psa); |
| } |
| |
| LabelTypes labelTypes = cd.getLabelTypes(); |
| for (Account.Id accountId : allUsers) { |
| Map<String, VotingRangeInfo> pvr = null; |
| PermissionBackend.ForChange perm = null; |
| if (detailed) { |
| perm = permissionBackend.absentUser(accountId).change(cd); |
| pvr = getPermittedVotingRanges(permittedLabels(accountId, cd)); |
| } |
| for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) { |
| Optional<LabelType> lt = labelTypes.byLabel(e.getKey()); |
| if (!lt.isPresent()) { |
| // Ignore submit record for undefined label; likely the submit rule |
| // author didn't intend for the label to show up in the table. |
| continue; |
| } |
| Integer value; |
| VotingRangeInfo permittedVotingRange = |
| pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null); |
| String tag = null; |
| Instant date = null; |
| PatchSetApproval psa = current.get(accountId, lt.get().getName()); |
| if (psa != null) { |
| value = Integer.valueOf(psa.value()); |
| if (value == 0) { |
| // This may be a dummy approval that was inserted when the reviewer |
| // was added. Explicitly check whether the user can vote on this |
| // label. |
| value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null; |
| } |
| tag = psa.tag().orElse(null); |
| date = psa.granted(); |
| if (psa.postSubmit()) { |
| logger.atWarning().log("unexpected post-submit approval on open change: %s", psa); |
| } |
| } else { |
| // Either the user cannot vote on this label, or they were added as a |
| // reviewer but have not responded yet. Explicitly check whether the |
| // user can vote on this label. |
| value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null; |
| } |
| addApproval( |
| e.getValue().label(), |
| approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date)); |
| } |
| } |
| } |
| |
| private List<SubmitRecord> submitRecords(ChangeData cd) { |
| return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT); |
| } |
| |
| private Map<String, VotingRangeInfo> getPermittedVotingRanges( |
| Map<String, Collection<String>> permittedLabels) { |
| Map<String, VotingRangeInfo> permittedVotingRanges = |
| Maps.newHashMapWithExpectedSize(permittedLabels.size()); |
| for (String label : permittedLabels.keySet()) { |
| List<Integer> permittedVotingRange = |
| permittedLabels.get(label).stream() |
| .map(this::parseRangeValue) |
| .filter(java.util.Objects::nonNull) |
| .sorted() |
| .collect(toList()); |
| |
| if (permittedVotingRange.isEmpty()) { |
| permittedVotingRanges.put(label, null); |
| } else { |
| int minPermittedValue = permittedVotingRange.get(0); |
| int maxPermittedValue = Iterables.getLast(permittedVotingRange); |
| permittedVotingRanges.put(label, new VotingRangeInfo(minPermittedValue, maxPermittedValue)); |
| } |
| } |
| return permittedVotingRanges; |
| } |
| |
| @AutoValue |
| abstract static class LabelWithStatus { |
| private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) { |
| return new AutoValue_LabelsJson_LabelWithStatus(label, status); |
| } |
| |
| abstract LabelInfo label(); |
| |
| @Nullable |
| abstract SubmitRecord.Label.Status status(); |
| } |
| } |