Record reviewer state in notes database
Stop implicitly adding/removing reviewers by adding an arbitrary
PatchSetApprovals with a 0 vote. This is ugly and error-prone,
especially when LabelTypes change.
Instead, use the existing ReviewerState enum, adding a REMOVED type
as a tombstone. When parsing, record these during the walk, and prune
all REMOVED tombstones after the fact.
Change-Id: Iffb0517eb0162eb6cce66bf36d905a6eb60e75da
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 7b7476f..25654d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -19,10 +19,10 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Lists;
-import com.google.common.collect.Multimaps;
import com.google.common.collect.Ordering;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
@@ -36,6 +36,7 @@
import com.google.gerrit.reviewdb.client.PatchSetInfo;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.change.ChangeKind;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gerrit.server.util.TimeUtil;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
@@ -77,21 +78,18 @@
public ApprovalsUtil() {
}
- public static enum ReviewerState {
- REVIEWER, CC;
- }
-
/**
* Get all reviewers for a change.
*
* @param db review database.
* @param changeId change ID.
* @return multimap of reviewers keyed by state, where each account appears
- * exactly once in {@link SetMultimap#values()}.
+ * exactly once in {@link SetMultimap#values()}, and
+ * {@link ReviewerState#REMOVED} is not present.
* @throws OrmException if reviewers for the change could not be read.
*/
- public SetMultimap<ReviewerState, Account.Id> getReviewers(ReviewDb db,
- Change.Id changeId) throws OrmException {
+ public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
+ ReviewDb db, Change.Id changeId) throws OrmException {
return getReviewers(db.patchSetApprovals().byChange(changeId));
}
@@ -101,9 +99,10 @@
* @param allApprovals all approvals to consider; must all belong to the same
* change.
* @return multimap of reviewers keyed by state, where each account appears
- * exactly once in {@link SetMultimap#values()}.
+ * exactly once in {@link SetMultimap#values()}, and
+ * {@link ReviewerState#REMOVED} is not present.
*/
- public static SetMultimap<ReviewerState, Account.Id> getReviewers(
+ public static ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers(
Iterable<PatchSetApproval> allApprovals) {
PatchSetApproval first = null;
SetMultimap<ReviewerState, Account.Id> reviewers =
@@ -125,7 +124,7 @@
reviewers.put(ReviewerState.CC, id);
}
}
- return Multimaps.unmodifiableSetMultimap(reviewers);
+ return ImmutableSetMultimap.copyOf(reviewers);
}
/**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 99292f3..1840e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -28,7 +28,6 @@
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -36,6 +35,7 @@
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidators;
import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.InvalidChangeOperationException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index a9710e1..59431b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -18,7 +18,6 @@
import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
-
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import static org.eclipse.jgit.lib.RefDatabase.ALL;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
@@ -64,7 +63,6 @@
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
@@ -90,6 +88,7 @@
import com.google.gerrit.server.mail.MailUtil.MailRecipients;
import com.google.gerrit.server.mail.MergedSender;
import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.ProjectCache;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
index 47baa9e..09f9e42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ChangeEmail.java
@@ -25,9 +25,9 @@
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetInfo;
import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.mail.ProjectWatch.Watchers;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gerrit.server.patch.PatchList;
import com.google.gerrit.server.patch.PatchListEntry;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index 801e599..ac367ef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -18,8 +18,8 @@
import com.google.gerrit.common.errors.NoSuchAccountException;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.ApprovalsUtil.ReviewerState;
import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gwtorm.server.OrmException;
import org.eclipse.jgit.revwalk.FooterKey;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 3438424..4d75d82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -21,10 +21,12 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Maps;
-import com.google.common.collect.Multimaps;
import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
import com.google.common.primitives.Ints;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
@@ -41,16 +43,20 @@
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.Collection;
import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/** View of a single {@link Change} based on the log of its notes branch. */
public class ChangeNotes extends VersionedMetaData {
@@ -90,8 +96,154 @@
}
}
+ private static class Parser {
+ private final Change.Id changeId;
+ private final ObjectId tip;
+ private final RevWalk walk;
+ private final ListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+ private final Map<Account.Id, ReviewerState> reviewers;
+
+ private Parser(Change.Id changeId, ObjectId tip, RevWalk walk) {
+ this.changeId = changeId;
+ this.tip = tip;
+ this.walk = walk;
+ approvals = ArrayListMultimap.create();
+ reviewers = Maps.newLinkedHashMap();
+ }
+
+ private void parseAll() throws ConfigInvalidException, IOException {
+ walk.markStart(walk.parseCommit(tip));
+ for (RevCommit commit : walk) {
+ parse(commit);
+ }
+ pruneReviewers();
+ for (Collection<PatchSetApproval> v : approvals.asMap().values()) {
+ Collections.sort((List<PatchSetApproval>) v, PSA_BY_TIME);
+ }
+ }
+
+ private void parse(RevCommit commit) throws ConfigInvalidException {
+ PatchSet.Id psId = parsePatchSetId(commit);
+ Account.Id accountId = parseIdent(commit);
+ List<PatchSetApproval> psas = approvals.get(psId);
+
+ Map<String, PatchSetApproval> curr =
+ Maps.newHashMapWithExpectedSize(psas.size());
+ for (PatchSetApproval psa : psas) {
+ if (psa.getAccountId().equals(accountId)) {
+ curr.put(psa.getLabel(), psa);
+ }
+ }
+
+ for (String line : commit.getFooterLines(FOOTER_LABEL)) {
+ PatchSetApproval psa = parseApproval(psId, accountId, commit, line);
+ if (!curr.containsKey(psa.getLabel())) {
+ curr.put(psa.getLabel(), psa);
+ psas.add(psa);
+ }
+ }
+ for (ReviewerState state : ReviewerState.values()) {
+ for (String line : commit.getFooterLines(state.getFooterKey())) {
+ parseReviewer(state, line);
+ }
+ }
+ }
+
+ private PatchSet.Id parsePatchSetId(RevCommit commit)
+ throws ConfigInvalidException {
+ List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
+ if (psIdLines.size() != 1) {
+ throw parseException("missing or multiple %s: %s",
+ FOOTER_PATCH_SET, psIdLines);
+ }
+ Integer psId = Ints.tryParse(psIdLines.get(0));
+ if (psId == null) {
+ throw parseException("invalid %s: %s",
+ FOOTER_PATCH_SET, psIdLines.get(0));
+ }
+ return new PatchSet.Id(changeId, psId);
+ }
+
+ private PatchSetApproval parseApproval(PatchSet.Id psId, Account.Id accountId,
+ RevCommit commit, String line) throws ConfigInvalidException {
+ try {
+ LabelVote l = LabelVote.parseWithEquals(line);
+ return new PatchSetApproval(
+ new PatchSetApproval.Key(
+ psId, parseIdent(commit), new LabelId(l.getLabel())),
+ l.getValue(),
+ new Timestamp(commit.getCommitterIdent().getWhen().getTime()));
+ } catch (IllegalArgumentException e) {
+ ConfigInvalidException pe =
+ parseException("invalid %s: %s", FOOTER_LABEL, line);
+ pe.initCause(e);
+ throw pe;
+ }
+ }
+
+ private Account.Id parseIdent(RevCommit commit)
+ throws ConfigInvalidException {
+ return parseIdent(commit.getCommitterIdent());
+ }
+
+ private Account.Id parseIdent(PersonIdent ident)
+ throws ConfigInvalidException {
+ String email = ident.getEmailAddress();
+ int at = email.indexOf('@');
+ if (at >= 0) {
+ String host = email.substring(at + 1, email.length());
+ Integer id = Ints.tryParse(email.substring(0, at));
+ if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
+ return new Account.Id(id);
+ }
+ }
+ throw parseException("invalid identity, expected <id>@%s: %s",
+ GERRIT_PLACEHOLDER_HOST, email);
+ }
+
+ private void parseReviewer(ReviewerState state, String line)
+ throws ConfigInvalidException {
+ PersonIdent ident = RawParseUtils.parsePersonIdent(line);
+ if (ident == null) {
+ throw parseException(
+ "invalid %s: %s", state.getFooterKey().getName(), line);
+ }
+ Account.Id accountId = parseIdent(ident);
+ if (!reviewers.containsKey(accountId)) {
+ reviewers.put(accountId, state);
+ }
+ }
+
+ private void pruneReviewers() {
+ Set<Account.Id> removed = Sets.newHashSetWithExpectedSize(reviewers.size());
+ Iterator<Map.Entry<Account.Id, ReviewerState>> rit =
+ reviewers.entrySet().iterator();
+ while (rit.hasNext()) {
+ Map.Entry<Account.Id, ReviewerState> e = rit.next();
+ if (e.getValue() == ReviewerState.REMOVED) {
+ removed.add(e.getKey());
+ rit.remove();
+ }
+ }
+
+ Iterator<Map.Entry<PatchSet.Id, PatchSetApproval>> ait =
+ approvals.entries().iterator();
+ while (ait.hasNext()) {
+ if (removed.contains(ait.next().getValue().getAccountId())) {
+ ait.remove();
+ }
+ }
+ }
+
+ private ConfigInvalidException parseException(String fmt, Object... args) {
+ return new ConfigInvalidException("Change " + changeId + ": "
+ + String.format(fmt, args));
+ }
+ }
+
private final Change change;
- private ListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+ private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+ private ImmutableSetMultimap<ReviewerState, Account.Id> reviewers;
@VisibleForTesting
ChangeNotes(Repository repo, Change change)
@@ -100,8 +252,12 @@
load(repo);
}
- public ListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
- return Multimaps.unmodifiableListMultimap(approvals);
+ public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+ return approvals;
+ }
+
+ public ImmutableSetMultimap<ReviewerState, Account.Id> getReviewers() {
+ return reviewers;
}
@Override
@@ -115,89 +271,21 @@
if (rev == null) {
return;
}
- approvals = ArrayListMultimap.create();
RevWalk walk = new RevWalk(reader);
- walk.markStart(walk.parseCommit(rev));
- for (RevCommit commit : walk) {
- parse(commit);
- }
- for (Collection<PatchSetApproval> v : approvals.asMap().values()) {
- Collections.sort((List<PatchSetApproval>) v, PSA_BY_TIME);
- }
- }
-
- private void parse(RevCommit commit) throws ConfigInvalidException {
- PatchSet.Id psId = parsePatchSetId(commit);
- Account.Id accountId = parseIdent(commit);
- List<PatchSetApproval> psas = approvals.get(psId);
-
- Map<String, PatchSetApproval> curr =
- Maps.newHashMapWithExpectedSize(psas.size());
- for (PatchSetApproval psa : psas) {
- if (psa.getAccountId().equals(accountId)) {
- curr.put(psa.getLabel(), psa);
- }
- }
-
- for (String line : commit.getFooterLines(FOOTER_LABEL)) {
- PatchSetApproval psa = parseApproval(psId, accountId, commit, line);
- if (!curr.containsKey(psa.getLabel())) {
- curr.put(psa.getLabel(), psa);
- psas.add(psa);
- }
- }
- }
-
- private PatchSet.Id parsePatchSetId(RevCommit commit)
- throws ConfigInvalidException {
- List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
- if (psIdLines.size() != 1) {
- throw parseException("missing or multiple %s: %s",
- FOOTER_PATCH_SET, psIdLines);
- }
- Integer psId = Ints.tryParse(psIdLines.get(0));
- if (psId == null) {
- throw parseException("invalid %s: %s",
- FOOTER_PATCH_SET, psIdLines.get(0));
- }
- return new PatchSet.Id(change.getId(), psId);
- }
-
- private PatchSetApproval parseApproval(PatchSet.Id psId, Account.Id accountId,
- RevCommit commit, String line) throws ConfigInvalidException {
try {
- LabelVote l = LabelVote.parseWithEquals(line);
- return new PatchSetApproval(
- new PatchSetApproval.Key(
- psId, parseIdent(commit), new LabelId(l.getLabel())),
- l.getValue(),
- new Timestamp(commit.getCommitterIdent().getWhen().getTime()));
- } catch (IllegalArgumentException e) {
- ConfigInvalidException pe =
- parseException("invalid %s: %s", FOOTER_LABEL, line);
- pe.initCause(e);
- throw pe;
- }
- }
-
- private Account.Id parseIdent(RevCommit commit)
- throws ConfigInvalidException {
- String email = commit.getCommitterIdent().getEmailAddress();
- int at = email.indexOf('@');
- if (at >= 0) {
- String host = email.substring(at + 1, email.length());
- Integer id = Ints.tryParse(email.substring(0, at));
- if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) {
- return new Account.Id(id);
+ Parser parser = new Parser(change.getId(), rev, walk);
+ parser.parseAll();
+ approvals = ImmutableListMultimap.copyOf(parser.approvals);
+ ImmutableSetMultimap.Builder<ReviewerState, Account.Id> reviewers =
+ ImmutableSetMultimap.builder();
+ for (Map.Entry<Account.Id, ReviewerState> e
+ : parser.reviewers.entrySet()) {
+ reviewers.put(e.getValue(), e.getKey());
}
+ this.reviewers = reviewers.build();
+ } finally {
+ walk.release();
}
- throw parseException("invalid committer, expected <id>@%s: %s",
- GERRIT_PLACEHOLDER_HOST, email);
- }
-
- private ConfigInvalidException parseException(String fmt, Object... args) {
- return new ConfigInvalidException("Change " + change.getId() + ": "
- + String.format(fmt, args));
}
@Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 4c85435..ae7beac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -28,13 +28,14 @@
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MetaDataUpdate;
import com.google.gerrit.server.git.VersionedMetaData;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -47,8 +48,9 @@
import org.eclipse.jgit.revwalk.RevCommit;
import java.io.IOException;
-import java.sql.Timestamp;
+import java.util.Date;
import java.util.Map;
+import java.util.TimeZone;
/**
* A single delta to apply atomically to a change.
@@ -57,65 +59,90 @@
* limitations on the set of modifications that can be handled in a single
* update. In particular, there is a single author and timestamp for each
* update.
+ * <p>
+ * This class is not thread-safe.
*/
public class ChangeUpdate extends VersionedMetaData {
@Singleton
public static class Factory {
private final GitRepositoryManager repoManager;
+ private final AccountCache accountCache;
private final MetaDataUpdate.User updateFactory;
private final ProjectCache projectCache;
private final Provider<IdentifiedUser> user;
+ private final PersonIdent serverIdent;
@Inject
Factory(
GitRepositoryManager repoManager,
+ AccountCache accountCache,
MetaDataUpdate.User updateFactory,
ProjectCache projectCache,
- Provider<IdentifiedUser> user) {
+ Provider<IdentifiedUser> user,
+ @GerritPersonIdent PersonIdent serverIdent) {
this.repoManager = repoManager;
+ this.accountCache = accountCache;
this.updateFactory = updateFactory;
this.projectCache = projectCache;
this.user = user;
+ this.serverIdent = serverIdent;
}
public ChangeUpdate create(Change change) {
- return create(change, TimeUtil.nowTs());
+ return create(change, serverIdent.getWhen());
}
- public ChangeUpdate create(Change change, Timestamp when) {
+ public ChangeUpdate create(Change change, Date when) {
return new ChangeUpdate(
- repoManager, updateFactory,
+ repoManager, accountCache, updateFactory,
projectCache.get(change.getProject()).getLabelTypes(),
- change, user.get().getAccountId(), when);
+ change, user.get().getAccount(), when, serverIdent.getTimeZone());
}
}
private final GitRepositoryManager repoManager;
+ private final AccountCache accountCache;
private final MetaDataUpdate.User updateFactory;
private final LabelTypes labelTypes;
private final Change change;
- private final Account.Id accountId;
- private final Timestamp when;
+ private final Account account;
+ private final Date when;
+ private final TimeZone tz;
private final Map<String, Short> approvals;
+ private final Map<Account.Id, ReviewerState> reviewers;
private String subject;
private PatchSet.Id psId;
@VisibleForTesting
- ChangeUpdate(GitRepositoryManager repoManager, LabelTypes labelTypes,
- Change change, Account.Id accountId, @Nullable Timestamp when) {
- this(repoManager, null, labelTypes, change, accountId, when);
+ ChangeUpdate(GitRepositoryManager repoManager, AccountCache accountCache,
+ LabelTypes labelTypes, Change change, Account account, Date when,
+ TimeZone tz) {
+ this(repoManager, accountCache, null, labelTypes, change, account, when,
+ tz);
}
private ChangeUpdate(GitRepositoryManager repoManager,
- @Nullable MetaDataUpdate.User updateFactory, LabelTypes labelTypes,
- Change change, Account.Id accountId, @Nullable Timestamp when) {
+ AccountCache accountCache, @Nullable MetaDataUpdate.User updateFactory,
+ LabelTypes labelTypes, Change change, Account account, Date when,
+ TimeZone tz) {
this.repoManager = repoManager;
+ this.accountCache = accountCache;
this.updateFactory = updateFactory;
this.labelTypes = labelTypes;
this.change = change;
- this.accountId = accountId;
+ this.account = account;
this.when = when;
+ this.tz = tz;
this.approvals = Maps.newTreeMap(labelTypes.nameComparator());
+ this.reviewers = Maps.newLinkedHashMap();
+ }
+
+ public Account getAccount() {
+ return account;
+ }
+
+ public Date getWhen() {
+ return when;
}
public void putApproval(String label, short value) {
@@ -131,6 +158,15 @@
this.psId = psId;
}
+ public void putReviewer(Account.Id reviewer, ReviewerState type) {
+ checkArgument(type != ReviewerState.REMOVED, "invalid ReviewerType");
+ reviewers.put(reviewer, type);
+ }
+
+ public void removeReviewer(Account.Id reviewer) {
+ reviewers.put(reviewer, ReviewerState.REMOVED);
+ }
+
public RevCommit commit() throws IOException {
return commit(checkNotNull(updateFactory, "MetaDataUpdate.Factory")
.create(change.getProject()));
@@ -149,18 +185,21 @@
md.setAllowEmpty(true);
CommitBuilder cb = md.getCommitBuilder();
- cb.setCommitter(new PersonIdent(
- cb.getAuthor().getName(),
- accountId.get() + "@" + GERRIT_PLACEHOLDER_HOST,
- when != null ? when : cb.getCommitter().getWhen(),
- cb.getCommitter().getTimeZone()));
- if (when != null) {
- md.getCommitBuilder().setAuthor(new PersonIdent(
- md.getCommitBuilder().getAuthor(), when));
- }
+ cb.setCommitter(newCommitter());
return super.commit(md);
}
+ public PersonIdent newIdent(Account author) {
+ return new PersonIdent(
+ author.getFullName(),
+ author.getId().get() + "@" + GERRIT_PLACEHOLDER_HOST,
+ when, tz);
+ }
+
+ public PersonIdent newCommitter() {
+ return newIdent(account);
+ }
+
@Override
protected String getRefName() {
return ChangeNoteUtil.changeRefName(change.getId());
@@ -168,7 +207,7 @@
@Override
protected void onSave(CommitBuilder commit) {
- if (approvals.isEmpty()) {
+ if (approvals.isEmpty() && reviewers.isEmpty()) {
return;
}
int ps = psId != null ? psId.get() : change.currentPatchSetId().get();
@@ -180,6 +219,13 @@
}
msg.append("\n\n");
addFooter(msg, FOOTER_PATCH_SET, ps);
+ for (Map.Entry<Account.Id, ReviewerState> e : reviewers.entrySet()) {
+ Account account = accountCache.get(e.getKey()).getAccount();
+ PersonIdent ident = newIdent(account);
+ addFooter(msg, e.getValue().getFooterKey())
+ .append(ident.getName())
+ .append(" <").append(ident.getEmailAddress()).append(">\n");
+ }
for (Map.Entry<String, Short> e : approvals.entrySet()) {
LabelType lt = labelTypes.byLabel(e.getKey());
if (lt != null) {
@@ -190,9 +236,13 @@
commit.setMessage(msg.toString());
}
+ private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
+ return sb.append(footer.getName()).append(": ");
+ }
+
private static void addFooter(StringBuilder sb, FooterKey footer,
Object value) {
- sb.append(footer.getName()).append(": ").append(value).append('\n');
+ addFooter(sb, footer).append(value).append('\n');
}
@Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
new file mode 100644
index 0000000..b829a69
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerState.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2013 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.notedb;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/** State of a reviewer on a change. */
+public enum ReviewerState {
+ /** The user has contributed at least one nonzero vote on the change. */
+ REVIEWER(new FooterKey("Reviewer")),
+
+ /** The reviewer was added to the change, but has not voted. */
+ CC(new FooterKey("CC")),
+
+ /** The user was previously a reviewer on the change, but was removed. */
+ REMOVED(new FooterKey("Removed"));
+
+ private final FooterKey footerKey;
+
+ private ReviewerState(FooterKey footerKey) {
+ this.footerKey = footerKey;
+ }
+
+ FooterKey getFooterKey() {
+ return footerKey;
+ }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 376d8c6..b21632a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -34,9 +34,9 @@
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.ApprovalsUtil.ReviewerState;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ReviewerState;
import com.google.gerrit.server.patch.PatchList;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.patch.PatchListEntry;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index b614921..6d72456 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -16,15 +16,14 @@
import static com.google.gerrit.server.project.Util.category;
import static com.google.gerrit.server.project.Util.value;
-
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
-
import static org.junit.Assert.assertEquals;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.gerrit.common.data.LabelTypes;
@@ -39,6 +38,7 @@
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.MetaDataUpdate;
import com.google.gerrit.server.util.TimeUtil;
+import com.google.gerrit.testutil.FakeAccountCache;
import com.google.gerrit.testutil.InMemoryRepositoryManager;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -63,7 +63,18 @@
private static final TimeZone TZ =
TimeZone.getTimeZone("America/Los_Angeles");
- private LabelTypes LABEL_TYPES = new LabelTypes(ImmutableList.of(
+ private static final Account CHANGE_OWNER;
+ private static final Account OTHER_ACCOUNT;
+ static {
+ CHANGE_OWNER = new Account(new Account.Id(1), TimeUtil.nowTs());
+ CHANGE_OWNER.setFullName("Change Owner");
+ CHANGE_OWNER.setPreferredEmail("change@owner.com");
+ OTHER_ACCOUNT = new Account(new Account.Id(2), TimeUtil.nowTs());
+ OTHER_ACCOUNT.setFullName("Other Account");
+ OTHER_ACCOUNT.setPreferredEmail("other@account.com");
+ }
+
+ private static final LabelTypes LABEL_TYPES = new LabelTypes(ImmutableList.of(
category("Verified",
value(1, "Verified"),
value(0, "No score"),
@@ -76,6 +87,7 @@
private Project.NameKey project;
private InMemoryRepositoryManager repoManager;
private InMemoryRepository repo;
+ private FakeAccountCache accountCache;
private volatile long clockStepMs;
@Before
@@ -83,6 +95,9 @@
project = new Project.NameKey("test-project");
repoManager = new InMemoryRepositoryManager();
repo = repoManager.createRepository(project);
+ accountCache = new FakeAccountCache();
+ accountCache.put(CHANGE_OWNER);
+ accountCache.put(OTHER_ACCOUNT);
}
@Before
@@ -107,10 +122,12 @@
@Test
public void approvalsCommitFormat() throws Exception {
- Change c = newChange(5);
- ChangeUpdate update = newUpdate(c, c.getOwner());
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) -1);
update.putApproval("Verified", (short) 1);
+ update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
commit(update);
assertEquals("refs/changes/01/1/meta", update.getRefName());
@@ -121,20 +138,22 @@
assertEquals("Update patch set 1\n"
+ "\n"
+ "Patch-Set: 1\n"
+ + "Reviewer: Change Owner <1@gerrit>\n"
+ + "CC: Other Account <2@gerrit>\n"
+ "Label: Verified=+1\n"
+ "Label: Code-Review=-1\n",
commit.getFullMessage());
PersonIdent author = commit.getAuthorIdent();
- assertEquals("Example User", author.getName());
- assertEquals("user@example.com", author.getEmailAddress());
+ assertEquals("Change Owner", author.getName());
+ assertEquals("change@owner.com", author.getEmailAddress());
assertEquals(new Date(c.getCreatedOn().getTime() + 1000),
author.getWhen());
assertEquals(TimeZone.getTimeZone("GMT-8:00"), author.getTimeZone());
PersonIdent committer = commit.getCommitterIdent();
- assertEquals("Example User", committer.getName());
- assertEquals("5@gerrit", committer.getEmailAddress());
+ assertEquals("Change Owner", committer.getName());
+ assertEquals("1@gerrit", committer.getEmailAddress());
assertEquals(author.getWhen(), committer.getWhen());
assertEquals(author.getTimeZone(), committer.getTimeZone());
} finally {
@@ -144,8 +163,8 @@
@Test
public void approvalsOnePatchSet() throws Exception {
- Change c = newChange(5);
- ChangeUpdate update = newUpdate(c, c.getOwner());
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) -1);
update.putApproval("Verified", (short) 1);
commit(update);
@@ -157,13 +176,13 @@
assertEquals(2, psas.size());
assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
- assertEquals(5, psas.get(0).getAccountId().get());
+ assertEquals(1, psas.get(0).getAccountId().get());
assertEquals("Verified", psas.get(0).getLabel());
assertEquals((short) 1, psas.get(0).getValue());
assertEquals(truncate(after(c, 1000)), psas.get(0).getGranted());
assertEquals(c.currentPatchSetId(), psas.get(1).getPatchSetId());
- assertEquals(5, psas.get(1).getAccountId().get());
+ assertEquals(1, psas.get(1).getAccountId().get());
assertEquals("Code-Review", psas.get(1).getLabel());
assertEquals((short) -1, psas.get(1).getValue());
assertEquals(psas.get(0).getGranted(), psas.get(1).getGranted());
@@ -171,14 +190,14 @@
@Test
public void approvalsMultiplePatchSets() throws Exception {
- Change c = newChange(5);
- ChangeUpdate update = newUpdate(c, c.getOwner());
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) -1);
commit(update);
PatchSet.Id ps1 = c.currentPatchSetId();
incrementPatchSet(c);
- update = newUpdate(c, c.getOwner());
+ update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) 1);
commit(update);
PatchSet.Id ps2 = c.currentPatchSetId();
@@ -189,14 +208,14 @@
PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
assertEquals(ps1, psa1.getPatchSetId());
- assertEquals(5, psa1.getAccountId().get());
+ assertEquals(1, psa1.getAccountId().get());
assertEquals("Code-Review", psa1.getLabel());
assertEquals((short) -1, psa1.getValue());
assertEquals(truncate(after(c, 1000)), psa1.getGranted());
PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
assertEquals(ps2, psa2.getPatchSetId());
- assertEquals(5, psa2.getAccountId().get());
+ assertEquals(1, psa2.getAccountId().get());
assertEquals("Code-Review", psa2.getLabel());
assertEquals((short) +1, psa2.getValue());
assertEquals(truncate(after(c, 2000)), psa2.getGranted());
@@ -204,8 +223,8 @@
@Test
public void approvalsMultipleApprovals() throws Exception {
- Change c = newChange(5);
- ChangeUpdate update = newUpdate(c, c.getOwner());
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) -1);
commit(update);
@@ -215,7 +234,7 @@
assertEquals("Code-Review", psa.getLabel());
assertEquals((short) -1, psa.getValue());
- update = newUpdate(c, c.getOwner());
+ update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) 1);
commit(update);
@@ -228,12 +247,12 @@
@Test
public void approvalsMultipleUsers() throws Exception {
- Change c = newChange(5);
- ChangeUpdate update = newUpdate(c, c.getOwner());
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
update.putApproval("Code-Review", (short) -1);
commit(update);
- update = newUpdate(c, new Account.Id(6));
+ update = newUpdate(c, OTHER_ACCOUNT);
update.putApproval("Code-Review", (short) 1);
commit(update);
@@ -244,33 +263,118 @@
assertEquals(2, psas.size());
assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
- assertEquals(5, psas.get(0).getAccountId().get());
+ assertEquals(1, psas.get(0).getAccountId().get());
assertEquals("Code-Review", psas.get(0).getLabel());
assertEquals((short) -1, psas.get(0).getValue());
assertEquals(truncate(after(c, 1000)), psas.get(0).getGranted());
assertEquals(c.currentPatchSetId(), psas.get(1).getPatchSetId());
- assertEquals(6, psas.get(1).getAccountId().get());
+ assertEquals(2, psas.get(1).getAccountId().get());
assertEquals("Code-Review", psas.get(1).getLabel());
assertEquals((short) 1, psas.get(1).getValue());
assertEquals(truncate(after(c, 2000)), psas.get(1).getGranted());
}
- private Change newChange(int accountId) {
+ @Test
+ public void multipleReviewers() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+ update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+ commit(update);
+
+ ChangeNotes notes = newNotes(c);
+ assertEquals(ImmutableSetMultimap.of(
+ ReviewerState.REVIEWER, new Account.Id(1),
+ ReviewerState.REVIEWER, new Account.Id(2)),
+ notes.getReviewers());
+ }
+
+ @Test
+ public void reviewerTypes() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+ update.putReviewer(CHANGE_OWNER.getId(), ReviewerState.REVIEWER);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
+ commit(update);
+
+ ChangeNotes notes = newNotes(c);
+ assertEquals(ImmutableSetMultimap.of(
+ ReviewerState.REVIEWER, new Account.Id(1),
+ ReviewerState.CC, new Account.Id(2)),
+ notes.getReviewers());
+ }
+
+ @Test
+ public void oneReviewerMultipleTypes() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+ commit(update);
+
+ ChangeNotes notes = newNotes(c);
+ assertEquals(ImmutableSetMultimap.of(
+ ReviewerState.REVIEWER, new Account.Id(2)),
+ notes.getReviewers());
+
+ update = newUpdate(c, OTHER_ACCOUNT);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.CC);
+ commit(update);
+
+ notes = newNotes(c);
+ assertEquals(ImmutableSetMultimap.of(
+ ReviewerState.CC, new Account.Id(2)),
+ notes.getReviewers());
+ }
+
+ @Test
+ public void removeReviewer() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, CHANGE_OWNER);
+ update.putReviewer(OTHER_ACCOUNT.getId(), ReviewerState.REVIEWER);
+ commit(update);
+
+ update = newUpdate(c, CHANGE_OWNER);
+ update.putApproval("Code-Review", (short) 1);
+ commit(update);
+
+ update = newUpdate(c, OTHER_ACCOUNT);
+ update.putApproval("Code-Review", (short) 1);
+ commit(update);
+
+ ChangeNotes notes = newNotes(c);
+ List<PatchSetApproval> psas =
+ notes.getApprovals().get(c.currentPatchSetId());
+ assertEquals(2, psas.size());
+ assertEquals(CHANGE_OWNER.getId(), psas.get(0).getAccountId());
+ assertEquals(OTHER_ACCOUNT.getId(), psas.get(1).getAccountId());
+
+ update = newUpdate(c, CHANGE_OWNER);
+ update.removeReviewer(OTHER_ACCOUNT.getId());
+ commit(update);
+
+ notes = newNotes(c);
+ psas = notes.getApprovals().get(c.currentPatchSetId());
+ assertEquals(1, psas.size());
+ assertEquals(CHANGE_OWNER.getId(), psas.get(0).getAccountId());
+ }
+
+ private Change newChange() {
Change.Id changeId = new Change.Id(1);
Change c = new Change(
new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
changeId,
- new Account.Id(accountId),
+ CHANGE_OWNER.getId(),
new Branch.NameKey(project, "master"),
TimeUtil.nowTs());
incrementPatchSet(c);
return c;
}
- private ChangeUpdate newUpdate(Change c, Account.Id owner)
+ private ChangeUpdate newUpdate(Change c, Account account)
throws ConfigInvalidException, IOException {
- return new ChangeUpdate(repoManager, LABEL_TYPES, c, owner, null);
+ return new ChangeUpdate(repoManager, accountCache, LABEL_TYPES, c, account,
+ TimeUtil.nowTs(), TZ);
}
private ChangeNotes newNotes(Change c)
@@ -297,11 +401,10 @@
private RevCommit commit(ChangeUpdate update) throws IOException {
MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED,
project, repo);
- Timestamp ts = TimeUtil.nowTs();
- md.getCommitBuilder().setAuthor(
- new PersonIdent("Example User", "user@example.com", ts, TZ));
- md.getCommitBuilder().setCommitter(
- new PersonIdent("Gerrit Test", "notthis@email.com", ts, TZ));
+ md.getCommitBuilder().setAuthor(new PersonIdent(
+ update.getAccount().getFullName(),
+ update.getAccount().getPreferredEmail(),
+ update.getWhen(), TZ));
return update.commit(md);
}
}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
new file mode 100644
index 0000000..db1ed05
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2013 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.testutil;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.util.TimeUtil;
+
+import java.util.Map;
+
+/** Fake implementation of {@link AccountCache} for testing. */
+public class FakeAccountCache implements AccountCache {
+ private final Map<Account.Id, AccountState> byId;
+ private final Map<String, AccountState> byUsername;
+
+ public FakeAccountCache() {
+ byId = Maps.newHashMap();
+ byUsername = Maps.newHashMap();
+ }
+
+ @Override
+ public synchronized AccountState get(Account.Id accountId) {
+ AccountState state = getIfPresent(accountId);
+ if (state != null) {
+ return state;
+ }
+ return newState(new Account(accountId, TimeUtil.nowTs()));
+ }
+
+ @Override
+ public synchronized AccountState getIfPresent(Account.Id accountId) {
+ return byId.get(accountId);
+ }
+
+ @Override
+ public synchronized AccountState getByUsername(String username) {
+ return byUsername.get(username);
+ }
+
+ @Override
+ public synchronized void evict(Account.Id accountId) {
+ byId.remove(accountId);
+ }
+
+ @Override
+ public synchronized void evictByUsername(String username) {
+ byUsername.remove(username);
+ }
+
+ public synchronized void put(Account account) {
+ AccountState state = newState(account);
+ byId.put(account.getId(), state);
+ if (account.getUserName() != null) {
+ byUsername.put(account.getUserName(), state);
+ }
+ }
+
+ private static AccountState newState(Account account) {
+ return new AccountState(
+ account, ImmutableSet.<AccountGroup.UUID> of(),
+ ImmutableSet.<AccountExternalId> of());
+ }
+}