View Javadoc
1   ////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code for adherence to a set of rules.
3   // Copyright (C) 2001-2017 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   * @author <a href="mailto:piotr.listkiewicz@gmail.com">liscju</a>
72   */
73  public class CommitValidationTest {
74  
75      private static final List<String> USERS_EXCLUDED_FROM_VALIDATION =
76              Arrays.asList("Roman Ivanov", "rnveach");
77  
78      private static final String ISSUE_COMMIT_MESSAGE_REGEX_PATTERN = "^Issue #\\d+: .*$";
79      private static final String PR_COMMIT_MESSAGE_REGEX_PATTERN = "^Pull #\\d+: .*$";
80      private static final String OTHER_COMMIT_MESSAGE_REGEX_PATTERN =
81              "^(minor|config|infra|doc|spelling): .*$";
82  
83      private static final String ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN =
84                "(" + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
85                + "(" + PR_COMMIT_MESSAGE_REGEX_PATTERN + ")|"
86                + "(" + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + ")";
87  
88      private static final Pattern ACCEPTED_COMMIT_MESSAGE_PATTERN =
89              Pattern.compile(ACCEPTED_COMMIT_MESSAGE_REGEX_PATTERN);
90  
91      private static final Pattern INVALID_POSTFIX_PATTERN = Pattern.compile("^.*[. \\t]$");
92  
93      private static final int PREVIOUS_COMMITS_TO_CHECK_COUNT = 10;
94  
95      private static final CommitsResolutionMode COMMITS_RESOLUTION_MODE =
96              CommitsResolutionMode.BY_LAST_COMMIT_AUTHOR;
97  
98      private static List<RevCommit> lastCommits;
99  
100     @BeforeClass
101     public static void setUp() throws Exception {
102         lastCommits = getCommitsToCheck();
103     }
104 
105     @Test
106     public void testHasCommits() {
107         assertTrue("must have at least one commit to validate",
108                 lastCommits != null && !lastCommits.isEmpty());
109     }
110 
111     @Test
112     public void testCommitMessage() {
113         assertEquals("should not accept commit message with periods on end", 3,
114                 validateCommitMessage("minor: Test. Test."));
115         assertEquals("should not accept commit message with spaces on end", 3,
116                 validateCommitMessage("minor: Test. "));
117         assertEquals("should not accept commit message with tabs on end", 3,
118                 validateCommitMessage("minor: Test.\t"));
119         assertEquals("should not accept commit message with period on end, ignoring new line",
120                 3, validateCommitMessage("minor: Test.\n"));
121         assertEquals("should not accept commit message with missing prefix", 1,
122                 validateCommitMessage("Test. Test"));
123         assertEquals("should not accept commit message with missing prefix", 1,
124                 validateCommitMessage("Test. Test\n"));
125         assertEquals("should not accept commit message with multiple lines with text", 2,
126                 validateCommitMessage("minor: Test.\nTest"));
127         assertEquals("should accept commit message with a new line on end", 0,
128                 validateCommitMessage("minor: Test\n"));
129         assertEquals("should accept commit message with multiple new lines on end", 0,
130                 validateCommitMessage("minor: Test\n\n"));
131         assertEquals("should accept commit message that ends properly", 0,
132                 validateCommitMessage("minor: Test. Test"));
133         assertEquals("should accept commit message with less than or equal to 200 characters",
134                 4, validateCommitMessage("minor: Test Test Test Test Test"
135                 + "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 Test Test Test "
137                 + "Test Test Test Test Test Test Test  Test Test Test Test Test Test"));
138     }
139 
140     @Test
141     public void testCommitMessageHasProperStructure() {
142         for (RevCommit commit : filterValidCommits(lastCommits)) {
143             final String commitMessage = commit.getFullMessage();
144             final int error = validateCommitMessage(commitMessage);
145 
146             if (error != 0) {
147                 final String commitId = commit.getId().getName();
148 
149                 fail(getInvalidCommitMessageFormattingError(commitId, commitMessage) + error);
150             }
151         }
152     }
153 
154     private static int validateCommitMessage(String commitMessage) {
155         final String message = commitMessage.replace("\r", "").replace("\n", "");
156         final String trimRight = commitMessage.replaceAll("[\\r\\n]+$", "");
157         final int result;
158 
159         if (!ACCEPTED_COMMIT_MESSAGE_PATTERN.matcher(message).matches()) {
160             // improper prefix
161             result = 1;
162         }
163         else if (!trimRight.equals(message)) {
164             // single line of text (multiple new lines are allowed on end because of
165             // git (1 new line) and github's web ui (2 new lines))
166             result = 2;
167         }
168         else if (INVALID_POSTFIX_PATTERN.matcher(message).matches()) {
169             // improper postfix
170             result = 3;
171         }
172         else if (message.length() > 200) {
173             // commit message has more than 200 characters
174             result = 4;
175         }
176         else {
177             result = 0;
178         }
179 
180         return result;
181     }
182 
183     private static List<RevCommit> getCommitsToCheck() throws Exception {
184         final List<RevCommit> commits;
185         try (Repository repo = new FileRepositoryBuilder().findGitDir().build()) {
186             final RevCommitsPair revCommitsPair = resolveRevCommitsPair(repo);
187             if (COMMITS_RESOLUTION_MODE == CommitsResolutionMode.BY_COUNTER) {
188                 commits = getCommitsByCounter(revCommitsPair.getFirst());
189                 commits.addAll(getCommitsByCounter(revCommitsPair.getSecond()));
190             }
191             else {
192                 commits = getCommitsByLastCommitAuthor(revCommitsPair.getFirst());
193                 commits.addAll(getCommitsByLastCommitAuthor(revCommitsPair.getSecond()));
194             }
195         }
196         return commits;
197     }
198 
199     private static List<RevCommit> filterValidCommits(List<RevCommit> revCommits) {
200         final List<RevCommit> filteredCommits = new LinkedList<>();
201         for (RevCommit commit : revCommits) {
202             final String commitAuthor = commit.getAuthorIdent().getName();
203             if (!USERS_EXCLUDED_FROM_VALIDATION.contains(commitAuthor)) {
204                 filteredCommits.add(commit);
205             }
206         }
207         return filteredCommits;
208     }
209 
210     private static RevCommitsPair resolveRevCommitsPair(Repository repo) {
211         RevCommitsPair revCommitIteratorPair;
212 
213         try (RevWalk revWalk = new RevWalk(repo); Git git = new Git(repo)) {
214             final Iterator<RevCommit> first;
215             final Iterator<RevCommit> second;
216             final ObjectId headId = repo.resolve(Constants.HEAD);
217             final RevCommit headCommit = revWalk.parseCommit(headId);
218 
219             if (isMergeCommit(headCommit)) {
220                 final RevCommit firstParent = headCommit.getParent(0);
221                 final RevCommit secondParent = headCommit.getParent(1);
222                 first = git.log().add(firstParent).call().iterator();
223                 second = git.log().add(secondParent).call().iterator();
224             }
225             else {
226                 first = git.log().call().iterator();
227                 second = Collections.emptyIterator();
228             }
229 
230             revCommitIteratorPair =
231                     new RevCommitsPair(new OmitMergeCommitsIterator(first),
232                             new OmitMergeCommitsIterator(second));
233         }
234         catch (GitAPIException | IOException ignored) {
235             revCommitIteratorPair = new RevCommitsPair();
236         }
237 
238         return revCommitIteratorPair;
239     }
240 
241     private static boolean isMergeCommit(RevCommit currentCommit) {
242         return currentCommit.getParentCount() > 1;
243     }
244 
245     private static List<RevCommit> getCommitsByCounter(
246             Iterator<RevCommit> previousCommitsIterator) {
247         final Spliterator<RevCommit> spliterator =
248             Spliterators.spliteratorUnknownSize(previousCommitsIterator, Spliterator.ORDERED);
249         return StreamSupport.stream(spliterator, false).limit(PREVIOUS_COMMITS_TO_CHECK_COUNT)
250             .collect(Collectors.toList());
251     }
252 
253     private static List<RevCommit> getCommitsByLastCommitAuthor(
254             Iterator<RevCommit> previousCommitsIterator) {
255         final List<RevCommit> commits = new LinkedList<>();
256 
257         if (previousCommitsIterator.hasNext()) {
258             final RevCommit lastCommit = previousCommitsIterator.next();
259             final String lastCommitAuthor = lastCommit.getAuthorIdent().getName();
260             commits.add(lastCommit);
261 
262             boolean wasLastCheckedCommitAuthorSameAsLastCommit = true;
263             while (wasLastCheckedCommitAuthorSameAsLastCommit
264                     && previousCommitsIterator.hasNext()) {
265                 final RevCommit currentCommit = previousCommitsIterator.next();
266                 final String currentCommitAuthor = currentCommit.getAuthorIdent().getName();
267                 if (currentCommitAuthor.equals(lastCommitAuthor)) {
268                     commits.add(currentCommit);
269                 }
270                 else {
271                     wasLastCheckedCommitAuthorSameAsLastCommit = false;
272                 }
273             }
274         }
275 
276         return commits;
277     }
278 
279     private static String getRulesForCommitMessageFormatting() {
280         return "Proper commit message should adhere to the following rules:\n"
281                 + "    1) Must match one of the following patterns:\n"
282                 + "        " + ISSUE_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
283                 + "        " + PR_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
284                 + "        " + OTHER_COMMIT_MESSAGE_REGEX_PATTERN + "\n"
285                 + "    2) It contains only one line of text\n"
286                 + "    3) Must not end with a period, space, or tab\n"
287                 + "    4) Commit message should be less than or equal to 200 characters\n"
288                 + "\n"
289                 + "The rule broken was: ";
290     }
291 
292     private static String getInvalidCommitMessageFormattingError(String commitId,
293             String commitMessage) {
294         return "Commit " + commitId + " message: \""
295                 + commitMessage.replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t")
296                 + "\" is invalid\n" + getRulesForCommitMessageFormatting();
297     }
298 
299     private enum CommitsResolutionMode {
300         BY_COUNTER, BY_LAST_COMMIT_AUTHOR
301     }
302 
303     private static class RevCommitsPair {
304         private final Iterator<RevCommit> first;
305         private final Iterator<RevCommit> second;
306 
307         RevCommitsPair() {
308             first = Collections.emptyIterator();
309             second = Collections.emptyIterator();
310         }
311 
312         RevCommitsPair(Iterator<RevCommit> first, Iterator<RevCommit> second) {
313             this.first = first;
314             this.second = second;
315         }
316 
317         public Iterator<RevCommit> getFirst() {
318             return first;
319         }
320 
321         public Iterator<RevCommit> getSecond() {
322             return second;
323         }
324     }
325 
326     private static class OmitMergeCommitsIterator implements Iterator<RevCommit> {
327 
328         private final Iterator<RevCommit> revCommitIterator;
329 
330         OmitMergeCommitsIterator(Iterator<RevCommit> revCommitIterator) {
331             this.revCommitIterator = revCommitIterator;
332         }
333 
334         @Override
335         public boolean hasNext() {
336             return revCommitIterator.hasNext();
337         }
338 
339         @Override
340         public RevCommit next() {
341             RevCommit currentCommit = revCommitIterator.next();
342             while (isMergeCommit(currentCommit)) {
343                 currentCommit = revCommitIterator.next();
344             }
345             return currentCommit;
346         }
347 
348         @Override
349         public void remove() {
350             throw new UnsupportedOperationException("remove");
351         }
352     }
353 }