blob: 7a575c8b0f69c9251ab2dc1dfee64564f29556c8 [file] [log] [blame]
// Copyright (C) 2012 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.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.ChangeHooks;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
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.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
import com.google.gerrit.extensions.api.changes.ReviewInput.NotifyHandling;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
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.CommentRange;
import com.google.gerrit.reviewdb.client.Patch;
import com.google.gerrit.reviewdb.client.PatchLineComment;
import com.google.gerrit.reviewdb.client.PatchSet;
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.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchLineCommentsUtil;
import com.google.gerrit.server.account.AccountsCollection;
import com.google.gerrit.server.git.BatchUpdate;
import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
import com.google.gerrit.server.git.BatchUpdate.Context;
import com.google.gerrit.server.git.UpdateException;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.util.LabelVote;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Singleton
public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
private static final Logger log = LoggerFactory.getLogger(PostReview.class);
static class Output {
Map<String, Short> labels;
}
private final Provider<ReviewDb> db;
private final BatchUpdate.Factory batchUpdateFactory;
private final ChangesCollection changes;
private final ChangeData.Factory changeDataFactory;
private final ApprovalsUtil approvalsUtil;
private final ChangeMessagesUtil cmUtil;
private final PatchLineCommentsUtil plcUtil;
private final PatchListCache patchListCache;
private final AccountsCollection accounts;
private final EmailReviewComments.Factory email;
private final ChangeHooks hooks;
@Inject
PostReview(Provider<ReviewDb> db,
BatchUpdate.Factory batchUpdateFactory,
ChangesCollection changes,
ChangeData.Factory changeDataFactory,
ApprovalsUtil approvalsUtil,
ChangeMessagesUtil cmUtil,
PatchLineCommentsUtil plcUtil,
PatchListCache patchListCache,
AccountsCollection accounts,
EmailReviewComments.Factory email,
ChangeHooks hooks) {
this.db = db;
this.batchUpdateFactory = batchUpdateFactory;
this.changes = changes;
this.changeDataFactory = changeDataFactory;
this.plcUtil = plcUtil;
this.patchListCache = patchListCache;
this.approvalsUtil = approvalsUtil;
this.cmUtil = cmUtil;
this.accounts = accounts;
this.email = email;
this.hooks = hooks;
}
@Override
public Output apply(RevisionResource revision, ReviewInput input)
throws RestApiException, UpdateException, OrmException {
return apply(revision, input, TimeUtil.nowTs());
}
public Output apply(RevisionResource revision, ReviewInput input,
Timestamp ts) throws RestApiException, UpdateException, OrmException {
if (revision.getEdit().isPresent()) {
throw new ResourceConflictException("cannot post review on edit");
}
if (input.onBehalfOf != null) {
revision = onBehalfOf(revision, input);
}
if (input.labels != null) {
checkLabels(revision, input.strictLabels, input.labels);
}
if (input.comments != null) {
checkComments(revision, input.comments);
}
if (input.notify == null) {
log.warn("notify = null; assuming notify = NONE");
input.notify = NotifyHandling.NONE;
}
try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
revision.getChange().getProject(), revision.getUser(), ts)) {
bu.addOp(
revision.getChange().getId(),
new Op(revision.getPatchSet().getId(), input));
bu.execute();
}
Output output = new Output();
output.labels = input.labels;
return output;
}
private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
throws BadRequestException, AuthException, UnprocessableEntityException,
OrmException {
if (in.labels == null || in.labels.isEmpty()) {
throw new AuthException(String.format(
"label required to post review on behalf of \"%s\"",
in.onBehalfOf));
}
ChangeControl caller = rev.getControl();
Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
while (itr.hasNext()) {
Map.Entry<String, Short> ent = itr.next();
LabelType type = caller.getLabelTypes().byLabel(ent.getKey());
if (type == null && in.strictLabels) {
throw new BadRequestException(String.format(
"label \"%s\" is not a configured label", ent.getKey()));
} else if (type == null) {
itr.remove();
continue;
}
PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
throw new AuthException(String.format(
"not permitted to modify label \"%s\" on behalf of \"%s\"",
ent.getKey(), in.onBehalfOf));
}
}
if (in.labels.isEmpty()) {
throw new AuthException(String.format(
"label required to post review on behalf of \"%s\"",
in.onBehalfOf));
}
ChangeControl target = caller.forUser(accounts.parse(in.onBehalfOf));
return new RevisionResource(changes.parse(target), rev.getPatchSet());
}
private void checkLabels(RevisionResource revision, boolean strict,
Map<String, Short> labels) throws BadRequestException, AuthException {
ChangeControl ctl = revision.getControl();
Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
while (itr.hasNext()) {
Map.Entry<String, Short> 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(RevisionResource revision, Map<String, List<CommentInput>> in)
throws BadRequestException, OrmException {
Iterator<Map.Entry<String, List<CommentInput>>> mapItr =
in.entrySet().iterator();
Set<String> filePaths =
Sets.newHashSet(changeDataFactory.create(
db.get(), revision.getControl()).filePaths(
revision.getPatchSet()));
while (mapItr.hasNext()) {
Map.Entry<String, List<CommentInput>> ent = mapItr.next();
String path = ent.getKey();
if (!filePaths.contains(path) && !Patch.COMMIT_MSG.equals(path)) {
throw new BadRequestException(String.format(
"file %s not found in revision %s",
path, revision.getChange().currentPatchSetId()));
}
List<CommentInput> list = ent.getValue();
if (list == null) {
mapItr.remove();
continue;
}
Iterator<CommentInput> listItr = list.iterator();
while (listItr.hasNext()) {
CommentInput c = listItr.next();
if (c == null) {
listItr.remove();
continue;
}
if (c.line != null && c.line < 0) {
throw new BadRequestException(String.format(
"negative line number %d not allowed on %s",
c.line, path));
}
c.message = Strings.nullToEmpty(c.message).trim();
if (c.message.isEmpty()) {
listItr.remove();
}
}
if (list.isEmpty()) {
mapItr.remove();
}
}
}
/**
* Used to compare PatchLineComments with CommentInput comments.
*/
@AutoValue
abstract static class CommentSetEntry {
private static CommentSetEntry create(Patch.Key key,
Integer line, Side side, HashCode message, CommentRange range) {
return new AutoValue_PostReview_CommentSetEntry(key, line, side, message,
range);
}
public static CommentSetEntry create(PatchLineComment comment) {
return create(comment.getKey().getParentKey(),
comment.getLine(),
Side.fromShort(comment.getSide()),
Hashing.sha1().hashString(comment.getMessage(), UTF_8),
comment.getRange());
}
abstract Patch.Key key();
@Nullable abstract Integer line();
abstract Side side();
abstract HashCode message();
@Nullable abstract CommentRange range();
}
private class Op extends BatchUpdate.Op {
private final PatchSet.Id psId;
private final ReviewInput in;
private IdentifiedUser user;
private Change change;
private PatchSet ps;
private ChangeMessage message;
private List<PatchLineComment> comments = new ArrayList<>();
private List<String> labelDelta = new ArrayList<>();
private Map<String, Short> categories = new HashMap<>();
private Op(PatchSet.Id psId, ReviewInput in) {
this.psId = psId;
this.in = in;
}
@Override
public void updateChange(ChangeContext ctx) throws OrmException {
user = ctx.getUser().asIdentifiedUser();
change = ctx.getChange();
if (change.getLastUpdatedOn().before(ctx.getWhen())) {
change.setLastUpdatedOn(ctx.getWhen());
}
ps = ctx.getDb().patchSets().get(psId);
ctx.getChangeUpdate().setPatchSetId(psId);
boolean dirty = false;
dirty |= insertComments(ctx);
dirty |= updateLabels(ctx);
dirty |= insertMessage(ctx);
if (dirty) {
ctx.getDb().changes().update(Collections.singleton(change));
}
}
@Override
public void postUpdate(Context ctx) {
if (message == null) {
return;
}
if (in.notify.compareTo(NotifyHandling.NONE) > 0) {
email.create(
in.notify,
change,
ps,
user,
message,
comments).sendAsync();
}
try {
hooks.doCommentAddedHook(change, user.getAccount(), ps,
message.getMessage(), categories, ctx.getDb());
} catch (OrmException e) {
log.warn("ChangeHook.doCommentAddedHook delivery failed", e);
}
}
private boolean insertComments(ChangeContext ctx) throws OrmException {
Map<String, List<CommentInput>> map = in.comments;
if (map == null) {
map = Collections.emptyMap();
}
Map<String, PatchLineComment> drafts = Collections.emptyMap();
if (!map.isEmpty() || in.drafts != DraftHandling.KEEP) {
if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
drafts = changeDrafts(ctx);
} else {
drafts = patchSetDrafts(ctx);
}
}
List<PatchLineComment> del = Lists.newArrayList();
List<PatchLineComment> ups = Lists.newArrayList();
Set<CommentSetEntry> existingIds = in.omitDuplicateComments
? readExistingComments(ctx)
: Collections.<CommentSetEntry>emptySet();
for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
String path = ent.getKey();
for (CommentInput c : ent.getValue()) {
String parent = Url.decode(c.inReplyTo);
PatchLineComment e = drafts.remove(Url.decode(c.id));
if (e == null) {
e = new PatchLineComment(
new PatchLineComment.Key(new Patch.Key(psId, path), null),
c.line != null ? c.line : 0,
user.getAccountId(),
parent, ctx.getWhen());
} else if (parent != null) {
e.setParentUuid(parent);
}
e.setStatus(PatchLineComment.Status.PUBLISHED);
e.setWrittenOn(ctx.getWhen());
e.setSide(c.side == Side.PARENT ? (short) 0 : (short) 1);
setCommentRevId(e, patchListCache, ctx.getChange(), ps);
e.setMessage(c.message);
if (c.range != null) {
e.setRange(new CommentRange(
c.range.startLine,
c.range.startCharacter,
c.range.endLine,
c.range.endCharacter));
e.setLine(c.range.endLine);
}
if (existingIds.contains(CommentSetEntry.create(e))) {
continue;
}
if (e.getKey().get() == null) {
e.getKey().set(ChangeUtil.messageUUID(ctx.getDb()));
}
ups.add(e);
}
}
switch (firstNonNull(in.drafts, DraftHandling.DELETE)) {
case KEEP:
default:
break;
case DELETE:
del.addAll(drafts.values());
break;
case PUBLISH:
case PUBLISH_ALL_REVISIONS:
for (PatchLineComment e : drafts.values()) {
e.setStatus(PatchLineComment.Status.PUBLISHED);
e.setWrittenOn(ctx.getWhen());
setCommentRevId(e, patchListCache, ctx.getChange(), ps);
ups.add(e);
}
break;
}
plcUtil.deleteComments(ctx.getDb(), ctx.getChangeUpdate(), del);
plcUtil.upsertComments(ctx.getDb(), ctx.getChangeUpdate(), ups);
comments.addAll(ups);
return !del.isEmpty() || !ups.isEmpty();
}
private Set<CommentSetEntry> readExistingComments(ChangeContext ctx)
throws OrmException {
Set<CommentSetEntry> r = new HashSet<>();
for (PatchLineComment c : plcUtil.publishedByChange(ctx.getDb(),
ctx.getChangeNotes())) {
r.add(CommentSetEntry.create(c));
}
return r;
}
private Map<String, PatchLineComment> changeDrafts(ChangeContext ctx)
throws OrmException {
Map<String, PatchLineComment> drafts = Maps.newHashMap();
for (PatchLineComment c : plcUtil.draftByChangeAuthor(
ctx.getDb(), ctx.getChangeNotes(), user.getAccountId())) {
drafts.put(c.getKey().get(), c);
}
return drafts;
}
private Map<String, PatchLineComment> patchSetDrafts(ChangeContext ctx)
throws OrmException {
Map<String, PatchLineComment> drafts = Maps.newHashMap();
for (PatchLineComment c : plcUtil.draftByPatchSetAuthor(ctx.getDb(),
psId, user.getAccountId(), ctx.getChangeNotes())) {
drafts.put(c.getKey().get(), c);
}
return drafts;
}
private boolean updateLabels(ChangeContext ctx) throws OrmException {
Map<String, Short> labels = in.labels;
if (labels == null) {
labels = Collections.emptyMap();
}
List<PatchSetApproval> del = Lists.newArrayList();
List<PatchSetApproval> ups = Lists.newArrayList();
Map<String, PatchSetApproval> current = scanLabels(ctx, del);
ChangeUpdate update = ctx.getChangeUpdate();
LabelTypes labelTypes = ctx.getChangeControl().getLabelTypes();
for (Map.Entry<String, Short> ent : labels.entrySet()) {
String name = ent.getKey();
LabelType lt = checkNotNull(labelTypes.byLabel(name), name);
if (ctx.getChange().getStatus().isClosed()) {
// TODO Allow updating some labels even when closed.
continue;
}
PatchSetApproval c = current.remove(lt.getName());
String normName = lt.getName();
if (ent.getValue() == null || ent.getValue() == 0) {
// User requested delete of this label.
if (c != null) {
if (c.getValue() != 0) {
addLabelDelta(normName, (short) 0);
}
del.add(c);
update.putApproval(ent.getKey(), (short) 0);
}
} else if (c != null && c.getValue() != ent.getValue()) {
c.setValue(ent.getValue());
c.setGranted(ctx.getWhen());
ups.add(c);
addLabelDelta(normName, c.getValue());
categories.put(normName, c.getValue());
update.putApproval(ent.getKey(), ent.getValue());
} else if (c != null && c.getValue() == ent.getValue()) {
current.put(normName, c);
} else if (c == null) {
c = new PatchSetApproval(new PatchSetApproval.Key(
psId,
user.getAccountId(),
lt.getLabelId()),
ent.getValue(), TimeUtil.nowTs());
c.setGranted(ctx.getWhen());
ups.add(c);
addLabelDelta(normName, c.getValue());
categories.put(normName, c.getValue());
update.putApproval(ent.getKey(), ent.getValue());
}
}
forceCallerAsReviewer(ctx, current, ups, del);
ctx.getDb().patchSetApprovals().delete(del);
ctx.getDb().patchSetApprovals().upsert(ups);
return !del.isEmpty() || !ups.isEmpty();
}
private void forceCallerAsReviewer(ChangeContext ctx,
Map<String, PatchSetApproval> current, List<PatchSetApproval> ups,
List<PatchSetApproval> del) {
if (current.isEmpty() && ups.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(
psId,
user.getAccountId(),
ctx.getChangeControl().getLabelTypes().getLabelTypes().get(0)
.getLabelId()),
(short) 0, TimeUtil.nowTs());
c.setGranted(ctx.getWhen());
ups.add(c);
} else {
// Pick a random label that is about to be deleted and keep it.
Iterator<PatchSetApproval> i = del.iterator();
PatchSetApproval c = i.next();
c.setValue((short) 0);
c.setGranted(ctx.getWhen());
i.remove();
ups.add(c);
}
}
}
private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx,
List<PatchSetApproval> del) throws OrmException {
LabelTypes labelTypes = ctx.getChangeControl().getLabelTypes();
Map<String, PatchSetApproval> current = Maps.newHashMap();
for (PatchSetApproval a : approvalsUtil.byPatchSetUser(
ctx.getDb(), ctx.getChangeControl(), psId, user.getAccountId())) {
if (a.isSubmit()) {
continue;
}
LabelType lt = labelTypes.byLabel(a.getLabelId());
if (lt != null) {
current.put(lt.getName(), a);
} else {
del.add(a);
}
}
return current;
}
private boolean insertMessage(ChangeContext ctx)
throws OrmException {
String msg = Strings.nullToEmpty(in.message).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(
psId.getParentKey(), ChangeUtil.messageUUID(ctx.getDb())),
user.getAccountId(),
ctx.getWhen(),
psId);
message.setMessage(String.format(
"Patch Set %d:%s",
psId.get(),
buf.toString()));
cmUtil.addChangeMessage(ctx.getDb(), ctx.getChangeUpdate(), message);
return true;
}
private void addLabelDelta(String name, short value) {
labelDelta.add(LabelVote.create(name, value).format());
}
}
}