blob: 2463288f8126d4bf97c3b15a5347ffc565950619 [file]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
"""
Reject Conventional Commits style commit subjects.
Airflow does not use Conventional Commits (``feat:``, ``fix:``, ``chore: ...``).
Commit messages should describe user impact in plain prose, e.g.
``Fix airflow dags test command failure without serialized Dags`` rather than
``fix(cli): dags test failure``.
As a ``commit-msg`` stage hook it receives the path to the file holding the commit
message being created and fails if the subject line looks like a Conventional
Commit.
With ``--from-stdin`` it instead reads subjects (one per line) from standard input
and fails if any of them looks like a Conventional Commit. This is how CI validates
the pull request title, which is the subject that lands on the target branch under
"squash and merge".
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
# Standard Conventional Commits types (https://www.conventionalcommits.org).
CONVENTIONAL_TYPES = (
"bugfix",
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
)
# Matches "type: ...", "type(scope): ..." and "type!: ..." (optionally combined),
# case-insensitively so "Fix:", "FEAT(api)!:" etc. are caught too. Airflow prose
# prefixes such as "UI:" or "API:" are not Conventional Commit types and are not
# matched.
CONVENTIONAL_COMMIT_REGEX = re.compile(
rf"^(?:{'|'.join(CONVENTIONAL_TYPES)})(?:\([^)]*\))?!?[:/]",
re.IGNORECASE,
)
def get_subject_line(commit_msg_file: str) -> str | None:
"""Return the first non-empty, non-comment line of the commit message, if any."""
for raw_line in Path(commit_msg_file).read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
return line
return None
def is_conventional_commit(subject: str) -> bool:
return bool(CONVENTIONAL_COMMIT_REGEX.match(subject))
def report(subject: str) -> None:
print(
"ERROR: Conventional Commits style commit message detected:\n\n"
f" {subject}\n\n"
"Airflow does not use Conventional Commits (feat:, fix:, chore: ...).\n"
"Write the subject as plain prose focused on user impact, for example:\n\n"
" Fix airflow dags test command failure without serialized Dags\n"
" UI: Fix Grid view not refreshing after task actions\n\n"
"See contributing-docs/05_pull_requests.rst for commit message guidance."
)
def iter_subjects(args: argparse.Namespace) -> list[str]:
if args.from_stdin:
# CI passes commit subjects, one per line, on stdin.
return [line.strip() for line in sys.stdin.read().splitlines() if line.strip()]
# commit-msg hooks pass the path(s) to the commit message file(s).
subjects = []
for commit_msg_file in args.files:
subject = get_subject_line(commit_msg_file)
if subject is not None:
subjects.append(subject)
return subjects
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--from-stdin",
action="store_true",
help="Read commit subjects (one per line) from stdin instead of from commit message files.",
)
parser.add_argument("files", nargs="*", help="Commit message file(s) passed by the commit-msg hook.")
args = parser.parse_args(argv)
failed = False
for subject in iter_subjects(args):
if is_conventional_commit(subject):
report(subject)
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())