LCOV - code coverage report
Current view: top level - server/notedb - ChangeNotesParser.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 657 689 95.4 %
Date: 2022-11-19 15:00:39 Functions: 84 86 97.7 %

          Line data    Source code
       1             : // Copyright (C) 2014 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.notedb;
      16             : 
      17             : import static com.google.common.base.MoreObjects.firstNonNull;
      18             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ASSIGNEE;
      19             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
      20             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
      21             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
      22             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
      23             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
      24             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
      25             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
      26             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
      27             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
      28             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
      29             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
      30             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
      31             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
      32             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
      33             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
      34             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
      35             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
      36             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
      37             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
      38             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
      39             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
      40             : import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
      41             : import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
      42             : import static java.util.Comparator.comparing;
      43             : import static java.util.Comparator.comparingInt;
      44             : import static java.util.stream.Collectors.joining;
      45             : 
      46             : import com.google.common.base.Enums;
      47             : import com.google.common.base.Splitter;
      48             : import com.google.common.collect.HashBasedTable;
      49             : import com.google.common.collect.ImmutableListMultimap;
      50             : import com.google.common.collect.ImmutableSet;
      51             : import com.google.common.collect.ImmutableTable;
      52             : import com.google.common.collect.ListMultimap;
      53             : import com.google.common.collect.Lists;
      54             : import com.google.common.collect.Maps;
      55             : import com.google.common.collect.MultimapBuilder;
      56             : import com.google.common.collect.Sets;
      57             : import com.google.common.collect.Table;
      58             : import com.google.common.collect.Tables;
      59             : import com.google.common.flogger.FluentLogger;
      60             : import com.google.common.primitives.Ints;
      61             : import com.google.errorprone.annotations.FormatMethod;
      62             : import com.google.gerrit.common.Nullable;
      63             : import com.google.gerrit.entities.Account;
      64             : import com.google.gerrit.entities.Address;
      65             : import com.google.gerrit.entities.AttentionSetUpdate;
      66             : import com.google.gerrit.entities.Change;
      67             : import com.google.gerrit.entities.ChangeMessage;
      68             : import com.google.gerrit.entities.HumanComment;
      69             : import com.google.gerrit.entities.LabelId;
      70             : import com.google.gerrit.entities.LabelType;
      71             : import com.google.gerrit.entities.PatchSet;
      72             : import com.google.gerrit.entities.PatchSetApproval;
      73             : import com.google.gerrit.entities.RefNames;
      74             : import com.google.gerrit.entities.SubmitRecord;
      75             : import com.google.gerrit.entities.SubmitRecord.Label.Status;
      76             : import com.google.gerrit.entities.SubmitRequirementResult;
      77             : import com.google.gerrit.metrics.Timer0;
      78             : import com.google.gerrit.server.AssigneeStatusUpdate;
      79             : import com.google.gerrit.server.ReviewerByEmailSet;
      80             : import com.google.gerrit.server.ReviewerSet;
      81             : import com.google.gerrit.server.ReviewerStatusUpdate;
      82             : import com.google.gerrit.server.account.externalids.ExternalIdCache;
      83             : import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
      84             : import com.google.gerrit.server.notedb.ChangeNotesParseApprovalUtil.ParsedPatchSetApproval;
      85             : import com.google.gerrit.server.util.LabelVote;
      86             : import java.io.IOException;
      87             : import java.nio.charset.Charset;
      88             : import java.sql.Timestamp;
      89             : import java.time.Instant;
      90             : import java.util.ArrayList;
      91             : import java.util.Collection;
      92             : import java.util.Collections;
      93             : import java.util.HashMap;
      94             : import java.util.HashSet;
      95             : import java.util.Iterator;
      96             : import java.util.LinkedHashMap;
      97             : import java.util.List;
      98             : import java.util.Map;
      99             : import java.util.Objects;
     100             : import java.util.Optional;
     101             : import java.util.Set;
     102             : import java.util.TreeSet;
     103             : import java.util.function.Function;
     104             : import java.util.stream.Collectors;
     105             : import org.eclipse.jgit.errors.ConfigInvalidException;
     106             : import org.eclipse.jgit.errors.InvalidObjectIdException;
     107             : import org.eclipse.jgit.lib.ObjectId;
     108             : import org.eclipse.jgit.lib.ObjectReader;
     109             : import org.eclipse.jgit.lib.PersonIdent;
     110             : import org.eclipse.jgit.notes.NoteMap;
     111             : import org.eclipse.jgit.revwalk.FooterKey;
     112             : import org.eclipse.jgit.util.RawParseUtils;
     113             : 
     114             : /**
     115             :  * Parses {@link ChangeNotesState} out of the change meta ref.
     116             :  *
     117             :  * <p>NOTE: all changes to the change notes storage format must be both forward and backward
     118             :  * compatible, i.e.:
     119             :  *
     120             :  * <ul>
     121             :  *   <li>The server, running the new binary version must be able to parse the data, written by the
     122             :  *       previous binary version.
     123             :  *   <li>The server, running the old binary version must be able to parse the data, written by the
     124             :  *       new binary version.
     125             :  * </ul>
     126             :  *
     127             :  * <p>Thus, when introducing storage format update, the following procedure must be used:
     128             :  *
     129             :  * <ol>
     130             :  *   <li>The read path ({@link ChangeNotesParser}) needs to be updated to handle both the old and
     131             :  *       the new data format.
     132             :  *   <li>In a separate change, the write path (e.g. {@link ChangeUpdate}, {@link ChangeNoteJson}) is
     133             :  *       updated to write the new format, guarded by {@link
     134             :  *       com.google.gerrit.server.experiments.ExperimentFeatures} flag, if possible.
     135             :  *   <li>Once the 'read' change is roll out and is roll back safe, the 'write' change can be
     136             :  *       submitted/the experiment flag can be flipped.
     137             :  * </ol>
     138             :  */
     139             : class ChangeNotesParser {
     140         103 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
     141             : 
     142         103 :   private static final Splitter RULE_SPLITTER = Splitter.on(": ");
     143         103 :   private static final Splitter HASHTAG_SPLITTER = Splitter.on(",");
     144             : 
     145             :   // Private final members initialized in the constructor.
     146             :   private final ChangeNoteJson changeNoteJson;
     147             :   private final NoteDbMetrics metrics;
     148             :   private final Change.Id id;
     149             :   private final ObjectId tip;
     150             :   private final ChangeNotesRevWalk walk;
     151             : 
     152             :   // Private final but mutable members initialized in the constructor and filled
     153             :   // in during the parsing process.
     154             :   private final Table<Account.Id, ReviewerStateInternal, Instant> reviewers;
     155             :   private final Table<Address, ReviewerStateInternal, Instant> reviewersByEmail;
     156             :   private final List<Account.Id> allPastReviewers;
     157             :   private final List<ReviewerStatusUpdate> reviewerUpdates;
     158             :   /** Holds only the most recent update per user. Older updates are discarded. */
     159             :   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
     160             :   /** Holds all updates to attention set. */
     161             :   private final List<AttentionSetUpdate> allAttentionSetUpdates;
     162             : 
     163             :   private final List<AssigneeStatusUpdate> assigneeUpdates;
     164             :   private final List<SubmitRecord> submitRecords;
     165             :   private final ListMultimap<ObjectId, HumanComment> humanComments;
     166             :   private final List<SubmitRequirementResult> submitRequirementResults;
     167             :   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
     168             :   private final Set<PatchSet.Id> deletedPatchSets;
     169             :   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
     170             :   private final List<PatchSet.Id> currentPatchSets;
     171             :   private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
     172             :   private final List<PatchSetApproval.Builder> bufferedApprovals;
     173             :   private final List<ChangeMessage> allChangeMessages;
     174             : 
     175             :   // Non-final private members filled in during the parsing process.
     176             :   private String branch;
     177             :   private Change.Status status;
     178             :   private String topic;
     179             :   private Set<String> hashtags;
     180             :   private Instant createdOn;
     181             :   private Instant lastUpdatedOn;
     182             :   private Account.Id ownerId;
     183             :   private String serverId;
     184             :   private String changeId;
     185             :   private String subject;
     186             :   private String originalSubject;
     187             :   private String submissionId;
     188             :   private String tag;
     189             :   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
     190             :   private Boolean isPrivate;
     191             :   private Boolean workInProgress;
     192             :   private Boolean previousWorkInProgressFooter;
     193             :   private Boolean hasReviewStarted;
     194             :   private ReviewerSet pendingReviewers;
     195             :   private ReviewerByEmailSet pendingReviewersByEmail;
     196             :   private Change.Id revertOf;
     197             :   private int updateCount;
     198             :   // Null indicates that the field was not parsed (yet).
     199             :   // We only set the value once, based on the latest update (the actual value or Optional.empty() if
     200             :   // the latest record unsets the field).
     201             :   private Optional<PatchSet.Id> cherryPickOf;
     202             :   private Instant mergedOn;
     203             :   private final ExternalIdCache externalIdCache;
     204             :   private final String gerritServerId;
     205             : 
     206             :   ChangeNotesParser(
     207             :       Change.Id changeId,
     208             :       ObjectId tip,
     209             :       ChangeNotesRevWalk walk,
     210             :       ChangeNoteJson changeNoteJson,
     211             :       NoteDbMetrics metrics,
     212             :       String gerritServerId,
     213         103 :       ExternalIdCache externalIdCache) {
     214         103 :     this.id = changeId;
     215         103 :     this.tip = tip;
     216         103 :     this.walk = walk;
     217         103 :     this.changeNoteJson = changeNoteJson;
     218         103 :     this.metrics = metrics;
     219         103 :     this.externalIdCache = externalIdCache;
     220         103 :     this.gerritServerId = gerritServerId;
     221         103 :     approvals = new LinkedHashMap<>();
     222         103 :     bufferedApprovals = new ArrayList<>();
     223         103 :     reviewers = HashBasedTable.create();
     224         103 :     reviewersByEmail = HashBasedTable.create();
     225         103 :     pendingReviewers = ReviewerSet.empty();
     226         103 :     pendingReviewersByEmail = ReviewerByEmailSet.empty();
     227         103 :     allPastReviewers = new ArrayList<>();
     228         103 :     reviewerUpdates = new ArrayList<>();
     229         103 :     latestAttentionStatus = new HashMap<>();
     230         103 :     allAttentionSetUpdates = new ArrayList<>();
     231         103 :     assigneeUpdates = new ArrayList<>();
     232         103 :     submitRecords = Lists.newArrayListWithExpectedSize(1);
     233         103 :     allChangeMessages = new ArrayList<>();
     234         103 :     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
     235         103 :     submitRequirementResults = new ArrayList<>();
     236         103 :     patchSets = new HashMap<>();
     237         103 :     deletedPatchSets = new HashSet<>();
     238         103 :     patchSetStates = new HashMap<>();
     239         103 :     currentPatchSets = new ArrayList<>();
     240         103 :   }
     241             : 
     242             :   ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
     243             :     // Don't include initial parse in timer, as this might do more I/O to page
     244             :     // in the block containing most commits. Later reads are not guaranteed to
     245             :     // avoid I/O, but often should.
     246         103 :     walk.reset();
     247         103 :     walk.markStart(walk.parseCommit(tip));
     248             : 
     249         103 :     try (Timer0.Context timer = metrics.parseLatency.start()) {
     250             :       ChangeNotesCommit commit;
     251         103 :       while ((commit = walk.next()) != null) {
     252         103 :         parse(commit);
     253             :       }
     254         103 :       if (hasReviewStarted == null) {
     255          22 :         if (previousWorkInProgressFooter == null) {
     256           2 :           hasReviewStarted = true;
     257             :         } else {
     258          21 :           hasReviewStarted = !previousWorkInProgressFooter;
     259             :         }
     260             :       }
     261         103 :       parseNotes();
     262         103 :       allPastReviewers.addAll(reviewers.rowKeySet());
     263         103 :       pruneReviewers();
     264         103 :       pruneReviewersByEmail();
     265             : 
     266         103 :       updatePatchSetStates();
     267         103 :       checkMandatoryFooters();
     268             :     }
     269             : 
     270         103 :     return buildState();
     271             :   }
     272             : 
     273             :   RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     274         103 :     return revisionNoteMap;
     275             :   }
     276             : 
     277             :   private ChangeNotesState buildState() throws ConfigInvalidException {
     278         103 :     return ChangeNotesState.create(
     279         103 :         tip.copy(),
     280             :         id,
     281         103 :         Change.key(changeId),
     282             :         createdOn,
     283             :         lastUpdatedOn,
     284             :         ownerId,
     285             :         serverId,
     286             :         branch,
     287         103 :         buildCurrentPatchSetId(),
     288             :         subject,
     289             :         topic,
     290             :         originalSubject,
     291             :         submissionId,
     292             :         status,
     293         103 :         firstNonNull(hashtags, ImmutableSet.of()),
     294         103 :         buildPatchSets(),
     295         103 :         buildApprovals(),
     296         103 :         ReviewerSet.fromTable(Tables.transpose(reviewers)),
     297         103 :         ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
     298             :         pendingReviewers,
     299             :         pendingReviewersByEmail,
     300             :         allPastReviewers,
     301         103 :         buildReviewerUpdates(),
     302         103 :         ImmutableSet.copyOf(latestAttentionStatus.values()),
     303             :         allAttentionSetUpdates,
     304             :         assigneeUpdates,
     305             :         submitRecords,
     306         103 :         buildAllMessages(),
     307             :         humanComments,
     308             :         submitRequirementResults,
     309         103 :         firstNonNull(isPrivate, false),
     310         103 :         firstNonNull(workInProgress, false),
     311         103 :         firstNonNull(hasReviewStarted, true),
     312             :         revertOf,
     313         103 :         cherryPickOf != null ? cherryPickOf.orElse(null) : null,
     314             :         updateCount,
     315             :         mergedOn);
     316             :   }
     317             : 
     318             :   private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
     319         103 :     Map<PatchSet.Id, PatchSet> result = Maps.newHashMapWithExpectedSize(patchSets.size());
     320         103 :     for (Map.Entry<PatchSet.Id, PatchSet.Builder> e : patchSets.entrySet()) {
     321             :       try {
     322         103 :         PatchSet ps = e.getValue().build();
     323         103 :         result.put(ps.id(), ps);
     324           0 :       } catch (Exception ex) {
     325           0 :         ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
     326           0 :         cie.initCause(ex);
     327           0 :         throw cie;
     328         103 :       }
     329         103 :     }
     330         103 :     return result;
     331             :   }
     332             : 
     333             :   @Nullable
     334             :   private PatchSet.Id buildCurrentPatchSetId() {
     335             :     // currentPatchSets are in parse order, i.e. newest first. Pick the first
     336             :     // patch set that was marked as current, excluding deleted patch sets.
     337         103 :     for (PatchSet.Id psId : currentPatchSets) {
     338         103 :       if (patchSetCommitParsed(psId)) {
     339         103 :         return psId;
     340             :       }
     341           0 :     }
     342           1 :     return null;
     343             :   }
     344             : 
     345             :   private ListMultimap<PatchSet.Id, PatchSetApproval> buildApprovals() {
     346             :     ListMultimap<PatchSet.Id, PatchSetApproval> result =
     347         103 :         MultimapBuilder.hashKeys().arrayListValues().build();
     348         103 :     for (PatchSetApproval.Builder a : approvals.values()) {
     349          68 :       if (!patchSetCommitParsed(a.key().patchSetId())) {
     350           0 :         continue; // Patch set deleted or missing.
     351          68 :       } else if (allPastReviewers.contains(a.key().accountId())
     352          66 :           && !reviewers.containsRow(a.key().accountId())) {
     353           5 :         continue; // Reviewer was explicitly removed.
     354             :       }
     355          68 :       result.put(a.key().patchSetId(), a.build());
     356          68 :     }
     357         103 :     if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
     358             :       // If the change is closed, check if there are "submit records" with approvals that do not
     359             :       // exist on the latest patch-set and copy them to the latest patch-set.
     360             :       // We do not invoke this logic if any approval is copied. This is because prior to change
     361             :       // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
     362             :       // dynamically (e.g. when requesting the change page). After that change, we started
     363             :       // persisting copied votes in NoteDb, so we don't need to do this back-filling.
     364             :       // Prior to that change (318135), we could've had changes with dynamically copied approvals
     365             :       // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
     366             :       // we need to back-fill these approvals.
     367          58 :       PatchSet.Id latestPs = buildCurrentPatchSetId();
     368          58 :       backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
     369          58 :           .forEach(a -> result.put(latestPs, a));
     370             :     }
     371         103 :     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     372         103 :     return result;
     373             :   }
     374             : 
     375             :   /**
     376             :    * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
     377             :    * record exists in NoteDb when the change was merged.
     378             :    */
     379             :   private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
     380             :       ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
     381          58 :     List<PatchSetApproval> copiedApprovals = new ArrayList<>();
     382          58 :     if (latestPs == null) {
     383           0 :       return copiedApprovals;
     384             :     }
     385          58 :     List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
     386          58 :     ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
     387          58 :     List<SubmitRecord.Label> submitRecordLabels =
     388          58 :         submitRecords.stream()
     389          58 :             .filter(r -> r.labels != null)
     390          58 :             .flatMap(r -> r.labels.stream())
     391          58 :             .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
     392          58 :             .collect(Collectors.toList());
     393          58 :     for (SubmitRecord.Label recordLabel : submitRecordLabels) {
     394          48 :       String labelName = recordLabel.label;
     395          48 :       Account.Id appliedBy = recordLabel.appliedBy;
     396          48 :       if (appliedBy == null || labelName == null) {
     397           0 :         continue;
     398             :       }
     399          48 :       boolean existsAtLatestPs =
     400          48 :           approvalsOnLatestPs.stream()
     401          48 :               .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
     402          48 :       if (existsAtLatestPs) {
     403          47 :         continue;
     404             :       }
     405             :       // Search for an approval for this label on the max previous patch-set and copy the approval.
     406           5 :       Collection<PatchSetApproval> userApprovals =
     407           5 :           approvalsByUser.get(appliedBy).stream()
     408           5 :               .filter(approval -> approval.label().equals(labelName))
     409           5 :               .collect(Collectors.toList());
     410           5 :       if (userApprovals.isEmpty()) {
     411           4 :         continue;
     412             :       }
     413           1 :       PatchSetApproval lastApproved =
     414           1 :           Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
     415           1 :       copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
     416           1 :     }
     417          58 :     return copiedApprovals;
     418             :   }
     419             : 
     420             :   private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
     421          58 :     return allApprovals.values().stream().anyMatch(approval -> approval.copied());
     422             :   }
     423             : 
     424             :   private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
     425             :       ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
     426          58 :     return allApprovals.values().stream()
     427          58 :         .collect(
     428          58 :             ImmutableListMultimap.toImmutableListMultimap(
     429          58 :                 PatchSetApproval::accountId, Function.identity()));
     430             :   }
     431             : 
     432             :   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     433         103 :     List<ReviewerStatusUpdate> result = new ArrayList<>();
     434         103 :     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
     435         103 :     for (ReviewerStatusUpdate u : Lists.reverse(reviewerUpdates)) {
     436          75 :       if (!Objects.equals(ownerId, u.reviewer()) && lastState.get(u.reviewer()) != u.state()) {
     437          49 :         result.add(u);
     438          49 :         lastState.put(u.reviewer(), u.state());
     439             :       }
     440          75 :     }
     441         103 :     return result;
     442             :   }
     443             : 
     444             :   private List<ChangeMessage> buildAllMessages() {
     445         103 :     return Lists.reverse(allChangeMessages);
     446             :   }
     447             : 
     448             :   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
     449         103 :     Instant commitTimestamp = getCommitTimestamp(commit);
     450             : 
     451         103 :     createdOn = commitTimestamp;
     452         103 :     parseTag(commit);
     453             : 
     454         103 :     if (branch == null) {
     455         103 :       branch = parseBranch(commit);
     456             :     }
     457             : 
     458         103 :     PatchSet.Id psId = parsePatchSetId(commit);
     459         103 :     PatchSetState psState = parsePatchSetState(commit);
     460         103 :     if (psState != null) {
     461           2 :       if (!patchSetStates.containsKey(psId)) {
     462           2 :         patchSetStates.put(psId, psState);
     463             :       }
     464           2 :       if (psState == PatchSetState.DELETED) {
     465           2 :         deletedPatchSets.add(psId);
     466             :       }
     467             :     }
     468             : 
     469         103 :     Account.Id accountId = parseIdent(commit);
     470         103 :     if (accountId != null) {
     471         103 :       ownerId = accountId;
     472         103 :       PersonIdent personIdent = commit.getAuthorIdent();
     473         103 :       serverId = NoteDbUtil.extractHostPartFromPersonIdent(personIdent);
     474         103 :     } else {
     475           2 :       serverId = "UNKNOWN_SERVER_ID";
     476             :     }
     477         103 :     Account.Id realAccountId = parseRealAccountId(commit, accountId);
     478             : 
     479         103 :     if (changeId == null) {
     480         103 :       changeId = parseChangeId(commit);
     481             :     }
     482             : 
     483         103 :     String currSubject = parseSubject(commit);
     484         103 :     if (currSubject != null) {
     485         103 :       if (subject == null) {
     486         103 :         subject = currSubject;
     487             :       }
     488         103 :       originalSubject = currSubject;
     489             :     }
     490             : 
     491         103 :     boolean hasChangeMessage =
     492         103 :         parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     493         103 :     if (topic == null) {
     494         103 :       topic = parseTopic(commit);
     495             :     }
     496             : 
     497         103 :     parseHashtags(commit);
     498         103 :     parseAttentionSetUpdates(commit);
     499         103 :     parseAssigneeUpdates(commitTimestamp, commit);
     500             : 
     501         103 :     parseSubmission(commit, commitTimestamp);
     502             : 
     503         103 :     if (lastUpdatedOn == null || commitTimestamp.isAfter(lastUpdatedOn)) {
     504         103 :       lastUpdatedOn = commitTimestamp;
     505             :     }
     506             : 
     507         103 :     if (deletedPatchSets.contains(psId)) {
     508             :       // Do not update PS details as PS was deleted and this meta data is of no relevance.
     509           2 :       return;
     510             :     }
     511             : 
     512             :     // Parse mutable patch set fields first so they can be recorded in the PendingPatchSetFields.
     513         103 :     parseDescription(psId, commit);
     514         103 :     parseGroups(psId, commit);
     515             : 
     516         103 :     ObjectId currRev = parseRevision(commit);
     517         103 :     if (currRev != null) {
     518         103 :       parsePatchSet(psId, currRev, accountId, commitTimestamp);
     519             :     }
     520         103 :     parseCurrentPatchSet(psId, commit);
     521             : 
     522         103 :     if (status == null) {
     523         103 :       status = parseStatus(commit);
     524             :     }
     525             : 
     526             :     // Parse approvals after status to treat approvals in the same commit as
     527             :     // "Status: merged" as non-post-submit.
     528         103 :     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
     529          68 :       parseApproval(psId, accountId, realAccountId, commitTimestamp, line);
     530          68 :     }
     531         103 :     for (String line : commit.getFooterLineValues(FOOTER_COPIED_LABEL)) {
     532          13 :       parseCopiedApproval(psId, commitTimestamp, line);
     533          13 :     }
     534             : 
     535         103 :     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
     536         103 :       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
     537          75 :         parseReviewer(commitTimestamp, state, line);
     538          75 :       }
     539         103 :       for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
     540          11 :         parseReviewerByEmail(commitTimestamp, state, line);
     541          11 :       }
     542             :       // Don't update timestamp when a reviewer was added, matching RevewDb
     543             :       // behavior.
     544             :     }
     545             : 
     546         103 :     if (isPrivate == null) {
     547         103 :       parseIsPrivate(commit);
     548             :     }
     549             : 
     550         103 :     if (revertOf == null) {
     551         103 :       revertOf = parseRevertOf(commit);
     552             :     }
     553             : 
     554         103 :     if (cherryPickOf == null) {
     555         103 :       cherryPickOf = parseCherryPickOf(commit);
     556             :     }
     557             : 
     558         103 :     previousWorkInProgressFooter = null;
     559         103 :     parseWorkInProgress(commit);
     560         103 :     if (countTowardsMaxUpdatesLimit(commit, hasChangeMessage)) {
     561         103 :       updateCount++;
     562             :     }
     563         103 :   }
     564             : 
     565             :   private void parseSubmission(ChangeNotesCommit commit, Instant commitTimestamp)
     566             :       throws ConfigInvalidException {
     567             :     // Only parse the most recent sumbit commit (there should be exactly one).
     568         103 :     if (submissionId == null) {
     569         103 :       submissionId = parseSubmissionId(commit);
     570             :     }
     571             : 
     572         103 :     if (submissionId != null && mergedOn == null) {
     573          57 :       mergedOn = commitTimestamp;
     574             :     }
     575             : 
     576         103 :     if (submitRecords.isEmpty()) {
     577             :       // Only parse the most recent set of submit records; any older ones are
     578             :       // still there, but not currently used.
     579         103 :       parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
     580             :     }
     581         103 :   }
     582             : 
     583             :   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
     584         103 :     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
     585             :   }
     586             : 
     587             :   @Nullable
     588             :   private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
     589         103 :     String branch = parseOneFooter(commit, FOOTER_BRANCH);
     590         103 :     return branch != null ? RefNames.fullName(branch) : null;
     591             :   }
     592             : 
     593             :   private String parseChangeId(ChangeNotesCommit commit) throws ConfigInvalidException {
     594         103 :     return parseOneFooter(commit, FOOTER_CHANGE_ID);
     595             :   }
     596             : 
     597             :   private String parseSubject(ChangeNotesCommit commit) throws ConfigInvalidException {
     598         103 :     return parseOneFooter(commit, FOOTER_SUBJECT);
     599             :   }
     600             : 
     601             :   private Account.Id parseRealAccountId(ChangeNotesCommit commit, Account.Id effectiveAccountId)
     602             :       throws ConfigInvalidException {
     603         103 :     String realUser = parseOneFooter(commit, FOOTER_REAL_USER);
     604         103 :     if (realUser == null) {
     605         103 :       return effectiveAccountId;
     606             :     }
     607           4 :     PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
     608           4 :     return parseIdent(ident);
     609             :   }
     610             : 
     611             :   private String parseTopic(ChangeNotesCommit commit) throws ConfigInvalidException {
     612         103 :     return parseOneFooter(commit, FOOTER_TOPIC);
     613             :   }
     614             : 
     615             :   @Nullable
     616             :   private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
     617             :       throws ConfigInvalidException {
     618         103 :     List<String> footerLines = commit.getFooterLineValues(footerKey);
     619         103 :     if (footerLines.isEmpty()) {
     620         103 :       return null;
     621         103 :     } else if (footerLines.size() > 1) {
     622           1 :       throw expectedOneFooter(footerKey, footerLines);
     623             :     }
     624         103 :     return footerLines.get(0);
     625             :   }
     626             : 
     627             :   private String parseExactlyOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
     628             :       throws ConfigInvalidException {
     629         103 :     String line = parseOneFooter(commit, footerKey);
     630         103 :     if (line == null) {
     631           1 :       throw expectedOneFooter(footerKey, Collections.emptyList());
     632             :     }
     633         103 :     return line;
     634             :   }
     635             : 
     636             :   @Nullable
     637             :   private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
     638         103 :     String sha = parseOneFooter(commit, FOOTER_COMMIT);
     639         103 :     if (sha == null) {
     640          82 :       return null;
     641             :     }
     642             :     try {
     643         103 :       return ObjectId.fromString(sha);
     644           1 :     } catch (InvalidObjectIdException e) {
     645           1 :       ConfigInvalidException cie = invalidFooter(FOOTER_COMMIT, sha);
     646           1 :       cie.initCause(e);
     647           1 :       throw cie;
     648             :     }
     649             :   }
     650             : 
     651             :   private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
     652             :       throws ConfigInvalidException {
     653         103 :     if (accountId == null) {
     654           0 :       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     655             :     }
     656         103 :     if (patchSetCommitParsed(psId)) {
     657           1 :       ObjectId commitId = patchSets.get(psId).commitId().orElseThrow(IllegalStateException::new);
     658           1 :       throw new ConfigInvalidException(
     659           1 :           String.format(
     660             :               "Multiple revisions parsed for patch set %s: %s and %s",
     661           1 :               psId.get(), commitId.name(), rev.name()));
     662             :     }
     663         103 :     patchSets
     664         103 :         .computeIfAbsent(psId, id -> PatchSet.builder())
     665         103 :         .id(psId)
     666         103 :         .commitId(rev)
     667         103 :         .uploader(accountId)
     668         103 :         .createdOn(ts);
     669             :     // Fields not set here:
     670             :     // * Groups, parsed earlier in parseGroups.
     671             :     // * Description, parsed earlier in parseDescription.
     672             :     // * Push certificate, parsed later in parseNotes.
     673         103 :   }
     674             : 
     675             :   private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit)
     676             :       throws ConfigInvalidException {
     677         103 :     String groupsStr = parseOneFooter(commit, FOOTER_GROUPS);
     678         103 :     if (groupsStr == null) {
     679          82 :       return;
     680             :     }
     681         103 :     checkPatchSetCommitNotParsed(psId, FOOTER_GROUPS);
     682         103 :     PatchSet.Builder pending = patchSets.computeIfAbsent(psId, id -> PatchSet.builder());
     683         103 :     if (pending.groups().isEmpty()) {
     684         103 :       pending.groups(PatchSet.splitGroups(groupsStr));
     685             :     }
     686         103 :   }
     687             : 
     688             :   private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit)
     689             :       throws ConfigInvalidException {
     690             :     // This commit implies a new current patch set if either it creates a new
     691             :     // patch set, or sets the current field explicitly.
     692         103 :     boolean current = false;
     693         103 :     if (parseOneFooter(commit, FOOTER_COMMIT) != null) {
     694         103 :       current = true;
     695             :     } else {
     696          82 :       String currentStr = parseOneFooter(commit, FOOTER_CURRENT);
     697          82 :       if (Boolean.TRUE.toString().equalsIgnoreCase(currentStr)) {
     698          11 :         current = true;
     699          82 :       } else if (currentStr != null) {
     700             :         // Only "true" is allowed; unsetting the current patch set makes no
     701             :         // sense.
     702           1 :         throw invalidFooter(FOOTER_CURRENT, currentStr);
     703             :       }
     704             :     }
     705         103 :     if (current) {
     706         103 :       currentPatchSets.add(psId);
     707             :     }
     708         103 :   }
     709             : 
     710             :   private void parseHashtags(ChangeNotesCommit commit) throws ConfigInvalidException {
     711             :     // Commits are parsed in reverse order and only the last set of hashtags
     712             :     // should be used.
     713         103 :     if (hashtags != null) {
     714           9 :       return;
     715             :     }
     716         103 :     List<String> hashtagsLines = commit.getFooterLineValues(FOOTER_HASHTAGS);
     717         103 :     if (hashtagsLines.isEmpty()) {
     718         103 :       return;
     719           9 :     } else if (hashtagsLines.size() > 1) {
     720           0 :       throw expectedOneFooter(FOOTER_HASHTAGS, hashtagsLines);
     721           9 :     } else if (hashtagsLines.get(0).isEmpty()) {
     722           1 :       hashtags = ImmutableSet.of();
     723             :     } else {
     724           9 :       hashtags = Sets.newHashSet(HASHTAG_SPLITTER.split(hashtagsLines.get(0)));
     725             :     }
     726           9 :   }
     727             : 
     728             :   private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
     729         103 :     List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
     730         103 :     for (String attentionString : attentionStrings) {
     731             : 
     732          52 :       Optional<AttentionSetUpdate> attentionStatus =
     733          52 :           ChangeNoteUtil.attentionStatusFromJson(
     734          52 :               Instant.ofEpochSecond(commit.getCommitTime()), attentionString);
     735          52 :       if (!attentionStatus.isPresent()) {
     736           0 :         throw invalidFooter(FOOTER_ATTENTION, attentionString);
     737             :       }
     738             :       // Processing is in reverse chronological order. Keep only the latest update.
     739          52 :       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
     740             : 
     741             :       // Keep all updates as well.
     742          52 :       allAttentionSetUpdates.add(attentionStatus.get());
     743          52 :     }
     744         103 :   }
     745             : 
     746             :   private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
     747             :       throws ConfigInvalidException {
     748         103 :     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
     749         103 :     if (assigneeValue != null) {
     750             :       Optional<Account.Id> parsedAssignee;
     751           7 :       if (assigneeValue.equals("")) {
     752             :         // Empty footer found, assignee deleted
     753           2 :         parsedAssignee = Optional.empty();
     754             :       } else {
     755           7 :         PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
     756           7 :         parsedAssignee = Optional.ofNullable(parseIdent(ident));
     757             :       }
     758           7 :       assigneeUpdates.add(AssigneeStatusUpdate.create(ts, ownerId, parsedAssignee));
     759             :     }
     760         103 :   }
     761             : 
     762             :   private void parseTag(ChangeNotesCommit commit) throws ConfigInvalidException {
     763         103 :     tag = null;
     764         103 :     List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
     765         103 :     if (tagLines.isEmpty()) {
     766          73 :       return;
     767         103 :     } else if (tagLines.size() == 1) {
     768         103 :       tag = tagLines.get(0);
     769             :     } else {
     770           1 :       throw expectedOneFooter(FOOTER_TAG, tagLines);
     771             :     }
     772         103 :   }
     773             : 
     774             :   @Nullable
     775             :   private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
     776         103 :     List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     777         103 :     if (statusLines.isEmpty()) {
     778          86 :       return null;
     779         103 :     } else if (statusLines.size() > 1) {
     780           1 :       throw expectedOneFooter(FOOTER_STATUS, statusLines);
     781             :     }
     782         103 :     Change.Status status =
     783         103 :         Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
     784         103 :     if (status == null) {
     785           1 :       throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
     786             :     }
     787             :     // All approvals after MERGED and before the next status change get the postSubmit
     788             :     // bit. (Currently the state can't change from MERGED to something else, but just in case.) The
     789             :     // exception is the legacy SUBM approval, which is never considered post-submit, but might end
     790             :     // up sorted after the submit during rebuilding.
     791         103 :     if (status == Change.Status.MERGED) {
     792          57 :       for (PatchSetApproval.Builder psa : bufferedApprovals) {
     793           4 :         if (!psa.key().isLegacySubmit()) {
     794           4 :           psa.postSubmit(true);
     795             :         }
     796           4 :       }
     797             :     }
     798         103 :     bufferedApprovals.clear();
     799         103 :     return status;
     800             :   }
     801             : 
     802             :   private PatchSet.Id parsePatchSetId(ChangeNotesCommit commit) throws ConfigInvalidException {
     803         103 :     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     804         103 :     int s = psIdLine.indexOf(' ');
     805         103 :     String psIdStr = s < 0 ? psIdLine : psIdLine.substring(0, s);
     806         103 :     Integer psId = Ints.tryParse(psIdStr);
     807         103 :     if (psId == null) {
     808           1 :       throw invalidFooter(FOOTER_PATCH_SET, psIdStr);
     809             :     }
     810         103 :     return PatchSet.id(id, psId);
     811             :   }
     812             : 
     813             :   @Nullable
     814             :   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
     815         103 :     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     816         103 :     int s = psIdLine.indexOf(' ');
     817         103 :     if (s < 0) {
     818         103 :       return null;
     819             :     }
     820           2 :     String withParens = psIdLine.substring(s + 1);
     821           2 :     if (withParens.startsWith("(") && withParens.endsWith(")")) {
     822           2 :       PatchSetState state =
     823           2 :           Enums.getIfPresent(
     824             :                   PatchSetState.class,
     825           2 :                   withParens.substring(1, withParens.length() - 1).toUpperCase())
     826           2 :               .orNull();
     827           2 :       if (state != null) {
     828           2 :         return state;
     829             :       }
     830             :     }
     831           1 :     throw invalidFooter(FOOTER_PATCH_SET, psIdLine);
     832             :   }
     833             : 
     834             :   private void parseDescription(PatchSet.Id psId, ChangeNotesCommit commit)
     835             :       throws ConfigInvalidException {
     836         103 :     List<String> descLines = commit.getFooterLineValues(FOOTER_PATCH_SET_DESCRIPTION);
     837         103 :     if (descLines.isEmpty()) {
     838         103 :       return;
     839             :     }
     840             : 
     841          24 :     checkPatchSetCommitNotParsed(psId, FOOTER_PATCH_SET_DESCRIPTION);
     842          24 :     if (descLines.size() == 1) {
     843          24 :       String desc = descLines.get(0).trim();
     844          24 :       PatchSet.Builder pending = patchSets.computeIfAbsent(psId, p -> PatchSet.builder());
     845          24 :       if (!pending.description().isPresent()) {
     846          24 :         pending.description(Optional.of(desc));
     847             :       }
     848          24 :     } else {
     849           0 :       throw expectedOneFooter(FOOTER_PATCH_SET_DESCRIPTION, descLines);
     850             :     }
     851          24 :   }
     852             : 
     853             :   private boolean parseChangeMessage(
     854             :       PatchSet.Id psId,
     855             :       Account.Id accountId,
     856             :       Account.Id realAccountId,
     857             :       ChangeNotesCommit commit,
     858             :       Instant ts) {
     859         103 :     Optional<String> changeMsgString = getChangeMessageString(commit);
     860         103 :     if (!changeMsgString.isPresent()) {
     861          44 :       return false;
     862             :     }
     863             : 
     864         103 :     ChangeMessage changeMessage =
     865         103 :         ChangeMessage.create(
     866         103 :             ChangeMessage.key(psId.changeId(), commit.name()),
     867             :             accountId,
     868             :             ts,
     869             :             psId,
     870         103 :             changeMsgString.get(),
     871             :             realAccountId,
     872             :             tag);
     873         103 :     return allChangeMessages.add(changeMessage);
     874             :   }
     875             : 
     876             :   public static Optional<String> getChangeMessageString(ChangeNotesCommit commit) {
     877         103 :     byte[] raw = commit.getRawBuffer();
     878         103 :     Charset enc = RawParseUtils.parseEncoding(raw);
     879             : 
     880         103 :     Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
     881         103 :     return range.map(
     882             :         commitMessageRange ->
     883         103 :             commitMessageRange.hasChangeMessage()
     884         103 :                 ? RawParseUtils.decode(
     885             :                     enc,
     886             :                     raw,
     887         103 :                     commitMessageRange.changeMessageStart(),
     888         103 :                     commitMessageRange.changeMessageEnd() + 1)
     889          44 :                 : null);
     890             :   }
     891             : 
     892             :   private void parseNotes() throws IOException, ConfigInvalidException {
     893         103 :     ObjectReader reader = walk.getObjectReader();
     894         103 :     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     895         103 :     revisionNoteMap =
     896         103 :         RevisionNoteMap.parse(
     897         103 :             changeNoteJson, reader, NoteMap.read(reader, tipCommit), HumanComment.Status.PUBLISHED);
     898         103 :     Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
     899             : 
     900         103 :     for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
     901          64 :       for (HumanComment c : e.getValue().getEntities()) {
     902          26 :         humanComments.put(e.getKey(), c);
     903          26 :       }
     904          64 :     }
     905             : 
     906             :     // Lookup submit requirement results from the revision notes of the last PS that has stored
     907             :     // submit requirements. This is important for cases where the change was abandoned/un-abandoned
     908             :     // multiple times. With each abandon, we store submit requirement results in NoteDb, so we can
     909             :     // end up having stored SRs in many revision notes. We should only return SRs from the last
     910             :     // PS of them.
     911             :     for (PatchSet.Builder ps :
     912         103 :         patchSets.values().stream()
     913         103 :             .sorted(comparingInt((PatchSet.Builder p) -> p.id().get()).reversed())
     914         103 :             .collect(Collectors.toList())) {
     915         103 :       Optional<ObjectId> maybePsCommitId = ps.commitId();
     916         103 :       if (!maybePsCommitId.isPresent()) {
     917           0 :         continue;
     918             :       }
     919         103 :       ObjectId psCommitId = maybePsCommitId.get();
     920         103 :       if (rns.containsKey(psCommitId)
     921          64 :           && rns.get(psCommitId).getSubmitRequirementsResult() != null) {
     922          54 :         rns.get(psCommitId)
     923          54 :             .getSubmitRequirementsResult()
     924          54 :             .forEach(sr -> submitRequirementResults.add(sr));
     925          54 :         break;
     926             :       }
     927         103 :     }
     928             : 
     929         103 :     for (PatchSet.Builder b : patchSets.values()) {
     930         103 :       ObjectId commitId =
     931         103 :           b.commitId()
     932         103 :               .orElseThrow(
     933             :                   () ->
     934           0 :                       new IllegalStateException("never parsed commit ID for patch set " + b.id()));
     935         103 :       ChangeRevisionNote rn = rns.get(commitId);
     936         103 :       if (rn != null && rn.getPushCert() != null) {
     937           1 :         b.pushCertificate(Optional.of(rn.getPushCert()));
     938             :       }
     939         103 :     }
     940         103 :   }
     941             : 
     942             :   /** Parses copied {@link PatchSetApproval}. */
     943             :   private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
     944             :       throws ConfigInvalidException {
     945          13 :     ParsedPatchSetApproval parsedPatchSetApproval =
     946          13 :         ChangeNotesParseApprovalUtil.parseCopiedApproval(line);
     947          13 :     checkFooter(
     948          13 :         parsedPatchSetApproval.accountIdent().isPresent(),
     949             :         FOOTER_COPIED_LABEL,
     950          13 :         parsedPatchSetApproval.footerLine());
     951          13 :     PersonIdent accountIdent =
     952          13 :         RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
     953             : 
     954          13 :     checkFooter(accountIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
     955          13 :     Account.Id accountId = parseIdent(accountIdent);
     956             : 
     957          13 :     Account.Id realAccountId = null;
     958          13 :     if (parsedPatchSetApproval.realAccountIdent().isPresent()) {
     959           2 :       PersonIdent realIdent =
     960           2 :           RawParseUtils.parsePersonIdent(parsedPatchSetApproval.realAccountIdent().get());
     961           2 :       checkFooter(realIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
     962           2 :       realAccountId = parseIdent(realIdent);
     963             :     }
     964             : 
     965             :     LabelVote labelVote;
     966             :     try {
     967          13 :       if (!parsedPatchSetApproval.isRemoval()) {
     968          13 :         labelVote = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     969             :       } else {
     970           2 :         String labelName = parsedPatchSetApproval.labelVote();
     971           2 :         LabelType.checkNameInternal(labelName);
     972           2 :         labelVote = LabelVote.create(labelName, (short) 0);
     973             :       }
     974           1 :     } catch (IllegalArgumentException e) {
     975           1 :       ConfigInvalidException pe =
     976           1 :           parseException(
     977           1 :               "invalid %s: %s", FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
     978           1 :       pe.initCause(e);
     979           1 :       throw pe;
     980          13 :     }
     981             : 
     982             :     PatchSetApproval.Builder psa =
     983          13 :         PatchSetApproval.builder()
     984          13 :             .key(PatchSetApproval.key(psId, accountId, LabelId.create(labelVote.label())))
     985          13 :             .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
     986          13 :             .value(labelVote.value())
     987          13 :             .granted(ts)
     988          13 :             .tag(parsedPatchSetApproval.tag())
     989          13 :             .copied(true);
     990          13 :     if (realAccountId != null) {
     991           2 :       psa.realAccountId(realAccountId);
     992             :     }
     993          13 :     approvals.putIfAbsent(psa.key(), psa);
     994          13 :     bufferedApprovals.add(psa);
     995          13 :   }
     996             : 
     997             :   private void parseApproval(
     998             :       PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Instant ts, String line)
     999             :       throws ConfigInvalidException {
    1000          68 :     if (accountId == null) {
    1001           1 :       throw parseException("patch set %s requires an identified user as uploader", psId.get());
    1002             :     }
    1003             :     PatchSetApproval.Builder psa;
    1004          68 :     ParsedPatchSetApproval parsedPatchSetApproval =
    1005          68 :         ChangeNotesParseApprovalUtil.parseApproval(line);
    1006          68 :     if (line.startsWith("-")) {
    1007          11 :       psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
    1008             :     } else {
    1009          68 :       psa = parseAddApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
    1010             :     }
    1011          68 :     bufferedApprovals.add(psa);
    1012          68 :   }
    1013             : 
    1014             :   /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteFooters#FOOTER_LABEL} value. */
    1015             :   private PatchSetApproval.Builder parseAddApproval(
    1016             :       PatchSet.Id psId,
    1017             :       Account.Id committerId,
    1018             :       Account.Id realAccountId,
    1019             :       Instant ts,
    1020             :       ParsedPatchSetApproval parsedPatchSetApproval)
    1021             :       throws ConfigInvalidException {
    1022             : 
    1023          68 :     Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
    1024             : 
    1025             :     LabelVote l;
    1026             :     try {
    1027          68 :       l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
    1028           1 :     } catch (IllegalArgumentException e) {
    1029           1 :       ConfigInvalidException pe =
    1030           1 :           parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
    1031           1 :       pe.initCause(e);
    1032           1 :       throw pe;
    1033          68 :     }
    1034             : 
    1035             :     PatchSetApproval.Builder psa =
    1036          68 :         PatchSetApproval.builder()
    1037          68 :             .key(PatchSetApproval.key(psId, approverId, LabelId.create(l.label())))
    1038          68 :             .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
    1039          68 :             .value(l.value())
    1040          68 :             .granted(ts)
    1041          68 :             .tag(Optional.ofNullable(tag));
    1042          68 :     if (!Objects.equals(realAccountId, committerId)) {
    1043           4 :       psa.realAccountId(realAccountId);
    1044             :     }
    1045          68 :     approvals.putIfAbsent(psa.key(), psa);
    1046          68 :     return psa;
    1047             :   }
    1048             : 
    1049             :   private PatchSetApproval.Builder parseRemoveApproval(
    1050             :       PatchSet.Id psId,
    1051             :       Account.Id committerId,
    1052             :       Account.Id realAccountId,
    1053             :       Instant ts,
    1054             :       ParsedPatchSetApproval parsedPatchSetApproval)
    1055             :       throws ConfigInvalidException {
    1056             : 
    1057          11 :     checkFooter(
    1058          11 :         parsedPatchSetApproval.footerLine().startsWith("-"),
    1059             :         FOOTER_LABEL,
    1060          11 :         parsedPatchSetApproval.footerLine());
    1061          11 :     Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
    1062             : 
    1063             :     try {
    1064          11 :       LabelType.checkNameInternal(parsedPatchSetApproval.labelVote());
    1065           1 :     } catch (IllegalArgumentException e) {
    1066           1 :       ConfigInvalidException pe =
    1067           1 :           parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
    1068           1 :       pe.initCause(e);
    1069           1 :       throw pe;
    1070          11 :     }
    1071             : 
    1072             :     // Store an actual 0-vote approval in the map for a removed approval, because ApprovalCopier
    1073             :     // needs an actual approval in order to block copying an earlier approval over a later delete.
    1074             :     PatchSetApproval.Builder remove =
    1075          11 :         PatchSetApproval.builder()
    1076          11 :             .key(
    1077          11 :                 PatchSetApproval.key(
    1078          11 :                     psId, approverId, LabelId.create(parsedPatchSetApproval.labelVote())))
    1079          11 :             .value(0)
    1080          11 :             .granted(ts);
    1081          11 :     if (!Objects.equals(realAccountId, committerId)) {
    1082           0 :       remove.realAccountId(realAccountId);
    1083             :     }
    1084          11 :     approvals.putIfAbsent(remove.key(), remove);
    1085          11 :     return remove;
    1086             :   }
    1087             : 
    1088             :   /**
    1089             :    * Identifies the {@link com.google.gerrit.entities.Account.Id} that issued the vote.
    1090             :    *
    1091             :    * <p>There are potentially 3 accounts involved here: 1. The account from the commit, which is the
    1092             :    * effective IdentifiedUser that produced the update. 2. The account in the label footer itself,
    1093             :    * which is used during submit to copy other users' labels to a new patch set. 3. The account in
    1094             :    * the Real-user footer, indicating that the whole update operation was executed by this user on
    1095             :    * behalf of the effective user.
    1096             :    */
    1097             :   private Account.Id parseApprover(
    1098             :       Account.Id committerId, ParsedPatchSetApproval parsedPatchSetApproval)
    1099             :       throws ConfigInvalidException {
    1100             :     Account.Id effectiveAccountId;
    1101          68 :     if (parsedPatchSetApproval.accountIdent().isPresent()) {
    1102           7 :       PersonIdent ident =
    1103           7 :           RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
    1104           7 :       checkFooter(ident != null, FOOTER_LABEL, parsedPatchSetApproval.footerLine());
    1105           7 :       effectiveAccountId = parseIdent(ident);
    1106           7 :     } else {
    1107          68 :       effectiveAccountId = committerId;
    1108             :     }
    1109          68 :     return effectiveAccountId;
    1110             :   }
    1111             : 
    1112             :   private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
    1113         103 :     SubmitRecord rec = null;
    1114             : 
    1115         103 :     for (String line : lines) {
    1116          55 :       int c = line.indexOf(": ");
    1117          55 :       if (c < 0) {
    1118          55 :         rec = new SubmitRecord();
    1119          55 :         submitRecords.add(rec);
    1120          55 :         int s = line.indexOf(' ');
    1121          55 :         String statusStr = s >= 0 ? line.substring(0, s) : line;
    1122          55 :         rec.status = Enums.getIfPresent(SubmitRecord.Status.class, statusStr).orNull();
    1123          55 :         checkFooter(rec.status != null, FOOTER_SUBMITTED_WITH, line);
    1124          55 :         if (s >= 0) {
    1125           1 :           rec.errorMessage = line.substring(s);
    1126             :         }
    1127          55 :       } else {
    1128          55 :         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
    1129          55 :         if (line.startsWith("Rule-Name: ")) {
    1130          54 :           String ruleName = RULE_SPLITTER.splitToList(line).get(1);
    1131          54 :           rec.ruleName = ruleName;
    1132          54 :           continue;
    1133             :         }
    1134          55 :         SubmitRecord.Label label = new SubmitRecord.Label();
    1135          55 :         if (rec.labels == null) {
    1136          55 :           rec.labels = new ArrayList<>();
    1137             :         }
    1138          55 :         rec.labels.add(label);
    1139             : 
    1140          55 :         label.status =
    1141          55 :             Enums.getIfPresent(SubmitRecord.Label.Status.class, line.substring(0, c)).orNull();
    1142          55 :         checkFooter(label.status != null, FOOTER_SUBMITTED_WITH, line);
    1143          55 :         int c2 = line.indexOf(": ", c + 2);
    1144          55 :         if (c2 >= 0) {
    1145          48 :           label.label = line.substring(c + 2, c2);
    1146          48 :           PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(c2 + 2));
    1147          48 :           checkFooter(ident != null, FOOTER_SUBMITTED_WITH, line);
    1148          48 :           label.appliedBy = parseIdent(ident);
    1149          48 :         } else {
    1150          13 :           label.label = line.substring(c + 2);
    1151             :         }
    1152             :       }
    1153          55 :     }
    1154         103 :   }
    1155             : 
    1156             :   @Nullable
    1157             :   private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
    1158             :     // Check if the author name/email is the same as the committer name/email,
    1159             :     // i.e. was the server ident at the time this commit was made.
    1160         103 :     PersonIdent a = commit.getAuthorIdent();
    1161         103 :     PersonIdent c = commit.getCommitterIdent();
    1162         103 :     if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
    1163           2 :       return null;
    1164             :     }
    1165         103 :     return parseIdent(a);
    1166             :   }
    1167             : 
    1168             :   private void parseReviewer(Instant ts, ReviewerStateInternal state, String line)
    1169             :       throws ConfigInvalidException {
    1170          75 :     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
    1171          75 :     if (ident == null) {
    1172           1 :       throw invalidFooter(state.getFooterKey(), line);
    1173             :     }
    1174          75 :     Account.Id accountId = parseIdent(ident);
    1175          75 :     reviewerUpdates.add(ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
    1176          75 :     if (!reviewers.containsRow(accountId)) {
    1177          75 :       reviewers.put(accountId, state, ts);
    1178             :     }
    1179          75 :   }
    1180             : 
    1181             :   private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line)
    1182             :       throws ConfigInvalidException {
    1183             :     Address adr;
    1184             :     try {
    1185          11 :       adr = Address.parse(line);
    1186           0 :     } catch (IllegalArgumentException e) {
    1187           0 :       ConfigInvalidException cie = invalidFooter(state.getByEmailFooterKey(), line);
    1188           0 :       cie.initCause(e);
    1189           0 :       throw cie;
    1190          11 :     }
    1191          11 :     if (!reviewersByEmail.containsRow(adr)) {
    1192          11 :       reviewersByEmail.put(adr, state, ts);
    1193             :     }
    1194          11 :   }
    1195             : 
    1196             :   private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
    1197         103 :     String raw = parseOneFooter(commit, FOOTER_PRIVATE);
    1198         103 :     if (raw == null) {
    1199          89 :       return;
    1200         103 :     } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
    1201          23 :       isPrivate = true;
    1202          23 :       return;
    1203         103 :     } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
    1204         103 :       isPrivate = false;
    1205         103 :       return;
    1206             :     }
    1207           0 :     throw invalidFooter(FOOTER_PRIVATE, raw);
    1208             :   }
    1209             : 
    1210             :   private void parseWorkInProgress(ChangeNotesCommit commit) throws ConfigInvalidException {
    1211         103 :     String raw = parseOneFooter(commit, FOOTER_WORK_IN_PROGRESS);
    1212         103 :     if (raw == null) {
    1213             :       // No change to WIP state in this revision.
    1214          90 :       previousWorkInProgressFooter = null;
    1215          90 :       return;
    1216         103 :     } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
    1217             :       // This revision moves the change into WIP.
    1218          25 :       previousWorkInProgressFooter = true;
    1219          25 :       if (workInProgress == null) {
    1220             :         // Because this is the first time workInProgress is being set, we know
    1221             :         // that this change's current state is WIP. All the reviewer updates
    1222             :         // we've seen so far are pending, so take a snapshot of the reviewers
    1223             :         // and reviewersByEmail tables.
    1224          25 :         pendingReviewers =
    1225          25 :             ReviewerSet.fromTable(Tables.transpose(ImmutableTable.copyOf(reviewers)));
    1226          25 :         pendingReviewersByEmail =
    1227          25 :             ReviewerByEmailSet.fromTable(Tables.transpose(ImmutableTable.copyOf(reviewersByEmail)));
    1228          25 :         workInProgress = true;
    1229             :       }
    1230          25 :       return;
    1231         103 :     } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
    1232         103 :       previousWorkInProgressFooter = false;
    1233         103 :       hasReviewStarted = true;
    1234         103 :       if (workInProgress == null) {
    1235         103 :         workInProgress = false;
    1236             :       }
    1237         103 :       return;
    1238             :     }
    1239           0 :     throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
    1240             :   }
    1241             : 
    1242             :   @Nullable
    1243             :   private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
    1244         103 :     String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
    1245         103 :     if (footer == null) {
    1246         103 :       return null;
    1247             :     }
    1248          15 :     Integer revertOf = Ints.tryParse(footer);
    1249          15 :     if (revertOf == null) {
    1250           0 :       throw invalidFooter(FOOTER_REVERT_OF, footer);
    1251             :     }
    1252          15 :     return Change.id(revertOf);
    1253             :   }
    1254             : 
    1255             :   /**
    1256             :    * Parses {@link ChangeNoteFooters#FOOTER_CHERRY_PICK_OF} of the commit.
    1257             :    *
    1258             :    * @param commit the commit to parse.
    1259             :    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
    1260             :    *     this commit.
    1261             :    * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
    1262             :    *     com.google.gerrit.entities.PatchSet.Id}.
    1263             :    */
    1264             :   @Nullable
    1265             :   private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
    1266             :       throws ConfigInvalidException {
    1267         103 :     String footer = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
    1268         103 :     if (footer == null) {
    1269             :       // The footer is missing, nothing to parse.
    1270         103 :       return null;
    1271          11 :     } else if (footer.equals("")) {
    1272             :       // Empty footer value, cherryPickOf was unset at this commit.
    1273           2 :       return Optional.empty();
    1274             :     } else {
    1275             :       try {
    1276          11 :         return Optional.of(PatchSet.Id.parse(footer));
    1277           0 :       } catch (IllegalArgumentException e) {
    1278           0 :         throw new ConfigInvalidException("\"" + footer + "\" is not a valid patchset", e);
    1279             :       }
    1280             :     }
    1281             :   }
    1282             : 
    1283             :   /**
    1284             :    * Returns the {@link Timestamp} when the commit was applied.
    1285             :    *
    1286             :    * <p>The author's date only notes when the commit was originally made. Thus, use the commiter's
    1287             :    * date as it accounts for the rebase, cherry-pick, commit --amend and other commands that rewrite
    1288             :    * the history of the branch.
    1289             :    *
    1290             :    * <p>Don't use {@link org.eclipse.jgit.revwalk.RevCommit#getCommitTime} directly because it
    1291             :    * returns int and would overflow.
    1292             :    *
    1293             :    * @param commit the commit to return commit time.
    1294             :    * @return the timestamp when the commit was applied.
    1295             :    */
    1296             :   private Instant getCommitTimestamp(ChangeNotesCommit commit) {
    1297         103 :     return commit.getCommitterIdent().getWhenAsInstant();
    1298             :   }
    1299             : 
    1300             :   private void pruneReviewers() {
    1301         103 :     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Instant>> rit =
    1302         103 :         reviewers.cellSet().iterator();
    1303         103 :     while (rit.hasNext()) {
    1304          75 :       Table.Cell<Account.Id, ReviewerStateInternal, Instant> e = rit.next();
    1305          75 :       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
    1306          17 :         rit.remove();
    1307             :       }
    1308          75 :     }
    1309         103 :   }
    1310             : 
    1311             :   private void pruneReviewersByEmail() {
    1312         103 :     Iterator<Table.Cell<Address, ReviewerStateInternal, Instant>> rit =
    1313         103 :         reviewersByEmail.cellSet().iterator();
    1314         103 :     while (rit.hasNext()) {
    1315          11 :       Table.Cell<Address, ReviewerStateInternal, Instant> e = rit.next();
    1316          11 :       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
    1317           8 :         rit.remove();
    1318             :       }
    1319          11 :     }
    1320         103 :   }
    1321             : 
    1322             :   private void updatePatchSetStates() {
    1323         103 :     Set<PatchSet.Id> missing = new TreeSet<>(comparing(PatchSet.Id::get));
    1324         103 :     patchSets.keySet().stream().filter(p -> !patchSetCommitParsed(p)).forEach(p -> missing.add(p));
    1325             : 
    1326         103 :     for (Map.Entry<PatchSet.Id, PatchSetState> e : patchSetStates.entrySet()) {
    1327           2 :       switch (e.getValue()) {
    1328             :         case PUBLISHED:
    1329             :         default:
    1330           1 :           break;
    1331             : 
    1332             :         case DELETED:
    1333           2 :           patchSets.remove(e.getKey());
    1334             :           break;
    1335             :       }
    1336           2 :     }
    1337             : 
    1338             :     // Post-process other collections to remove items corresponding to deleted
    1339             :     // (or otherwise missing) patch sets. This is safer than trying to prevent
    1340             :     // insertion, as it will also filter out items racily added after the patch
    1341             :     // set was deleted.
    1342         103 :     int pruned =
    1343         103 :         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
    1344         103 :     pruned +=
    1345         103 :         pruneEntitiesForMissingPatchSets(
    1346         103 :             humanComments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
    1347         103 :     pruned +=
    1348         103 :         pruneEntitiesForMissingPatchSets(
    1349         103 :             approvals.values(), psa -> psa.key().patchSetId(), missing);
    1350             : 
    1351         103 :     if (!missing.isEmpty()) {
    1352           1 :       logger.atWarning().log(
    1353             :           "ignoring %s additional entities due to missing patch sets: %s", pruned, missing);
    1354             :     }
    1355         103 :   }
    1356             : 
    1357             :   private <T> int pruneEntitiesForMissingPatchSets(
    1358             :       Iterable<T> ents, Function<T, PatchSet.Id> psIdFunc, Set<PatchSet.Id> missing) {
    1359         103 :     int pruned = 0;
    1360         103 :     for (Iterator<T> it = ents.iterator(); it.hasNext(); ) {
    1361         103 :       PatchSet.Id psId = psIdFunc.apply(it.next());
    1362         103 :       if (!patchSetCommitParsed(psId)) {
    1363           1 :         pruned++;
    1364           1 :         missing.add(psId);
    1365           1 :         it.remove();
    1366         103 :       } else if (deletedPatchSets.contains(psId)) {
    1367           0 :         it.remove(); // Not an error we need to report, don't increment pruned.
    1368             :       }
    1369         103 :     }
    1370         103 :     return pruned;
    1371             :   }
    1372             : 
    1373             :   private void checkMandatoryFooters() throws ConfigInvalidException {
    1374         103 :     List<FooterKey> missing = new ArrayList<>();
    1375         103 :     if (branch == null) {
    1376           0 :       missing.add(FOOTER_BRANCH);
    1377             :     }
    1378         103 :     if (changeId == null) {
    1379           0 :       missing.add(FOOTER_CHANGE_ID);
    1380             :     }
    1381         103 :     if (originalSubject == null || subject == null) {
    1382           0 :       missing.add(FOOTER_SUBJECT);
    1383             :     }
    1384         103 :     if (!missing.isEmpty()) {
    1385           0 :       throw parseException(
    1386             :           "%s",
    1387           0 :           "Missing footers: " + missing.stream().map(FooterKey::getName).collect(joining(", ")));
    1388             :     }
    1389         103 :   }
    1390             : 
    1391             :   private ConfigInvalidException expectedOneFooter(FooterKey footer, List<String> actual) {
    1392           1 :     return parseException("missing or multiple %s: %s", footer.getName(), actual);
    1393             :   }
    1394             : 
    1395             :   private ConfigInvalidException invalidFooter(FooterKey footer, String actual) {
    1396           1 :     return parseException("invalid %s: %s", footer.getName(), actual);
    1397             :   }
    1398             : 
    1399             :   private void checkFooter(boolean expr, FooterKey footer, String actual)
    1400             :       throws ConfigInvalidException {
    1401          57 :     if (!expr) {
    1402           1 :       throw invalidFooter(footer, actual);
    1403             :     }
    1404          57 :   }
    1405             : 
    1406             :   private void checkPatchSetCommitNotParsed(PatchSet.Id psId, FooterKey footer)
    1407             :       throws ConfigInvalidException {
    1408         103 :     if (patchSetCommitParsed(psId)) {
    1409           0 :       throw parseException(
    1410             :           "%s field found for patch set %s before patch set was originally defined",
    1411           0 :           footer.getName(), psId.get());
    1412             :     }
    1413         103 :   }
    1414             : 
    1415             :   private boolean patchSetCommitParsed(PatchSet.Id psId) {
    1416         103 :     PatchSet.Builder pending = patchSets.get(psId);
    1417         103 :     return pending != null && pending.commitId().isPresent();
    1418             :   }
    1419             : 
    1420             :   @FormatMethod
    1421             :   private ConfigInvalidException parseException(String fmt, Object... args) {
    1422           1 :     return ChangeNotes.parseException(id, fmt, args);
    1423             :   }
    1424             : 
    1425             :   private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
    1426         103 :     return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
    1427         103 :         .orElseThrow(
    1428           1 :             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
    1429             :   }
    1430             : 
    1431             :   protected boolean countTowardsMaxUpdatesLimit(
    1432             :       ChangeNotesCommit commit, boolean hasChangeMessage) {
    1433         103 :     return !commit.isAttentionSetCommitOnly(hasChangeMessage);
    1434             :   }
    1435             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750