File: gitlint_rules.py

package info (click to toggle)
slurm-wlm-contrib 25.05.4-2
  • links: PTS, VCS
  • area: contrib
  • in suites: sid
  • size: 50,864 kB
  • sloc: ansic: 546,579; exp: 61,212; python: 20,435; sh: 9,371; javascript: 6,528; makefile: 4,136; perl: 3,717; pascal: 131
file content (231 lines) | stat: -rw-r--r-- 7,446 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
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