summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/ApprovalsUtil.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/ApprovalsUtil.java')
-rw-r--r--java/com/google/gerrit/server/ApprovalsUtil.java465
1 files changed, 465 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
new file mode 100644
index 0000000000..3625de6a45
--- /dev/null
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -0,0 +1,465 @@
+// Copyright (C) 2009 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.ChangePermission;
+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.ProjectCache;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Utility functions to manipulate patchset approvals.
+ *
+ * <p>Approvals are overloaded, they represent both approvals and reviewers which should be CCed on
+ * a change. To ensure that reviewers are not lost there must always be an approval on each patchset
+ * for each reviewer, even if the reviewer hasn't actually given a score to the change. To mark the
+ * "no score" case, a dummy approval, which may live in any of the available categories, with a
+ * score of 0 is used.
+ *
+ * <p>The methods in this class only modify the gwtorm database.
+ */
+@Singleton
+public class ApprovalsUtil {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final Ordering<PatchSetApproval> SORT_APPROVALS =
+ Ordering.from(comparing(PatchSetApproval::getGranted));
+
+ public static List<PatchSetApproval> sortApprovals(Iterable<PatchSetApproval> approvals) {
+ return SORT_APPROVALS.sortedCopy(approvals);
+ }
+
+ public static PatchSetApproval newApproval(
+ PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
+ PatchSetApproval psa =
+ new PatchSetApproval(
+ new PatchSetApproval.Key(psId, user.getAccountId(), labelId),
+ Shorts.checkedCast(value),
+ when);
+ user.updateRealAccountId(psa::setRealAccountId);
+ return psa;
+ }
+
+ private static Iterable<PatchSetApproval> filterApprovals(
+ Iterable<PatchSetApproval> psas, Account.Id accountId) {
+ return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
+ }
+
+ private final NotesMigration migration;
+ private final ApprovalCopier copier;
+ private final PermissionBackend permissionBackend;
+ private final ProjectCache projectCache;
+
+ @VisibleForTesting
+ @Inject
+ public ApprovalsUtil(
+ NotesMigration migration,
+ ApprovalCopier copier,
+ PermissionBackend permissionBackend,
+ ProjectCache projectCache) {
+ this.migration = migration;
+ this.copier = copier;
+ this.permissionBackend = permissionBackend;
+ this.projectCache = projectCache;
+ }
+
+ /**
+ * Get all reviewers for a change.
+ *
+ * @param db review database.
+ * @param notes change notes.
+ * @return reviewers for the change.
+ * @throws OrmException if reviewers for the change could not be read.
+ */
+ public ReviewerSet getReviewers(ReviewDb db, ChangeNotes notes) throws OrmException {
+ if (!migration.readChanges()) {
+ return ReviewerSet.fromApprovals(db.patchSetApprovals().byChange(notes.getChangeId()));
+ }
+ return notes.load().getReviewers();
+ }
+
+ /**
+ * Get all reviewers and CCed accounts for a change.
+ *
+ * @param allApprovals all approvals to consider; must all belong to the same change.
+ * @return reviewers for the change.
+ * @throws OrmException if reviewers for the change could not be read.
+ */
+ public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals)
+ throws OrmException {
+ if (!migration.readChanges()) {
+ return ReviewerSet.fromApprovals(allApprovals);
+ }
+ return notes.load().getReviewers();
+ }
+
+ /**
+ * Get updates to reviewer set. Always returns empty list for ReviewDb.
+ *
+ * @param notes change notes.
+ * @return reviewer updates for the change.
+ * @throws OrmException if reviewer updates for the change could not be read.
+ */
+ public List<ReviewerStatusUpdate> getReviewerUpdates(ChangeNotes notes) throws OrmException {
+ if (!migration.readChanges()) {
+ return ImmutableList.of();
+ }
+ return notes.load().getReviewerUpdates();
+ }
+
+ public List<PatchSetApproval> addReviewers(
+ ReviewDb db,
+ ChangeUpdate update,
+ LabelTypes labelTypes,
+ Change change,
+ PatchSet ps,
+ PatchSetInfo info,
+ Iterable<Account.Id> wantReviewers,
+ Collection<Account.Id> existingReviewers)
+ throws OrmException {
+ return addReviewers(
+ db,
+ update,
+ labelTypes,
+ change,
+ ps.getId(),
+ info.getAuthor().getAccount(),
+ info.getCommitter().getAccount(),
+ wantReviewers,
+ existingReviewers);
+ }
+
+ public List<PatchSetApproval> addReviewers(
+ ReviewDb db,
+ ChangeNotes notes,
+ ChangeUpdate update,
+ LabelTypes labelTypes,
+ Change change,
+ Iterable<Account.Id> wantReviewers)
+ throws OrmException {
+ PatchSet.Id psId = change.currentPatchSetId();
+ Collection<Account.Id> existingReviewers;
+ if (migration.readChanges()) {
+ // If using NoteDB, we only want reviewers in the REVIEWER state.
+ existingReviewers = notes.load().getReviewers().byState(REVIEWER);
+ } else {
+ // Prior to NoteDB, we gather all reviewers regardless of state.
+ existingReviewers = getReviewers(db, notes).all();
+ }
+ // Existing reviewers should include pending additions in the REVIEWER
+ // state, taken from ChangeUpdate.
+ existingReviewers = Lists.newArrayList(existingReviewers);
+ for (Map.Entry<Account.Id, ReviewerStateInternal> entry : update.getReviewers().entrySet()) {
+ if (entry.getValue() == REVIEWER) {
+ existingReviewers.add(entry.getKey());
+ }
+ }
+ return addReviewers(
+ db, update, labelTypes, change, psId, null, null, wantReviewers, existingReviewers);
+ }
+
+ private List<PatchSetApproval> addReviewers(
+ ReviewDb db,
+ ChangeUpdate update,
+ LabelTypes labelTypes,
+ Change change,
+ PatchSet.Id psId,
+ Account.Id authorId,
+ Account.Id committerId,
+ Iterable<Account.Id> wantReviewers,
+ Collection<Account.Id> existingReviewers)
+ throws OrmException {
+ List<LabelType> allTypes = labelTypes.getLabelTypes();
+ if (allTypes.isEmpty()) {
+ return ImmutableList.of();
+ }
+
+ Set<Account.Id> need = Sets.newLinkedHashSet(wantReviewers);
+ if (authorId != null && canSee(db, update.getNotes(), authorId)) {
+ need.add(authorId);
+ }
+
+ if (committerId != null && canSee(db, update.getNotes(), committerId)) {
+ need.add(committerId);
+ }
+ need.remove(change.getOwner());
+ need.removeAll(existingReviewers);
+ if (need.isEmpty()) {
+ return ImmutableList.of();
+ }
+
+ List<PatchSetApproval> cells = Lists.newArrayListWithCapacity(need.size());
+ LabelId labelId = Iterables.getLast(allTypes).getLabelId();
+ for (Account.Id account : need) {
+ cells.add(
+ new PatchSetApproval(
+ new PatchSetApproval.Key(psId, account, labelId), (short) 0, update.getWhen()));
+ update.putReviewer(account, REVIEWER);
+ }
+ db.patchSetApprovals().upsert(cells);
+ return Collections.unmodifiableList(cells);
+ }
+
+ private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
+ try {
+ if (!projectCache.checkedGet(notes.getProjectName()).statePermitsRead()) {
+ return false;
+ }
+ permissionBackend
+ .absentUser(accountId)
+ .change(notes)
+ .database(db)
+ .check(ChangePermission.READ);
+ return true;
+ } catch (AuthException e) {
+ return false;
+ } catch (IOException | PermissionBackendException e) {
+ logger.atWarning().withCause(e).log(
+ "Failed to check if account %d can see change %d",
+ accountId.get(), notes.getChangeId().get());
+ return false;
+ }
+ }
+
+ /**
+ * Adds accounts to a change as reviewers in the CC state.
+ *
+ * @param notes change notes.
+ * @param update change update.
+ * @param wantCCs accounts to CC.
+ * @return whether a change was made.
+ * @throws OrmException
+ */
+ public Collection<Account.Id> addCcs(
+ ChangeNotes notes, ChangeUpdate update, Collection<Account.Id> wantCCs) throws OrmException {
+ return addCcs(update, wantCCs, notes.load().getReviewers());
+ }
+
+ private Collection<Account.Id> addCcs(
+ ChangeUpdate update, Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
+ Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
+ need.removeAll(existingReviewers.all());
+ need.removeAll(update.getReviewers().keySet());
+ for (Account.Id account : need) {
+ update.putReviewer(account, CC);
+ }
+ return need;
+ }
+
+ /**
+ * Adds approvals to ChangeUpdate for a new patch set, and writes to ReviewDb.
+ *
+ * @param db review database.
+ * @param update change update.
+ * @param labelTypes label types for the containing project.
+ * @param ps patch set being approved.
+ * @param user user adding approvals.
+ * @param approvals approvals to add.
+ * @throws RestApiException
+ * @throws OrmException
+ */
+ public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
+ ReviewDb db,
+ ChangeUpdate update,
+ LabelTypes labelTypes,
+ PatchSet ps,
+ CurrentUser user,
+ Map<String, Short> approvals)
+ throws RestApiException, OrmException, PermissionBackendException {
+ Account.Id accountId = user.getAccountId();
+ checkArgument(
+ accountId.equals(ps.getUploader()),
+ "expected user %s to match patch set uploader %s",
+ accountId,
+ ps.getUploader());
+ if (approvals.isEmpty()) {
+ return ImmutableList.of();
+ }
+ checkApprovals(approvals, permissionBackend.user(user).database(db).change(update.getNotes()));
+ List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
+ Date ts = update.getWhen();
+ for (Map.Entry<String, Short> vote : approvals.entrySet()) {
+ LabelType lt = labelTypes.byLabel(vote.getKey());
+ cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
+ }
+ for (PatchSetApproval psa : cells) {
+ update.putApproval(psa.getLabel(), psa.getValue());
+ }
+ db.patchSetApprovals().insert(cells);
+ return cells;
+ }
+
+ public static void checkLabel(LabelTypes labelTypes, String name, Short value)
+ throws BadRequestException {
+ LabelType label = labelTypes.byLabel(name);
+ if (label == null) {
+ throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
+ }
+ if (label.getValue(value) == null) {
+ throw new BadRequestException(
+ String.format("label \"%s\": %d is not a valid value", name, value));
+ }
+ }
+
+ private static void checkApprovals(
+ Map<String, Short> approvals, PermissionBackend.ForChange forChange)
+ throws AuthException, PermissionBackendException {
+ for (Map.Entry<String, Short> vote : approvals.entrySet()) {
+ String name = vote.getKey();
+ Short value = vote.getValue();
+ try {
+ forChange.check(new LabelPermission.WithValue(name, value));
+ } catch (AuthException e) {
+ throw new AuthException(
+ String.format("applying label \"%s\": %d is restricted", name, value));
+ }
+ }
+ }
+
+ public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ReviewDb db, ChangeNotes notes)
+ throws OrmException {
+ if (!migration.readChanges()) {
+ ImmutableListMultimap.Builder<PatchSet.Id, PatchSetApproval> result =
+ ImmutableListMultimap.builder();
+ for (PatchSetApproval psa : db.patchSetApprovals().byChange(notes.getChangeId())) {
+ result.put(psa.getPatchSetId(), psa);
+ }
+ return result.build();
+ }
+ return notes.load().getApprovals();
+ }
+
+ public Iterable<PatchSetApproval> byPatchSet(
+ ReviewDb db,
+ ChangeNotes notes,
+ PatchSet.Id psId,
+ @Nullable RevWalk rw,
+ @Nullable Config repoConfig)
+ throws OrmException {
+ if (!migration.readChanges()) {
+ return sortApprovals(db.patchSetApprovals().byPatchSet(psId));
+ }
+ return copier.getForPatchSet(db, notes, psId, rw, repoConfig);
+ }
+
+ public Iterable<PatchSetApproval> byPatchSetUser(
+ ReviewDb db,
+ ChangeNotes notes,
+ PatchSet.Id psId,
+ Account.Id accountId,
+ @Nullable RevWalk rw,
+ @Nullable Config repoConfig)
+ throws OrmException {
+ if (!migration.readChanges()) {
+ return sortApprovals(db.patchSetApprovals().byPatchSetUser(psId, accountId));
+ }
+ return filterApprovals(byPatchSet(db, notes, psId, rw, repoConfig), accountId);
+ }
+
+ public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes, PatchSet.Id c) {
+ if (c == null) {
+ return null;
+ }
+ try {
+ // Submit approval is never copied, so bypass expensive byPatchSet call.
+ return getSubmitter(c, byChange(db, notes).get(c));
+ } catch (OrmException e) {
+ return null;
+ }
+ }
+
+ public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
+ if (c == null) {
+ return null;
+ }
+ PatchSetApproval submitter = null;
+ for (PatchSetApproval a : approvals) {
+ if (a.getPatchSetId().equals(c) && a.getValue() > 0 && a.isLegacySubmit()) {
+ if (submitter == null || a.getGranted().compareTo(submitter.getGranted()) > 0) {
+ submitter = a;
+ }
+ }
+ }
+ return submitter;
+ }
+
+ public static String renderMessageWithApprovals(
+ int patchSetId, Map<String, Short> n, Map<String, PatchSetApproval> c) {
+ StringBuilder msgs = new StringBuilder("Uploaded patch set " + patchSetId);
+ if (!n.isEmpty()) {
+ boolean first = true;
+ for (Map.Entry<String, Short> e : n.entrySet()) {
+ if (c.containsKey(e.getKey()) && c.get(e.getKey()).getValue() == e.getValue()) {
+ continue;
+ }
+ if (first) {
+ msgs.append(":");
+ first = false;
+ }
+ msgs.append(" ").append(LabelVote.create(e.getKey(), e.getValue()).format());
+ }
+ }
+ return msgs.toString();
+ }
+}