| 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 |