summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/change/LabelsJson.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/change/LabelsJson.java')
-rw-r--r--java/com/google/gerrit/server/change/LabelsJson.java553
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();
+ }
+}