1   ////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code for adherence to a set of rules.
3   // Copyright (C) 2001-2019 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.internal;
21  
22  import static org.junit.Assert.assertEquals;
23  import static org.junit.Assert.assertTrue;
24  import static org.junit.Assert.fail;
25  
26  import java.io.IOException;
27  import java.util.Arrays;
28  import java.util.Collections;
29  import java.util.Iterator;
30  import java.util.LinkedList;
31  import java.util.List;
32  import java.util.Spliterator;
33  import java.util.Spliterators;
34  import java.util.regex.Pattern;
35  import java.util.stream.Collectors;
36  import java.util.stream.StreamSupport;
37  
38  import org.eclipse.jgit.api.Git;
39  import org.eclipse.jgit.api.errors.GitAPIException;
40  import org.eclipse.jgit.lib.Constants;
41  import org.eclipse.jgit.lib.ObjectId;
42  import org.eclipse.jgit.lib.Repository;
43  import org.eclipse.jgit.revwalk.RevCommit;
44  import org.eclipse.jgit.revwalk.RevWalk;
45  import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
46  import org.junit.BeforeClass;
47  import org.junit.Test;
48  
49  /**
50   * Validate commit message has proper structure.
51   *
52   * <p>Commits to check are resolved from different places according
53   * to type of commit in current HEAD. If current HEAD commit is
54   * non-merge commit , previous commits are resolved due to current
55   * HEAD commit. Otherwise if it is a merge commit, it will invoke
56   * resolving previous commits due to commits which was merged.</p>
57   *
58   * <p>After calculating commits to start with ts resolves previous
59   * commits according to COMMITS_RESOLUTION_MODE variable.
60   * At default(BY_LAST_COMMIT_AUTHOR) it checks first commit author
61   * and return all consecutive commits with same author. Second
62   * mode(BY_COUNTER) makes returning first PREVIOUS_COMMITS_TO_CHECK_COUNT
63   * commits after starter commit.</p>
64   *
65   * <p>Resolved commits are filtered according to author. If commit author
66   * belong to list USERS_EXCLUDED_FROM_VALIDATION then this commit will
67   * not be validated.</p>
68   *
69   * <p>Filtered commit list is checked if their messages has proper structure.</p>
70   *
71   */
72  public class CommitValidationTest {
73  
74      private static final List<String> USERS_EXCLUDED_FROM_VALIDATION =
75              Arrays.asList("Roman Ivanov", "rnveach");
76  
77      private static final String ISSUE_COMMIT_MESSAGE_REGEX_PATTERN = "^Issue #\\d+: .*$";
78      private static final String PR_COMMIT_MESSAGE_REGEX_PATTERN = "^Pull #\\d+: .*$";
79      private static final String OTHER_COMMIT_MESSAGE_REGEX_PATTERN =
80              "^(minor|config|infra|doc|spelling): .*$";
81  
82      private static final String ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN =
83                "(" + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
84                + "(" + PR_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
85                + "(" + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + ")";
86  
87      private static final Pattern ACCEPTED_COMMIT_MESSAGE_PATTERN =
88              Pattern.compile(ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN);
89  
90      private static final Pattern INVALID_POSTFIX_PATTERN = Pattern.compile("^.*[. \\t]$");
91  
92      private static final int PREVIOUS_COMMITS_TO_CHECK_COUNT = 10;
93  
94      private static final CommitsResolutionMode COMMITS_RESOLUTION_MODE =
95              CommitsResolutionMode.BY_LAST_COMMIT_AUTHOR;
96  
97      private static List<RevCommit> lastCommits;
98  
99      @BeforeClass
100     public static void setUp() throws Exception {
101         lastCommits = getCommitsToCheck();
102     }
103 
104     @Test
105     public void testHasCommits() {
106         assertTrue("must have at least one commit to validate",
107                 lastCommits != null && !lastCommits.isEmpty());
108     }
109 
110     @Test
111     public void testCommitMessage() {
112         assertEquals("should not accept commit message with periods on end", 3,
113                 validateCommitMessage("minor: Test. Test."));
114         assertEquals("should not accept commit message with spaces on end", 3,
115                 validateCommitMessage("minor: Test. "));
116         assertEquals("should not accept commit message with tabs on end", 3,
117                 validateCommitMessage("minor: Test.\t"));
118         assertEquals("should not accept commit message with period on end, ignoring new line",
119                 3, validateCommitMessage("minor: Test.\n"));
120         assertEquals("should not accept commit message with missing prefix", 1,
121                 validateCommitMessage("Test. Test"));
122         assertEquals("should not accept commit message with missing prefix", 1,
123                 validateCommitMessage("Test. Test\n"));
124         assertEquals("should not accept commit message with multiple lines with text", 2,
125                 validateCommitMessage("minor: Test.\nTest"));
126         assertEquals("should accept commit message with a new line on end", 0,
127                 validateCommitMessage("minor: Test\n"));
128         assertEquals("should accept commit message with multiple new lines on end", 0,
129                 validateCommitMessage("minor: Test\n\n"));
130         assertEquals("should accept commit message that ends properly", 0,
131                 validateCommitMessage("minor: Test. Test"));
132         assertEquals("should accept commit message with less than or equal to 200 characters",
133                 4, validateCommitMessage("minor: Test Test Test Test Test"
134                 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
135                 + "Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test "
136                 + "Test Test Test Test Test Test Test  Test Test Test Test Test Test"));
137     }
138 
139     @Test
140     public void testCommitMessageHasProperStructure() {
141         for (RevCommit commit : filterValidCommits(lastCommits)) {
142             final String commitMessage = commit.getFullMessage();
143             final int error = validateCommitMessage(commitMessage);
144 
145             if (error != 0) {
146                 final String commitId = commit.getId().getName();
147 
148                 fail(getInvalidCommitMessageFormattingError(commitId, commitMessage) + error);
149             }
150         }
151     }
152 
153     private static int validateCommitMessage(String commitMessage) {
154         final String message = commitMessage.replace("\r", "").replace("\n", "");
155         final String trimRight = commitMessage.replaceAll("[\\r\\n]+$", "");
156         final int result;
157 
158         if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
159             // improper prefix
160             result = 1;
161         }
162         else if (!trimRight.equals(message)) {
163             // single line of text (multiple new lines are allowed on end because of
164             // git (1 new line) and github's web ui (2 new lines))
165             result = 2;
166         }
167         else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
168             // improper postfix
169             result = 3;
170         }
171         else if (message.length() > 200) {
172             // commit message has more than 200 characters
173             result = 4;
174         }
175         else {
176             result = 0;
177         }
178 
179         return result;
180     }
181 
182     private static List<RevCommit> getCommitsToCheck() throws Exception {
183         final List<RevCommit> commits;
184         try (Repository repo = new FileRepositoryBuilder().findGitDir().build()) {
185             final RevCommitsPair revCommitsPair = resolveRevCommitsPair(repo);
186             if (COMMITS_RESOLUTION_MODE == CommitsResolutionMode.BY_COUNTER) {
187                 commits = getCommitsByCounter(revCommitsPair.getFirst());
188                 commits.addAll(getCommitsByCounter(revCommitsPair.getSecond()));
189             }
190             else {
191                 commits = getCommitsByLastCommitAuthor(revCommitsPair.getFirst());
192                 commits.addAll(getCommitsByLastCommitAuthor(revCommitsPair.getSecond()));
193             }
194         }
195         return commits;
196     }
197 
198     private static List<RevCommit> filterValidCommits(List<RevCommit> revCommits) {
199         final List<RevCommit> filteredCommits = new LinkedList<>();
200         for (RevCommit commit : revCommits) {
201             final String commitAuthor = commit.getAuthorIdent().getName();
202             if (!USERS_EXCLUDED_FROM_VALIDATION.contains(commitAuthor)) {
203                 filteredCommits.add(commit);
204             }
205         }
206         return filteredCommits;
207     }
208 
209     private static RevCommitsPair resolveRevCommitsPair(Repository repo) {
210         RevCommitsPair revCommitIteratorPair;
211 
212         try (RevWalk revWalk = new RevWalk(repo); Git git = new Git(repo)) {
213             final Iterator<RevCommit> first;
214             final Iterator<RevCommit> second;
215             final ObjectId headId = repo.resolve(Constants.HEAD);
216             final RevCommit headCommit = revWalk.parseCommit(headId);
217 
218             if (isMergeCommit(headCommit)) {
219                 final RevCommit firstParent = headCommit.getParent(0);
220                 final RevCommit secondParent = headCommit.getParent(1);
221                 first = git.log().add(firstParent).call().iterator();
222                 second = git.log().add(secondParent).call().iterator();
223             }
224             else {
225                 first = git.log().call().iterator();
226                 second = Collections.emptyIterator();
227             }
228 
229             revCommitIteratorPair =
230                     new RevCommitsPair(new OmitMergeCommitsIterator(first),
231                             new OmitMergeCommitsIterator(second));
232         }
233         catch (GitAPIException | IOException ignored) {
234             revCommitIteratorPair = new RevCommitsPair();
235         }
236 
237         return revCommitIteratorPair;
238     }
239 
240     private static boolean isMergeCommit(RevCommit currentCommit) {
241         return currentCommit.getParentCount() > 1;
242     }
243 
244     private static List<RevCommit> getCommitsByCounter(
245             Iterator<RevCommit> previousCommitsIterator) {
246         final Spliterator<RevCommit> spliterator =
247             Spliterators.spliteratorUnknownSize(previousCommitsIterator, Spliterator.ORDERED);
248         return StreamSupport.stream(spliterator, false).limit(PREVIOUS_COMMITS_TO_CHECK_COUNT)
249             .collect(Collectors.toList());
250     }
251 
252     private static List<RevCommit> getCommitsByLastCommitAuthor(
253             Iterator<RevCommit> previousCommitsIterator) {
254         final List<RevCommit> commits = new LinkedList<>();
255 
256         if (previousCommitsIterator.hasNext()) {
257             final RevCommit lastCommit = previousCommitsIterator.next();
258             final String lastCommitAuthor = lastCommit.getAuthorIdent().getName();
259             commits.add(lastCommit);
260 
261             boolean wasLastCheckedCommitAuthorSameAsLastCommit = true;
262             while (wasLastCheckedCommitAuthorSameAsLastCommit
263                     && previousCommitsIterator.hasNext()) {
264                 final RevCommit currentCommit = previousCommitsIterator.next();
265                 final String currentCommitAuthor = currentCommit.getAuthorIdent().getName();
266                 if (currentCommitAuthor.equals(lastCommitAuthor)) {
267                     commits.add(currentCommit);
268                 }
269                 else {
270                     wasLastCheckedCommitAuthorSameAsLastCommit = false;
271                 }
272             }
273         }
274 
275         return commits;
276     }
277 
278     private static String getRulesForCommitMessageFormatting() {
279         return "Proper commit message should adhere to the following rules:\n"
280                 + "    1) Must match one of the following patterns:\n"
281                 + "        " + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
282                 + "        " + PR_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
283                 + "        " + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
284                 + "    2) It contains only one line of text\n"
285                 + "    3) Must not end with a period, space, or tab\n"
286                 + "    4) Commit message should be less than or equal to 200 characters\n"
287                 + "\n"
288                 + "The rule broken was: ";
289     }
290 
291     private static String getInvalidCommitMessageFormattingError(String commitId,
292             String commitMessage) {
293         return "Commit " + commitId + " message: \""
294                 + commitMessage.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t")
295                 + "\" is invalid\n" + getRulesForCommitMessageFormatting();
296     }
297 
298     private enum CommitsResolutionMode {
299 
300         BY_COUNTER,
301         BY_LAST_COMMIT_AUTHOR,
302 
303     }
304 
305     private static class RevCommitsPair {
306 
307         private final Iterator<RevCommit> first;
308         private final Iterator<RevCommit> second;
309 
310         /* package */ RevCommitsPair() {
311             first = Collections.emptyIterator();
312             second = Collections.emptyIterator();
313         }
314 
315         /* package */ RevCommitsPair(Iterator<RevCommit> first, Iterator<RevCommit> second) {
316             this.first = first;
317             this.second = second;
318         }
319 
320         public Iterator<RevCommit> getFirst() {
321             return first;
322         }
323 
324         public Iterator<RevCommit> getSecond() {
325             return second;
326         }
327 
328     }
329 
330     private static class OmitMergeCommitsIterator implements Iterator<RevCommit> {
331 
332         private final Iterator<RevCommit> revCommitIterator;
333 
334         /* package */ OmitMergeCommitsIterator(Iterator<RevCommit> revCommitIterator) {
335             this.revCommitIterator = revCommitIterator;
336         }
337 
338         @Override
339         public boolean hasNext() {
340             return revCommitIterator.hasNext();
341         }
342 
343         @Override
344         public RevCommit next() {
345             RevCommit currentCommit = revCommitIterator.next();
346             while (isMergeCommit(currentCommit)) {
347                 currentCommit = revCommitIterator.next();
348             }
349             return currentCommit;
350         }
351 
352         @Override
353         public void remove() {
354             throw new UnsupportedOperationException("remove");
355         }
356 
357     }
358 
359 }