blob: b63c11eaf02d566110763dd881ffb4d55ee283dc [file] [log] [blame]
import re
from dataclasses import dataclass
from enum import Enum
from itertools import groupby, takewhile, chain
from typing import Any
from gitlint.rules import CommitRule, RuleViolation
class ExtendedEnum(Enum):
@classmethod
def values(cls) -> dict[str, str]:
return dict(map(lambda ty: (ty.value, ty.name), cls))
@classmethod
def case_values(cls) -> dict[str, str]:
return {
opt: name
for val, name in cls.values().items()
for opt in [val, val.upper(), val.lower(), val.capitalize()]
}
class TrailerType(ExtendedEnum):
CHANGELOG = "Changelog"
CHERRY_PICK = "Cherry-picked"
CID = "CID"
CO_AUTHOR = "Co-authored-by"
ISSUE = "Issue"
SIGNED_OFF = "Signed-off-by"
TICKET = "Ticket"
@classmethod
def from_str(cls, label: str) -> Any:
"""get enum value from string label"""
typ = getattr(cls, label, None)
if typ is None:
val = cls.case_values().get(label, None)
if val is None:
return None
typ = getattr(cls, val, None)
return typ
@dataclass
class Trailer:
tag: TrailerType | str
lines: list[str]
trailer_re = re.compile(r"([\w\-]+?): (\w.+)")
trailer_line_re = re.compile(r"\s+.*")
def __repr__(self):
return self.content
@property
def content(self):
return "\n".join(self.lines)
@classmethod
def from_lines(cls, lines: list[str]) -> tuple[Any, list[str]]:
"""make a trailer from a list of lines, return the excess
Return None if not a valid Trailer"""
top = lines[0]
m = cls.trailer_re.match(top)
if m is None:
return None, lines
tag = TrailerType.from_str(m[1])
if tag is None:
tag = m[1]
group = [top]
i = 1
for i, peek in enumerate(lines[1:], start=i):
# if the next line is the start of a trailer, this group is done
if Trailer.is_trailer(peek):
break
if Trailer.is_trailer_multiline(peek):
group.append(peek)
else:
break
else:
# if for ended on multiline, increment i to consume last line
i += 1
return (cls(tag, group), lines[i:])
@classmethod
def pull_non_trailer(cls, lines: list[str]) -> tuple[str, list[str]]:
"""Pull off any non-trailer lines, return the rest.
The first line could look like a trailer, but pull it anyway"""
if lines[0] == "":
return ("", lines[1:])
group = [lines[0]]
i = 1
for i, line in enumerate(lines[1:], start=i):
# if the next line is the start of a trailer, this group is done
if Trailer.is_trailer(line) or line == "":
break
group.append(line)
return ("\n".join(group), lines[i:])
@classmethod
def is_trailer(cls, text: str) -> bool:
return cls.trailer_re.match(text)
@classmethod
def is_trailer_multiline(cls, line: str) -> bool:
return cls.trailer_line_re.match(line)
@classmethod
def check_valid_trailer(cls, maybe_trailer):
pass
def is_known(self):
return isinstance(self.tag, TrailerType)
def split_trailers_and_body(body: list[str]):
# print(body)
lines = body.copy()
while lines:
trailer, lines = Trailer.from_lines(lines)
if trailer is not None:
yield trailer
else:
section, lines = Trailer.pull_non_trailer(lines)
if section is not None:
yield section
class TrailerValidation(CommitRule):
"""Enforce that multiline changelog trailer starts with a blank space."""
# A rule MUST have a human friendly name
name = "changelog-trailer"
# A rule MUST have a *unique* id
# We recommend starting with UC (for User-defined Commit-rule).
id = "UC100"
def validate(self, commit):
violations = []
sections = [
list(paragraph)
for k, paragraph in groupby(
split_trailers_and_body(commit.message.body), lambda s: not s
)
if not k
]
if len(sections) == 0:
# empty body
return violations
# check trailer section
trailer_section = sections[-1]
if len(trailer_section) == 0:
# TODO error here? This means the body is empty
# or what if there are multiple newlines at the end?
print("empty body?")
return violations
nontrailer = [line for line in trailer_section if not isinstance(line, Trailer)]
# check for mis-formatted trailers in last paragraph
violations.extend(
RuleViolation(self.id, f"Misformatted trailer: '{line}'")
for line in nontrailer
if any(line.startswith(tag) for tag in TrailerType.case_values().keys())
)
# check for no trailers or merged body and trailers
if not isinstance(trailer_section[0], Trailer):
if isinstance(trailer_section[-1], Trailer):
violations.append(
RuleViolation(
self.id,
"Newline required between trailer section and main commit message body.",
)
)
return violations
# No trailers at all!
return violations
# check for non-trailer lines in the trailer section
violations.extend(
RuleViolation(
self.id,
f"Trailer section should include only trailers: '{line.strip()}' is not a trailer. Multi-line trailer might need indents?",
)
for line in nontrailer
)
# check for unknown trailers in trailer section
trailers = [tr for tr in trailer_section if isinstance(tr, Trailer)]
unknown_trailers = [tr for tr in trailers if not tr.is_known()]
violations.extend(
RuleViolation(
self.id,
f"Trailer section has unknown trailer: '{tr}'",
)
for tr in unknown_trailers
)
# check for trailers at end of last body section
if len(sections) > 1:
last_body_section = sections[-2]
trailers_in_body = list(
takewhile(
lambda line: isinstance(line, Trailer), reversed(last_body_section)
)
)
violations.extend(
RuleViolation(self.id, f"Trailer should be in trailer section: '{tr}'")
for tr in trailers_in_body
)
# check for valid trailers outside the trailer section
violations.extend(
RuleViolation(self.id, f"Valid trailer outside trailer section: '{line}'")
for line in chain.from_iterable(sections[:-1])
if isinstance(line, Trailer) and line.is_known()
)
# check line length of trailers
violations.extend(
RuleViolation(
self.id,
f"Trailer line too long, must be less than 76 characters. '{line}'",
)
for tr in trailers
for line in tr.lines
if len(line) > 76
)
return violations