From cce18ed11ba0900299beededb207f12b416d550f Mon Sep 17 00:00:00 2001
From: LoopThrough-i-j <dganguly1120@gmail.com>
Date: Thu, 25 Feb 2021 09:43:09 +0530
Subject: [PATCH] lint: Setup gitlint.

Setup gitlint for developers to write well formatted
commit messages.

Note: .gitlint, gitlint-rules.py and lint-commits
are taken directly from zulip/zulip with minor changes.
---
 .gitlint               |  15 +++++
 requirements.txt       |   1 +
 tools/gitlint-rules.py | 145 +++++++++++++++++++++++++++++++++++++++++
 tools/lint             |   5 ++
 tools/lint-commits     |  27 ++++++++
 5 files changed, 193 insertions(+)
 create mode 100644 .gitlint
 create mode 100644 tools/gitlint-rules.py
 create mode 100755 tools/lint-commits

diff --git a/.gitlint b/.gitlint
new file mode 100644
index 00000000..951649a8
--- /dev/null
+++ b/.gitlint
@@ -0,0 +1,15 @@
+# This file is copied from the original .gitlint at zulip/zulip.
+# Please don't edit here; instead update the zulip/zulip copy and then resync this file.
+
+[general]
+ignore=title-trailing-punctuation, body-min-length, body-is-missing
+extra-path=tools/gitlint-rules.py
+
+[title-match-regex]
+regex=^(.+:\ )?[A-Z].+\.$
+
+[title-max-length]
+line-length=76
+
+[body-max-line-length]
+line-length=76
diff --git a/requirements.txt b/requirements.txt
index 286366b2..8ff55d0a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,3 +9,4 @@ pytest
 -e ./zulip_botserver
 -e git+https://github.com/zulip/zulint@14e3974001bf8442a6a3486125865660f1f2eb68#egg=zulint==1.0.0
 mypy==0.790
+gitlint>=0.13.0
diff --git a/tools/gitlint-rules.py b/tools/gitlint-rules.py
new file mode 100644
index 00000000..322f96f0
--- /dev/null
+++ b/tools/gitlint-rules.py
@@ -0,0 +1,145 @@
+# This file is copied from the original at tools/lib/gitlint-rules.py in zulip/zulip.
+# Please don't edit here; instead update the zulip/zulip copy and then resync this file.
+
+from typing import List
+
+from gitlint.git import GitCommit
+from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
+
+# Word list from https://github.com/m1foley/fit-commit
+# Copyright (c) 2015 Mike Foley
+# License: MIT
+# Ref: fit_commit/validators/tense.rb
+WORD_SET = {
+    'adds', 'adding', 'added',
+    'allows', 'allowing', 'allowed',
+    'amends', 'amending', 'amended',
+    'bumps', 'bumping', 'bumped',
+    'calculates', 'calculating', 'calculated',
+    'changes', 'changing', 'changed',
+    'cleans', 'cleaning', 'cleaned',
+    'commits', 'committing', 'committed',
+    'corrects', 'correcting', 'corrected',
+    'creates', 'creating', 'created',
+    'darkens', 'darkening', 'darkened',
+    'disables', 'disabling', 'disabled',
+    'displays', 'displaying', 'displayed',
+    'documents', 'documenting', 'documented',
+    'drys', 'drying', 'dryed',
+    'ends', 'ending', 'ended',
+    'enforces', 'enforcing', 'enforced',
+    'enqueues', 'enqueuing', 'enqueued',
+    'extracts', 'extracting', 'extracted',
+    'finishes', 'finishing', 'finished',
+    'fixes', 'fixing', 'fixed',
+    'formats', 'formatting', 'formatted',
+    'guards', 'guarding', 'guarded',
+    'handles', 'handling', 'handled',
+    'hides', 'hiding', 'hid',
+    'increases', 'increasing', 'increased',
+    'ignores', 'ignoring', 'ignored',
+    'implements', 'implementing', 'implemented',
+    'improves', 'improving', 'improved',
+    'keeps', 'keeping', 'kept',
+    'kills', 'killing', 'killed',
+    'makes', 'making', 'made',
+    'merges', 'merging', 'merged',
+    'moves', 'moving', 'moved',
+    'permits', 'permitting', 'permitted',
+    'prevents', 'preventing', 'prevented',
+    'pushes', 'pushing', 'pushed',
+    'rebases', 'rebasing', 'rebased',
+    'refactors', 'refactoring', 'refactored',
+    'removes', 'removing', 'removed',
+    'renames', 'renaming', 'renamed',
+    'reorders', 'reordering', 'reordered',
+    'replaces', 'replacing', 'replaced',
+    'requires', 'requiring', 'required',
+    'restores', 'restoring', 'restored',
+    'sends', 'sending', 'sent',
+    'sets', 'setting',
+    'separates', 'separating', 'separated',
+    'shows', 'showing', 'showed',
+    'simplifies', 'simplifying', 'simplified',
+    'skips', 'skipping', 'skipped',
+    'sorts', 'sorting',
+    'speeds', 'speeding', 'sped',
+    'starts', 'starting', 'started',
+    'supports', 'supporting', 'supported',
+    'takes', 'taking', 'took',
+    'testing', 'tested',  # 'tests' excluded to reduce false negative
+    'truncates', 'truncating', 'truncated',
+    'updates', 'updating', 'updated',
+    'uses', 'using', 'used',
+}
+
+imperative_forms = [
+    'add', 'allow', 'amend', 'bump', 'calculate', 'change', 'clean', 'commit',
+    'correct', 'create', 'darken', 'disable', 'display', 'document', 'dry',
+    'end', 'enforce', 'enqueue', 'extract', 'finish', 'fix', 'format', 'guard',
+    'handle', 'hide', 'ignore', 'implement', 'improve', 'increase', 'keep',
+    'kill', 'make', 'merge', 'move', 'permit', 'prevent', 'push', 'rebase',
+    'refactor', 'remove', 'rename', 'reorder', 'replace', 'require', 'restore',
+    'send', 'separate', 'set', 'show', 'simplify', 'skip', 'sort', 'speed',
+    'start', 'support', 'take', 'test', 'truncate', 'update', 'use',
+]
+imperative_forms.sort()
+
+
+def head_binary_search(key: str, words: List[str]) -> str:
+    """ Find the imperative mood version of `word` by looking at the first
+    3 characters. """
+
+    # Edge case: 'disable' and 'display' have the same 3 starting letters.
+    if key in ['displays', 'displaying', 'displayed']:
+        return 'display'
+
+    lower = 0
+    upper = len(words) - 1
+
+    while True:
+        if lower > upper:
+            # Should not happen
+            raise Exception(f"Cannot find imperative mood of {key}")
+
+        mid = (lower + upper) // 2
+        imperative_form = words[mid]
+
+        if key[:3] == imperative_form[:3]:
+            return imperative_form
+        elif key < imperative_form:
+            upper = mid - 1
+        elif key > imperative_form:
+            lower = mid + 1
+
+
+class ImperativeMood(LineRule):
+    """ This rule will enforce that the commit message title uses imperative
+    mood. This is done by checking if the first word is in `WORD_SET`, if so
+    show the word in the correct mood. """
+
+    name = "title-imperative-mood"
+    id = "Z1"
+    target = CommitMessageTitle
+
+    error_msg = ('The first word in commit title should be in imperative mood '
+                 '("{word}" -> "{imperative}"): "{title}"')
+
+    def validate(self, line: str, commit: GitCommit) -> List[RuleViolation]:
+        violations = []
+
+        # Ignore the section tag (ie `<section tag>: <message body>.`)
+        words = line.split(': ', 1)[-1].split()
+        first_word = words[0].lower()
+
+        if first_word in WORD_SET:
+            imperative = head_binary_search(first_word, imperative_forms)
+            violation = RuleViolation(self.id, self.error_msg.format(
+                word=first_word,
+                imperative=imperative,
+                title=commit.message.title,
+            ))
+
+            violations.append(violation)
+
+        return violations
diff --git a/tools/lint b/tools/lint
index 58d34b70..9baa8797 100755
--- a/tools/lint
+++ b/tools/lint
@@ -15,6 +15,7 @@ EXCLUDED_FILES = [
 def run() -> None:
     parser = argparse.ArgumentParser()
     add_default_linter_arguments(parser)
+    parser.add_argument('--no-gitlint', action='store_true', help='Disable gitlint')
     args = parser.parse_args()
 
     linter_config = LinterConfig(args)
@@ -27,6 +28,10 @@ def run() -> None:
     linter_config.external_linter('flake8', ['flake8'], ['py'],
                                   description="Standard Python linter (config: .flake8)")
 
+    if not args.no_gitlint:
+        linter_config.external_linter('gitlint', ['tools/lint-commits'],
+                                      description="Git Lint for commit messages")
+
     @linter_config.lint
     def custom_py() -> int:
         """Runs custom checks for python files (config: tools/linter_lib/custom_check.py)"""
diff --git a/tools/lint-commits b/tools/lint-commits
new file mode 100755
index 00000000..3d6422a9
--- /dev/null
+++ b/tools/lint-commits
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+# This file is copied from the original tools/commit-message-lint at zulip/zulip,
+# Edited at Line 14 Col 97 (zulip -> python-zulip-api)
+# Please don't edit here; instead update the zulip/zulip copy and then resync this file.
+
+# Lint all commit messages that are newer than upstream/master if running
+# locally or the commits in the push or PR Gh-Actions.
+
+# The rules can be found in /.gitlint
+
+if [[ "
+$(git remote -v)
+" =~ '
+'([^[:space:]]*)[[:space:]]*(https://github\.com/|ssh://git@github\.com/|git@github\.com:)zulip/python-zulip-api(\.git|/)?\ \(fetch\)'
+' ]]; then
+    range="${BASH_REMATCH[1]}/master..HEAD"
+else
+    range="upstream/master..HEAD"
+fi
+
+commits=$(git log "$range" | wc -l)
+if [ "$commits" -gt 0 ]; then
+    # Only run gitlint with non-empty commit lists, to avoid a printed
+    # warning.
+    gitlint --commits "$range"
+fi