diff options
Diffstat (limited to 'java/com/google/gerrit/server/ApprovalsUtil.java')
-rw-r--r-- | java/com/google/gerrit/server/ApprovalsUtil.java | 465 |
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(); + } +} |