Read/write attention set in ChangeNotes

Context: https://www.gerritcodereview.com/design-docs/attention-set.html

This change adds code for NoteDb to store and read attention sets.

We considered storing this data in the file that also holds the
comments (adding a dedicated file would require significant
refactoring). This would better accomodate the structured attention set
data, but this file is attached to a specific patch set whereas the
attention set relates to the change as a whole. We agreed that the
commit message is the better storage location, even when this is the
first footer line that will have JSON in it and be potentially long.

Change-Id: I9e48cf6c1bb6d6d8f28f9ca251cac2c3529af4c8
This commit is contained in:
Joerg Zieren 2020-02-17 16:25:43 +01:00
parent 8f2648461c
commit 361ea4b790
15 changed files with 434 additions and 78 deletions

View File

@ -0,0 +1,72 @@
// Copyright (C) 2020 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.entities;
import com.google.auto.value.AutoValue;
import com.google.gerrit.common.Nullable;
import java.time.Instant;
/**
* A single update to the attention set. To reconstruct the attention set these instances are parsed
* in reverse chronological order. Since each update contains all required information and
* invalidates all previous state (hence the name -Status rather than -Update), only the most recent
* record is relevant for each user.
*
* <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
* details.
*/
@AutoValue
public abstract class AttentionStatus {
/** Users can be added to or removed from the attention set. */
public enum Operation {
ADD,
REMOVE
}
/**
* The time at which this status was set. This is null for instances to be written because the
* timestamp in the commit message will be used.
*/
@Nullable
public abstract Instant timestamp();
/** The user included in or excluded from the attention set. */
public abstract Account.Id account();
/** Indicates whether the user is added to or removed from the attention set. */
public abstract Operation operation();
/** A short human readable reason that explains this status (e.g. "manual"). */
public abstract String reason();
/**
* Create an instance from data read from NoteDB. This includes the timestamp taken from the
* commit.
*/
public static AttentionStatus createFromRead(
Instant timestamp, Account.Id account, Operation operation, String reason) {
return new AutoValue_AttentionStatus(timestamp, account, operation, reason);
}
/**
* Create an instance to be written to NoteDB. This has no timestamp because the timestamp of the
* commit will be used.
*/
public static AttentionStatus createForWrite(
Account.Id account, Operation operation, String reason) {
return new AutoValue_AttentionStatus(null, account, operation, reason);
}
}

View File

@ -110,7 +110,7 @@ public abstract class AbstractChangeUpdate {
ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
checkUserType(u);
if (u instanceof IdentifiedUser) {
return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
return noteUtil.newIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
} else if (u instanceof InternalUser) {
return serverIdent;
}
@ -164,10 +164,6 @@ public abstract class AbstractChangeUpdate {
return accountId;
}
protected PersonIdent newIdent(Account.Id authorId, Date when) {
return noteUtil.newIdent(authorId, when, serverIdent);
}
/** Whether no updates have been done. */
public abstract boolean isEmpty();

View File

@ -15,10 +15,13 @@
package com.google.gerrit.server.notedb;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gson.Gson;
import com.google.inject.Inject;
import java.time.Instant;
import java.util.Date;
import java.util.Optional;
import org.eclipse.jgit.lib.PersonIdent;
@ -27,42 +30,31 @@ import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.util.RawParseUtils;
public class ChangeNoteUtil {
public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
new FooterKey("Patch-set-description");
public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
static final String AUTHOR = "Author";
static final String BASE_PATCH_SET = "Base-for-patch-set";
static final String COMMENT_RANGE = "Comment-range";
static final String FILE = "File";
static final String LENGTH = "Bytes";
static final String PARENT = "Parent";
static final String PARENT_NUMBER = "Parent-number";
static final String PATCH_SET = "Patch-set";
static final String REAL_AUTHOR = "Real-author";
static final String REVISION = "Revision";
static final String UUID = "UUID";
static final String UNRESOLVED = "Unresolved";
static final String TAG = FOOTER_TAG.getName();
static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
static final FooterKey FOOTER_LABEL = new FooterKey("Label");
static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
static final FooterKey FOOTER_STATUS = new FooterKey("Status");
static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
static final FooterKey FOOTER_TAG = new FooterKey("Tag");
static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
private final ChangeNoteJson changeNoteJson;
private final String serverId;
@ -77,21 +69,17 @@ public class ChangeNoteUtil {
return changeNoteJson;
}
public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
public PersonIdent newIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
return new PersonIdent(
"Gerrit User " + authorId.toString(),
authorId.get() + "@" + serverId,
when,
serverIdent.getTimeZone());
getUsername(accountId), getEmailAddress(accountId), when, serverIdent.getTimeZone());
}
@VisibleForTesting
public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
return new PersonIdent(
"Gerrit User " + author.id(),
author.id().get() + "@" + serverId,
when,
serverIdent.getTimeZone());
private static String getUsername(Account.Id accountId) {
return "Gerrit User " + accountId.toString();
}
private String getEmailAddress(Account.Id accountId) {
return accountId.get() + "@" + serverId;
}
public static Optional<CommitMessageRange> parseCommitMessageRange(RevCommit commit) {
@ -151,6 +139,7 @@ public class ChangeNoteUtil {
@AutoValue
public abstract static class CommitMessageRange {
public abstract int subjectStart();
public abstract int subjectEnd();
@ -165,6 +154,7 @@ public class ChangeNoteUtil {
@AutoValue.Builder
public abstract static class Builder {
abstract Builder subjectStart(int subjectStart);
abstract Builder subjectEnd(int subjectEnd);
@ -176,4 +166,51 @@ public class ChangeNoteUtil {
abstract CommitMessageRange build();
}
}
/** Helper class for JSON serialization. Timestamp is taken from the commit. */
private static class AttentionStatusInNoteDb {
final String personIdent;
final AttentionStatus.Operation operation;
final String reason;
AttentionStatusInNoteDb(
String personIndent, AttentionStatus.Operation operation, String reason) {
this.personIdent = personIndent;
this.operation = operation;
this.reason = reason;
}
}
/** The returned {@link Optional} holds the parsed entity or is empty if parsing failed. */
static Optional<AttentionStatus> attentionStatusFromJson(
Instant timestamp, String attentionString) {
AttentionStatusInNoteDb inNoteDb =
gson.fromJson(attentionString, AttentionStatusInNoteDb.class);
PersonIdent personIdent = RawParseUtils.parsePersonIdent(inNoteDb.personIdent);
if (personIdent == null) {
return Optional.empty();
}
Optional<Account.Id> account = NoteDbUtil.parseIdent(personIdent);
return account.map(
id -> AttentionStatus.createFromRead(timestamp, id, inNoteDb.operation, inNoteDb.reason));
}
String attentionStatusToJson(AttentionStatus attentionStatus) {
PersonIdent personIdent =
new PersonIdent(
getUsername(attentionStatus.account()), getEmailAddress(attentionStatus.account()));
StringBuilder stringBuilder = new StringBuilder();
appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
return gson.toJson(
new AttentionStatusInNoteDb(
stringBuilder.toString(), attentionStatus.operation(), attentionStatus.reason()));
}
static void appendIdentString(StringBuilder stringBuilder, String name, String emailAddress) {
PersonIdent.appendSanitized(stringBuilder, name);
stringBuilder.append(" <");
PersonIdent.appendSanitized(stringBuilder, emailAddress);
stringBuilder.append('>');
}
}

View File

@ -40,6 +40,7 @@ import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
@ -369,6 +370,10 @@ public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
return state.reviewerUpdates();
}
public ImmutableList<AttentionStatus> getAttentionUpdates() {
return state.attentionUpdates();
}
/**
* @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
* order of the set is the order in which they were assigned.

View File

@ -16,6 +16,7 @@ package com.google.gerrit.server.notedb;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
@ -43,6 +44,7 @@ import static java.util.stream.Collectors.joining;
import com.google.common.base.Enums;
import com.google.common.base.Splitter;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.ListMultimap;
@ -57,6 +59,7 @@ import com.google.common.primitives.Ints;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.Comment;
@ -75,6 +78,7 @@ import com.google.gerrit.server.util.LabelVote;
import java.io.IOException;
import java.nio.charset.Charset;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@ -102,7 +106,6 @@ class ChangeNotesParser {
// Private final members initialized in the constructor.
private final ChangeNoteJson changeNoteJson;
private final NoteDbMetrics metrics;
private final Change.Id id;
private final ObjectId tip;
@ -114,6 +117,9 @@ class ChangeNotesParser {
private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
private final List<Account.Id> allPastReviewers;
private final List<ReviewerStatusUpdate> reviewerUpdates;
/** Holds only the most recent update per user. Older updates are discarded. */
private final Map<Account.Id, AttentionStatus> latestAttentionStatus;
private final List<AssigneeStatusUpdate> assigneeUpdates;
private final List<SubmitRecord> submitRecords;
private final ListMultimap<ObjectId, Comment> comments;
@ -169,6 +175,7 @@ class ChangeNotesParser {
pendingReviewersByEmail = ReviewerByEmailSet.empty();
allPastReviewers = new ArrayList<>();
reviewerUpdates = new ArrayList<>();
latestAttentionStatus = new HashMap<>();
assigneeUpdates = new ArrayList<>();
submitRecords = Lists.newArrayListWithExpectedSize(1);
allChangeMessages = new ArrayList<>();
@ -239,6 +246,7 @@ class ChangeNotesParser {
pendingReviewersByEmail,
allPastReviewers,
buildReviewerUpdates(),
ImmutableList.copyOf(latestAttentionStatus.values()),
assigneeUpdates,
submitRecords,
buildAllMessages(),
@ -359,6 +367,7 @@ class ChangeNotesParser {
}
parseHashtags(commit);
parseAttentionUpdates(commit);
parseAssigneeUpdates(ts, commit);
if (submissionId == null) {
@ -569,6 +578,21 @@ class ChangeNotesParser {
}
}
private void parseAttentionUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
for (String attentionString : attentionStrings) {
Optional<AttentionStatus> attentionStatus =
ChangeNoteUtil.attentionStatusFromJson(
Instant.ofEpochSecond(commit.getCommitTime()), attentionString);
if (!attentionStatus.isPresent()) {
throw invalidFooter(FOOTER_ATTENTION, attentionString);
}
// Processing is in reverse chronological order. Keep only the latest update.
latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
}
}
private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
throws ConfigInvalidException {
String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);

View File

@ -35,6 +35,7 @@ import com.google.common.collect.Table;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
@ -55,6 +56,7 @@ import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionStatusProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@ -66,6 +68,7 @@ import com.google.gson.Gson;
import com.google.protobuf.ByteString;
import com.google.protobuf.MessageLite;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -83,11 +86,12 @@ import org.eclipse.jgit.lib.ObjectId;
*/
@AutoValue
public abstract class ChangeNotesState {
static ChangeNotesState empty(Change change) {
return Builder.empty(change.getId()).build();
}
static Builder builder() {
private static Builder builder() {
return new AutoValue_ChangeNotesState.Builder();
}
@ -115,6 +119,7 @@ public abstract class ChangeNotesState {
ReviewerByEmailSet pendingReviewersByEmail,
List<Account.Id> allPastReviewers,
List<ReviewerStatusUpdate> reviewerUpdates,
List<AttentionStatus> attentionStatusUpdates,
List<AssigneeStatusUpdate> assigneeUpdates,
List<SubmitRecord> submitRecords,
List<ChangeMessage> changeMessages,
@ -165,6 +170,7 @@ public abstract class ChangeNotesState {
.pendingReviewersByEmail(pendingReviewersByEmail)
.allPastReviewers(allPastReviewers)
.reviewerUpdates(reviewerUpdates)
.attentionUpdates(attentionStatusUpdates)
.assigneeUpdates(assigneeUpdates)
.submitRecords(submitRecords)
.changeMessages(changeMessages)
@ -180,6 +186,7 @@ public abstract class ChangeNotesState {
*/
@AutoValue
abstract static class ChangeColumns {
static Builder builder() {
return new AutoValue_ChangeNotesState_ChangeColumns.Builder();
}
@ -229,6 +236,7 @@ public abstract class ChangeNotesState {
@AutoValue.Builder
abstract static class Builder {
abstract Builder changeKey(Change.Key changeKey);
abstract Builder createdOn(Timestamp createdOn);
@ -297,6 +305,8 @@ public abstract class ChangeNotesState {
abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
abstract ImmutableList<AttentionStatus> attentionUpdates();
abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
abstract ImmutableList<SubmitRecord> submitRecords();
@ -361,6 +371,7 @@ public abstract class ChangeNotesState {
@AutoValue.Builder
abstract static class Builder {
static Builder empty(Change.Id changeId) {
return new AutoValue_ChangeNotesState.Builder()
.changeId(changeId)
@ -373,6 +384,7 @@ public abstract class ChangeNotesState {
.pendingReviewersByEmail(ReviewerByEmailSet.empty())
.allPastReviewers(ImmutableList.of())
.reviewerUpdates(ImmutableList.of())
.attentionUpdates(ImmutableList.of())
.assigneeUpdates(ImmutableList.of())
.submitRecords(ImmutableList.of())
.changeMessages(ImmutableList.of())
@ -406,6 +418,8 @@ public abstract class ChangeNotesState {
abstract Builder reviewerUpdates(List<ReviewerStatusUpdate> reviewerUpdates);
abstract Builder attentionUpdates(List<AttentionStatus> attentionUpdates);
abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@ -473,6 +487,7 @@ public abstract class ChangeNotesState {
object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
object.attentionUpdates().forEach(u -> b.addAttentionStatus(toAttentionStatusProto(u)));
object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
object
.submitRecords()
@ -556,6 +571,15 @@ public abstract class ChangeNotesState {
.build();
}
private static AttentionStatusProto toAttentionStatusProto(AttentionStatus attentionStatus) {
return AttentionStatusProto.newBuilder()
.setDate(attentionStatus.timestamp().toEpochMilli())
.setAccount(attentionStatus.account().get())
.setOperation(attentionStatus.operation().name())
.setReason(attentionStatus.reason())
.build();
}
private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
AssigneeStatusUpdateProto.Builder builder =
AssigneeStatusUpdateProto.newBuilder()
@ -596,6 +620,7 @@ public abstract class ChangeNotesState {
.allPastReviewers(
proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
.reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
.attentionUpdates(toAttentionUpdateList(proto.getAttentionStatusList()))
.assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
.submitRecords(
proto.getSubmitRecordList().stream()
@ -694,6 +719,20 @@ public abstract class ChangeNotesState {
return b.build();
}
private static ImmutableList<AttentionStatus> toAttentionUpdateList(
List<AttentionStatusProto> protos) {
ImmutableList.Builder<AttentionStatus> b = ImmutableList.builder();
for (AttentionStatusProto proto : protos) {
b.add(
AttentionStatus.createFromRead(
Instant.ofEpochMilli(proto.getDate()),
Account.id(proto.getAccount()),
AttentionStatus.Operation.valueOf(proto.getOperation()),
proto.getReason()));
}
return b.build();
}
private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
List<AssigneeStatusUpdateProto> protos) {
ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();

View File

@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
@ -52,6 +53,7 @@ import com.google.common.collect.Table;
import com.google.common.collect.TreeBasedTable;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.Project;
@ -125,6 +127,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
private String submissionId;
private String topic;
private String commit;
private List<AttentionStatus> attentionUpdates;
private Optional<Account.Id> assignee;
private Set<String> hashtags;
private String changeMessage;
@ -368,6 +371,21 @@ public class ChangeUpdate extends AbstractChangeUpdate {
this.hashtags = hashtags;
}
/**
* All updates must have a timestamp of null since we use the commit's timestamp. There also must
* not be multiple updates for a single user.
*/
void setAttentionUpdates(List<AttentionStatus> attentionUpdates) {
checkArgument(
attentionUpdates.stream().noneMatch(x -> x.timestamp() != null),
"must not specify timestamp for write");
checkArgument(
attentionUpdates.stream().map(AttentionStatus::account).distinct().count()
== attentionUpdates.size(),
"must not specify multiple updates for single user");
this.attentionUpdates = attentionUpdates;
}
public void setAssignee(Account.Id assignee) {
checkArgument(assignee != null, "use removeAssignee");
this.assignee = Optional.of(assignee);
@ -578,6 +596,12 @@ public class ChangeUpdate extends AbstractChangeUpdate {
addFooter(msg, FOOTER_COMMIT, commit);
}
if (attentionUpdates != null) {
for (AttentionStatus attentionUpdate : attentionUpdates) {
addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionStatusToJson(attentionUpdate));
}
}
if (assignee != null) {
if (assignee.isPresent()) {
addFooter(msg, FOOTER_ASSIGNEE);
@ -713,6 +737,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
&& status == null
&& submissionId == null
&& submitRecords == null
&& attentionUpdates == null
&& assignee == null
&& hashtags == null
&& topic == null
@ -774,12 +799,8 @@ public class ChangeUpdate extends AbstractChangeUpdate {
}
private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
PersonIdent ident = newIdent(accountId, when);
PersonIdent.appendSanitized(sb, ident.getName());
sb.append(" <");
PersonIdent.appendSanitized(sb, ident.getEmailAddress());
sb.append('>');
PersonIdent ident = noteUtil.newIdent(accountId, when, serverIdent);
ChangeNoteUtil.appendIdentString(sb, ident.getName(), ident.getEmailAddress());
return sb;
}
}

View File

@ -3065,7 +3065,7 @@ public class ChangeIT extends AbstractDaemonTest {
assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
PersonIdent expectedAuthor =
changeNoteUtil.newIdent(getAccount(admin.id()), c.updated, serverIdent.get());
changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.updated, serverIdent.get());
assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
assertThat(commitPatchSetCreation.getCommitterIdent())
.isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@ -3074,7 +3074,7 @@ public class ChangeIT extends AbstractDaemonTest {
RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
expectedAuthor =
changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
assertThat(commitChangeCreation.getCommitterIdent())
.isEqualTo(new PersonIdent(serverIdent.get(), c.created));

View File

@ -1071,7 +1071,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
PersonIdent committer = serverIdent.get();
PersonIdent author =
noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
tr.branch(RefNames.changeMetaRef(cd3.getId()))
.commit()
.author(author)

View File

@ -415,7 +415,7 @@ public class CreateChangeIT extends AbstractDaemonTest {
assertThat(commit.getShortMessage()).isEqualTo("Create change");
PersonIdent expectedAuthor =
changeNoteUtil.newIdent(getAccount(admin.id()), c.created, serverIdent.get());
changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
assertThat(commit.getCommitterIdent())

View File

@ -830,7 +830,8 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
PersonIdent committer = serverIdent.get();
PersonIdent author = noteUtil.newIdent(getAccount(admin.id()), committer.getWhen(), committer);
PersonIdent author =
noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
serverSideTestRepo
.branch(RefNames.changeMetaRef(id))
.commit()

View File

@ -518,7 +518,9 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
private RevCommit writeCommit(String body) throws Exception {
ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
return writeCommit(
body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
body,
noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
false);
}
private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
@ -529,7 +531,7 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
return writeCommit(
body,
noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
initWorkInProgress);
}

View File

@ -28,6 +28,7 @@ import com.google.common.collect.Iterables;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.Comment;
@ -44,6 +45,7 @@ import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionStatusProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
@ -55,6 +57,7 @@ import com.google.inject.TypeLiteral;
import com.google.protobuf.ByteString;
import java.lang.reflect.Type;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -64,6 +67,7 @@ import org.junit.Before;
import org.junit.Test;
public class ChangeNotesStateTest {
private static final Change.Id ID = Change.id(123);
private static final ObjectId SHA =
ObjectId.fromString("1234567812345678123456781234567812345678");
@ -95,6 +99,13 @@ public class ChangeNotesStateTest {
return ChangeNotesState.Builder.empty(ID).metaId(SHA).columns(cols);
}
private ChangeNotesStateProto.Builder newProtoBuilder() {
return ChangeNotesStateProto.newBuilder()
.setMetaId(SHA_BYTES)
.setChangeId(ID.get())
.setColumns(colsProto);
}
@Test
public void serializeChangeKey() throws Exception {
assertRoundTrip(
@ -588,6 +599,39 @@ public class ChangeNotesStateTest {
.build());
}
@Test
public void serializeAttentionUpdates() throws Exception {
assertRoundTrip(
newBuilder()
.attentionUpdates(
ImmutableList.of(
AttentionStatus.createFromRead(
Instant.EPOCH.plusSeconds(23),
Account.id(1000),
AttentionStatus.Operation.ADD,
"reason 1"),
AttentionStatus.createFromRead(
Instant.EPOCH.plusSeconds(42),
Account.id(2000),
AttentionStatus.Operation.REMOVE,
"reason 2")))
.build(),
newProtoBuilder()
.addAttentionStatus(
AttentionStatusProto.newBuilder()
.setDate(23_000) // epoch millis
.setAccount(1000)
.setOperation("ADD")
.setReason("reason 1"))
.addAttentionStatus(
AttentionStatusProto.newBuilder()
.setDate(42_000) // epoch millis
.setAccount(2000)
.setOperation("REMOVE")
.setReason("reason 2"))
.build());
}
@Test
public void serializeAssigneeUpdates() throws Exception {
assertRoundTrip(
@ -744,6 +788,9 @@ public class ChangeNotesStateTest {
.put(
"reviewerUpdates",
new TypeLiteral<ImmutableList<ReviewerStatusUpdate>>() {}.getType())
.put(
"attentionUpdates",
new TypeLiteral<ImmutableList<AttentionStatus>>() {}.getType())
.put(
"assigneeUpdates",
new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
@ -824,10 +871,10 @@ public class ChangeNotesStateTest {
.hasFields(
ImmutableMap.of(
"table",
new TypeLiteral<
ImmutableTable<
ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
"accounts", new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
new TypeLiteral<
ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
"accounts",
new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
}
@Test
@ -836,9 +883,10 @@ public class ChangeNotesStateTest {
.hasFields(
ImmutableMap.of(
"table",
new TypeLiteral<
ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
"users", new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
new TypeLiteral<
ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
"users",
new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
}
@Test

View File

@ -38,6 +38,8 @@ import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionStatus;
import com.google.gerrit.entities.AttentionStatus.Operation;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
@ -58,6 +60,7 @@ import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.TestChanges;
import com.google.inject.Inject;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -687,6 +690,92 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
assertThat(updated.getObjectId()).isEqualTo(initial.getObjectId());
}
@Test
public void defaultAttentionSetIsEmpty() throws Exception {
Change c = newChange();
ChangeNotes notes = newNotes(c);
assertThat(notes.getAttentionUpdates()).isEmpty();
}
@Test
public void addAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
AttentionStatus attentionStatus =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
update.setAttentionUpdates(ImmutableList.of(attentionStatus));
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getAttentionUpdates()).containsExactly(addTimestamp(attentionStatus, c));
}
@Test
public void filterLatestAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
AttentionStatus attentionStatus =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
update.setAttentionUpdates(ImmutableList.of(attentionStatus));
update.commit();
update = newUpdate(c, changeOwner);
attentionStatus =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
update.setAttentionUpdates(ImmutableList.of(attentionStatus));
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getAttentionUpdates()).containsExactly(addTimestamp(attentionStatus, c));
}
@Test
public void addAttentionStatus_rejectTimestamp() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
AttentionStatus attentionStatus =
AttentionStatus.createFromRead(
Instant.now(), changeOwner.getAccountId(), Operation.ADD, "test");
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> update.setAttentionUpdates(ImmutableList.of(attentionStatus)));
assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
}
@Test
public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
AttentionStatus attentionStatus0 =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
AttentionStatus attentionStatus1 =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> update.setAttentionUpdates(ImmutableList.of(attentionStatus0, attentionStatus1)));
assertThat(thrown)
.hasMessageThat()
.contains("must not specify multiple updates for single user");
}
@Test
public void addAttentionStatusForMultipleUsers() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
AttentionStatus attentionStatus0 =
AttentionStatus.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
AttentionStatus attentionStatus1 =
AttentionStatus.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
update.setAttentionUpdates(ImmutableList.of(attentionStatus0, attentionStatus1));
update.commit();
ChangeNotes notes = newNotes(c);
assertThat(notes.getAttentionUpdates())
.containsExactly(addTimestamp(attentionStatus0, c), addTimestamp(attentionStatus1, c));
}
@Test
public void assigneeCommit() throws Exception {
Change c = newChange();
@ -3140,4 +3229,13 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
update.commit();
return tr.parseBody(commit);
}
private AttentionStatus addTimestamp(AttentionStatus attentionStatus, Change c) {
Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
return AttentionStatus.createFromRead(
timestamp.toInstant(),
attentionStatus.account(),
attentionStatus.operation(),
attentionStatus.reason());
}
}

View File

@ -75,7 +75,7 @@ message ChangeNotesKeyProto {
// Instead, we just take the tedious yet simple approach of having a "has_foo"
// field for each nullable field "foo", indicating whether or not foo is null.
//
// Next ID: 23
// Next ID: 24
message ChangeNotesStateProto {
// Effectively required, even though the corresponding ChangeNotesState field
// is optional, since the field is only absent when NoteDb is disabled, in
@ -133,7 +133,7 @@ message ChangeNotesStateProto {
// which case attempting to use the ChangeNotesCache is programmer error.
ChangeColumnsProto columns = 3;
reserved 4; // past_assignee
reserved 4; // past_assignee
repeated string hashtag = 5;
@ -167,6 +167,7 @@ message ChangeNotesStateProto {
// Next ID: 5
message ReviewerStatusUpdateProto {
// Epoch millis.
int64 date = 1;
int32 updated_by = 2;
int32 reviewer = 3;
@ -194,14 +195,26 @@ message ChangeNotesStateProto {
bool has_server_id = 21;
message AssigneeStatusUpdateProto {
// Epoch millis.
int64 date = 1;
int32 updated_by = 2;
int32 current_assignee = 3;
bool has_current_assignee = 4;
}
repeated AssigneeStatusUpdateProto assignee_update = 22;
}
// An update to the attention set of the change. See class AttentionStatus for
// context.
message AttentionStatusProto {
// Epoch millis.
int64 date = 1;
int32 account = 2;
// Maps to enum AttentionStatus.Operation
string operation = 3;
string reason = 4;
}
repeated AttentionStatusProto attention_status = 23;
}
// Serialized form of com.google.gerrit.server.query.change.ConflictKey
message ConflictKeyProto {