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;
16 :
17 : import static com.google.common.base.MoreObjects.firstNonNull;
18 : import static com.google.common.base.Preconditions.checkArgument;
19 : import static java.util.Comparator.comparing;
20 : import static java.util.stream.Collectors.toCollection;
21 : import static java.util.stream.Collectors.toList;
22 :
23 : import com.google.common.collect.ComparisonChain;
24 : import com.google.common.collect.Lists;
25 : import com.google.common.collect.Ordering;
26 : import com.google.gerrit.common.Nullable;
27 : import com.google.gerrit.entities.Account;
28 : import com.google.gerrit.entities.Change;
29 : import com.google.gerrit.entities.ChangeMessage;
30 : import com.google.gerrit.entities.Comment;
31 : import com.google.gerrit.entities.HumanComment;
32 : import com.google.gerrit.entities.Patch;
33 : import com.google.gerrit.entities.PatchSet;
34 : import com.google.gerrit.entities.Project;
35 : import com.google.gerrit.entities.RefNames;
36 : import com.google.gerrit.entities.RobotComment;
37 : import com.google.gerrit.exceptions.StorageException;
38 : import com.google.gerrit.extensions.client.Side;
39 : import com.google.gerrit.extensions.common.CommentInfo;
40 : import com.google.gerrit.server.config.AllUsersName;
41 : import com.google.gerrit.server.config.GerritServerId;
42 : import com.google.gerrit.server.git.GitRepositoryManager;
43 : import com.google.gerrit.server.notedb.ChangeNotes;
44 : import com.google.gerrit.server.notedb.ChangeUpdate;
45 : import com.google.gerrit.server.patch.DiffNotAvailableException;
46 : import com.google.gerrit.server.patch.DiffOperations;
47 : import com.google.gerrit.server.patch.DiffOptions;
48 : import com.google.gerrit.server.patch.filediff.FileDiffOutput;
49 : import com.google.gerrit.server.update.ChangeContext;
50 : import com.google.inject.Inject;
51 : import com.google.inject.Singleton;
52 : import java.io.IOException;
53 : import java.time.Instant;
54 : import java.util.ArrayList;
55 : import java.util.Collection;
56 : import java.util.HashSet;
57 : import java.util.List;
58 : import java.util.Map;
59 : import java.util.Optional;
60 : import java.util.Set;
61 : import org.eclipse.jgit.lib.ObjectId;
62 : import org.eclipse.jgit.lib.Ref;
63 : import org.eclipse.jgit.lib.Repository;
64 : import org.eclipse.jgit.revwalk.RevCommit;
65 :
66 : /** Utility functions to manipulate Comments. */
67 : @Singleton
68 : public class CommentsUtil {
69 152 : public static final Ordering<Comment> COMMENT_ORDER =
70 152 : new Ordering<>() {
71 : @Override
72 : public int compare(Comment c1, Comment c2) {
73 20 : return ComparisonChain.start()
74 20 : .compare(c1.key.filename, c2.key.filename)
75 20 : .compare(c1.key.patchSetId, c2.key.patchSetId)
76 20 : .compare(c1.side, c2.side)
77 20 : .compare(c1.lineNbr, c2.lineNbr)
78 20 : .compare(c1.writtenOn, c2.writtenOn)
79 20 : .result();
80 : }
81 : };
82 :
83 152 : public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
84 152 : new Ordering<>() {
85 : @Override
86 : public int compare(CommentInfo a, CommentInfo b) {
87 11 : return ComparisonChain.start()
88 11 : .compare(a.path, b.path, NULLS_FIRST)
89 11 : .compare(a.patchSet, b.patchSet, NULLS_FIRST)
90 11 : .compare(side(a), side(b))
91 11 : .compare(a.line, b.line, NULLS_FIRST)
92 11 : .compare(a.inReplyTo, b.inReplyTo, NULLS_FIRST)
93 11 : .compare(a.message, b.message)
94 11 : .compare(a.id, b.id)
95 11 : .result();
96 : }
97 :
98 : private int side(CommentInfo c) {
99 11 : return firstNonNull(c.side, Side.REVISION).ordinal();
100 : }
101 : };
102 :
103 : public static PatchSet.Id getCommentPsId(Change.Id changeId, Comment comment) {
104 0 : return PatchSet.id(changeId, comment.key.patchSetId);
105 : }
106 :
107 : @Nullable
108 : public static String extractMessageId(@Nullable String tag) {
109 2 : if (tag == null || !tag.startsWith("mailMessageId=")) {
110 2 : return null;
111 : }
112 1 : return tag.substring("mailMessageId=".length());
113 : }
114 :
115 152 : private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
116 :
117 : private final DiffOperations diffOperations;
118 : private final GitRepositoryManager repoManager;
119 : private final AllUsersName allUsers;
120 : private final String serverId;
121 :
122 : @Inject
123 : CommentsUtil(
124 : DiffOperations diffOperations,
125 : GitRepositoryManager repoManager,
126 : AllUsersName allUsers,
127 152 : @GerritServerId String serverId) {
128 152 : this.diffOperations = diffOperations;
129 152 : this.repoManager = repoManager;
130 152 : this.allUsers = allUsers;
131 152 : this.serverId = serverId;
132 152 : }
133 :
134 : public HumanComment newHumanComment(
135 : ChangeNotes changeNotes,
136 : CurrentUser currentUser,
137 : Instant when,
138 : String path,
139 : PatchSet.Id psId,
140 : short side,
141 : String message,
142 : @Nullable Boolean unresolved,
143 : @Nullable String parentUuid) {
144 28 : if (unresolved == null) {
145 23 : if (parentUuid == null) {
146 : // Default to false if comment is not descended from another.
147 23 : unresolved = false;
148 : } else {
149 : // Inherit unresolved value from inReplyTo comment if not specified.
150 4 : Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
151 4 : Optional<HumanComment> parent = getPublishedHumanComment(changeNotes, key);
152 :
153 : // If the comment was not found, it is descended from a robot comment, or the UUID is
154 : // invalid. Either way, we use the default.
155 4 : unresolved = parent.map(p -> p.unresolved).orElse(false);
156 : }
157 : }
158 28 : HumanComment c =
159 : new HumanComment(
160 28 : new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
161 28 : currentUser.getAccountId(),
162 : when,
163 : side,
164 : message,
165 : serverId,
166 28 : unresolved);
167 28 : c.parentUuid = parentUuid;
168 28 : currentUser.updateRealAccountId(c::setRealAuthor);
169 28 : return c;
170 : }
171 :
172 : public RobotComment newRobotComment(
173 : ChangeContext ctx,
174 : String path,
175 : PatchSet.Id psId,
176 : short side,
177 : String message,
178 : String robotId,
179 : String robotRunId) {
180 9 : RobotComment c =
181 : new RobotComment(
182 9 : new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
183 9 : ctx.getUser().getAccountId(),
184 9 : ctx.getWhen(),
185 : side,
186 : message,
187 : serverId,
188 : robotId,
189 : robotRunId);
190 9 : ctx.getUser().updateRealAccountId(c::setRealAuthor);
191 9 : return c;
192 : }
193 :
194 : public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, Comment.Key key) {
195 6 : return publishedHumanCommentsByChange(notes).stream()
196 6 : .filter(c -> key.equals(c.key))
197 6 : .findFirst();
198 : }
199 :
200 : public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, String uuid) {
201 4 : return publishedHumanCommentsByChange(notes).stream()
202 4 : .filter(c -> c.key.uuid.equals(uuid))
203 4 : .findFirst();
204 : }
205 :
206 : public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
207 9 : return draftByChangeAuthor(notes, user.getAccountId()).stream()
208 9 : .filter(c -> key.equals(c.key))
209 9 : .findFirst();
210 : }
211 :
212 : public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
213 103 : notes.load();
214 103 : return sort(Lists.newArrayList(notes.getHumanComments().values()));
215 : }
216 :
217 : public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
218 103 : notes.load();
219 103 : return sort(Lists.newArrayList(notes.getRobotComments().values()));
220 : }
221 :
222 : public Optional<RobotComment> getRobotComment(ChangeNotes notes, String uuid) {
223 3 : return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
224 : }
225 :
226 : public List<HumanComment> draftByChange(ChangeNotes notes) {
227 2 : List<HumanComment> comments = new ArrayList<>();
228 2 : for (Ref ref : getDraftRefs(notes.getChangeId())) {
229 2 : Account.Id account = Account.Id.fromRefSuffix(ref.getName());
230 2 : if (account != null) {
231 2 : comments.addAll(draftByChangeAuthor(notes, account));
232 : }
233 2 : }
234 2 : return sort(comments);
235 : }
236 :
237 : public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
238 0 : List<HumanComment> comments = new ArrayList<>();
239 0 : comments.addAll(publishedByPatchSet(notes, psId));
240 :
241 0 : for (Ref ref : getDraftRefs(notes.getChangeId())) {
242 0 : Account.Id account = Account.Id.fromRefSuffix(ref.getName());
243 0 : if (account != null) {
244 0 : comments.addAll(draftByPatchSetAuthor(psId, account, notes));
245 : }
246 0 : }
247 0 : return sort(comments);
248 : }
249 :
250 : public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
251 0 : return commentsOnFile(notes.load().getHumanComments().values(), file);
252 : }
253 :
254 : public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
255 13 : return removeCommentsOnAncestorOfCommitMessage(
256 13 : commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
257 : }
258 :
259 : public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
260 4 : return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
261 : }
262 :
263 : /**
264 : * This method populates the "changeMessageId" field of the comments parameter based on timestamp
265 : * matching. The comments objects will be modified.
266 : *
267 : * <p>Each comment will be matched to the nearest next change message in timestamp
268 : *
269 : * @param comments the list of comments
270 : * @param changeMessages list of change messages
271 : */
272 : public static void linkCommentsToChangeMessages(
273 : List<? extends CommentInfo> comments,
274 : List<ChangeMessage> changeMessages,
275 : boolean skipAutoGeneratedMessages) {
276 14 : ArrayList<ChangeMessage> sortedChangeMessages =
277 14 : changeMessages.stream()
278 14 : .sorted(comparing(ChangeMessage::getWrittenOn))
279 14 : .collect(toCollection(ArrayList::new));
280 :
281 14 : ArrayList<CommentInfo> sortedCommentInfos =
282 14 : comments.stream().sorted(comparing(c -> c.updated)).collect(toCollection(ArrayList::new));
283 :
284 14 : int cmItr = 0;
285 14 : for (CommentInfo comment : sortedCommentInfos) {
286 : // Keep advancing the change message pointer until we associate the comment to the next change
287 : // message in timestamp
288 14 : while (cmItr < sortedChangeMessages.size()) {
289 14 : ChangeMessage cm = sortedChangeMessages.get(cmItr);
290 14 : if (isAfter(comment, cm) || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
291 13 : cmItr += 1;
292 : } else {
293 : break;
294 : }
295 13 : }
296 14 : if (cmItr < changeMessages.size()) {
297 14 : comment.changeMessageId = sortedChangeMessages.get(cmItr).getKey().uuid();
298 : }
299 14 : }
300 14 : }
301 :
302 : private static boolean isAutoGenerated(ChangeMessage cm) {
303 : // Ignore Gerrit auto-generated messages, allowing to link against human change messages that
304 : // have an auto-generated tag
305 12 : return ChangeMessagesUtil.isAutogeneratedByGerrit(cm.getTag());
306 : }
307 :
308 : private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
309 14 : return c.getUpdated().isAfter(cm.getWrittenOn());
310 : }
311 :
312 : /**
313 : * For the commit message the A side in a diff view is always empty when a comparison against an
314 : * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
315 : * the auto-merge commit message on side A when for a merge commit a comparison against the
316 : * auto-merge was done. From that time there may still be comments on the auto-merge commit
317 : * message and those we want to filter out.
318 : */
319 : private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
320 13 : return list.stream()
321 13 : .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
322 13 : .collect(toList());
323 : }
324 :
325 : public List<HumanComment> draftByPatchSetAuthor(
326 : PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
327 26 : return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
328 : }
329 :
330 : public List<HumanComment> draftByChangeFileAuthor(
331 : ChangeNotes notes, String file, Account.Id author) {
332 0 : return commentsOnFile(notes.load().getDraftComments(author).values(), file);
333 : }
334 :
335 : public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
336 16 : List<HumanComment> comments = new ArrayList<>();
337 16 : comments.addAll(notes.getDraftComments(author).values());
338 16 : return sort(comments);
339 : }
340 :
341 : public void putHumanComments(
342 : ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
343 66 : for (HumanComment c : comments) {
344 25 : update.putComment(status, c);
345 25 : }
346 66 : }
347 :
348 : public void putRobotComments(ChangeUpdate update, Iterable<RobotComment> comments) {
349 7 : for (RobotComment c : comments) {
350 7 : update.putRobotComment(c);
351 7 : }
352 7 : }
353 :
354 : public void deleteHumanComments(ChangeUpdate update, Iterable<HumanComment> comments) {
355 9 : for (HumanComment c : comments) {
356 9 : update.deleteComment(c);
357 9 : }
358 9 : }
359 :
360 : public void deleteCommentByRewritingHistory(
361 : ChangeUpdate update, Comment.Key commentKey, String newMessage) {
362 3 : update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
363 3 : }
364 :
365 : private static List<HumanComment> commentsOnFile(
366 : Collection<HumanComment> allComments, String file) {
367 0 : List<HumanComment> result = new ArrayList<>(allComments.size());
368 0 : for (HumanComment c : allComments) {
369 0 : String currentFilename = c.key.filename;
370 0 : if (currentFilename.equals(file)) {
371 0 : result.add(c);
372 : }
373 0 : }
374 0 : return sort(result);
375 : }
376 :
377 : private static <T extends Comment> List<T> commentsOnPatchSet(
378 : Collection<T> allComments, PatchSet.Id psId) {
379 30 : List<T> result = new ArrayList<>(allComments.size());
380 30 : for (T c : allComments) {
381 23 : if (c.key.patchSetId == psId.get()) {
382 23 : result.add(c);
383 : }
384 23 : }
385 30 : return sort(result);
386 : }
387 :
388 : public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
389 28 : checkArgument(
390 28 : c.key.patchSetId == ps.id().get(),
391 : "cannot set commit ID for patch set %s on comment %s",
392 28 : ps.id(),
393 : c);
394 28 : if (c.getCommitId() == null) {
395 : // This code is very much down into our stack and shouldn't be used for validation. Hence,
396 : // don't throw an exception here if we can't find a commit for the indicated side but
397 : // simply use the all-null ObjectId.
398 28 : c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
399 : }
400 28 : }
401 :
402 : /**
403 : * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
404 : *
405 : * @param change the change to which the commit belongs
406 : * @param patchset the patchset to which the commit belongs
407 : * @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
408 : * 0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
409 : * parent commit of a merge commit
410 : * @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
411 : * change/patchset
412 : * @throws StorageException if the SHA-1 is unavailable for an unknown reason
413 : */
414 : public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
415 28 : if (Side.fromShort(side) == Side.PARENT) {
416 3 : if (side < 0) {
417 3 : int parentNumber = Math.abs(side);
418 3 : return resolveParentCommit(change.getProject(), patchset, parentNumber);
419 : }
420 3 : return Optional.ofNullable(resolveAutoMergeCommit(change, patchset));
421 : }
422 28 : return Optional.of(patchset.commitId());
423 : }
424 :
425 : private Optional<ObjectId> resolveParentCommit(
426 : Project.NameKey project, PatchSet patchset, int parentNumber) {
427 3 : try (Repository repository = repoManager.openRepository(project)) {
428 3 : RevCommit commit = repository.parseCommit(patchset.commitId());
429 3 : if (commit.getParentCount() < parentNumber) {
430 3 : return Optional.empty();
431 : }
432 3 : return Optional.of(commit.getParent(parentNumber - 1));
433 3 : } catch (IOException e) {
434 0 : throw new StorageException(e);
435 : }
436 : }
437 :
438 : @Nullable
439 : private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
440 : try {
441 : // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
442 : // unignore the test in PortedCommentsIT.
443 3 : Map<String, FileDiffOutput> modifiedFiles =
444 3 : diffOperations.listModifiedFilesAgainstParent(
445 3 : change.getProject(), patchset.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
446 3 : return modifiedFiles.isEmpty()
447 0 : ? null
448 3 : : modifiedFiles.values().iterator().next().oldCommitId();
449 0 : } catch (DiffNotAvailableException e) {
450 0 : throw new StorageException(e);
451 : }
452 : }
453 :
454 : /**
455 : * Get NoteDb draft refs for a change.
456 : *
457 : * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
458 : * comments. A zombie draft is one which has been published but the write to delete the draft ref
459 : * from All-Users failed.
460 : *
461 : * @param changeId change ID.
462 : * @return raw refs from All-Users repo.
463 : */
464 : public Collection<Ref> getDraftRefs(Change.Id changeId) {
465 5 : try (Repository repo = repoManager.openRepository(allUsers)) {
466 5 : return getDraftRefs(repo, changeId);
467 0 : } catch (IOException e) {
468 0 : throw new StorageException(e);
469 : }
470 : }
471 :
472 : /** returns all changes that contain draft comments of {@code accountId}. */
473 : public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
474 7 : try (Repository repo = repoManager.openRepository(allUsers)) {
475 7 : return getChangesWithDrafts(repo, accountId);
476 0 : } catch (IOException e) {
477 0 : throw new StorageException(e);
478 : }
479 : }
480 :
481 : private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
482 5 : return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
483 : }
484 :
485 : private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
486 : throws IOException {
487 7 : Set<Change.Id> changes = new HashSet<>();
488 7 : for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
489 7 : Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
490 7 : if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
491 7 : Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
492 7 : if (changeId == null) {
493 0 : continue;
494 : }
495 7 : changes.add(changeId);
496 : }
497 7 : }
498 7 : return changes;
499 : }
500 :
501 : private static <T extends Comment> List<T> sort(List<T> comments) {
502 103 : comments.sort(COMMENT_ORDER);
503 103 : return comments;
504 : }
505 : }
|