Add reviewers by email to NoteDb

This change adds code for NoteDb to store reviewers and CCs that do not
currently have a Gerrit account.

Once completed, this feature will send out emails to these reviewers and
provide an additional way of notifying users who do not have a
Gerrit account.

The feature will be supported exclusively on NoteDb.

In a follow-up change, we will add migration logic for reviewers by
email to regular reviewers should the user decide to create a Gerrit
account. This migration will happen on a per-change basis when the user
interacts with the change.

Bug: Issue 4134
Change-Id: I8b9f53f9ba0928f721e608c402257542775289f9
This commit is contained in:
Patrick Hiesel 2017-03-15 08:46:18 +01:00
parent 58b40f1632
commit 1d31699e03
8 changed files with 271 additions and 0 deletions

View File

@ -0,0 +1,79 @@
// Copyright (C) 2017 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 com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Table;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import java.sql.Timestamp;
/**
* Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
*
* <p>A given account may appear in multiple states and at different timestamps. No reviewers with
* state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
*/
public class ReviewerByEmailSet {
private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
public static ReviewerByEmailSet fromTable(
Table<ReviewerStateInternal, Address, Timestamp> table) {
return new ReviewerByEmailSet(table);
}
public static ReviewerByEmailSet empty() {
return EMPTY;
}
private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
private ImmutableSet<Address> users;
private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
this.table = ImmutableTable.copyOf(table);
}
public ImmutableSet<Address> all() {
if (users == null) {
// Idempotent and immutable, don't bother locking.
users = ImmutableSet.copyOf(table.columnKeySet());
}
return users;
}
public ImmutableSet<Address> byState(ReviewerStateInternal state) {
return table.row(state).keySet();
}
public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
return table;
}
@Override
public boolean equals(Object o) {
return (o instanceof ReviewerByEmailSet) && table.equals(((ReviewerByEmailSet) o).table);
}
@Override
public int hashCode() {
return table.hashCode();
}
@Override
public String toString() {
return getClass().getSimpleName() + table;
}
}

View File

@ -48,6 +48,7 @@ import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.client.RobotComment;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.git.RefCache;
@ -428,6 +429,11 @@ public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
return state.reviewers();
}
/** @return reviewers that do not currently have a Gerrit account and were added by email. */
public ReviewerByEmailSet getReviewersByEmail() {
return state.reviewersByEmail();
}
public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
return state.reviewerUpdates();
}

View File

@ -63,8 +63,10 @@ import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
import com.google.gerrit.server.util.LabelVote;
import java.io.IOException;
@ -128,6 +130,7 @@ class ChangeNotesParser {
// Private final but mutable members initialized in the constructor and filled
// in during the parsing process.
private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
private final List<Account.Id> allPastReviewers;
private final List<ReviewerStatusUpdate> reviewerUpdates;
private final List<SubmitRecord> submitRecords;
@ -174,6 +177,7 @@ class ChangeNotesParser {
approvals = new LinkedHashMap<>();
bufferedApprovals = new ArrayList<>();
reviewers = HashBasedTable.create();
reviewersByEmail = HashBasedTable.create();
allPastReviewers = new ArrayList<>();
reviewerUpdates = new ArrayList<>();
submitRecords = Lists.newArrayListWithExpectedSize(1);
@ -201,6 +205,7 @@ class ChangeNotesParser {
parseNotes();
allPastReviewers.addAll(reviewers.rowKeySet());
pruneReviewers();
pruneReviewersByEmail();
updatePatchSetStates();
checkMandatoryFooters();
@ -234,6 +239,7 @@ class ChangeNotesParser {
patchSets,
buildApprovals(),
ReviewerSet.fromTable(Tables.transpose(reviewers)),
ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
allPastReviewers,
buildReviewerUpdates(),
submitRecords,
@ -374,6 +380,9 @@ class ChangeNotesParser {
for (String line : commit.getFooterLineValues(state.getFooterKey())) {
parseReviewer(ts, state, line);
}
for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
parseReviewerByEmail(ts, state, line);
}
// Don't update timestamp when a reviewer was added, matching RevewDb
// behavior.
}
@ -917,6 +926,19 @@ class ChangeNotesParser {
}
}
private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
throws ConfigInvalidException {
Address adr;
try {
adr = Address.parse(line);
} catch (IllegalArgumentException e) {
throw invalidFooter(state.getByEmailFooterKey(), line);
}
if (!reviewersByEmail.containsRow(adr)) {
reviewersByEmail.put(adr, state, ts);
}
}
private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
if (raw == null) {
@ -956,6 +978,17 @@ class ChangeNotesParser {
}
}
private void pruneReviewersByEmail() {
Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
reviewersByEmail.cellSet().iterator();
while (rit.hasNext()) {
Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
rit.remove();
}
}
}
private void updatePatchSetStates() {
Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {

View File

@ -34,6 +34,7 @@ import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
@ -65,6 +66,7 @@ public abstract class ChangeNotesState {
ImmutableList.of(),
ImmutableList.of(),
ReviewerSet.empty(),
ReviewerByEmailSet.empty(),
ImmutableList.of(),
ImmutableList.of(),
ImmutableList.of(),
@ -95,6 +97,7 @@ public abstract class ChangeNotesState {
Map<PatchSet.Id, PatchSet> patchSets,
ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
ReviewerSet reviewers,
ReviewerByEmailSet reviewersByEmail,
List<Account.Id> allPastReviewers,
List<ReviewerStatusUpdate> reviewerUpdates,
List<SubmitRecord> submitRecords,
@ -128,6 +131,7 @@ public abstract class ChangeNotesState {
ImmutableList.copyOf(patchSets.entrySet()),
ImmutableList.copyOf(approvals.entries()),
reviewers,
reviewersByEmail,
ImmutableList.copyOf(allPastReviewers),
ImmutableList.copyOf(reviewerUpdates),
ImmutableList.copyOf(submitRecords),
@ -204,6 +208,8 @@ public abstract class ChangeNotesState {
abstract ReviewerSet reviewers();
abstract ReviewerByEmailSet reviewersByEmail();
abstract ImmutableList<Account.Id> allPastReviewers();
abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();

View File

@ -61,6 +61,7 @@ import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.util.LabelVote;
@ -128,6 +129,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
private final Table<String, Account.Id, Optional<Short>> approvals;
private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
private final List<Comment> comments = new ArrayList<>();
private String commitSubject;
@ -471,6 +473,15 @@ public class ChangeUpdate extends AbstractChangeUpdate {
reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
}
public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
reviewersByEmail.put(reviewer, type);
}
public void removeReviewerByEmail(Address reviewer) {
reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
}
public void setPatchSetState(PatchSetState psState) {
this.psState = psState;
}
@ -660,6 +671,10 @@ public class ChangeUpdate extends AbstractChangeUpdate {
addIdent(msg, e.getKey()).append('\n');
}
for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
}
for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
addFooter(msg, FOOTER_LABEL);
// Label names/values are safe to append without sanitizing.
@ -749,6 +764,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
&& changeMessage == null
&& comments.isEmpty()
&& reviewers.isEmpty()
&& reviewersByEmail.isEmpty()
&& changeId == null
&& branch == null
&& status == null

View File

@ -58,6 +58,10 @@ public enum ReviewerStateInternal {
return footerKey;
}
FooterKey getByEmailFooterKey() {
return new FooterKey(footerKey.getName() + "-email");
}
public ReviewerState asReviewerState() {
return state;
}

View File

@ -49,6 +49,7 @@ import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
import com.google.gerrit.server.util.RequestId;
import com.google.gerrit.testutil.TestChanges;
@ -3298,6 +3299,105 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
assertThat(notes.isPrivate()).isFalse();
}
@Test
public void defaultReviewersByEmailIsEmpty() throws Exception {
Change c = newChange();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().all()).isEmpty();
}
@Test
public void putReviewerByEmail() throws Exception {
Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
}
@Test
public void putAndRemoveReviewerByEmail() throws Exception {
Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
update.commit();
update = newUpdate(c, changeOwner);
update.removeReviewerByEmail(adr);
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().all()).isEmpty();
}
@Test
public void putRemoveAndAddBackReviewerByEmail() throws Exception {
Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
update.commit();
update = newUpdate(c, changeOwner);
update.removeReviewerByEmail(adr);
update.commit();
update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
}
@Test
public void putReviewerByEmailAndCcByEmail() throws Exception {
Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
update.commit();
update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
.containsExactly(adrReviewer);
assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
.containsExactly(adrCc);
assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
}
@Test
public void putReviewerByEmailAndChangeToCc() throws Exception {
Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
update.commit();
update = newUpdate(c, changeOwner);
update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
}
private boolean testJson() {
return noteUtil.getWriteJson();
}

View File

@ -23,6 +23,7 @@ import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.util.RequestId;
import com.google.gerrit.testutil.ConfigSuite;
import com.google.gerrit.testutil.TestChanges;
@ -382,6 +383,32 @@ public class CommitMessageOutputTest extends AbstractChangeNotesTest {
assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
}
@Test
public void reviewerByEmail() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(
new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
update.commit();
assertBodyEquals(
"Update patch set 1\n\nPatch-set: 1\n"
+ "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
update.getResult());
}
@Test
public void ccByEmail() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
update.commit();
assertBodyEquals(
"Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
update.getResult());
}
private RevCommit parseCommit(ObjectId id) throws Exception {
if (id instanceof RevCommit) {
return (RevCommit) id;