summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/comment/CommentContextLoader.java
blob: 8fbb25913dd15f7565c776939c898fae466d3443 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
// Copyright (C) 2020 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.comment;

import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.MERGE_LIST;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.stream.Collectors.groupingBy;

import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.PatchScript;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.CommentContext;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.server.change.FileContentUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.mime.FileTypeRegistry;
import com.google.gerrit.server.patch.ComparisonType;
import com.google.gerrit.server.patch.SrcContentResolver;
import com.google.gerrit.server.patch.Text;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import eu.medsea.mimeutil.MimeType;
import eu.medsea.mimeutil.MimeUtil2;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;

/**
 * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
 * source file surrounding and including the area where the comment was written.
 */
public class CommentContextLoader {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  private final FileTypeRegistry registry;
  private final GitRepositoryManager repoManager;
  private final Project.NameKey project;
  private final ProjectState projectState;

  public interface Factory {
    CommentContextLoader create(Project.NameKey project);
  }

  @Inject
  CommentContextLoader(
      FileTypeRegistry registry,
      GitRepositoryManager repoManager,
      ProjectCache projectCache,
      @Assisted Project.NameKey project) {
    this.registry = registry;
    this.repoManager = repoManager;
    this.project = project;
    projectState = projectCache.get(project).orElseThrow(illegalState(project));
  }

  /**
   * Load the comment context for multiple contextInputs at once. This method will open the
   * repository and read the source files for all necessary contextInputs' file paths.
   *
   * @param contextInputs a list of contextInputs.
   * @return a Map where all entries consist of the input contextInputs and the values are their
   *     corresponding {@link CommentContext}.
   */
  public Map<ContextInput, CommentContext> getContext(Collection<ContextInput> contextInputs)
      throws IOException {
    ImmutableMap.Builder<ContextInput, CommentContext> result =
        ImmutableMap.builderWithExpectedSize(Iterables.size(contextInputs));

    // Group contextInputs by commit ID so that each commit is parsed only once
    Map<ObjectId, List<ContextInput>> commentsByCommitId =
        contextInputs.stream().collect(groupingBy(ContextInput::commitId));

    try (Repository repo = repoManager.openRepository(project);
        RevWalk rw = new RevWalk(repo)) {
      for (ObjectId commitId : commentsByCommitId.keySet()) {
        RevCommit commit;
        try {
          commit = rw.parseCommit(commitId);
        } catch (IncorrectObjectTypeException | MissingObjectException e) {
          logger.atWarning().log("Commit %s is missing or has an incorrect object type", commitId);
          commentsByCommitId
              .get(commitId)
              .forEach(contextInput -> result.put(contextInput, CommentContext.empty()));
          continue;
        }
        for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
          Optional<Range> range = getStartAndEndLines(contextInput);
          if (!range.isPresent()) {
            result.put(contextInput, CommentContext.empty());
            continue;
          }
          String filePath = contextInput.filePath();
          switch (filePath) {
            case COMMIT_MSG:
              result.put(
                  contextInput,
                  getContextForCommitMessage(
                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
              break;
            case MERGE_LIST:
              result.put(
                  contextInput,
                  getContextForMergeList(
                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
              break;
            default:
              result.put(
                  contextInput,
                  getContextForFilePath(
                      repo, rw, commit, filePath, range.get(), contextInput.contextPadding()));
          }
        }
      }
      return result.build();
    }
  }

  private CommentContext getContextForCommitMessage(
      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
      throws IOException {
    Text text = Text.forCommit(reader, commit);
    return createContext(
        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
  }

  private CommentContext getContextForMergeList(
      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
      throws IOException {
    ComparisonType cmp = ComparisonType.againstParent(1);
    Text text = Text.forMergeList(cmp, reader, commit);
    return createContext(
        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_MERGE_LIST);
  }

  private CommentContext getContextForFilePath(
      Repository repo,
      RevWalk rw,
      RevCommit commit,
      String filePath,
      Range commentRange,
      int contextPadding)
      throws IOException {
    // TODO(ghareeb): We can further group the comments by file paths to avoid opening
    // the same file multiple times.
    try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filePath, commit.getTree())) {
      if (tw == null) {
        logger.atWarning().log(
            "Could not find path %s in the git tree of ID %s.", filePath, commit.getTree().getId());
        return CommentContext.empty();
      }
      ObjectId id = tw.getObjectId(0);
      byte[] sourceContent = SrcContentResolver.getSourceContent(repo, id, tw.getFileMode(0));
      Text textSrc = new Text(sourceContent);
      String contentType = getContentType(tw, filePath, textSrc);
      return createContext(textSrc, commentRange, contextPadding, contentType);
    }
  }

  private String getContentType(TreeWalk tw, String filePath, Text src) {
    PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(tw.getFileMode(0));
    String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
    if (src.size() > 0 && PatchScript.FileMode.SYMLINK != fileMode) {
      MimeType registryMimeType = registry.getMimeType(filePath, src.getContent());
      mimeType = registryMimeType.toString();
    }
    return FileContentUtil.resolveContentType(projectState, filePath, fileMode, mimeType);
  }

  private static CommentContext createContext(
      Text src, Range commentRange, int contextPadding, String contentType) {
    if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
      // TODO(ghareeb): We should throw an exception in this case. See
      // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
      // diff contains an extra line not in the original file.
      return CommentContext.empty();
    }
    commentRange = adjustRange(commentRange, contextPadding, src.size());
    ImmutableMap.Builder<Integer, String> context =
        ImmutableMap.builderWithExpectedSize(commentRange.end() - commentRange.start());
    for (int i = commentRange.start(); i < commentRange.end(); i++) {
      context.put(i, src.getString(i - 1));
    }
    return CommentContext.create(context.build(), contentType);
  }

  /**
   * Adjust the {@code commentRange} parameter by adding {@code contextPadding} lines before and
   * after the comment range.
   */
  private static Range adjustRange(Range commentRange, int contextPadding, int fileLines) {
    int newStartLine = commentRange.start() - contextPadding;
    int newEndLine = commentRange.end() + contextPadding;
    return Range.create(Math.max(1, newStartLine), Math.min(fileLines + 1, newEndLine));
  }

  private static Optional<Range> getStartAndEndLines(ContextInput comment) {
    if (comment.range() != null) {
      return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
    } else if (comment.lineNumber() > 0) {
      return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
    }
    return Optional.empty();
  }

  @AutoValue
  abstract static class Range {
    static Range create(int start, int end) {
      return new AutoValue_CommentContextLoader_Range(start, end);
    }

    /** Start line of the comment (inclusive). */
    abstract int start();

    /** End line of the comment (exclusive). */
    abstract int end();

    /** Number of lines covered by this range. */
    int size() {
      return end() - start();
    }
  }

  /** This entity only contains comment fields needed to load the comment context. */
  @AutoValue
  abstract static class ContextInput {
    static ContextInput fromComment(Comment comment, int contextPadding) {
      return new AutoValue_CommentContextLoader_ContextInput.Builder()
          .commitId(comment.getCommitId())
          .filePath(comment.key.filename)
          .range(comment.range)
          .lineNumber(comment.lineNbr)
          .contextPadding(contextPadding)
          .build();
    }

    /** 20 bytes SHA-1 of the patchset commit containing the file where the comment is written. */
    abstract ObjectId commitId();

    /** File path where the comment is written. */
    abstract String filePath();

    /**
     * Position of the comment in the file (start line, start char, end line, end char). This field
     * can be null if the range is not available for this comment.
     */
    @Nullable
    abstract Comment.Range range();

    /**
     * The 1-based line number where the comment is written. A value 0 means that the line number is
     * not available for this comment.
     */
    abstract Integer lineNumber();

    /** Number of extra lines of context that should be added before and after the comment range. */
    abstract Integer contextPadding();

    @AutoValue.Builder
    public abstract static class Builder {

      public abstract Builder commitId(ObjectId commitId);

      public abstract Builder filePath(String filePath);

      public abstract Builder range(@Nullable Comment.Range range);

      public abstract Builder lineNumber(Integer lineNumber);

      public abstract Builder contextPadding(Integer contextPadding);

      public abstract ContextInput build();
    }
  }
}