blob: 80b7190df729223e53576927cde2bcff250340f3 [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.change;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import com.google.auto.value.AutoValue;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.extensions.common.ProblemInfo.Status;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.Accounts;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.PatchSetState;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.plugincontext.PluginItemContext;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
/**
* Checks changes for various kinds of inconsistency and corruption.
*
* <p>A single instance may be reused for checking multiple changes, but not concurrently.
*/
public class ConsistencyChecker {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@AutoValue
public abstract static class Result {
private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
return new AutoValue_ConsistencyChecker_Result(
notes.getChangeId(), notes.getChange(), problems);
}
public abstract Change.Id id();
@Nullable
public abstract Change change();
public abstract List<ProblemInfo> problems();
}
private final ChangeNotes.Factory notesFactory;
private final Accounts accounts;
private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
private final GitRepositoryManager repoManager;
private final PatchSetInfoFactory patchSetInfoFactory;
private final PatchSetInserter.Factory patchSetInserterFactory;
private final PatchSetUtil psUtil;
private final Provider<CurrentUser> user;
private final Provider<PersonIdent> serverIdent;
private final RetryHelper retryHelper;
private BatchUpdate.Factory updateFactory;
private FixInput fix;
private ChangeNotes notes;
private Repository repo;
private RevWalk rw;
private ObjectInserter oi;
private RevCommit tip;
private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
private PatchSet currPs;
private RevCommit currPsCommit;
private List<ProblemInfo> problems;
@Inject
ConsistencyChecker(
@GerritPersonIdent Provider<PersonIdent> serverIdent,
ChangeNotes.Factory notesFactory,
Accounts accounts,
PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
GitRepositoryManager repoManager,
PatchSetInfoFactory patchSetInfoFactory,
PatchSetInserter.Factory patchSetInserterFactory,
PatchSetUtil psUtil,
Provider<CurrentUser> user,
RetryHelper retryHelper) {
this.accounts = accounts;
this.accountPatchReviewStore = accountPatchReviewStore;
this.notesFactory = notesFactory;
this.patchSetInfoFactory = patchSetInfoFactory;
this.patchSetInserterFactory = patchSetInserterFactory;
this.psUtil = psUtil;
this.repoManager = repoManager;
this.retryHelper = retryHelper;
this.serverIdent = serverIdent;
this.user = user;
reset();
}
private void reset() {
updateFactory = null;
notes = null;
repo = null;
rw = null;
problems = new ArrayList<>();
}
private Change change() {
return notes.getChange();
}
public Result check(ChangeNotes notes, @Nullable FixInput f) {
requireNonNull(notes);
try {
return retryHelper.execute(
buf -> {
try {
reset();
this.updateFactory = buf;
this.notes = notes;
fix = f;
checkImpl();
return result();
} finally {
if (rw != null) {
rw.getObjectReader().close();
rw.close();
oi.close();
}
if (repo != null) {
repo.close();
}
}
});
} catch (RestApiException e) {
return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
} catch (UpdateException e) {
return logAndReturnOneProblem(e, notes, "Error checking change");
}
}
private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) {
logger.atWarning().withCause(e).log("Error checking change %s", notes.getChangeId());
return Result.create(notes, ImmutableList.of(problem(problem)));
}
private void checkImpl() {
checkOwner();
checkCurrentPatchSetEntity();
// All checks that require the repo.
if (!openRepo()) {
return;
}
if (!checkPatchSets()) {
return;
}
checkMerged();
}
private void checkOwner() {
try {
if (!accounts.get(change().getOwner()).isPresent()) {
problem("Missing change owner: " + change().getOwner());
}
} catch (IOException | ConfigInvalidException e) {
error("Failed to look up owner", e);
}
}
private void checkCurrentPatchSetEntity() {
try {
currPs = psUtil.current(notes);
if (currPs == null) {
problem(
String.format("Current patch set %d not found", change().currentPatchSetId().get()));
}
} catch (StorageException e) {
error("Failed to look up current patch set", e);
}
}
private boolean openRepo() {
Project.NameKey project = change().getDest().getParentKey();
try {
repo = repoManager.openRepository(project);
oi = repo.newObjectInserter();
rw = new RevWalk(oi.newReader());
return true;
} catch (RepositoryNotFoundException e) {
return error("Destination repository not found: " + project, e);
} catch (IOException e) {
return error("Failed to open repository: " + project, e);
}
}
private boolean checkPatchSets() {
List<PatchSet> all;
try {
// Iterate in descending order.
all = PS_ID_ORDER.sortedCopy(psUtil.byChange(notes));
} catch (StorageException e) {
return error("Failed to look up patch sets", e);
}
patchSetsBySha = MultimapBuilder.hashKeys(all.size()).treeSetValues(PS_ID_ORDER).build();
Map<String, Ref> refs;
try {
refs =
repo.getRefDatabase()
.exactRef(all.stream().map(ps -> ps.getId().toRefName()).toArray(String[]::new));
} catch (IOException e) {
error("error reading refs", e);
refs = Collections.emptyMap();
}
List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
for (PatchSet ps : all) {
// Check revision format.
int psNum = ps.getId().get();
String refName = ps.getId().toRefName();
ObjectId objId = parseObjectId(ps.getRevision().get(), "patch set " + psNum);
if (objId == null) {
continue;
}
patchSetsBySha.put(objId, ps);
// Check ref existence.
ProblemInfo refProblem = null;
Ref ref = refs.get(refName);
if (ref == null) {
refProblem = problem("Ref missing: " + refName);
} else if (!objId.equals(ref.getObjectId())) {
String actual = ref.getObjectId() != null ? ref.getObjectId().name() : "null";
refProblem =
problem(
String.format(
"Expected %s to point to %s, found %s", ref.getName(), objId.name(), actual));
}
// Check object existence.
RevCommit psCommit = parseCommit(objId, String.format("patch set %d", psNum));
if (psCommit == null) {
if (fix != null && fix.deletePatchSetIfCommitMissing) {
deletePatchSetOps.add(new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
}
continue;
} else if (refProblem != null && fix != null) {
fixPatchSetRef(refProblem, ps);
}
if (ps.getId().equals(change().currentPatchSetId())) {
currPsCommit = psCommit;
}
}
// Delete any bad patch sets found above, in a single update.
deletePatchSets(deletePatchSetOps);
// Check for duplicates.
for (Map.Entry<ObjectId, Collection<PatchSet>> e : patchSetsBySha.asMap().entrySet()) {
if (e.getValue().size() > 1) {
problem(
String.format(
"Multiple patch sets pointing to %s: %s",
e.getKey().name(), Collections2.transform(e.getValue(), PatchSet::getPatchSetId)));
}
}
return currPs != null && currPsCommit != null;
}
private void checkMerged() {
String refName = change().getDest().get();
Ref dest;
try {
dest = repo.getRefDatabase().exactRef(refName);
} catch (IOException e) {
problem("Failed to look up destination ref: " + refName);
return;
}
if (dest == null) {
problem("Destination ref not found (may be new branch): " + refName);
return;
}
tip = parseCommit(dest.getObjectId(), "destination ref " + refName);
if (tip == null) {
return;
}
if (fix != null && fix.expectMergedAs != null) {
checkExpectMergedAs();
} else {
boolean merged;
try {
merged = rw.isMergedInto(currPsCommit, tip);
} catch (IOException e) {
problem("Error checking whether patch set " + currPs.getId().get() + " is merged");
return;
}
checkMergedBitMatchesStatus(currPs.getId(), currPsCommit, merged);
}
}
private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
String refName = change().getDest().get();
return problem(
formatProblemMessage(
"Patch set %d (%s) is merged into destination ref %s (%s), but change"
+ " status is %s",
psId.get(), commit.name(), refName, tip.name()));
}
private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit, boolean merged) {
String refName = change().getDest().get();
if (merged && !change().isMerged()) {
ProblemInfo p = wrongChangeStatus(psId, commit);
if (fix != null) {
fixMerged(p);
}
} else if (!merged && change().isMerged()) {
problem(
formatProblemMessage(
"Patch set %d (%s) is not merged into"
+ " destination ref %s (%s), but change status is %s",
currPs.getId().get(), commit.name(), refName, tip.name()));
}
}
private String formatProblemMessage(
String message, int psId, String commitName, String refName, String tipName) {
return String.format(
message,
psId,
commitName,
refName,
tipName,
ChangeUtil.status(change()).toUpperCase(Locale.US));
}
private void checkExpectMergedAs() {
ObjectId objId = parseObjectId(fix.expectMergedAs, "expected merged commit");
RevCommit commit = parseCommit(objId, "expected merged commit");
if (commit == null) {
return;
}
try {
if (!rw.isMergedInto(commit, tip)) {
problem(
String.format(
"Expected merged commit %s is not merged into destination ref %s (%s)",
commit.name(), change().getDest().get(), tip.name()));
return;
}
List<PatchSet.Id> thisCommitPsIds = new ArrayList<>();
for (Ref ref : repo.getRefDatabase().getRefsByPrefix(REFS_CHANGES)) {
if (!ref.getObjectId().equals(commit)) {
continue;
}
PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
if (psId == null) {
continue;
}
try {
Change c =
notesFactory.createChecked(change().getProject(), psId.getParentKey()).getChange();
if (!c.getDest().equals(change().getDest())) {
continue;
}
} catch (StorageException e) {
warn(e);
// Include this patch set; should cause an error below, which is good.
}
thisCommitPsIds.add(psId);
}
switch (thisCommitPsIds.size()) {
case 0:
// No patch set for this commit; insert one.
rw.parseBody(commit);
String changeId =
Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
// Missing Change-Id footer is ok, but mismatched is not.
if (changeId != null && !changeId.equals(change().getKey().get())) {
problem(
String.format(
"Expected merged commit %s has Change-Id: %s, but expected %s",
commit.name(), changeId, change().getKey().get()));
return;
}
insertMergedPatchSet(commit, null, false);
break;
case 1:
// Existing patch set ref pointing to this commit.
PatchSet.Id id = thisCommitPsIds.get(0);
if (id.equals(change().currentPatchSetId())) {
// If it's the current patch set, we can just fix the status.
fixMerged(wrongChangeStatus(id, commit));
} else if (id.get() > change().currentPatchSetId().get()) {
// If it's newer than the current patch set, reuse this patch set
// ID when inserting a new merged patch set.
insertMergedPatchSet(commit, id, true);
} else {
// If it's older than the current patch set, just delete the old
// ref, and use a new ID when inserting a new merged patch set.
insertMergedPatchSet(commit, id, false);
}
break;
default:
problem(
String.format(
"Multiple patch sets for expected merged commit %s: %s",
commit.name(),
thisCommitPsIds.stream()
.sorted(comparing(PatchSet.Id::get))
.collect(toImmutableList())));
break;
}
} catch (IOException e) {
error("Error looking up expected merged commit " + fix.expectMergedAs, e);
}
}
private void insertMergedPatchSet(
final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
if (!user.get().isIdentifiedUser()) {
notFound.status = Status.FIX_FAILED;
notFound.outcome = "Must be called by an identified user to insert new patch set";
return;
}
ProblemInfo insertPatchSetProblem;
ProblemInfo deleteOldPatchSetProblem;
if (psIdToDelete == null) {
insertPatchSetProblem =
problem(
String.format(
"Expected merged commit %s has no associated patch set", commit.name()));
deleteOldPatchSetProblem = null;
} else {
String msg =
String.format(
"Expected merge commit %s corresponds to patch set %s,"
+ " not the current patch set %s",
commit.name(), psIdToDelete.get(), change().currentPatchSetId().get());
// Maybe an identical problem, but different fix.
deleteOldPatchSetProblem = reuseOldPsId ? null : problem(msg);
insertPatchSetProblem = problem(msg);
}
List<ProblemInfo> currProblems = new ArrayList<>(3);
currProblems.add(notFound);
if (deleteOldPatchSetProblem != null) {
currProblems.add(deleteOldPatchSetProblem);
}
currProblems.add(insertPatchSetProblem);
try {
PatchSet.Id psId =
(psIdToDelete != null && reuseOldPsId)
? psIdToDelete
: ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit);
try (BatchUpdate bu = newBatchUpdate()) {
bu.setRepository(repo, rw, oi);
if (psIdToDelete != null) {
// Delete the given patch set ref. If reuseOldPsId is true,
// PatchSetInserter will reinsert the same ref, making it a no-op.
bu.addOp(
notes.getChangeId(),
new BatchUpdateOp() {
@Override
public void updateRepo(RepoContext ctx) throws IOException {
ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
}
});
if (!reuseOldPsId) {
bu.addOp(
notes.getChangeId(),
new DeletePatchSetFromDbOp(requireNonNull(deleteOldPatchSetProblem), psIdToDelete));
}
}
bu.setNotify(NotifyResolver.Result.none());
bu.addOp(
notes.getChangeId(),
inserter
.setValidate(false)
.setFireRevisionCreated(false)
.setAllowClosed(true)
.setMessage("Patch set for merged commit inserted by consistency checker"));
bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
bu.execute();
}
notes = notesFactory.createChecked(inserter.getChange());
insertPatchSetProblem.status = Status.FIXED;
insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
} catch (StorageException | IOException | UpdateException | RestApiException e) {
warn(e);
for (ProblemInfo pi : currProblems) {
pi.status = Status.FIX_FAILED;
pi.outcome = "Error inserting merged patch set";
}
return;
}
}
private static class FixMergedOp implements BatchUpdateOp {
private final ProblemInfo p;
private FixMergedOp(ProblemInfo p) {
this.p = p;
}
@Override
public boolean updateChange(ChangeContext ctx) {
ctx.getChange().setStatus(Change.Status.MERGED);
ctx.getUpdate(ctx.getChange().currentPatchSetId()).fixStatus(Change.Status.MERGED);
p.status = Status.FIXED;
p.outcome = "Marked change as merged";
return true;
}
}
private void fixMerged(ProblemInfo p) {
try (BatchUpdate bu = newBatchUpdate()) {
bu.setRepository(repo, rw, oi);
bu.addOp(notes.getChangeId(), new FixMergedOp(p));
bu.execute();
} catch (UpdateException | RestApiException e) {
logger.atWarning().withCause(e).log("Error marking %s as merged", notes.getChangeId());
p.status = Status.FIX_FAILED;
p.outcome = "Error updating status to merged";
}
}
private BatchUpdate newBatchUpdate() {
return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
}
private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
try {
RefUpdate ru = repo.updateRef(ps.getId().toRefName());
ru.setForceUpdate(true);
ru.setNewObjectId(ObjectId.fromString(ps.getRevision().get()));
ru.setRefLogIdent(newRefLogIdent());
ru.setRefLogMessage("Repair patch set ref", true);
RefUpdate.Result result = ru.update();
switch (result) {
case NEW:
case FORCED:
case FAST_FORWARD:
case NO_CHANGE:
p.status = Status.FIXED;
p.outcome = "Repaired patch set ref";
return;
case IO_FAILURE:
case LOCK_FAILURE:
case NOT_ATTEMPTED:
case REJECTED:
case REJECTED_CURRENT_BRANCH:
case RENAMED:
case REJECTED_MISSING_OBJECT:
case REJECTED_OTHER_REASON:
default:
p.status = Status.FIX_FAILED;
p.outcome = "Failed to update patch set ref: " + result;
return;
}
} catch (IOException e) {
String msg = "Error fixing patch set ref";
logger.atWarning().withCause(e).log("%s %s", msg, ps.getId().toRefName());
p.status = Status.FIX_FAILED;
p.outcome = msg;
}
}
private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
try (BatchUpdate bu = newBatchUpdate()) {
bu.setRepository(repo, rw, oi);
for (DeletePatchSetFromDbOp op : ops) {
checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
bu.addOp(notes.getChangeId(), op);
}
bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
bu.execute();
} catch (NoPatchSetsWouldRemainException e) {
for (DeletePatchSetFromDbOp op : ops) {
op.p.status = Status.FIX_FAILED;
op.p.outcome = e.getMessage();
}
} catch (UpdateException | RestApiException e) {
String msg = "Error deleting patch set";
logger.atWarning().withCause(e).log("%s of change %s", msg, ops.get(0).psId.getParentKey());
for (DeletePatchSetFromDbOp op : ops) {
// Overwrite existing statuses that were set before the transaction was
// rolled back.
op.p.status = Status.FIX_FAILED;
op.p.outcome = msg;
}
}
}
private class DeletePatchSetFromDbOp implements BatchUpdateOp {
private final ProblemInfo p;
private final PatchSet.Id psId;
private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
this.p = p;
this.psId = psId;
}
@Override
public boolean updateChange(ChangeContext ctx) throws PatchSetInfoNotAvailableException {
// Delete dangling key references.
accountPatchReviewStore.run(s -> s.clearReviewed(psId));
// For NoteDb setting the state to deleted is sufficient to filter everything out.
ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
p.status = Status.FIXED;
p.outcome = "Deleted patch set";
return true;
}
}
private static class NoPatchSetsWouldRemainException extends RestApiException {
private static final long serialVersionUID = 1L;
private NoPatchSetsWouldRemainException() {
super("Cannot delete patch set; no patch sets would remain");
}
}
private class UpdateCurrentPatchSetOp implements BatchUpdateOp {
private final Set<PatchSet.Id> toDelete;
private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
toDelete = new HashSet<>();
for (DeletePatchSetFromDbOp op : deleteOps) {
toDelete.add(op.psId);
}
}
@Override
public boolean updateChange(ChangeContext ctx)
throws PatchSetInfoNotAvailableException, NoPatchSetsWouldRemainException {
if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
return false;
}
TreeSet<PatchSet.Id> all = new TreeSet<>(comparing(PatchSet.Id::get));
// Doesn't make any assumptions about the order in which deletes happen
// and whether they are seen by this op; we are already given the full set
// of patch sets that will eventually be deleted in this update.
for (PatchSet ps : psUtil.byChange(ctx.getNotes())) {
if (!toDelete.contains(ps.getId())) {
all.add(ps.getId());
}
}
if (all.isEmpty()) {
throw new NoPatchSetsWouldRemainException();
}
ctx.getChange().setCurrentPatchSet(patchSetInfoFactory.get(ctx.getNotes(), all.last()));
return true;
}
}
private PersonIdent newRefLogIdent() {
CurrentUser u = user.get();
if (u.isIdentifiedUser()) {
return u.asIdentifiedUser().newRefLogIdent();
}
return serverIdent.get();
}
private ObjectId parseObjectId(String objIdStr, String desc) {
try {
return ObjectId.fromString(objIdStr);
} catch (IllegalArgumentException e) {
problem(String.format("Invalid revision on %s: %s", desc, objIdStr));
return null;
}
}
private RevCommit parseCommit(ObjectId objId, String desc) {
try {
return rw.parseCommit(objId);
} catch (MissingObjectException e) {
problem(String.format("Object missing: %s: %s", desc, objId.name()));
} catch (IncorrectObjectTypeException e) {
problem(String.format("Not a commit: %s: %s", desc, objId.name()));
} catch (IOException e) {
problem(String.format("Failed to look up: %s: %s", desc, objId.name()));
}
return null;
}
private ProblemInfo problem(String msg) {
ProblemInfo p = new ProblemInfo();
p.message = requireNonNull(msg);
problems.add(p);
return p;
}
private ProblemInfo lastProblem() {
return problems.get(problems.size() - 1);
}
private boolean error(String msg, Throwable t) {
problem(msg);
// TODO(dborowitz): Expose stack trace to administrators.
warn(t);
return false;
}
private void warn(Throwable t) {
logger.atWarning().withCause(t).log(
"Error in consistency check of change %s", notes.getChangeId());
}
private Result result() {
return Result.create(notes, problems);
}
}