diff options
Diffstat (limited to 'java/com/google/gerrit/server/change/LabelsJson.java')
-rw-r--r-- | java/com/google/gerrit/server/change/LabelsJson.java | 553 |
1 files changed, 553 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java new file mode 100644 index 0000000000..787e8ef0f7 --- /dev/null +++ b/java/com/google/gerrit/server/change/LabelsJson.java @@ -0,0 +1,553 @@ +// 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.ImmutableSet; +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.Sets; +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.common.data.LabelType; +import com.google.gerrit.common.data.LabelTypes; +import com.google.gerrit.common.data.LabelValue; +import com.google.gerrit.common.data.SubmitRecord; +import com.google.gerrit.extensions.client.ListChangesOption; +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.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.Account.Id; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.PatchSetApproval; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.ApprovalsUtil; +import com.google.gerrit.server.account.AccountLoader; +import com.google.gerrit.server.notedb.ChangeNotes; +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.query.change.ChangeData; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.assistedinject.Assisted; +import java.sql.Timestamp; +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.Set; +import java.util.TreeMap; + +/** + * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards. + */ +public class LabelsJson { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public interface Factory { + LabelsJson create(Iterable<ListChangesOption> options); + } + + private final Provider<ReviewDb> db; + private final ApprovalsUtil approvalsUtil; + private final ChangeNotes.Factory notesFactory; + private final PermissionBackend permissionBackend; + private final boolean lazyLoad; + + @Inject + LabelsJson( + Provider<ReviewDb> db, + ApprovalsUtil approvalsUtil, + ChangeNotes.Factory notesFactory, + PermissionBackend permissionBackend, + @Assisted Iterable<ListChangesOption> options) { + this.db = db; + this.approvalsUtil = approvalsUtil; + this.notesFactory = notesFactory; + this.permissionBackend = permissionBackend; + this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD); + } + + /** + * 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. + */ + Map<String, LabelInfo> labelsFor( + AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed) + throws OrmException, PermissionBackendException { + if (!standard && !detailed) { + return null; + } + + LabelTypes labelTypes = cd.getLabelTypes(); + Map<String, LabelWithStatus> withStatus = + cd.change().getStatus() == Change.Status.MERGED + ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed) + : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed); + return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label)); + } + + /** Returns all labels that the provided user has permission to vote on. */ + Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd) + throws OrmException, PermissionBackendException { + boolean isMerged = cd.change().getStatus() == Change.Status.MERGED; + LabelTypes labelTypes = cd.getLabelTypes(); + Map<String, LabelType> toCheck = new HashMap<>(); + for (SubmitRecord rec : submitRecords(cd)) { + if (rec.labels != null) { + for (SubmitRecord.Label r : rec.labels) { + LabelType type = labelTypes.byLabel(r.label); + if (type != null && (!isMerged || type.allowPostSubmit())) { + toCheck.put(type.getName(), type); + } + } + } + } + + Map<String, Short> labels = null; + Set<LabelPermission.WithValue> can = + permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values()); + SetMultimap<String, String> permitted = LinkedHashMultimap.create(); + for (SubmitRecord rec : submitRecords(cd)) { + if (rec.labels == null) { + continue; + } + for (SubmitRecord.Label r : rec.labels) { + LabelType type = labelTypes.byLabel(r.label); + if (type == null || (isMerged && !type.allowPostSubmit())) { + continue; + } + + for (LabelValue v : type.getValues()) { + boolean ok = can.contains(new LabelPermission.WithValue(type, v)); + if (isMerged) { + if (labels == null) { + labels = currentLabels(filterApprovalsBy, cd); + } + short prev = labels.getOrDefault(type.getName(), (short) 0); + ok &= v.getValue() >= prev; + } + if (ok) { + permitted.put(r.label, v.formatValue()); + } + } + } + } + + 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); + } + return permitted.asMap(); + } + + private static boolean containsAnyOf( + ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) { + return !Sets.intersection(toFind, set).isEmpty(); + } + + 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 OrmException, PermissionBackendException { + Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard); + if (detailed) { + setAllApprovals(accountLoader, cd, labels); + } + for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) { + LabelType type = labelTypes.byLabel(e.getKey()); + if (type == null) { + continue; + } + if (standard) { + for (PatchSetApproval psa : cd.currentApprovals()) { + if (type.matches(psa)) { + short val = psa.getValue(); + Account.Id accountId = psa.getAccountId(); + setLabelScores(accountLoader, type, e.getValue(), val, accountId); + } + } + } + if (detailed) { + setLabelValues(type, 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, + Integer value, + VotingRangeInfo permittedVotingRange, + String tag, + Timestamp 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(Account.Id accountId, ChangeData cd) + throws OrmException { + Map<String, Short> result = new HashMap<>(); + for (PatchSetApproval psa : + approvalsUtil.byPatchSetUser( + db.get(), + lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()), + cd.change().currentPatchSetId(), + accountId, + null, + null)) { + result.put(psa.getLabel(), psa.getValue()); + } + return result; + } + + private Map<String, LabelWithStatus> labelsForSubmittedChange( + AccountLoader accountLoader, + ChangeData cd, + LabelTypes labelTypes, + boolean standard, + boolean detailed) + throws OrmException, 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.getAccountId()); + } + } + + Set<String> labelNames = new HashSet<>(); + SetMultimap<Id, PatchSetApproval> current = MultimapBuilder.hashKeys().hashSetValues().build(); + for (PatchSetApproval a : cd.currentApprovals()) { + allUsers.add(a.getAccountId()); + LabelType type = labelTypes.byLabel(a.getLabelId()); + if (type != null) { + labelNames.add(type.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.getAccountId(), 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)); + } + } + + if (detailed) { + labels.entrySet().stream() + .filter(e -> labelTypes.byLabel(e.getKey()) != null) + .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), 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)) { + LabelType type = labelTypes.byLabel(psa.getLabelId()); + if (type == null) { + continue; + } + + short val = psa.getValue(); + ApprovalInfo info = byLabel.get(type.getName()); + if (info != null) { + info.value = Integer.valueOf(val); + info.permittedVotingRange = pvr.getOrDefault(type.getName(), null); + info.date = psa.getGranted(); + info.tag = psa.getTag(); + if (psa.isPostSubmit()) { + info.postSubmit = true; + } + } + if (!standard) { + continue; + } + + setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId); + } + } + return labels; + } + + private Map<String, LabelWithStatus> initLabels( + AccountLoader accountLoader, ChangeData cd, LabelTypes labelTypes, boolean standard) { + 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 (standard) { + 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)); + } + } + } + return labels; + } + + 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) + throws OrmException, PermissionBackendException { + Change.Status status = cd.change().getStatus(); + checkState( + status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status); + + // Include a user in the output for this label if either: + // - They are an explicit reviewer. + // - They ever voted on this change. + Set<Id> allUsers = new HashSet<>(); + allUsers.addAll(cd.reviewers().byState(ReviewerStateInternal.REVIEWER)); + for (PatchSetApproval psa : cd.approvals().values()) { + allUsers.add(psa.getAccountId()); + } + + Table<Id, String, PatchSetApproval> current = + HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size()); + for (PatchSetApproval psa : cd.currentApprovals()) { + current.put(psa.getAccountId(), psa.getLabel(), psa); + } + + LabelTypes labelTypes = cd.getLabelTypes(); + for (Account.Id accountId : allUsers) { + PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd); + Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd)); + for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) { + LabelType lt = labelTypes.byLabel(e.getKey()); + if (lt == null) { + // 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.getOrDefault(lt.getName(), null); + String tag = null; + Timestamp date = null; + PatchSetApproval psa = current.get(accountId, lt.getName()); + if (psa != null) { + value = Integer.valueOf(psa.getValue()); + 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.test(new LabelPermission(lt)) ? 0 : null; + } + tag = psa.getTag(); + date = psa.getGranted(); + if (psa.isPostSubmit()) { + 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.test(new LabelPermission(lt)) ? 0 : null; + } + addApproval( + e.getValue().label(), + approvalInfo(accountLoader, accountId, value, permittedVotingRange, tag, date)); + } + } + } + + /** + * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed + * from either an index-backed or a database-backed {@link ChangeData} depending on {@code + * lazyload}. + */ + private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd) + throws OrmException { + PermissionBackend.WithUser withUser = permissionBackend.absentUser(user).database(db); + return lazyLoad + ? withUser.change(cd) + : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change())); + } + + 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(); + } +} |