// Copyright (C) 2012 The Android Open Source Project // Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies). // // 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.checkNotNull; import com.google.common.base.Objects; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.gerrit.common.ChangeHooks; import com.google.gerrit.common.data.LabelType; import com.google.gerrit.common.data.LabelTypes; import com.google.gerrit.common.data.Permission; import com.google.gerrit.common.data.PermissionRange; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.DefaultInput; import com.google.gerrit.extensions.restapi.RestModifyView; import com.google.gerrit.extensions.restapi.Url; import com.google.gerrit.reviewdb.client.Change; import com.google.gerrit.reviewdb.client.ChangeMessage; import com.google.gerrit.reviewdb.client.Patch; import com.google.gerrit.reviewdb.client.PatchLineComment; import com.google.gerrit.reviewdb.client.PatchSetApproval; import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.change.PostReview.Input; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.project.ChangeControl; import com.google.gerrit.server.util.BooleanExpression; import com.google.gwtorm.server.OrmException; import com.google.inject.Inject; import org.eclipse.jgit.lib.Config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.Timestamp; import java.text.ParseException; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; public class PostReview implements RestModifyView { private static final Logger log = LoggerFactory.getLogger(PostReview.class); public static class Input { @DefaultInput public String message; public Map labels; Map> comments; /** * If true require all labels to be within the user's permitted ranges based * on access controls, attempting to use a label not granted to the user * will fail the entire modify operation early. If false the operation will * execute anyway, but the proposed labels given by the user will be * modified to be the "best" value allowed by the access controls, or * ignored if the label does not exist. */ public boolean strictLabels = true; /** * How to process draft comments already in the database that were not also * described in this input request. */ public DraftHandling drafts = DraftHandling.DELETE; /** Who to send email notifications to after review is stored. */ public NotifyHandling notify = NotifyHandling.ALL; public boolean changeReviewable; public boolean dontAddReviewNote = false; } public static enum DraftHandling { DELETE, PUBLISH, KEEP; } public static enum NotifyHandling { NONE, OWNER, OWNER_REVIEWERS, ALL; } static class Comment { String id; CommentInfo.Side side; int line; String inReplyTo; String message; } static class Output { Map labels; String message; } private final ReviewDb db; private final EmailReviewComments.Factory email; @Deprecated private final ChangeHooks hooks; private Change change; private ChangeMessage message; private Timestamp timestamp; private List comments = Lists.newArrayList(); private List labelDelta = Lists.newArrayList(); private Map categories = Maps.newHashMap(); private final Config config; @Inject PostReview(ReviewDb db, EmailReviewComments.Factory email, ChangeHooks hooks, @GerritServerConfig Config config) { this.db = db; this.email = email; this.hooks = hooks; this.config = config; } @Override public Object apply(RevisionResource revision, Input input) throws AuthException, BadRequestException, OrmException { if (input.labels != null) { checkLabels(revision, input.strictLabels, input.labels); } if (input.comments != null) { checkComments(input.comments); } if (input.notify == null) { log.warn("notify = null; assuming notify = NONE"); input.notify = NotifyHandling.NONE; } db.changes().beginTransaction(revision.getChange().getId()); try { change = db.changes().get(revision.getChange().getId()); ChangeUtil.updated(change); timestamp = change.getLastUpdatedOn(); boolean dirty = false; dirty |= insertComments(revision, input.comments, input.drafts); dirty |= updateLabels(revision, input.labels); if (!input.dontAddReviewNote) { dirty |= insertMessage(revision, input.message); } if (dirty) { db.changes().update(Collections.singleton(change)); db.commit(); } } finally { db.rollback(); } if (input.notify.compareTo(NotifyHandling.NONE) > 0 && message != null) { if (!isEmailRejectedByFilter(input.labels, revision)) { email.create( input.notify, change, revision.getPatchSet(), revision.getAccountId(), message, comments).sendAsync(); } fireCommentAddedHook(revision); } Output output = new Output(); output.labels = input.labels; output.message = ""; if (input.labels != null && !input.labels.isEmpty() && input.changeReviewable) { if (change.getStatus().isCI()) { output.message = "The change was staged while you were reviewing it. " + "Due to this, only your comments were published, while your review scores were dropped."; } else if (!revision.getPatchSet().getId().equals(change.currentPatchSetId())) { output.message = "A new patch set was pushed while you were reviewing the change. " + "Due to this, only your comments were published, while your review scores were dropped."; } } return output; } private void checkLabels(RevisionResource revision, boolean strict, Map labels) throws BadRequestException, AuthException { ChangeControl ctl = revision.getControl(); Iterator> itr = labels.entrySet().iterator(); while (itr.hasNext()) { Map.Entry ent = itr.next(); LabelType lt = revision.getControl().getLabelTypes() .byLabel(ent.getKey()); if (lt == null) { if (strict) { throw new BadRequestException(String.format( "label \"%s\" is not a configured label", ent.getKey())); } else { itr.remove(); continue; } } if (ent.getValue() == null || ent.getValue() == 0) { // Always permit 0, even if it is not within range. // Later null/0 will be deleted and revoke the label. continue; } if (lt.getValue(ent.getValue()) == null) { if (strict) { throw new BadRequestException(String.format( "label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue())); } else { itr.remove(); continue; } } String name = lt.getName(); PermissionRange range = ctl.getRange(Permission.forLabel(name)); if (range == null || !range.contains(ent.getValue())) { if (strict) { throw new AuthException(String.format( "Applying label \"%s\": %d is restricted", ent.getKey(), ent.getValue())); } else if (range == null || range.isEmpty()) { ent.setValue((short) 0); } else { ent.setValue((short) range.squash(ent.getValue())); } } } } private void checkComments(Map> in) throws BadRequestException { Iterator>> mapItr = in.entrySet().iterator(); while (mapItr.hasNext()) { Map.Entry> ent = mapItr.next(); String path = ent.getKey(); List list = ent.getValue(); if (list == null) { mapItr.remove(); continue; } Iterator listItr = list.iterator(); while (listItr.hasNext()) { Comment c = listItr.next(); if (c.line < 0) { throw new BadRequestException(String.format( "negative line number %d not allowed on %s", c.line, path)); } c.message = Strings.emptyToNull(c.message).trim(); if (c.message.isEmpty()) { listItr.remove(); } } if (list.isEmpty()) { mapItr.remove(); } } } private boolean insertComments(RevisionResource rsrc, Map> in, DraftHandling draftsHandling) throws OrmException { if (in == null) { in = Collections.emptyMap(); } Map drafts = Collections.emptyMap(); if (!in.isEmpty() || draftsHandling != DraftHandling.KEEP) { drafts = scanDraftComments(rsrc); } List del = Lists.newArrayList(); List ins = Lists.newArrayList(); List upd = Lists.newArrayList(); for (Map.Entry> ent : in.entrySet()) { String path = ent.getKey(); for (Comment c : ent.getValue()) { String parent = Url.decode(c.inReplyTo); PatchLineComment e = drafts.remove(Url.decode(c.id)); boolean create = e == null; if (create) { e = new PatchLineComment( new PatchLineComment.Key( new Patch.Key(rsrc.getPatchSet().getId(), path), ChangeUtil.messageUUID(db)), c.line, rsrc.getAccountId(), parent); } else if (parent != null) { e.setParentUuid(parent); } e.setStatus(PatchLineComment.Status.PUBLISHED); e.setWrittenOn(timestamp); e.setSide(c.side == CommentInfo.Side.PARENT ? (short) 0 : (short) 1); e.setMessage(c.message); (create ? ins : upd).add(e); } } switch (Objects.firstNonNull(draftsHandling, DraftHandling.DELETE)) { case KEEP: default: break; case DELETE: del.addAll(drafts.values()); break; case PUBLISH: for (PatchLineComment e : drafts.values()) { e.setStatus(PatchLineComment.Status.PUBLISHED); e.setWrittenOn(timestamp); upd.add(e); } break; } db.patchComments().delete(del); db.patchComments().insert(ins); db.patchComments().update(upd); comments.addAll(ins); comments.addAll(upd); return !del.isEmpty() || !ins.isEmpty() || !upd.isEmpty(); } private Map scanDraftComments( RevisionResource rsrc) throws OrmException { Map drafts = Maps.newHashMap(); for (PatchLineComment c : db.patchComments().draftByPatchSetAuthor( rsrc.getPatchSet().getId(), rsrc.getAccountId())) { drafts.put(c.getKey().get(), c); } return drafts; } private boolean updateLabels(RevisionResource rsrc, Map labels) throws OrmException { if (labels == null) { labels = Collections.emptyMap(); } List del = Lists.newArrayList(); List ins = Lists.newArrayList(); List upd = Lists.newArrayList(); Map current = scanLabels(rsrc, del); LabelTypes labelTypes = rsrc.getControl().getLabelTypes(); for (Map.Entry ent : labels.entrySet()) { String name = ent.getKey(); LabelType lt = checkNotNull(labelTypes.byLabel(name), name); if (change.getStatus().isClosed()) { // TODO Allow updating some labels even when closed. continue; } if (change.getStatus().isCI()) { // If the state of a change is INTEGRATING, STAGED or STAGING review // scores are not allowed. This check is necessary here because some // other user may have changed the state of a change while another // user is reviewing it. Scores are dropped but comments are kept. continue; } if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) { // Scores are also dropped if a new patch set is pushed. continue; } PatchSetApproval c = current.remove(name); if (ent.getValue() == null || ent.getValue() == 0) { // User requested delete of this label. if (c != null) { if (c.getValue() != 0) { labelDelta.add("-" + name); } del.add(c); } } else if (c != null && c.getValue() != ent.getValue()) { c.setValue(ent.getValue()); c.setGranted(timestamp); c.cache(change); upd.add(c); labelDelta.add(format(name, c.getValue())); categories.put(name, c.getValue()); } else if (c != null && c.getValue() == ent.getValue()) { current.put(name, c); } else if (c == null) { c = new PatchSetApproval(new PatchSetApproval.Key( rsrc.getPatchSet().getId(), rsrc.getAccountId(), lt.getLabelId()), ent.getValue()); c.setGranted(timestamp); c.cache(change); ins.add(c); labelDelta.add(format(name, c.getValue())); categories.put(name, c.getValue()); } } forceCallerAsReviewer(rsrc, current, ins, upd, del); db.patchSetApprovals().delete(del); db.patchSetApprovals().insert(ins); db.patchSetApprovals().update(upd); return !del.isEmpty() || !ins.isEmpty() || !upd.isEmpty(); } private void forceCallerAsReviewer(RevisionResource rsrc, Map current, List ins, List upd, List del) { if (current.isEmpty() && ins.isEmpty() && upd.isEmpty()) { // TODO Find another way to link reviewers to changes. if (del.isEmpty()) { // If no existing label is being set to 0, hack in the caller // as a reviewer by picking the first server-wide LabelType. PatchSetApproval c = new PatchSetApproval(new PatchSetApproval.Key( rsrc.getPatchSet().getId(), rsrc.getAccountId(), rsrc.getControl().getLabelTypes().getLabelTypes().get(0) .getLabelId()), (short) 0); c.setGranted(timestamp); c.cache(change); ins.add(c); } else { // Pick a random label that is about to be deleted and keep it. Iterator i = del.iterator(); PatchSetApproval c = i.next(); c.setValue((short) 0); c.setGranted(timestamp); c.cache(change); i.remove(); upd.add(c); } } } private Map scanLabels(RevisionResource rsrc, List del) throws OrmException { LabelTypes labelTypes = rsrc.getControl().getLabelTypes(); Map current = Maps.newHashMap(); for (PatchSetApproval a : db.patchSetApprovals().byPatchSetUser( rsrc.getPatchSet().getId(), rsrc.getAccountId())) { if (a.isSubmit() || a.isStaged()) { continue; } LabelType lt = labelTypes.byLabel(a.getLabelId()); if (lt != null) { current.put(lt.getName(), a); } else { del.add(a); } } return current; } private static String format(String name, short value) { StringBuilder sb = new StringBuilder(name.length() + 2); sb.append(name); if (value >= 0) { sb.append('+'); } sb.append(value); return sb.toString(); } private boolean insertMessage(RevisionResource rsrc, String msg) throws OrmException { msg = Strings.nullToEmpty(msg).trim(); StringBuilder buf = new StringBuilder(); for (String d : labelDelta) { buf.append(" ").append(d); } if (comments.size() == 1) { buf.append("\n\n(1 comment)"); } else if (comments.size() > 1) { buf.append(String.format("\n\n(%d comments)", comments.size())); } if (!msg.isEmpty()) { buf.append("\n\n").append(msg); } if (buf.length() == 0) { return false; } message = new ChangeMessage( new ChangeMessage.Key(change.getId(), ChangeUtil.messageUUID(db)), rsrc.getAccountId(), timestamp, rsrc.getPatchSet().getId()); message.setMessage(String.format( "Patch Set %d:%s", rsrc.getPatchSet().getPatchSetId(), buf.toString())); db.changeMessages().insert(Collections.singleton(message)); return true; } @Deprecated private void fireCommentAddedHook(RevisionResource rsrc) { IdentifiedUser user = (IdentifiedUser) rsrc.getControl().getCurrentUser(); try { hooks.doCommentAddedHook(change, user.getAccount(), rsrc.getPatchSet(), message.getMessage(), categories, db); } catch (OrmException e) { log.warn("ChangeHook.doCommentAddedHook delivery failed", e); } } private boolean isEmailRejectedByFilter(Map labels, RevisionResource rsrc) { if (labels == null) { return false; } try { String f = config.getString("review", null, "filter"); if (f != null) { // Only care about filter if available in configuration BooleanExpression ef = new BooleanExpression(f); HashMap am = new HashMap(); Iterator> itr = labels.entrySet().iterator(); while (itr.hasNext()) { Map.Entry ent = itr.next(); LabelType lt = rsrc.getControl().getLabelTypes() .byLabel(ent.getKey()); if (lt == null) { continue; } am.put(ent.getKey(), Short.toString(ent.getValue())); } IdentifiedUser user = (IdentifiedUser) rsrc.getControl().getCurrentUser(); am.put("reviewer", user.getUserName()); // If filter expression evaluates to false // this return true and no email will be sent return !ef.evaluate(am); } } catch (ParseException e) { log.error("Failed to parse filter expression from configuration", e); } return false; } }