| // Copyright (C) 2013 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.notedb; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.gerrit.server.notedb.ChangeNoteUtil.GERRIT_PLACEHOLDER_HOST; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Function; |
| import com.google.common.base.Predicate; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ArrayListMultimap; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableListMultimap; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.ImmutableSetMultimap; |
| import com.google.common.collect.ImmutableSortedMap; |
| import com.google.common.collect.ImmutableSortedSet; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.ListMultimap; |
| import com.google.common.collect.Multimap; |
| import com.google.common.collect.Multimaps; |
| import com.google.common.collect.Ordering; |
| import com.google.common.primitives.Ints; |
| import com.google.common.util.concurrent.AsyncFunction; |
| import com.google.common.util.concurrent.CheckedFuture; |
| import com.google.common.util.concurrent.Futures; |
| import com.google.common.util.concurrent.ListenableFuture; |
| import com.google.common.util.concurrent.ListeningExecutorService; |
| import com.google.gerrit.common.data.SubmitRecord; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Branch; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.ChangeMessage; |
| 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.Project; |
| import com.google.gerrit.reviewdb.client.RefNames; |
| import com.google.gerrit.reviewdb.client.RevId; |
| import com.google.gerrit.reviewdb.server.ReviewDb; |
| import com.google.gerrit.reviewdb.server.ReviewDbUtil; |
| import com.google.gerrit.server.config.AllUsersName; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.project.NoSuchChangeException; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.query.change.ChangeData; |
| import com.google.gerrit.server.query.change.InternalChangeQuery; |
| import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.lib.Ref; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.Callable; |
| |
| /** View of a single {@link Change} based on the log of its notes branch. */ |
| public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> { |
| static final Ordering<PatchSetApproval> PSA_BY_TIME = |
| Ordering.natural().onResultOf( |
| new Function<PatchSetApproval, Timestamp>() { |
| @Override |
| public Timestamp apply(PatchSetApproval input) { |
| return input.getGranted(); |
| } |
| }); |
| |
| public static final Ordering<ChangeMessage> MESSAGE_BY_TIME = |
| Ordering.natural().onResultOf( |
| new Function<ChangeMessage, Timestamp>() { |
| @Override |
| public Timestamp apply(ChangeMessage input) { |
| return input.getWrittenOn(); |
| } |
| }); |
| |
| public static ConfigInvalidException parseException(Change.Id changeId, |
| String fmt, Object... args) { |
| return new ConfigInvalidException("Change " + changeId + ": " |
| + String.format(fmt, args)); |
| } |
| |
| public static Account.Id parseIdent(PersonIdent ident, Change.Id changeId) |
| throws ConfigInvalidException { |
| String email = ident.getEmailAddress(); |
| int at = email.indexOf('@'); |
| if (at >= 0) { |
| String host = email.substring(at + 1, email.length()); |
| Integer id = Ints.tryParse(email.substring(0, at)); |
| if (id != null && host.equals(GERRIT_PLACEHOLDER_HOST)) { |
| return new Account.Id(id); |
| } |
| } |
| throw parseException(changeId, "invalid identity, expected <id>@%s: %s", |
| GERRIT_PLACEHOLDER_HOST, email); |
| } |
| |
| @Singleton |
| public static class Factory { |
| private static final Logger log = LoggerFactory.getLogger(Factory.class); |
| |
| private final GitRepositoryManager repoManager; |
| private final NotesMigration migration; |
| private final AllUsersName allUsers; |
| private final Provider<InternalChangeQuery> queryProvider; |
| private final ProjectCache projectCache; |
| |
| @VisibleForTesting |
| @Inject |
| public Factory(GitRepositoryManager repoManager, |
| NotesMigration migration, |
| AllUsersName allUsers, |
| Provider<InternalChangeQuery> queryProvider, |
| ProjectCache projectCache) { |
| this.repoManager = repoManager; |
| this.migration = migration; |
| this.allUsers = allUsers; |
| this.queryProvider = queryProvider; |
| this.projectCache = projectCache; |
| } |
| |
| public ChangeNotes createChecked(ReviewDb db, Change c) |
| throws OrmException, NoSuchChangeException { |
| ChangeNotes notes = create(db, c.getProject(), c.getId()); |
| if (notes.getChange() == null) { |
| throw new NoSuchChangeException(c.getId()); |
| } |
| return notes; |
| } |
| |
| public ChangeNotes createChecked(ReviewDb db, Project.NameKey project, |
| Change.Id changeId) throws OrmException, NoSuchChangeException { |
| ChangeNotes notes = create(db, project, changeId); |
| if (notes.getChange() == null) { |
| throw new NoSuchChangeException(changeId); |
| } |
| return notes; |
| } |
| |
| public ChangeNotes createChecked(Change.Id changeId) |
| throws OrmException, NoSuchChangeException { |
| InternalChangeQuery query = queryProvider.get().noFields(); |
| List<ChangeData> changes = query.byLegacyChangeId(changeId); |
| if (changes.isEmpty()) { |
| throw new NoSuchChangeException(changeId); |
| } |
| if (changes.size() != 1) { |
| log.error( |
| String.format("Multiple changes found for %d", changeId.get())); |
| throw new NoSuchChangeException(changeId); |
| } |
| return changes.get(0).notes(); |
| } |
| |
| public ChangeNotes create(ReviewDb db, Project.NameKey project, |
| Change.Id changeId) throws OrmException { |
| Change change = unwrap(db).changes().get(changeId); |
| checkArgument(change.getProject().equals(project), |
| "passed project %s when creating ChangeNotes for %s, but actual" |
| + " project is %s", |
| project, changeId, change.getProject()); |
| // TODO: Throw NoSuchChangeException when the change is not found in the |
| // database |
| return new ChangeNotes(repoManager, migration, allUsers, project, |
| change).load(); |
| } |
| |
| /** |
| * Create change notes for a change that was loaded from index. This method |
| * should only be used when database access is harmful and potentially stale |
| * data from the index is acceptable. |
| * |
| * @param change change loaded from secondary index |
| * @return change notes |
| */ |
| public ChangeNotes createFromIndexedChange(Change change) { |
| return new ChangeNotes(repoManager, migration, allUsers, |
| change.getProject(), change); |
| } |
| |
| public ChangeNotes createForNew(Change change) throws OrmException { |
| return new ChangeNotes(repoManager, migration, allUsers, |
| change.getProject(), change).load(); |
| } |
| |
| // TODO(dborowitz): Remove when deleting index schemas <27. |
| public ChangeNotes createFromIdOnlyWhenNotedbDisabled( |
| ReviewDb db, Change.Id changeId) throws OrmException { |
| checkState(!migration.readChanges(), "do not call" |
| + " createFromIdOnlyWhenNotedbDisabled when notedb is enabled"); |
| Change change = unwrap(db).changes().get(changeId); |
| return new ChangeNotes(repoManager, migration, allUsers, |
| change.getProject(), change).load(); |
| } |
| |
| // TODO(ekempin): Remove when database backend is deleted |
| /** |
| * Instantiate ChangeNotes for a change that has been loaded by a batch read |
| * from the database. |
| */ |
| private ChangeNotes createFromChangeOnlyWhenNotedbDisabled(Change change) |
| throws OrmException { |
| checkState(!migration.readChanges(), "do not call" |
| + " createFromChangeWhenNotedbDisabled when notedb is enabled"); |
| return new ChangeNotes(repoManager, migration, allUsers, |
| change.getProject(), change).load(); |
| } |
| |
| public CheckedFuture<ChangeNotes, OrmException> createAsync( |
| final ListeningExecutorService executorService, final ReviewDb db, |
| final Project.NameKey project, final Change.Id changeId) { |
| return Futures.makeChecked( |
| Futures.transformAsync(unwrap(db).changes().getAsync(changeId), |
| new AsyncFunction<Change, ChangeNotes>() { |
| @Override |
| public ListenableFuture<ChangeNotes> apply( |
| final Change change) { |
| return executorService.submit(new Callable<ChangeNotes>() { |
| @Override |
| public ChangeNotes call() throws Exception { |
| checkArgument(change.getProject().equals(project), |
| "passed project %s when creating ChangeNotes for %s," |
| + " but actual project is %s", |
| project, changeId, change.getProject()); |
| return new ChangeNotes(repoManager, migration, |
| allUsers, project, change).load(); |
| } |
| }); |
| } |
| }), new Function<Exception, OrmException>() { |
| @Override |
| public OrmException apply(Exception e) { |
| if (e instanceof OrmException) { |
| return (OrmException) e; |
| } |
| return new OrmException(e); |
| } |
| }); |
| } |
| |
| public List<ChangeNotes> create(ReviewDb db, |
| Collection<Change.Id> changeIds) throws OrmException { |
| List<ChangeNotes> notes = new ArrayList<>(); |
| if (migration.enabled()) { |
| for (Change.Id changeId : changeIds) { |
| try { |
| notes.add(createChecked(changeId)); |
| } catch (NoSuchChangeException e) { |
| // Ignore missing changes to match Access#get(Iterable) behavior. |
| } |
| } |
| return notes; |
| } |
| |
| for (Change c : unwrap(db).changes().get(changeIds)) { |
| notes.add(createFromChangeOnlyWhenNotedbDisabled(c)); |
| } |
| return notes; |
| } |
| |
| public List<ChangeNotes> create(ReviewDb db, Project.NameKey project, |
| Collection<Change.Id> changeIds, Predicate<ChangeNotes> predicate) |
| throws OrmException { |
| List<ChangeNotes> notes = new ArrayList<>(); |
| if (migration.enabled()) { |
| for (Change.Id cid : changeIds) { |
| ChangeNotes cn = create(db, project, cid); |
| if (cn.getChange() != null && predicate.apply(cn)) { |
| notes.add(cn); |
| } |
| } |
| return notes; |
| } |
| |
| for (Change c : unwrap(db).changes().get(changeIds)) { |
| if (c != null && project.equals(c.getDest().getParentKey())) { |
| ChangeNotes cn = createFromChangeOnlyWhenNotedbDisabled(c); |
| if (predicate.apply(cn)) { |
| notes.add(cn); |
| } |
| } |
| } |
| return notes; |
| } |
| |
| public ListMultimap<Project.NameKey, ChangeNotes> create(ReviewDb db, |
| Predicate<ChangeNotes> predicate) throws IOException, OrmException { |
| ListMultimap<Project.NameKey, ChangeNotes> m = ArrayListMultimap.create(); |
| if (migration.readChanges()) { |
| for (Project.NameKey project : projectCache.all()) { |
| try (Repository repo = repoManager.openRepository(project)) { |
| List<ChangeNotes> changes = scanNotedb(repo, db, project); |
| for (ChangeNotes cn : changes) { |
| if (predicate.apply(cn)) { |
| m.put(project, cn); |
| } |
| } |
| } |
| } |
| } else { |
| for (Change change : unwrap(db).changes().all()) { |
| ChangeNotes notes = createFromChangeOnlyWhenNotedbDisabled(change); |
| if (predicate.apply(notes)) { |
| m.put(change.getProject(), notes); |
| } |
| } |
| } |
| return ImmutableListMultimap.copyOf(m); |
| } |
| |
| public List<ChangeNotes> scan(Repository repo, ReviewDb db, |
| Project.NameKey project) throws OrmException, IOException { |
| if (!migration.readChanges()) { |
| return scanDb(repo, db); |
| } |
| |
| return scanNotedb(repo, db, project); |
| } |
| |
| private List<ChangeNotes> scanDb(Repository repo, ReviewDb db) |
| throws OrmException, IOException { |
| Set<Change.Id> ids = scan(repo); |
| List<ChangeNotes> notes = new ArrayList<>(ids.size()); |
| // A batch size of N may overload get(Iterable), so use something smaller, |
| // but still >1. |
| for (List<Change.Id> batch : Iterables.partition(ids, 30)) { |
| for (Change change : unwrap(db).changes().get(batch)) { |
| notes.add(createFromChangeOnlyWhenNotedbDisabled(change)); |
| } |
| } |
| return notes; |
| } |
| |
| private List<ChangeNotes> scanNotedb(Repository repo, ReviewDb db, |
| Project.NameKey project) throws OrmException, IOException { |
| Set<Change.Id> ids = scan(repo); |
| List<ChangeNotes> changeNotes = new ArrayList<>(ids.size()); |
| for (Change.Id id : ids) { |
| changeNotes.add(create(db, project, id)); |
| } |
| return changeNotes; |
| } |
| |
| public static Set<Change.Id> scan(Repository repo) throws IOException { |
| Map<String, Ref> refs = |
| repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES); |
| Set<Change.Id> ids = new HashSet<>(refs.size()); |
| for (Ref r : refs.values()) { |
| Change.Id id = Change.Id.fromRef(r.getName()); |
| if (id != null) { |
| ids.add(id); |
| } |
| } |
| return ids; |
| } |
| |
| private static ReviewDb unwrap(ReviewDb db) { |
| if (db instanceof DisabledChangesReviewDbWrapper) { |
| db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate(); |
| } |
| return db; |
| } |
| } |
| |
| private final Project.NameKey project; |
| private final Change change; |
| private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets; |
| private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals; |
| private ImmutableSetMultimap<ReviewerStateInternal, Account.Id> reviewers; |
| private ImmutableList<Account.Id> allPastReviewers; |
| private ImmutableList<SubmitRecord> submitRecords; |
| private ImmutableList<ChangeMessage> allChangeMessages; |
| private ImmutableListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet; |
| private ImmutableListMultimap<RevId, PatchLineComment> comments; |
| private ImmutableSet<String> hashtags; |
| |
| // Parsed note map state, used by ChangeUpdate to make in-place editing of |
| // notes easier. |
| RevisionNoteMap revisionNoteMap; |
| |
| private final AllUsersName allUsers; |
| private DraftCommentNotes draftCommentNotes; |
| |
| @VisibleForTesting |
| public ChangeNotes(GitRepositoryManager repoManager, NotesMigration migration, |
| AllUsersName allUsers, Project.NameKey project, |
| Change change) { |
| super(repoManager, migration, change != null ? change.getId() : null); |
| this.allUsers = allUsers; |
| this.project = project; |
| this.change = change != null ? new Change(change) : null; |
| } |
| |
| public Change getChange() { |
| return change; |
| } |
| |
| public ImmutableMap<PatchSet.Id, PatchSet> getPatchSets() { |
| return patchSets; |
| } |
| |
| public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() { |
| return approvals; |
| } |
| |
| public ImmutableSetMultimap<ReviewerStateInternal, Account.Id> getReviewers() { |
| return reviewers; |
| } |
| |
| /** |
| * |
| * @return a ImmutableSet of all hashtags for this change sorted in alphabetical order. |
| */ |
| public ImmutableSet<String> getHashtags() { |
| return ImmutableSortedSet.copyOf(hashtags); |
| } |
| |
| /** |
| * @return a list of all users who have ever been a reviewer on this change. |
| */ |
| public ImmutableList<Account.Id> getAllPastReviewers() { |
| return allPastReviewers; |
| } |
| |
| /** |
| * @return submit records stored during the most recent submit; only for |
| * changes that were actually submitted. |
| */ |
| public ImmutableList<SubmitRecord> getSubmitRecords() { |
| return submitRecords; |
| } |
| |
| /** @return all change messages, in chronological order, oldest first. */ |
| public ImmutableList<ChangeMessage> getChangeMessages() { |
| return allChangeMessages; |
| } |
| |
| /** |
| * @return change messages by patch set, in chronological order, oldest |
| * first. |
| */ |
| public ImmutableListMultimap<PatchSet.Id, ChangeMessage> |
| getChangeMessagesByPatchSet() { |
| return changeMessagesByPatchSet; |
| } |
| |
| /** @return inline comments on each revision. */ |
| public ImmutableListMultimap<RevId, PatchLineComment> getComments() { |
| return comments; |
| } |
| |
| public ImmutableListMultimap<RevId, PatchLineComment> getDraftComments( |
| Account.Id author) throws OrmException { |
| loadDraftComments(author); |
| final Multimap<RevId, PatchLineComment> published = comments; |
| // Filter out any draft comments that also exist in the published map, in |
| // case the update to All-Users to delete them during the publish operation |
| // failed. |
| Multimap<RevId, PatchLineComment> filtered = Multimaps.filterEntries( |
| draftCommentNotes.getComments(), |
| new Predicate<Map.Entry<RevId, PatchLineComment>>() { |
| @Override |
| public boolean apply(Map.Entry<RevId, PatchLineComment> in) { |
| for (PatchLineComment c : published.get(in.getKey())) { |
| if (c.getKey().equals(in.getValue().getKey())) { |
| return false; |
| } |
| } |
| return true; |
| } |
| }); |
| return ImmutableListMultimap.copyOf( |
| filtered); |
| } |
| |
| /** |
| * If draft comments have already been loaded for this author, then they will |
| * not be reloaded. However, this method will load the comments if no draft |
| * comments have been loaded or if the caller would like the drafts for |
| * another author. |
| */ |
| private void loadDraftComments(Account.Id author) |
| throws OrmException { |
| if (draftCommentNotes == null || |
| !author.equals(draftCommentNotes.getAuthor())) { |
| draftCommentNotes = new DraftCommentNotes(repoManager, migration, |
| allUsers, getChangeId(), author); |
| draftCommentNotes.load(); |
| } |
| } |
| |
| @VisibleForTesting |
| DraftCommentNotes getDraftCommentNotes() { |
| return draftCommentNotes; |
| } |
| |
| public boolean containsComment(PatchLineComment c) throws OrmException { |
| if (containsCommentPublished(c)) { |
| return true; |
| } |
| loadDraftComments(c.getAuthor()); |
| return draftCommentNotes.containsComment(c); |
| } |
| |
| public boolean containsCommentPublished(PatchLineComment c) { |
| for (PatchLineComment l : getComments().values()) { |
| if (c.getKey().equals(l.getKey())) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| @Override |
| protected String getRefName() { |
| return ChangeNoteUtil.changeRefName(getChangeId()); |
| } |
| |
| public PatchSet getCurrentPatchSet() { |
| PatchSet.Id psId = change.currentPatchSetId(); |
| return checkNotNull(patchSets.get(psId), |
| "missing current patch set %s", psId.get()); |
| } |
| |
| @Override |
| protected void onLoad() throws IOException, ConfigInvalidException { |
| ObjectId rev = getRevision(); |
| if (rev == null) { |
| loadDefaults(); |
| return; |
| } |
| try (RevWalk walk = new RevWalk(reader); |
| ChangeNotesParser parser = new ChangeNotesParser(project, |
| change.getId(), rev, walk, repoManager)) { |
| parser.parseAll(); |
| |
| if (parser.status != null) { |
| change.setStatus(parser.status); |
| } |
| approvals = parser.buildApprovals(); |
| changeMessagesByPatchSet = parser.buildMessagesByPatchSet(); |
| allChangeMessages = parser.buildAllMessages(); |
| comments = ImmutableListMultimap.copyOf(parser.comments); |
| revisionNoteMap = parser.revisionNoteMap; |
| change.setKey(new Change.Key(parser.changeId)); |
| change.setDest(new Branch.NameKey(project, parser.branch)); |
| change.setTopic(Strings.emptyToNull(parser.topic)); |
| change.setCreatedOn(parser.createdOn); |
| change.setLastUpdatedOn(parser.lastUpdatedOn); |
| change.setOwner(parser.ownerId); |
| change.setSubmissionId(parser.submissionId); |
| patchSets = ImmutableSortedMap.copyOf( |
| parser.patchSets, ReviewDbUtil.intKeyOrdering()); |
| |
| if (!patchSets.isEmpty()) { |
| change.setCurrentPatchSet( |
| parser.currentPatchSetId, parser.subject, parser.originalSubject); |
| } else { |
| // TODO(dborowitz): This should be an error, but for now it's required |
| // for some tests to pass. |
| change.clearCurrentPatchSet(); |
| } |
| |
| if (parser.hashtags != null) { |
| hashtags = ImmutableSet.copyOf(parser.hashtags); |
| } else { |
| hashtags = ImmutableSet.of(); |
| } |
| ImmutableSetMultimap.Builder<ReviewerStateInternal, Account.Id> reviewers = |
| ImmutableSetMultimap.builder(); |
| for (Map.Entry<Account.Id, ReviewerStateInternal> e |
| : parser.reviewers.entrySet()) { |
| reviewers.put(e.getValue(), e.getKey()); |
| } |
| this.reviewers = reviewers.build(); |
| this.allPastReviewers = ImmutableList.copyOf(parser.allPastReviewers); |
| |
| submitRecords = ImmutableList.copyOf(parser.submitRecords); |
| } |
| } |
| |
| @Override |
| protected void loadDefaults() { |
| approvals = ImmutableListMultimap.of(); |
| reviewers = ImmutableSetMultimap.of(); |
| submitRecords = ImmutableList.of(); |
| allChangeMessages = ImmutableList.of(); |
| changeMessagesByPatchSet = ImmutableListMultimap.of(); |
| comments = ImmutableListMultimap.of(); |
| hashtags = ImmutableSet.of(); |
| } |
| |
| @Override |
| protected boolean onSave(CommitBuilder commit) { |
| throw new UnsupportedOperationException( |
| getClass().getSimpleName() + " is read-only"); |
| } |
| |
| @Override |
| public Project.NameKey getProjectName() { |
| return project; |
| } |
| } |