dev(ci): refactor commit linter

A new approach to make linter rules more flexible.
This commit is contained in:
Henrik Lissner 2021-09-16 02:43:13 +02:00
parent f2588b0e90
commit 650f7a82e3

View file

@ -32,37 +32,49 @@
;;; Git hooks ;;; Git hooks
(defvar doom-cli-commit-rules (defvar doom-cli-commit-rules
(list (cons "^[^\n]\\{10,\\}$" (list (fn! (&key subject)
"Subject is too short (<10) and should be more descriptive") (when (<= (length subject) 10)
(cons 'error "Subject is too short (<10) and should be more descriptive")))
(cons "^\\(\\(revert\\|bump\\)!?: \\|[^\n]\\{,72\\}\\)$" (fn! (&key subject type)
"Subject too long; <=50 is ideal, 72 is max") (unless (memq type '(bump revert))
(let ((len (length subject)))
(cond ((> len 50)
(cons 'warning
(format "Subject is %d characters; <=50 is ideal, 72 is max"
len)))
((> len 72)
(cons 'error
(format "Subject is %d characters; <=50 is ideal, 72 is max"
len)))))))
(cons (concat (fn! (&key type)
"^\\(" (unless (memq type '(bump dev docs feat fix merge module nit perf
(regexp-opt refactor release revert test tweak))
'("bump" "dev" "docs" "feat" "fix" "merge" "module" "nit" "perf" (cons 'error (format "Commit has an invalid type (%s)" type))))
"refactor" "release" "revert" "test" "tweak"))
"\\)!?[^ :]*: ")
"Invalid type")
(cons (lambda () (fn! (&key summary)
(when (re-search-forward "^[^ :]+: " nil t) (when (or (not (stringp summary))
(and (looking-at "[A-Z][^-]") (string-blank-p summary))
(not (looking-at "\\(SPC\\|TAB\\|ESC\\|LFD\\|DEL\\|RET\\)"))))) (cons 'error "Commit has no summary")))
"Do not capitalize the first word of the subject")
(cons (lambda () (fn! (&key summary subject)
(looking-at "^\\(bump\\|revert\\|release\\|merge\\|module\\)!?([^)]+):")) (and (stringp summary)
"This type's scope goes after the colon, not before") (string-match-p "^[A-Z][^-]" summary)
(not (string-match-p "\\(SPC\\|TAB\\|ESC\\|LFD\\|DEL\\|RET\\)" summary))
(cons 'error (format "%S in summary is capitalized; do not capitalize the summary"
(car (split-string summary " "))))))
(cons (lambda () (fn! (&key type scopes summary)
(when (looking-at "\\([^ :!(]\\)+!?(\\([^)]+\\)): ") (and (memq type '(bump revert release merge module))
(let ((type (match-string 1))) scopes
(or (member type '("bump" "revert" "merge" "module" "release")) (cons 'error
(not (format "Scopes for %s commits should go after the colon, not before"
(cl-loop type))))
with scopes =
(fn! (&key type scopes)
(unless (memq type '(bump revert merge module release))
(cl-loop with scopes =
(cl-loop for path (cl-loop for path
in (cdr (doom-module-load-path (list doom-modules-dir))) in (cdr (doom-module-load-path (list doom-modules-dir)))
for (_category . module) for (_category . module)
@ -72,7 +84,7 @@
with regexp-scopes = '("^&") with regexp-scopes = '("^&")
with type-scopes = with type-scopes =
(pcase type (pcase type
("docs" (`docs
(cons "install" (cons "install"
(mapcar #'file-name-base (mapcar #'file-name-base
(doom-glob doom-docs-dir "[a-z]*.org"))))) (doom-glob doom-docs-dir "[a-z]*.org")))))
@ -80,114 +92,180 @@
(concat (string-join regexp-scopes "\\|") (concat (string-join regexp-scopes "\\|")
"\\|" "\\|"
(regexp-opt (append type-scopes extra-scopes scopes))) (regexp-opt (append type-scopes extra-scopes scopes)))
for scope in (split-string (match-string 2) ",") for scope in scopes
if (or (not (stringp scope)) if (not (string-match scopes-re scope))
(string-match scopes-re scope)) collect scope into error-scopes
return t)))))) finally return
"Invalid scope") (when error-scopes
(cons 'error (format "Commit has invalid scope(s): %s"
error-scopes))))))
(cons (lambda () (fn! (&key scopes)
(when (looking-at "\\([^ :!(]\\)+!?(\\([^)]+\\)): ") (unless (equal scopes (sort scopes #'string-lessp))
(let ((scopes (split-string (match-string 1) ","))) (cons 'error "Scopes are not in lexicographical order")))
(not (equal scopes (sort scopes #'string-lessp))))))
"Scopes not in lexical order")
(cons (lambda () (fn! (&key type body)
(catch 'found (unless (memq type '(bump revert merge))
(unless (looking-at "\\(bump\\|revert\\|merge\\)") (catch 'result
(with-temp-buffer
(save-excursion (insert body))
(while (re-search-forward "^[^\n]\\{73,\\}" nil t) (while (re-search-forward "^[^\n]\\{73,\\}" nil t)
;; Exclude ref lines, bump lines, or lines with URLs ;; Exclude ref lines, bump lines, comments, lines with URLs,
;; or indented lines
(save-excursion (save-excursion
(or (re-search-backward "^\\(Ref\\|Close\\|Fix\\|Revert\\) " nil t) (or (let ((bump-re "\\(https?://.+\\|[^/]+\\)/[^/]+@[a-z0-9]\\{12\\}"))
(let ((bump-re "\\(https?://.+\\|[^/]+\\)/[^/]+@[a-z0-9]\\{12\\}"))
(re-search-backward (format "^%s -> %s$" bump-re bump-re) nil t)) (re-search-backward (format "^%s -> %s$" bump-re bump-re) nil t))
(re-search-backward "https?://[^ ]+\\{73,\\}" nil t) (re-search-backward "https?://[^ ]+\\{73,\\}" nil t)
(throw 'found t))))))) (re-search-backward "^\\(?:#\\| +\\)" nil t)
"Body line length exceeds 72 characters") (throw 'result (cons 'error "Line(s) in commit body exceed 72 characters")))))))))
(cons (lambda () (fn! (&key bang body type)
(when (looking-at "[^ :!(]+![(:]") (if bang
(not (re-search-forward "^BREAKING CHANGE: .+" nil t)))) (cond ((not (string-match-p "^BREAKING CHANGE:" body))
"'!' present in type, but missing 'BREAKING CHANGE:' in body") (cons 'error "'!' present in commit type, but missing 'BREAKING CHANGE:' in body"))
((not (string-match-p "^BREAKING CHANGE: .+" body))
(cons 'error "'BREAKING CHANGE:' present in commit body, but missing explanation")))
(when (string-match-p "^BREAKING CHANGE:" body)
(cons 'error (format "'BREAKING CHANGE:' present in body, but missing '!' after %S"
type)))))
(cons (lambda () (fn! (&key type body)
(when (looking-at "[^ :!]+\\(([^)])\\)?: ") (and (eq type 'bump)
(re-search-forward "^BREAKING CHANGE: .+" nil t))) (let ((bump-re "\\(?:https?://.+\\|[^/]+\\)/[^/]+@\\([a-z0-9]+\\)"))
"'BREAKING CHANGE:' present in body, but missing '!' in type") (not (string-match-p (concat "^" bump-re " -> " bump-re "$")
body)))
(cons 'error "Bump commit is missing commit hash diffs")))
(cons (lambda () (fn! (&key body)
(when (re-search-forward "^BREAKING CHANGE:" nil t) (with-temp-buffer
(not (looking-at " [^\n]+")))) (insert body)
"'BREAKING CHANGE:' present in body, but empty") (catch 'result
(let ((bump-re "^\\(?:https?://.+\\|[^/]+\\)/[^/]+@\\([a-z0-9]+\\)"))
(cons (lambda () (while (re-search-backward bump-re nil t)
(when (looking-at "bump: ") (when (/= (length (match-string 1)) 12)
(let ((bump-re "^\\(https?://.+\\|[^/]+\\)/[^/]+@[a-z0-9]\\{12\\}")) (throw 'result (cons 'error (format "Commit hash in %S must be 12 characters long"
(re-search-forward (concat "^" bump-re " -> " bump-re "$") (match-string 0))))))))))
nil t))))
"Bump commit doesn't contain commit diff")
;; TODO Add bump validations for revert: type. ;; TODO Add bump validations for revert: type.
(cons (lambda () (fn! (&key body)
(re-search-forward "^\\(\\(Fix\\|Clos\\|Revert\\)ed\\|Reference[sd]\\|Refs\\): " (when (string-match-p "^\\(\\(Fix\\|Clos\\|Revert\\)ed\\|Reference[sd]\\|Refs\\):? " body)
nil t)) (cons 'error "No present tense or imperative mood for a reference line")))
"Use present tense & imperative mood for references, and without a colon")
(cons (lambda () (fn! (&key refs)
(and (seq-filter (lambda (ref)
(string-match-p "^\\(\\(Fix\\|Close\\|Revert\\)\\|Ref\\): " ref))
refs)
(cons 'error "Colon after reference line keyword; omit the colon on Fix, Close, Revert, and Ref lines")))
(fn! (&key refs)
(catch 'found (catch 'found
(while (re-search-forward "^\\(?:Fix\\|Close\\|Revert\\|Ref\\) \\([^\n]+\\)$" (dolist (line refs)
nil t) (cl-destructuring-bind (type . ref) (split-string line " +")
(let ((ref (match-string 1))) (setq ref (string-join ref " "))
(or (string-match "^\\(https?://.+\\|[^/]+/[^/]+\\)?\\(#[0-9]+\\|@[a-z0-9]+\\)" ref) (or (string-match "^\\(https?://.+\\|[^/]+/[^/]+\\)?\\(#[0-9]+\\|@[a-z0-9]+\\)" ref)
(string-match "^https?://" ref) (string-match "^https?://" ref)
(and (string-match "^[a-z0-9]\\{12\\}$" ref) (and (string-match "^[a-z0-9]\\{12\\}$" ref)
(= (car (doom-call-process "git" "show" ref)) (= (car (doom-call-process "git" "show" ref))
0)) 0))
(throw 'found t)))))) (throw 'found
"Invalid footer reference (should be an issue, PR, URL, or commit)") (cons 'error
(format "%S is not a valid issue/PR, URL, or 12-char commit hash"
line))))))))
;; TODO Check that bump/revert SUBJECT list: 1) valid modules and 2) ;; TODO Check that bump/revert SUBJECT list: 1) valid modules and 2)
;; modules whose files are actually being touched. ;; modules whose files are actually being touched.
;; TODO Ensure your diff corraborates your SCOPE ;; TODO Ensure your diff corraborates your SCOPE
)) ))
(defun doom-cli--ci-hook-commit-msg (file) (defun doom-cli--ci-hook-commit-msg (file)
(with-temp-buffer (with-temp-buffer
(insert-file-contents file) (insert-file-contents file)
(let (errors) (doom-cli--ci--lint
(dolist (rule doom-cli-commit-rules) (list (cons
(cl-destructuring-bind (pred . msg) rule "CURRENT"
(goto-char (point-min)) (buffer-substring (point-min)
(save-match-data (and (re-search-forward "^# Please enter the commit message" nil t)
(when (if (functionp pred) (match-beginning 0))))))))
(funcall pred)
(if (stringp pred)
(not (re-search-forward pred nil t))
(error "Invalid predicate: %S" pred)))
(push msg errors)))))
(when errors
(print! (error "Your commit message failed to pass Doom's conventions, here's why:"))
(dolist (error (reverse errors))
(print-group! (print! (info error))))
(terpri)
(print! "See https://gist.github.com/hlissner/4d78e396acb897d9b2d8be07a103a854 for details")
(throw 'exit 0))
t)))
;; ;;
;;; ;;;
(defun doom-cli--ci-lint-commits (from &optional to) (defun doom-cli--ci--lint (commits)
(let ((errors? 0) (let ((errors? 0)
commits) (warnings? 0))
(print! (start "Linting %d commits" (length commits)))
(print-group!
(dolist (commit commits)
(let (subject body refs summary type scopes bang refs errors warnings)
(with-temp-buffer (with-temp-buffer
(insert (save-excursion (insert (cdr commit)))
(cdr (doom-call-process (setq subject (buffer-substring (point-min) (line-end-position))
"git" "log" body (buffer-substring
(format "%s..%s" from (or to "HEAD"))))) (line-beginning-position 3)
(save-excursion
(or (and (re-search-forward (format "\n\n%s "
(regexp-opt '("Co-authored-by:" "Signed-off-by:" "Fix" "Ref" "Close" "Revert")
t))
nil t)
(match-beginning 1))
(point-max))))
refs (split-string
(save-excursion
(buffer-substring
(or (and (re-search-forward (format "\n\n%s "
(regexp-opt '("Co-authored-by:" "Signed-off-by:" "Fix" "Ref" "Close" "Revert")
t))
nil t)
(match-beginning 1))
(point-max))
(point-max)))
"\n" t))
(save-match-data
(when (looking-at "^\\([a-zA-Z0-9_-]+\\)\\(!?\\)\\(?:(\\([^)]+\\))\\)?: \\([^\n]+\\)")
(setq type (intern (match-string 1))
bang (equal (match-string 2) "!")
scopes (ignore-errors (split-string (match-string 3) ","))
summary (match-string 4)))))
(dolist (fn doom-cli-commit-rules)
(pcase (funcall fn
:bang bang
:body body
:refs refs
:scopes scopes
:subject subject
:summary summary
:type type)
(`(,type . ,msg)
(push msg (if (eq type 'error) errors warnings)))))
(if (and (null errors) (null warnings))
(print! (success "%s %s") (substring (car commit) 0 7) subject)
(print! (start "%s %s") (substring (car commit) 0 7) subject))
(print-group!
(when errors
(cl-incf errors?)
(dolist (e (reverse errors))
(print! (error "%s" e))))
(when warnings
(cl-incf warnings?)
(dolist (e (reverse warnings))
(print! (warn "%s" e))))))))
(when (> warnings? 0)
(print! (warn "Warnings: %d") errors?))
(when (> errors? 0)
(print! (error "Failures: %d") errors?))
(if (not (or (> errors? 0) (> warnings? 0)))
(print! (success "There were no issues!"))
(terpri)
(print! "See https://docs.doomemacs.org/latest/#/developers/conventions/git-commits for details")
(when (> errors? 0)
(throw 'exit 1)))))
(defun doom-cli--ci--read-commits ()
(let (commits)
(while (re-search-backward "^commit \\([a-z0-9]\\{40\\}\\)" nil t) (while (re-search-backward "^commit \\([a-z0-9]\\{40\\}\\)" nil t)
(push (cons (match-string 1) (push (cons (match-string 1)
(replace-regexp-in-string (replace-regexp-in-string
@ -198,33 +276,13 @@
(if (re-search-forward "\ncommit \\([a-z0-9]\\{40\\}\\)" nil t) (if (re-search-forward "\ncommit \\([a-z0-9]\\{40\\}\\)" nil t)
(match-beginning 0) (match-beginning 0)
(point-max)))))) (point-max))))))
commits))) commits))
(dolist (commit commits) commits))
(defun doom-cli--ci-lint-commits (from &optional to)
(with-temp-buffer (with-temp-buffer
(let (errors) (insert
(save-excursion (insert (cdr commit))) (cdr (doom-call-process
(dolist (rule doom-cli-commit-rules) "git" "log"
(save-excursion (format "%s..%s" from (or to "HEAD")))))
(save-match-data (doom-cli--ci--lint (doom-cli--ci--read-commits))))
(cl-destructuring-bind (pred . msg) rule
(and (cond ((functionp pred)
(funcall pred))
((stringp pred)
(not (re-search-forward pred nil t)))
((error "Invalid predicate: %S" pred)))
(push msg errors))))))
(if (not errors)
(print! (success "Commit %s") (car commit))
(cl-incf errors?)
(print! (error "Commit %s") (car commit))
(print-group!
(print! "%S" (cdr commit))
(dolist (e (reverse errors))
(print! (error "%s" e))))))))
(when (> errors? 0)
(terpri)
(print! "%d commit(s) failed the linter" errors?)
(terpri)
(print! "See https://doomemacs.org/project.org#commit-message-formatting for details")
(throw 'exit 1)))
t)