com.google.gerrit.server.notedb.ChangeNotesParser.java Source code

Java tutorial

Introduction

Here is the source code for com.google.gerrit.server.notedb.ChangeNotesParser.java

Source

// 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.notedb;

import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
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_COMMIT;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
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.FOOTER_PATCH_SET_DESCRIPTION;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
import static java.util.stream.Collectors.joining;

import com.google.auto.value.AutoValue;
import com.google.common.base.Enums;
import com.google.common.base.Splitter;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import com.google.common.collect.Tables;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.metrics.Timer1;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.Comment;
import com.google.gerrit.reviewdb.client.LabelId;
import com.google.gerrit.reviewdb.client.PatchLineComment;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.mail.Address;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
import com.google.gerrit.server.util.LabelVote;
import java.io.IOException;
import java.nio.charset.Charset;
import java.sql.Timestamp;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.util.GitDateParser;
import org.eclipse.jgit.util.RawParseUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class ChangeNotesParser {
    private static final Logger log = LoggerFactory.getLogger(ChangeNotesParser.class);

    // Sentinel RevId indicating a mutable field on a patch set was parsed, but
    // the parser does not yet know its commit SHA-1.
    private static final RevId PARTIAL_PATCH_SET = new RevId("INVALID PARTIAL PATCH SET");

    @AutoValue
    abstract static class ApprovalKey {
        abstract PatchSet.Id psId();

        abstract Account.Id accountId();

        abstract String label();

        private static ApprovalKey create(PatchSet.Id psId, Account.Id accountId, String label) {
            return new AutoValue_ChangeNotesParser_ApprovalKey(psId, accountId, label);
        }
    }

    // Private final members initialized in the constructor.
    private final ChangeNoteUtil noteUtil;
    private final NoteDbMetrics metrics;
    private final Change.Id id;
    private final ObjectId tip;
    private final ChangeNotesRevWalk walk;

    // Private final but mutable members initialized in the constructor and filled
    // in during the parsing process.
    private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
    private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
    private final List<Account.Id> allPastReviewers;
    private final List<ReviewerStatusUpdate> reviewerUpdates;
    private final List<SubmitRecord> submitRecords;
    private final ListMultimap<RevId, Comment> comments;
    private final Map<PatchSet.Id, PatchSet> patchSets;
    private final Set<PatchSet.Id> deletedPatchSets;
    private final Map<PatchSet.Id, PatchSetState> patchSetStates;
    private final List<PatchSet.Id> currentPatchSets;
    private final Map<ApprovalKey, PatchSetApproval> approvals;
    private final List<PatchSetApproval> bufferedApprovals;
    private final List<ChangeMessage> allChangeMessages;
    private final ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet;

    // Non-final private members filled in during the parsing process.
    private String branch;
    private Change.Status status;
    private String topic;
    private Optional<Account.Id> assignee;
    private List<Account.Id> pastAssignees;
    private Set<String> hashtags;
    private Timestamp createdOn;
    private Timestamp lastUpdatedOn;
    private Account.Id ownerId;
    private String changeId;
    private String subject;
    private String originalSubject;
    private String submissionId;
    private String tag;
    private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
    private Timestamp readOnlyUntil;
    private Boolean isPrivate;

    ChangeNotesParser(Change.Id changeId, ObjectId tip, ChangeNotesRevWalk walk, ChangeNoteUtil noteUtil,
            NoteDbMetrics metrics) {
        this.id = changeId;
        this.tip = tip;
        this.walk = walk;
        this.noteUtil = noteUtil;
        this.metrics = metrics;
        approvals = new LinkedHashMap<>();
        bufferedApprovals = new ArrayList<>();
        reviewers = HashBasedTable.create();
        reviewersByEmail = HashBasedTable.create();
        allPastReviewers = new ArrayList<>();
        reviewerUpdates = new ArrayList<>();
        submitRecords = Lists.newArrayListWithExpectedSize(1);
        allChangeMessages = new ArrayList<>();
        changeMessagesByPatchSet = LinkedListMultimap.create();
        comments = MultimapBuilder.hashKeys().arrayListValues().build();
        patchSets = new HashMap<>();
        deletedPatchSets = new HashSet<>();
        patchSetStates = new HashMap<>();
        currentPatchSets = new ArrayList<>();
    }

    ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
        // Don't include initial parse in timer, as this might do more I/O to page
        // in the block containing most commits. Later reads are not guaranteed to
        // avoid I/O, but often should.
        walk.reset();
        walk.markStart(walk.parseCommit(tip));

        try (Timer1.Context timer = metrics.parseLatency.start(CHANGES)) {
            ChangeNotesCommit commit;
            while ((commit = walk.next()) != null) {
                parse(commit);
            }
            parseNotes();
            allPastReviewers.addAll(reviewers.rowKeySet());
            pruneReviewers();
            pruneReviewersByEmail();

            updatePatchSetStates();
            checkMandatoryFooters();
        }

        return buildState();
    }

    RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
        return revisionNoteMap;
    }

    private ChangeNotesState buildState() {
        return ChangeNotesState.create(tip.copy(), id, new Change.Key(changeId), createdOn, lastUpdatedOn, ownerId,
                branch, buildCurrentPatchSetId(), subject, topic, originalSubject, submissionId,
                assignee != null ? assignee.orElse(null) : null, status,
                Sets.newLinkedHashSet(Lists.reverse(pastAssignees)), hashtags, patchSets, buildApprovals(),
                ReviewerSet.fromTable(Tables.transpose(reviewers)),
                ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)), allPastReviewers,
                buildReviewerUpdates(), submitRecords, buildAllMessages(), buildMessagesByPatchSet(), comments,
                readOnlyUntil, isPrivate);
    }

    private PatchSet.Id buildCurrentPatchSetId() {
        // currentPatchSets are in parse order, i.e. newest first. Pick the first
        // patch set that was marked as current, excluding deleted patch sets.
        for (PatchSet.Id psId : currentPatchSets) {
            if (patchSets.containsKey(psId)) {
                return psId;
            }
        }
        return null;
    }

    private ListMultimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
        ListMultimap<PatchSet.Id, PatchSetApproval> result = MultimapBuilder.hashKeys().arrayListValues().build();
        for (PatchSetApproval a : approvals.values()) {
            if (!patchSets.containsKey(a.getPatchSetId())) {
                continue; // Patch set deleted or missing.
            } else if (allPastReviewers.contains(a.getAccountId()) && !reviewers.containsRow(a.getAccountId())) {
                continue; // Reviewer was explicitly removed.
            }
            result.put(a.getPatchSetId(), a);
        }
        for (Collection<PatchSetApproval> v : result.asMap().values()) {
            Collections.sort((List<PatchSetApproval>) v, ChangeNotes.PSA_BY_TIME);
        }
        return result;
    }

    private List<ReviewerStatusUpdate> buildReviewerUpdates() {
        List<ReviewerStatusUpdate> result = new ArrayList<>();
        HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
        for (ReviewerStatusUpdate u : Lists.reverse(reviewerUpdates)) {
            if (!Objects.equals(ownerId, u.reviewer()) && lastState.get(u.reviewer()) != u.state()) {
                result.add(u);
                lastState.put(u.reviewer(), u.state());
            }
        }
        return result;
    }

    private List<ChangeMessage> buildAllMessages() {
        return Lists.reverse(allChangeMessages);
    }

    private ListMultimap<PatchSet.Id, ChangeMessage> buildMessagesByPatchSet() {
        for (Collection<ChangeMessage> v : changeMessagesByPatchSet.asMap().values()) {
            Collections.reverse((List<ChangeMessage>) v);
        }
        return changeMessagesByPatchSet;
    }

    private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
        Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());

        createdOn = ts;
        parseTag(commit);

        if (branch == null) {
            branch = parseBranch(commit);
        }

        PatchSet.Id psId = parsePatchSetId(commit);
        PatchSetState psState = parsePatchSetState(commit);
        if (psState != null) {
            if (!patchSetStates.containsKey(psId)) {
                patchSetStates.put(psId, psState);
            }
            if (psState == PatchSetState.DELETED) {
                deletedPatchSets.add(psId);
            }
        }

        Account.Id accountId = parseIdent(commit);
        if (accountId != null) {
            ownerId = accountId;
        }
        Account.Id realAccountId = parseRealAccountId(commit, accountId);

        if (changeId == null) {
            changeId = parseChangeId(commit);
        }

        String currSubject = parseSubject(commit);
        if (currSubject != null) {
            if (subject == null) {
                subject = currSubject;
            }
            originalSubject = currSubject;
        }

        parseChangeMessage(psId, accountId, realAccountId, commit, ts);
        if (topic == null) {
            topic = parseTopic(commit);
        }

        parseHashtags(commit);
        parseAssignee(commit);

        if (submissionId == null) {
            submissionId = parseSubmissionId(commit);
        }

        ObjectId currRev = parseRevision(commit);
        if (currRev != null) {
            parsePatchSet(psId, currRev, accountId, ts);
        }
        parseGroups(psId, commit);
        parseCurrentPatchSet(psId, commit);

        if (submitRecords.isEmpty()) {
            // Only parse the most recent set of submit records; any older ones are
            // still there, but not currently used.
            parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
        }

        if (status == null) {
            status = parseStatus(commit);
        }

        // Parse approvals after status to treat approvals in the same commit as
        // "Status: merged" as non-post-submit.
        for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
            parseApproval(psId, accountId, realAccountId, ts, line);
        }

        for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
            for (String line : commit.getFooterLineValues(state.getFooterKey())) {
                parseReviewer(ts, state, line);
            }
            for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
                parseReviewerByEmail(ts, state, line);
            }
            // Don't update timestamp when a reviewer was added, matching RevewDb
            // behavior.
        }

        if (readOnlyUntil == null) {
            parseReadOnlyUntil(commit);
        }

        if (isPrivate == null) {
            parseIsPrivate(commit);
        }

        if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
            lastUpdatedOn = ts;
        }

        parseDescription(psId, commit);
    }

    private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
        return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
    }

    private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
        String branch = parseOneFooter(commit, FOOTER_BRANCH);
        return branch != null ? RefNames.fullName(branch) : null;
    }

    private String parseChangeId(ChangeNotesCommit commit) throws ConfigInvalidException {
        return parseOneFooter(commit, FOOTER_CHANGE_ID);
    }

    private String parseSubject(ChangeNotesCommit commit) throws ConfigInvalidException {
        return parseOneFooter(commit, FOOTER_SUBJECT);
    }

    private Account.Id parseRealAccountId(ChangeNotesCommit commit, Account.Id effectiveAccountId)
            throws ConfigInvalidException {
        String realUser = parseOneFooter(commit, FOOTER_REAL_USER);
        if (realUser == null) {
            return effectiveAccountId;
        }
        PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
        return noteUtil.parseIdent(ident, id);
    }

    private String parseTopic(ChangeNotesCommit commit) throws ConfigInvalidException {
        return parseOneFooter(commit, FOOTER_TOPIC);
    }

    private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey) throws ConfigInvalidException {
        List<String> footerLines = commit.getFooterLineValues(footerKey);
        if (footerLines.isEmpty()) {
            return null;
        } else if (footerLines.size() > 1) {
            throw expectedOneFooter(footerKey, footerLines);
        }
        return footerLines.get(0);
    }

    private String parseExactlyOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
            throws ConfigInvalidException {
        String line = parseOneFooter(commit, footerKey);
        if (line == null) {
            throw expectedOneFooter(footerKey, Collections.<String>emptyList());
        }
        return line;
    }

    private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
        String sha = parseOneFooter(commit, FOOTER_COMMIT);
        if (sha == null) {
            return null;
        }
        try {
            return ObjectId.fromString(sha);
        } catch (InvalidObjectIdException e) {
            ConfigInvalidException cie = invalidFooter(FOOTER_COMMIT, sha);
            cie.initCause(e);
            throw cie;
        }
    }

    private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Timestamp ts)
            throws ConfigInvalidException {
        if (accountId == null) {
            throw parseException("patch set %s requires an identified user as uploader", psId.get());
        }
        PatchSet ps = patchSets.get(psId);
        if (ps == null) {
            ps = new PatchSet(psId);
            patchSets.put(psId, ps);
        } else if (!ps.getRevision().equals(PARTIAL_PATCH_SET)) {
            if (deletedPatchSets.contains(psId)) {
                // Do not update PS details as PS was deleted and this meta data is of
                // no relevance
                return;
            }
            throw new ConfigInvalidException(String.format("Multiple revisions parsed for patch set %s: %s and %s",
                    psId.get(), patchSets.get(psId).getRevision(), rev.name()));
        }
        ps.setRevision(new RevId(rev.name()));
        ps.setUploader(accountId);
        ps.setCreatedOn(ts);
    }

    private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit) throws ConfigInvalidException {
        String groupsStr = parseOneFooter(commit, FOOTER_GROUPS);
        if (groupsStr == null) {
            return;
        }
        PatchSet ps = patchSets.get(psId);
        if (ps == null) {
            ps = new PatchSet(psId);
            ps.setRevision(PARTIAL_PATCH_SET);
            patchSets.put(psId, ps);
        } else if (!ps.getGroups().isEmpty()) {
            return;
        }
        ps.setGroups(PatchSet.splitGroups(groupsStr));
    }

    private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit) throws ConfigInvalidException {
        // This commit implies a new current patch set if either it creates a new
        // patch set, or sets the current field explicitly.
        boolean current = false;
        if (parseOneFooter(commit, FOOTER_COMMIT) != null) {
            current = true;
        } else {
            String currentStr = parseOneFooter(commit, FOOTER_CURRENT);
            if (Boolean.TRUE.toString().equalsIgnoreCase(currentStr)) {
                current = true;
            } else if (currentStr != null) {
                // Only "true" is allowed; unsetting the current patch set makes no
                // sense.
                throw invalidFooter(FOOTER_CURRENT, currentStr);
            }
        }
        if (current) {
            currentPatchSets.add(psId);
        }
    }

    private void parseHashtags(ChangeNotesCommit commit) throws ConfigInvalidException {
        // Commits are parsed in reverse order and only the last set of hashtags
        // should be used.
        if (hashtags != null) {
            return;
        }
        List<String> hashtagsLines = commit.getFooterLineValues(FOOTER_HASHTAGS);
        if (hashtagsLines.isEmpty()) {
            return;
        } else if (hashtagsLines.size() > 1) {
            throw expectedOneFooter(FOOTER_HASHTAGS, hashtagsLines);
        } else if (hashtagsLines.get(0).isEmpty()) {
            hashtags = ImmutableSet.of();
        } else {
            hashtags = Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
        }
    }

    private void parseAssignee(ChangeNotesCommit commit) throws ConfigInvalidException {
        if (pastAssignees == null) {
            pastAssignees = Lists.newArrayList();
        }
        String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
        if (assigneeValue != null) {
            Optional<Account.Id> parsedAssignee;
            if (assigneeValue.equals("")) {
                // Empty footer found, assignee deleted
                parsedAssignee = Optional.empty();
            } else {
                PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
                parsedAssignee = Optional.ofNullable(noteUtil.parseIdent(ident, id));
            }
            if (assignee == null) {
                assignee = parsedAssignee;
            }
            if (parsedAssignee.isPresent()) {
                pastAssignees.add(parsedAssignee.get());
            }
        }
    }

    private void parseTag(ChangeNotesCommit commit) throws ConfigInvalidException {
        tag = null;
        List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
        if (tagLines.isEmpty()) {
            return;
        } else if (tagLines.size() == 1) {
            tag = tagLines.get(0);
        } else {
            throw expectedOneFooter(FOOTER_TAG, tagLines);
        }
    }

    private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
        List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
        if (statusLines.isEmpty()) {
            return null;
        } else if (statusLines.size() > 1) {
            throw expectedOneFooter(FOOTER_STATUS, statusLines);
        }
        Change.Status status = Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
        if (status == null) {
            throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
        }
        // All approvals after MERGED and before the next status change get the postSubmit
        // bit. (Currently the state can't change from MERGED to something else, but just in case.) The
        // exception is the legacy SUBM approval, which is never considered post-submit, but might end
        // up sorted after the submit during rebuilding.
        if (status == Change.Status.MERGED) {
            for (PatchSetApproval psa : bufferedApprovals) {
                if (!psa.isLegacySubmit()) {
                    psa.setPostSubmit(true);
                }
            }
        }
        bufferedApprovals.clear();
        return status;
    }

    private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit) throws ConfigInvalidException {
        String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
        int s = psIdLine.indexOf(' ');
        String psIdStr = s < 0 ? psIdLine : psIdLine.substring(0, s);
        Integer psId = Ints.tryParse(psIdStr);
        if (psId == null) {
            throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
        }
        return new PatchSet.Id(id, psId);
    }

    private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
        String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
        int s = psIdLine.indexOf(' ');
        if (s < 0) {
            return null;
        }
        String withParens = psIdLine.substring(s + 1);
        if (withParens.startsWith("(") && withParens.endsWith(")")) {
            PatchSetState state = Enums.getIfPresent(PatchSetState.class,
                    withParens.substring(1, withParens.length() - 1).toUpperCase()).orNull();
            if (state != null) {
                return state;
            }
        }
        throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
    }

    private void parseDescription(PatchSet.Id psId, ChangeNotesCommit commit) throws ConfigInvalidException {
        List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
        if (descLines.isEmpty()) {
            return;
        } else if (descLines.size() == 1) {
            String desc = descLines.get(0).trim();
            PatchSet ps = patchSets.get(psId);
            if (ps == null) {
                ps = new PatchSet(psId);
                ps.setRevision(PARTIAL_PATCH_SET);
                patchSets.put(psId, ps);
            }
            if (ps.getDescription() == null) {
                ps.setDescription(desc);
            }
        } else {
            throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
        }
    }

    private void parseChangeMessage(PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId,
            ChangeNotesCommit commit, Timestamp ts) {
        byte[] raw = commit.getRawBuffer();
        int size = raw.length;
        Charset enc = RawParseUtils.parseEncoding(raw);

        int subjectStart = RawParseUtils.commitMessage(raw, 0);
        if (subjectStart < 0 || subjectStart >= size) {
            return;
        }

        int subjectEnd = RawParseUtils.endOfParagraph(raw, subjectStart);
        if (subjectEnd == size) {
            return;
        }

        int changeMessageStart;

        if (raw[subjectEnd] == '\n') {
            changeMessageStart = subjectEnd + 2; //\n\n ends paragraph
        } else if (raw[subjectEnd] == '\r') {
            changeMessageStart = subjectEnd + 4; //\r\n\r\n ends paragraph
        } else {
            return;
        }

        int ptr = size - 1;
        int changeMessageEnd = -1;
        while (ptr > changeMessageStart) {
            ptr = RawParseUtils.prevLF(raw, ptr, '\r');
            if (ptr == -1) {
                break;
            }
            if (raw[ptr] == '\n') {
                changeMessageEnd = ptr - 1;
                break;
            } else if (raw[ptr] == '\r') {
                changeMessageEnd = ptr - 3;
                break;
            }
        }

        if (ptr <= changeMessageStart) {
            return;
        }

        String changeMsgString = RawParseUtils.decode(enc, raw, changeMessageStart, changeMessageEnd + 1);
        ChangeMessage changeMessage = new ChangeMessage(new ChangeMessage.Key(psId.getParentKey(), commit.name()),
                accountId, ts, psId);
        changeMessage.setMessage(changeMsgString);
        changeMessage.setTag(tag);
        changeMessage.setRealAuthor(realAccountId);
        changeMessagesByPatchSet.put(psId, changeMessage);
        allChangeMessages.add(changeMessage);
    }

    private void parseNotes() throws IOException, ConfigInvalidException {
        ObjectReader reader = walk.getObjectReader();
        ChangeNotesCommit tipCommit = walk.parseCommit(tip);
        revisionNoteMap = RevisionNoteMap.parse(noteUtil, id, reader, NoteMap.read(reader, tipCommit),
                PatchLineComment.Status.PUBLISHED);
        Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;

        for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
            for (Comment c : e.getValue().getComments()) {
                comments.put(e.getKey(), c);
            }
        }

        for (PatchSet ps : patchSets.values()) {
            ChangeRevisionNote rn = rns.get(ps.getRevision());
            if (rn != null && rn.getPushCert() != null) {
                ps.setPushCertificate(rn.getPushCert());
            }
        }
    }

    private void parseApproval(PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts,
            String line) throws ConfigInvalidException {
        if (accountId == null) {
            throw parseException("patch set %s requires an identified user as uploader", psId.get());
        }
        PatchSetApproval psa;
        if (line.startsWith("-")) {
            psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
        } else {
            psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
        }
        bufferedApprovals.add(psa);
    }

    private PatchSetApproval parseAddApproval(PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId,
            Timestamp ts, String line) throws ConfigInvalidException {
        // There are potentially 3 accounts involved here:
        //  1. The account from the commit, which is the effective IdentifiedUser
        //     that produced the update.
        //  2. The account in the label footer itself, which is used during submit
        //     to copy other users' labels to a new patch set.
        //  3. The account in the Real-user footer, indicating that the whole
        //     update operation was executed by this user on behalf of the effective
        //     user.
        Account.Id effectiveAccountId;
        String labelVoteStr;
        int s = line.indexOf(' ');
        if (s > 0) {
            // Account in the label line (2) becomes the effective ID of the
            // approval. If there is a real user (3) different from the commit user
            // (2), we actually don't store that anywhere in this case; it's more
            // important to record that the real user (3) actually initiated submit.
            labelVoteStr = line.substring(0, s);
            PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
            checkFooter(ident != null, FOOTER_LABEL, line);
            effectiveAccountId = noteUtil.parseIdent(ident, id);
        } else {
            labelVoteStr = line;
            effectiveAccountId = committerId;
        }

        LabelVote l;
        try {
            l = LabelVote.parseWithEquals(labelVoteStr);
        } catch (IllegalArgumentException e) {
            ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
            pe.initCause(e);
            throw pe;
        }

        PatchSetApproval psa = new PatchSetApproval(
                new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(l.label())), l.value(), ts);
        psa.setTag(tag);
        if (!Objects.equals(realAccountId, committerId)) {
            psa.setRealAccountId(realAccountId);
        }
        ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, l.label());
        if (!approvals.containsKey(k)) {
            approvals.put(k, psa);
        }
        return psa;
    }

    private PatchSetApproval parseRemoveApproval(PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId,
            Timestamp ts, String line) throws ConfigInvalidException {
        // See comments in parseAddApproval about the various users involved.
        Account.Id effectiveAccountId;
        String label;
        int s = line.indexOf(' ');
        if (s > 0) {
            label = line.substring(1, s);
            PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
            checkFooter(ident != null, FOOTER_LABEL, line);
            effectiveAccountId = noteUtil.parseIdent(ident, id);
        } else {
            label = line.substring(1);
            effectiveAccountId = committerId;
        }

        try {
            LabelType.checkNameInternal(label);
        } catch (IllegalArgumentException e) {
            ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
            pe.initCause(e);
            throw pe;
        }

        // Store an actual 0-vote approval in the map for a removed approval, for
        // several reasons:
        //  - This is closer to the ReviewDb representation, which leads to less
        //    confusion and special-casing of NoteDb.
        //  - More importantly, ApprovalCopier needs an actual approval in order to
        //    block copying an earlier approval over a later delete.
        PatchSetApproval remove = new PatchSetApproval(
                new PatchSetApproval.Key(psId, effectiveAccountId, new LabelId(label)), (short) 0, ts);
        if (!Objects.equals(realAccountId, committerId)) {
            remove.setRealAccountId(realAccountId);
        }
        ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label);
        if (!approvals.containsKey(k)) {
            approvals.put(k, remove);
        }
        return remove;
    }

    private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
        SubmitRecord rec = null;

        for (String line : lines) {
            int c = line.indexOf(": ");
            if (c < 0) {
                rec = new SubmitRecord();
                submitRecords.add(rec);
                int s = line.indexOf(' ');
                String statusStr = s >= 0 ? line.substring(0, s) : line;
                rec.status = Enums.getIfPresent(SubmitRecord.Status.class, statusStr).orNull();
                checkFooter(rec.status != null, FOOTER_SUBMITTED_WITH, line);
                if (s >= 0) {
                    rec.errorMessage = line.substring(s);
                }
            } else {
                checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
                SubmitRecord.Label label = new SubmitRecord.Label();
                if (rec.labels == null) {
                    rec.labels = new ArrayList<>();
                }
                rec.labels.add(label);

                label.status = Enums.getIfPresent(SubmitRecord.Label.Status.class, line.substring(0, c)).orNull();
                checkFooter(label.status != null, FOOTER_SUBMITTED_WITH, line);
                int c2 = line.indexOf(": ", c + 2);
                if (c2 >= 0) {
                    label.label = line.substring(c + 2, c2);
                    PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
                    checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
                    label.appliedBy = noteUtil.parseIdent(ident, id);
                } else {
                    label.label = line.substring(c + 2);
                }
            }
        }
    }

    private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
        // Check if the author name/email is the same as the committer name/email,
        // i.e. was the server ident at the time this commit was made.
        PersonIdent a = commit.getAuthorIdent();
        PersonIdent c = commit.getCommitterIdent();
        if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
            return null;
        }
        return noteUtil.parseIdent(commit.getAuthorIdent(), id);
    }

    private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
            throws ConfigInvalidException {
        PersonIdent ident = RawParseUtils.parsePersonIdent(line);
        if (ident == null) {
            throw invalidFooter(state.getFooterKey(), line);
        }
        Account.Id accountId = noteUtil.parseIdent(ident, id);
        reviewerUpdates.add(ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
        if (!reviewers.containsRow(accountId)) {
            reviewers.put(accountId, state, ts);
        }
    }

    private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
            throws ConfigInvalidException {
        Address adr;
        try {
            adr = Address.parse(line);
        } catch (IllegalArgumentException e) {
            throw invalidFooter(state.getByEmailFooterKey(), line);
        }
        if (!reviewersByEmail.containsRow(adr)) {
            reviewersByEmail.put(adr, state, ts);
        }
    }

    private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
        String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
        if (raw == null) {
            return;
        }
        try {
            readOnlyUntil = new Timestamp(GitDateParser.parse(raw, null, Locale.US).getTime());
        } catch (ParseException e) {
            ConfigInvalidException cie = invalidFooter(FOOTER_READ_ONLY_UNTIL, raw);
            cie.initCause(e);
            throw cie;
        }
    }

    private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
        String raw = parseOneFooter(commit, FOOTER_PRIVATE);
        if (raw == null) {
            return;
        } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
            isPrivate = true;
            return;
        } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
            isPrivate = false;
            return;
        }
        throw invalidFooter(FOOTER_PRIVATE, raw);
    }

    private void pruneReviewers() {
        Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit = reviewers.cellSet().iterator();
        while (rit.hasNext()) {
            Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
            if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
                rit.remove();
            }
        }
    }

    private void pruneReviewersByEmail() {
        Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit = reviewersByEmail.cellSet().iterator();
        while (rit.hasNext()) {
            Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
            if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
                rit.remove();
            }
        }
    }

    private void updatePatchSetStates() {
        Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
        for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext();) {
            PatchSet ps = it.next();
            if (ps.getRevision().equals(PARTIAL_PATCH_SET)) {
                missing.add(ps.getId());
                it.remove();
            }
        }
        for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
            switch (e.getValue()) {
            case PUBLISHED:
            default:
                break;

            case DELETED:
                patchSets.remove(e.getKey());
                break;

            case DRAFT:
                PatchSet ps = patchSets.get(e.getKey());
                if (ps != null) {
                    ps.setDraft(true);
                }
                break;
            }
        }

        // Post-process other collections to remove items corresponding to deleted
        // (or otherwise missing) patch sets. This is safer than trying to prevent
        // insertion, as it will also filter out items racily added after the patch
        // set was deleted.
        changeMessagesByPatchSet.keys().retainAll(patchSets.keySet());

        int pruned = pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
        pruned += pruneEntitiesForMissingPatchSets(comments.values(), c -> new PatchSet.Id(id, c.key.patchSetId),
                missing);
        pruned += pruneEntitiesForMissingPatchSets(approvals.values(), PatchSetApproval::getPatchSetId, missing);

        if (!missing.isEmpty()) {
            log.warn("ignoring {} additional entities due to missing patch sets: {}", pruned, missing);
        }
    }

    private <T> int pruneEntitiesForMissingPatchSets(Iterable<T> ents, Function<T, PatchSet.Id> psIdFunc,
            Set<PatchSet.Id> missing) {
        int pruned = 0;
        for (Iterator<T> it = ents.iterator(); it.hasNext();) {
            PatchSet.Id psId = psIdFunc.apply(it.next());
            if (!patchSets.containsKey(psId)) {
                pruned++;
                missing.add(psId);
                it.remove();
            } else if (deletedPatchSets.contains(psId)) {
                it.remove(); // Not an error we need to report, don't increment pruned.
            }
        }
        return pruned;
    }

    private void checkMandatoryFooters() throws ConfigInvalidException {
        List<FooterKey> missing = new ArrayList<>();
        if (branch == null) {
            missing.add(FOOTER_BRANCH);
        }
        if (changeId == null) {
            missing.add(FOOTER_CHANGE_ID);
        }
        if (originalSubject == null || subject == null) {
            missing.add(FOOTER_SUBJECT);
        }
        if (!missing.isEmpty()) {
            throw parseException(
                    "Missing footers: " + missing.stream().map(FooterKey::getName).collect(joining(", ")));
        }
    }

    private ConfigInvalidException expectedOneFooter(FooterKey footer, List<String> actual) {
        return parseException("missing or multiple %s: %s", footer.getName(), actual);
    }

    private ConfigInvalidException invalidFooter(FooterKey footer, String actual) {
        return parseException("invalid %s: %s", footer.getName(), actual);
    }

    private void checkFooter(boolean expr, FooterKey footer, String actual) throws ConfigInvalidException {
        if (!expr) {
            throw invalidFooter(footer, actual);
        }
    }

    private ConfigInvalidException parseException(String fmt, Object... args) {
        return ChangeNotes.parseException(id, fmt, args);
    }
}