Store PatchSetApprovals in a git-notes-based database
Add a new ref namespace refs/changes/*/*/meta for storing the state of a Gerrit change in Git. Information about the change is mostly stored in commit messages, the entire history of which are read at load time to populate information about the change. Eventually, information tied to a particular patch set will be stored in a git note referring to the patch set's commit. This data format only allows fast access to data on a per-change basis. Lookups by other keys (e.g. approvals by user) will depend on the secondary index. The commit message format is simple: Update patch set 1 <optional cover message> Patch-Set: 1 Label: Code-Review=+1 The subject line is machine-generated and callers should attempt to make it descriptive, but this is not strictly required. The cover message is user-provided (not implemented in this change). At least the Patch-Set footer line is required for the time being; it is likely we will want to enforce there always being at least one footer to fend off user-provided cover messages that themselves contains footers. The author of the commit is the end-user performing the update, using their full name and email address. The committer is the same user, but includes the Gerrit account ID in the email address for disambiguation. (Eventually, we may decide that the (name, email) combination is enough to uniquely identify a user, but for now we still require the account ID.) On the code side, reading and writing changes from this ref is implemented with two VersionedMetaData classes: ChangeNotes implements the read operation. When a ChangeNotes is loaded, it traverses the entire history of the ref and populates the object with all relevant information. Traversing the entire history should be a fast operation on most changes, which typically have roughly tens of operations. ChangeUpdate implements the write operation. Unlike the traditional database, where operations are organized around read-modify-write operations on various entities spread across tables, a ChangeUpdate encapsulates a set of updates that can be applied atomically. Because each commit has a single author, timestamp, and patch set, these are logically restricted to operations performed by a single user on a single patch set. This corresponds better to the typical usage of the database, where a single end-user is performing an operation on a single change via a REST endpoint. ChangeNotes and ChangeUpdate are separate VersionedMetaData subclasses for a few reasons, mostly technical. First, it should not be necessary in all cases to slurp all change data via ChangeNotes before preparing a ChangeUpdate. Second, due to subtleties like LabelType sorting, it would be tricky to make ChangeNotes mutable in such a way that the result of reading it after one or more mutations would be the same as if that change were read freshly from the git data. Keeping ChangeNotes immutable and ChangeUpdate separate makes it less tempting to assume this behavior. This change implements only reading/writing for ChangeNotes/ChangeUpdate; they are not yet used in any read/write code paths in the server. Change-Id: Id28978a92f3b197c8b5be6e2db0c1e3923f2211e
This commit is contained in:
@@ -97,6 +97,7 @@ public class MetaDataUpdate {
|
|||||||
private final Project.NameKey projectName;
|
private final Project.NameKey projectName;
|
||||||
private final Repository db;
|
private final Repository db;
|
||||||
private final CommitBuilder commit;
|
private final CommitBuilder commit;
|
||||||
|
private boolean allowEmpty;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
|
public MetaDataUpdate(GitReferenceUpdated gitRefUpdated,
|
||||||
@@ -118,6 +119,10 @@ public class MetaDataUpdate {
|
|||||||
getCommitBuilder().getCommitter().getTimeZone()));
|
getCommitBuilder().getCommitter().getTimeZone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setAllowEmpty(boolean allowEmpty) {
|
||||||
|
this.allowEmpty = allowEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
/** Close the cached Repository handle. */
|
/** Close the cached Repository handle. */
|
||||||
public void close() {
|
public void close() {
|
||||||
getRepository().close();
|
getRepository().close();
|
||||||
@@ -131,6 +136,10 @@ public class MetaDataUpdate {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean allowEmpty() {
|
||||||
|
return allowEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
public CommitBuilder getCommitBuilder() {
|
public CommitBuilder getCommitBuilder() {
|
||||||
return commit;
|
return commit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ public abstract class VersionedMetaData {
|
|||||||
doSave(config, commit);
|
doSave(config, commit);
|
||||||
|
|
||||||
final ObjectId res = newTree.writeTree(inserter);
|
final ObjectId res = newTree.writeTree(inserter);
|
||||||
if (res.equals(srcTree)) {
|
if (res.equals(srcTree) && !update.allowEmpty()) {
|
||||||
// If there are no changes to the content, don't create the commit.
|
// If there are no changes to the content, don't create the commit.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// 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 com.google.gerrit.reviewdb.client.Change;
|
||||||
|
import com.google.gerrit.reviewdb.client.RefNames;
|
||||||
|
|
||||||
|
import org.eclipse.jgit.revwalk.FooterKey;
|
||||||
|
|
||||||
|
public class ChangeNoteUtil {
|
||||||
|
static final String GERRIT_PLACEHOLDER_HOST = "gerrit";
|
||||||
|
|
||||||
|
static final FooterKey FOOTER_LABEL = new FooterKey("Label");
|
||||||
|
static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-Set");
|
||||||
|
|
||||||
|
public static String changeRefName(Change.Id id) {
|
||||||
|
StringBuilder r = new StringBuilder();
|
||||||
|
r.append(RefNames.REFS_CHANGES);
|
||||||
|
int n = id.get();
|
||||||
|
int m = n % 100;
|
||||||
|
if (m < 10) {
|
||||||
|
r.append('0');
|
||||||
|
}
|
||||||
|
r.append(m);
|
||||||
|
r.append('/');
|
||||||
|
r.append(n);
|
||||||
|
r.append("/meta");
|
||||||
|
return r.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChangeNoteUtil() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// 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 static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
|
||||||
|
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
|
||||||
|
import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.base.Function;
|
||||||
|
import com.google.common.collect.ArrayListMultimap;
|
||||||
|
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.primitives.Ints;
|
||||||
|
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.reviewdb.client.PatchSetApproval;
|
||||||
|
import com.google.gerrit.reviewdb.client.PatchSetApproval.LabelId;
|
||||||
|
import com.google.gerrit.server.git.GitRepositoryManager;
|
||||||
|
import com.google.gerrit.server.git.VersionedMetaData;
|
||||||
|
import com.google.gerrit.server.util.LabelVote;
|
||||||
|
import com.google.gwtorm.server.OrmException;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
|
||||||
|
import org.eclipse.jgit.errors.ConfigInvalidException;
|
||||||
|
import org.eclipse.jgit.lib.CommitBuilder;
|
||||||
|
import org.eclipse.jgit.lib.ObjectId;
|
||||||
|
import org.eclipse.jgit.lib.Repository;
|
||||||
|
import org.eclipse.jgit.revwalk.RevCommit;
|
||||||
|
import org.eclipse.jgit.revwalk.RevWalk;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** View of a single {@link Change} based on the log of its notes branch. */
|
||||||
|
public class ChangeNotes extends VersionedMetaData {
|
||||||
|
private static final Ordering<PatchSetApproval> PSA_BY_TIME =
|
||||||
|
Ordering.natural().onResultOf(
|
||||||
|
new Function<PatchSetApproval, Timestamp>() {
|
||||||
|
@Override
|
||||||
|
public Timestamp apply(PatchSetApproval input) {
|
||||||
|
return input.getGranted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public static class Factory {
|
||||||
|
private final GitRepositoryManager repoManager;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Factory(GitRepositoryManager repoManager) {
|
||||||
|
this.repoManager = repoManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(dborowitz): Wrap fewer exceptions if/when we kill gwtorm.
|
||||||
|
public ChangeNotes load(Change change) throws OrmException {
|
||||||
|
Repository repo;
|
||||||
|
try {
|
||||||
|
repo = repoManager.openRepository(change.getProject());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new OrmException(e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ChangeNotes(repo, change);
|
||||||
|
} catch (ConfigInvalidException | IOException e) {
|
||||||
|
throw new OrmException(e);
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Change change;
|
||||||
|
private ListMultimap<PatchSet.Id, PatchSetApproval> approvals;
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
ChangeNotes(Repository repo, Change change)
|
||||||
|
throws ConfigInvalidException, IOException {
|
||||||
|
this.change = change;
|
||||||
|
load(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
|
||||||
|
return Multimaps.unmodifiableListMultimap(approvals);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getRefName() {
|
||||||
|
return ChangeNoteUtil.changeRefName(change.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLoad() throws IOException, ConfigInvalidException {
|
||||||
|
ObjectId rev = getRevision();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
protected void onSave(CommitBuilder commit) {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
getClass().getSimpleName() + " is read-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 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 static com.google.common.base.Preconditions.checkArgument;
|
||||||
|
import static com.google.common.base.Preconditions.checkNotNull;
|
||||||
|
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
|
||||||
|
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
|
||||||
|
import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.collect.Maps;
|
||||||
|
import com.google.gerrit.common.Nullable;
|
||||||
|
import com.google.gerrit.common.data.LabelType;
|
||||||
|
import com.google.gerrit.common.data.LabelTypes;
|
||||||
|
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.IdentifiedUser;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import org.eclipse.jgit.errors.ConfigInvalidException;
|
||||||
|
import org.eclipse.jgit.lib.CommitBuilder;
|
||||||
|
import org.eclipse.jgit.lib.PersonIdent;
|
||||||
|
import org.eclipse.jgit.lib.Repository;
|
||||||
|
import org.eclipse.jgit.revwalk.FooterKey;
|
||||||
|
import org.eclipse.jgit.revwalk.RevCommit;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single delta to apply atomically to a change.
|
||||||
|
* <p>
|
||||||
|
* This delta becomes a single commit on the notes branch, so there are
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
public class ChangeUpdate extends VersionedMetaData {
|
||||||
|
@Singleton
|
||||||
|
public static class Factory {
|
||||||
|
private final GitRepositoryManager repoManager;
|
||||||
|
private final MetaDataUpdate.User updateFactory;
|
||||||
|
private final ProjectCache projectCache;
|
||||||
|
private final Provider<IdentifiedUser> user;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Factory(
|
||||||
|
GitRepositoryManager repoManager,
|
||||||
|
MetaDataUpdate.User updateFactory,
|
||||||
|
ProjectCache projectCache,
|
||||||
|
Provider<IdentifiedUser> user) {
|
||||||
|
this.repoManager = repoManager;
|
||||||
|
this.updateFactory = updateFactory;
|
||||||
|
this.projectCache = projectCache;
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChangeUpdate create(Change change) {
|
||||||
|
return create(change, TimeUtil.nowTs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChangeUpdate create(Change change, Timestamp when) {
|
||||||
|
return new ChangeUpdate(
|
||||||
|
repoManager, updateFactory,
|
||||||
|
projectCache.get(change.getProject()).getLabelTypes(),
|
||||||
|
change, user.get().getAccountId(), when);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final GitRepositoryManager repoManager;
|
||||||
|
private final MetaDataUpdate.User updateFactory;
|
||||||
|
private final LabelTypes labelTypes;
|
||||||
|
private final Change change;
|
||||||
|
private final Account.Id accountId;
|
||||||
|
private final Timestamp when;
|
||||||
|
private final Map<String, Short> approvals;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChangeUpdate(GitRepositoryManager repoManager,
|
||||||
|
@Nullable MetaDataUpdate.User updateFactory, LabelTypes labelTypes,
|
||||||
|
Change change, Account.Id accountId, @Nullable Timestamp when) {
|
||||||
|
this.repoManager = repoManager;
|
||||||
|
this.updateFactory = updateFactory;
|
||||||
|
this.labelTypes = labelTypes;
|
||||||
|
this.change = change;
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.when = when;
|
||||||
|
this.approvals = Maps.newTreeMap(labelTypes.nameComparator());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putApproval(String label, short value) {
|
||||||
|
approvals.put(label, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSubject(String subject) {
|
||||||
|
this.subject = subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPatchSetId(PatchSet.Id psId) {
|
||||||
|
checkArgument(psId == null || psId.getParentKey().equals(change.getKey()));
|
||||||
|
this.psId = psId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RevCommit commit() throws IOException {
|
||||||
|
return commit(checkNotNull(updateFactory, "MetaDataUpdate.Factory")
|
||||||
|
.create(change.getProject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RevCommit commit(MetaDataUpdate md) throws IOException {
|
||||||
|
Repository repo = repoManager.openRepository(change.getProject());
|
||||||
|
try {
|
||||||
|
load(repo);
|
||||||
|
} catch (ConfigInvalidException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return super.commit(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getRefName() {
|
||||||
|
return ChangeNoteUtil.changeRefName(change.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSave(CommitBuilder commit) {
|
||||||
|
if (approvals.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int ps = psId != null ? psId.get() : change.currentPatchSetId().get();
|
||||||
|
StringBuilder msg = new StringBuilder();
|
||||||
|
if (subject != null) {
|
||||||
|
msg.append(subject);
|
||||||
|
} else {
|
||||||
|
msg.append("Update patch set ").append(ps);
|
||||||
|
}
|
||||||
|
msg.append("\n\n");
|
||||||
|
addFooter(msg, FOOTER_PATCH_SET, ps);
|
||||||
|
for (Map.Entry<String, Short> e : approvals.entrySet()) {
|
||||||
|
LabelType lt = labelTypes.byLabel(e.getKey());
|
||||||
|
if (lt != null) {
|
||||||
|
addFooter(msg, FOOTER_LABEL,
|
||||||
|
new LabelVote(lt.getName(), e.getValue()).formatWithEquals());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
commit.setMessage(msg.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addFooter(StringBuilder sb, FooterKey footer,
|
||||||
|
Object value) {
|
||||||
|
sb.append(footer.getName()).append(": ").append(value).append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onLoad() throws IOException, ConfigInvalidException {
|
||||||
|
// Do nothing; just reads current revision.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// 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 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.Iterables;
|
||||||
|
import com.google.common.collect.ListMultimap;
|
||||||
|
import com.google.gerrit.common.data.LabelTypes;
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.reviewdb.client.Branch;
|
||||||
|
import com.google.gerrit.reviewdb.client.Change;
|
||||||
|
import com.google.gerrit.reviewdb.client.PatchSet;
|
||||||
|
import com.google.gerrit.reviewdb.client.PatchSetApproval;
|
||||||
|
import com.google.gerrit.reviewdb.client.PatchSetInfo;
|
||||||
|
import com.google.gerrit.reviewdb.client.Project;
|
||||||
|
import com.google.gerrit.server.ChangeUtil;
|
||||||
|
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.InMemoryRepositoryManager;
|
||||||
|
|
||||||
|
import org.eclipse.jgit.errors.ConfigInvalidException;
|
||||||
|
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
|
||||||
|
import org.eclipse.jgit.lib.PersonIdent;
|
||||||
|
import org.eclipse.jgit.revwalk.RevCommit;
|
||||||
|
import org.eclipse.jgit.revwalk.RevWalk;
|
||||||
|
import org.joda.time.DateTimeUtils;
|
||||||
|
import org.joda.time.DateTimeUtils.MillisProvider;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
public class ChangeNotesTest {
|
||||||
|
private static final TimeZone TZ =
|
||||||
|
TimeZone.getTimeZone("America/Los_Angeles");
|
||||||
|
|
||||||
|
private LabelTypes LABEL_TYPES = new LabelTypes(ImmutableList.of(
|
||||||
|
category("Verified",
|
||||||
|
value(1, "Verified"),
|
||||||
|
value(0, "No score"),
|
||||||
|
value(-1, "Fails")),
|
||||||
|
category("Code-Review",
|
||||||
|
value(1, "Looks Good To Me"),
|
||||||
|
value(0, "No score"),
|
||||||
|
value(-1, "Do Not Submit"))));
|
||||||
|
|
||||||
|
private Project.NameKey project;
|
||||||
|
private InMemoryRepositoryManager repoManager;
|
||||||
|
private InMemoryRepository repo;
|
||||||
|
private volatile long clockStepMs;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setUp() throws Exception {
|
||||||
|
project = new Project.NameKey("test-project");
|
||||||
|
repoManager = new InMemoryRepositoryManager();
|
||||||
|
repo = repoManager.createRepository(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setMillisProvider() {
|
||||||
|
clockStepMs = MILLISECONDS.convert(1, SECONDS);
|
||||||
|
final AtomicLong clockMs = new AtomicLong(
|
||||||
|
MILLISECONDS.convert(ChangeUtil.SORT_KEY_EPOCH_MINS, MINUTES)
|
||||||
|
+ MILLISECONDS.convert(60, DAYS));
|
||||||
|
|
||||||
|
DateTimeUtils.setCurrentMillisProvider(new MillisProvider() {
|
||||||
|
@Override
|
||||||
|
public long getMillis() {
|
||||||
|
return clockMs.getAndAdd(clockStepMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void resetMillisProvider() {
|
||||||
|
DateTimeUtils.setCurrentMillisSystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void approvalsCommitFormat() throws Exception {
|
||||||
|
Change c = newChange(5);
|
||||||
|
ChangeUpdate update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) -1);
|
||||||
|
update.putApproval("Verified", (short) 1);
|
||||||
|
commit(update);
|
||||||
|
assertEquals("refs/changes/01/1/meta", update.getRefName());
|
||||||
|
|
||||||
|
RevWalk walk = new RevWalk(repo);
|
||||||
|
try {
|
||||||
|
RevCommit commit = walk.parseCommit(update.getRevision());
|
||||||
|
walk.parseBody(commit);
|
||||||
|
assertEquals("Update patch set 1\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "Patch-Set: 1\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(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(author.getWhen(), committer.getWhen());
|
||||||
|
assertEquals(author.getTimeZone(), committer.getTimeZone());
|
||||||
|
} finally {
|
||||||
|
walk.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void approvalsOnePatchSet() throws Exception {
|
||||||
|
Change c = newChange(5);
|
||||||
|
ChangeUpdate update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) -1);
|
||||||
|
update.putApproval("Verified", (short) 1);
|
||||||
|
commit(update);
|
||||||
|
|
||||||
|
ChangeNotes notes = newNotes(c);
|
||||||
|
assertEquals(1, notes.getApprovals().keySet().size());
|
||||||
|
List<PatchSetApproval> psas =
|
||||||
|
notes.getApprovals().get(c.currentPatchSetId());
|
||||||
|
assertEquals(2, psas.size());
|
||||||
|
|
||||||
|
assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
|
||||||
|
assertEquals(5, 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("Code-Review", psas.get(1).getLabel());
|
||||||
|
assertEquals((short) -1, psas.get(1).getValue());
|
||||||
|
assertEquals(psas.get(0).getGranted(), psas.get(1).getGranted());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void approvalsMultiplePatchSets() throws Exception {
|
||||||
|
Change c = newChange(5);
|
||||||
|
ChangeUpdate update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) -1);
|
||||||
|
commit(update);
|
||||||
|
PatchSet.Id ps1 = c.currentPatchSetId();
|
||||||
|
|
||||||
|
incrementPatchSet(c);
|
||||||
|
update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) 1);
|
||||||
|
commit(update);
|
||||||
|
PatchSet.Id ps2 = c.currentPatchSetId();
|
||||||
|
|
||||||
|
ChangeNotes notes = newNotes(c);
|
||||||
|
ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
|
||||||
|
assertEquals(2, notes.getApprovals().keySet().size());
|
||||||
|
|
||||||
|
PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
|
||||||
|
assertEquals(ps1, psa1.getPatchSetId());
|
||||||
|
assertEquals(5, 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("Code-Review", psa2.getLabel());
|
||||||
|
assertEquals((short) +1, psa2.getValue());
|
||||||
|
assertEquals(truncate(after(c, 2000)), psa2.getGranted());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void approvalsMultipleApprovals() throws Exception {
|
||||||
|
Change c = newChange(5);
|
||||||
|
ChangeUpdate update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) -1);
|
||||||
|
commit(update);
|
||||||
|
|
||||||
|
ChangeNotes notes = newNotes(c);
|
||||||
|
PatchSetApproval psa = Iterables.getOnlyElement(
|
||||||
|
notes.getApprovals().get(c.currentPatchSetId()));
|
||||||
|
assertEquals("Code-Review", psa.getLabel());
|
||||||
|
assertEquals((short) -1, psa.getValue());
|
||||||
|
|
||||||
|
update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) 1);
|
||||||
|
commit(update);
|
||||||
|
|
||||||
|
notes = newNotes(c);
|
||||||
|
psa = Iterables.getOnlyElement(
|
||||||
|
notes.getApprovals().get(c.currentPatchSetId()));
|
||||||
|
assertEquals("Code-Review", psa.getLabel());
|
||||||
|
assertEquals((short) 1, psa.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void approvalsMultipleUsers() throws Exception {
|
||||||
|
Change c = newChange(5);
|
||||||
|
ChangeUpdate update = newUpdate(c, c.getOwner());
|
||||||
|
update.putApproval("Code-Review", (short) -1);
|
||||||
|
commit(update);
|
||||||
|
|
||||||
|
update = newUpdate(c, new Account.Id(6));
|
||||||
|
update.putApproval("Code-Review", (short) 1);
|
||||||
|
commit(update);
|
||||||
|
|
||||||
|
ChangeNotes notes = newNotes(c);
|
||||||
|
assertEquals(1, notes.getApprovals().keySet().size());
|
||||||
|
List<PatchSetApproval> psas =
|
||||||
|
notes.getApprovals().get(c.currentPatchSetId());
|
||||||
|
assertEquals(2, psas.size());
|
||||||
|
|
||||||
|
assertEquals(c.currentPatchSetId(), psas.get(0).getPatchSetId());
|
||||||
|
assertEquals(5, 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("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) {
|
||||||
|
Change.Id changeId = new Change.Id(1);
|
||||||
|
Change c = new Change(
|
||||||
|
new Change.Key("Iabcd1234abcd1234abcd1234abcd1234abcd1234"),
|
||||||
|
changeId,
|
||||||
|
new Account.Id(accountId),
|
||||||
|
new Branch.NameKey(project, "master"),
|
||||||
|
TimeUtil.nowTs());
|
||||||
|
incrementPatchSet(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChangeUpdate newUpdate(Change c, Account.Id owner)
|
||||||
|
throws ConfigInvalidException, IOException {
|
||||||
|
return new ChangeUpdate(repoManager, LABEL_TYPES, c, owner, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChangeNotes newNotes(Change c)
|
||||||
|
throws ConfigInvalidException, IOException {
|
||||||
|
return new ChangeNotes(repo, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void incrementPatchSet(Change change) {
|
||||||
|
PatchSet.Id curr = change.currentPatchSetId();
|
||||||
|
PatchSetInfo ps = new PatchSetInfo(new PatchSet.Id(
|
||||||
|
change.getId(), curr != null ? curr.get() + 1 : 1));
|
||||||
|
ps.setSubject("Change subject");
|
||||||
|
change.setCurrentPatchSet(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Timestamp truncate(Timestamp ts) {
|
||||||
|
return new Timestamp((ts.getTime() / 1000) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Timestamp after(Change c, long millis) {
|
||||||
|
return new Timestamp(c.getCreatedOn().getTime() + millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
return update.commit(md);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user