esys-gitlog 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- esys_gitlog/__init__.py +3 -0
- esys_gitlog/__main__.py +6 -0
- esys_gitlog/app.py +215 -0
- esys_gitlog/cli.py +323 -0
- esys_gitlog/config.py +202 -0
- esys_gitlog/detection.py +145 -0
- esys_gitlog/display.py +82 -0
- esys_gitlog/git_adapter.py +370 -0
- esys_gitlog/identifiers.py +46 -0
- esys_gitlog/interpret.py +308 -0
- esys_gitlog/links.py +87 -0
- esys_gitlog/model.py +138 -0
- esys_gitlog/pager.py +106 -0
- esys_gitlog/pipeline.py +44 -0
- esys_gitlog/preferences.py +380 -0
- esys_gitlog/qa.py +196 -0
- esys_gitlog/render.py +372 -0
- esys_gitlog/report.py +117 -0
- esys_gitlog/tool_paths.py +62 -0
- esys_gitlog-0.1.0.dist-info/METADATA +77 -0
- esys_gitlog-0.1.0.dist-info/RECORD +25 -0
- esys_gitlog-0.1.0.dist-info/WHEEL +5 -0
- esys_gitlog-0.1.0.dist-info/entry_points.txt +2 -0
- esys_gitlog-0.1.0.dist-info/licenses/LICENSE.txt +18 -0
- esys_gitlog-0.1.0.dist-info/top_level.txt +1 -0
esys_gitlog/interpret.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Pure interpretation: subject + body -> claims."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from esys_gitlog.config import AnalysisConfig, ProfileRules
|
|
8
|
+
from esys_gitlog.identifiers import matches_family
|
|
9
|
+
from esys_gitlog.model import Claim, CommitResult
|
|
10
|
+
|
|
11
|
+
_TRAILER_LINE = re.compile(r"^(?P<key>[A-Za-z0-9][A-Za-z0-9 -]*):\s?(?P<value>.*)$")
|
|
12
|
+
_PREFIX_REF = re.compile(r"^(?P<token>[A-Za-z][A-Za-z0-9._-]*-\d+)\b")
|
|
13
|
+
_BREAKING_KEYS = frozenset({"breaking change", "breaking"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def title_pattern(types: tuple[str, ...]) -> re.Pattern[str]:
|
|
17
|
+
"""Conventional title pattern for the configured type list."""
|
|
18
|
+
type_alt = "|".join(re.escape(item) for item in types)
|
|
19
|
+
return re.compile(
|
|
20
|
+
rf"^(WIP: )?("
|
|
21
|
+
rf"(?P<type>{type_alt})"
|
|
22
|
+
rf"(?:\((?P<scope>[a-z0-9._/-]+)\))?"
|
|
23
|
+
rf"(?P<breaking>!)?"
|
|
24
|
+
rf"): (?P<description>[A-Za-z0-9].+)$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _title_pattern(types: tuple[str, ...]) -> re.Pattern[str]:
|
|
29
|
+
return title_pattern(types)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _split_refs_tokens(value: str) -> list[str]:
|
|
33
|
+
"""Split a Refs value on commas and spaces, keeping Bug N together."""
|
|
34
|
+
text = value.strip()
|
|
35
|
+
if not text:
|
|
36
|
+
return []
|
|
37
|
+
pattern = re.compile(
|
|
38
|
+
r"Bug\s+#?\d+|issue\s+#\d+|[^,\s]+",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
tokens: list[str] = []
|
|
42
|
+
for match in pattern.finditer(text.replace(",", " ")):
|
|
43
|
+
token = match.group(0).strip(" ,")
|
|
44
|
+
if token:
|
|
45
|
+
tokens.append(token)
|
|
46
|
+
return tokens
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_trailers(body_lines: list[str]) -> list[tuple[str, str, int, int, bool]]:
|
|
50
|
+
"""Return (key, value, line, column, empty) for footer trailers.
|
|
51
|
+
|
|
52
|
+
Line numbers are 1-based in the full message (subject is line 1).
|
|
53
|
+
"""
|
|
54
|
+
if not body_lines:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
blank_index: int | None = None
|
|
58
|
+
for index, line in enumerate(body_lines):
|
|
59
|
+
if line.strip() == "":
|
|
60
|
+
blank_index = index
|
|
61
|
+
|
|
62
|
+
if blank_index is None:
|
|
63
|
+
candidate = list(body_lines)
|
|
64
|
+
start_line = 2
|
|
65
|
+
else:
|
|
66
|
+
candidate = list(body_lines[blank_index + 1 :])
|
|
67
|
+
start_line = blank_index + 3
|
|
68
|
+
|
|
69
|
+
if not candidate:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
while candidate and not _TRAILER_LINE.match(candidate[0]):
|
|
73
|
+
candidate = candidate[1:]
|
|
74
|
+
start_line += 1
|
|
75
|
+
if not candidate:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
trailers: list[tuple[str, str, int, int, bool]] = []
|
|
79
|
+
line_no = start_line
|
|
80
|
+
index = 0
|
|
81
|
+
while index < len(candidate):
|
|
82
|
+
line = candidate[index]
|
|
83
|
+
match = _TRAILER_LINE.match(line)
|
|
84
|
+
if match is None:
|
|
85
|
+
break
|
|
86
|
+
key = match.group("key")
|
|
87
|
+
value = match.group("value") or ""
|
|
88
|
+
colon_at = line.index(":")
|
|
89
|
+
value_column = colon_at + 2
|
|
90
|
+
index += 1
|
|
91
|
+
while index < len(candidate) and (
|
|
92
|
+
candidate[index].startswith(" ") or candidate[index].startswith("\t")
|
|
93
|
+
):
|
|
94
|
+
continuation = candidate[index].lstrip(" \t")
|
|
95
|
+
value = f"{value}\n{continuation}"
|
|
96
|
+
index += 1
|
|
97
|
+
line_no += 1
|
|
98
|
+
empty = value.strip() == ""
|
|
99
|
+
trailers.append((key, value, line_no, value_column, empty))
|
|
100
|
+
line_no += 1
|
|
101
|
+
return trailers
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _reference_key_names(rules: ProfileRules) -> set[str]:
|
|
105
|
+
return {key.casefold() for key in rules.reference_keys} | {"refs"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def interpret_commit(subject: str, body: str, config: AnalysisConfig) -> CommitResult:
|
|
109
|
+
"""Interpret one commit message under the resolved profile."""
|
|
110
|
+
result = CommitResult()
|
|
111
|
+
rules = config.profile
|
|
112
|
+
subject_line = subject.splitlines()[0] if subject else ""
|
|
113
|
+
body_lines = body.splitlines() if body else []
|
|
114
|
+
|
|
115
|
+
if rules.conventional and "title" in rules.conventional_locations:
|
|
116
|
+
_interpret_title(subject_line, rules, result)
|
|
117
|
+
|
|
118
|
+
if rules.references and "prefix" in rules.reference_locations:
|
|
119
|
+
_interpret_prefix_refs(subject_line, rules, result)
|
|
120
|
+
|
|
121
|
+
trailer_type_ok = False
|
|
122
|
+
for key, value, line, column, empty in _parse_trailers(body_lines):
|
|
123
|
+
key_folded = key.casefold()
|
|
124
|
+
if empty:
|
|
125
|
+
result.claims.append(
|
|
126
|
+
Claim(
|
|
127
|
+
field="trailer",
|
|
128
|
+
value="",
|
|
129
|
+
source="trailer",
|
|
130
|
+
line=line,
|
|
131
|
+
column=column,
|
|
132
|
+
key=key,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if key_folded in ("type",) and rules.conventional:
|
|
138
|
+
result.claims.append(
|
|
139
|
+
Claim(
|
|
140
|
+
field="type",
|
|
141
|
+
value=value.strip(),
|
|
142
|
+
source="trailer",
|
|
143
|
+
line=line,
|
|
144
|
+
column=column,
|
|
145
|
+
key=key,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
if value.strip() in rules.types:
|
|
149
|
+
trailer_type_ok = True
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if key_folded in ("scope",) and rules.conventional:
|
|
153
|
+
result.claims.append(
|
|
154
|
+
Claim(
|
|
155
|
+
field="scope",
|
|
156
|
+
value=value.strip(),
|
|
157
|
+
source="trailer",
|
|
158
|
+
line=line,
|
|
159
|
+
column=column,
|
|
160
|
+
key=key,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if key_folded in _BREAKING_KEYS:
|
|
166
|
+
result.claims.append(
|
|
167
|
+
Claim(
|
|
168
|
+
field="breaking",
|
|
169
|
+
value=value,
|
|
170
|
+
source="trailer",
|
|
171
|
+
line=line,
|
|
172
|
+
column=column,
|
|
173
|
+
key=key,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if (
|
|
179
|
+
key_folded in _reference_key_names(rules)
|
|
180
|
+
and rules.references
|
|
181
|
+
and "refs" in rules.reference_locations
|
|
182
|
+
):
|
|
183
|
+
_interpret_refs(key, value, line, column, rules, result)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if key_folded == "signed-off-by" and rules.signed_off_by:
|
|
187
|
+
result.signed_off_by.append(value)
|
|
188
|
+
result.claims.append(
|
|
189
|
+
Claim(
|
|
190
|
+
field="signed-off-by",
|
|
191
|
+
value=value,
|
|
192
|
+
source="trailer",
|
|
193
|
+
line=line,
|
|
194
|
+
column=column,
|
|
195
|
+
key=key,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
result.generic_trailers.append((key, value))
|
|
201
|
+
result.claims.append(
|
|
202
|
+
Claim(
|
|
203
|
+
field="trailer",
|
|
204
|
+
value=value,
|
|
205
|
+
source="trailer",
|
|
206
|
+
line=line,
|
|
207
|
+
column=column,
|
|
208
|
+
key=key,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if (
|
|
213
|
+
rules.conventional
|
|
214
|
+
and "trailer" in rules.conventional_locations
|
|
215
|
+
and trailer_type_ok
|
|
216
|
+
and "title" not in rules.conventional_locations
|
|
217
|
+
):
|
|
218
|
+
result.conventional_matched = True
|
|
219
|
+
elif (
|
|
220
|
+
rules.conventional
|
|
221
|
+
and "trailer" in rules.conventional_locations
|
|
222
|
+
and "title" in rules.conventional_locations
|
|
223
|
+
and trailer_type_ok
|
|
224
|
+
):
|
|
225
|
+
result.conventional_matched = True
|
|
226
|
+
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _interpret_title(subject_line: str, rules: ProfileRules, result: CommitResult) -> None:
|
|
231
|
+
match = _title_pattern(rules.types).fullmatch(subject_line)
|
|
232
|
+
if match is None:
|
|
233
|
+
return
|
|
234
|
+
result.conventional_matched = True
|
|
235
|
+
result.claims.append(
|
|
236
|
+
Claim(
|
|
237
|
+
field="type",
|
|
238
|
+
value=match.group("type"),
|
|
239
|
+
source="subject",
|
|
240
|
+
line=1,
|
|
241
|
+
column=match.start("type") + 1,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
scope_value = match.group("scope")
|
|
245
|
+
if scope_value is not None:
|
|
246
|
+
result.claims.append(
|
|
247
|
+
Claim(
|
|
248
|
+
field="scope",
|
|
249
|
+
value=scope_value,
|
|
250
|
+
source="subject",
|
|
251
|
+
line=1,
|
|
252
|
+
column=match.start("scope") + 1,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
if match.group("breaking"):
|
|
256
|
+
result.claims.append(
|
|
257
|
+
Claim(
|
|
258
|
+
field="breaking",
|
|
259
|
+
value="!",
|
|
260
|
+
source="subject",
|
|
261
|
+
line=1,
|
|
262
|
+
column=match.start("breaking") + 1,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _interpret_prefix_refs(subject_line: str, rules: ProfileRules, result: CommitResult) -> None:
|
|
268
|
+
match = _PREFIX_REF.match(subject_line)
|
|
269
|
+
if match is None:
|
|
270
|
+
return
|
|
271
|
+
token = match.group("token")
|
|
272
|
+
result.claims.append(
|
|
273
|
+
Claim(
|
|
274
|
+
field="refs",
|
|
275
|
+
value=token,
|
|
276
|
+
source="subject",
|
|
277
|
+
line=1,
|
|
278
|
+
column=1,
|
|
279
|
+
key="prefix",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
if matches_family(token, rules.identifier_family):
|
|
283
|
+
result.references_matched = True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _interpret_refs(
|
|
287
|
+
key: str,
|
|
288
|
+
value: str,
|
|
289
|
+
line: int,
|
|
290
|
+
column: int,
|
|
291
|
+
rules: ProfileRules,
|
|
292
|
+
result: CommitResult,
|
|
293
|
+
) -> None:
|
|
294
|
+
for token in _split_refs_tokens(value):
|
|
295
|
+
relative = value.find(token)
|
|
296
|
+
token_column = column + max(relative, 0)
|
|
297
|
+
result.claims.append(
|
|
298
|
+
Claim(
|
|
299
|
+
field="refs",
|
|
300
|
+
value=token,
|
|
301
|
+
source="trailer",
|
|
302
|
+
line=line,
|
|
303
|
+
column=token_column,
|
|
304
|
+
key=key,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
if matches_family(token, rules.identifier_family):
|
|
308
|
+
result.references_matched = True
|
esys_gitlog/links.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Resolve ticket and forge commit URLs for the report (CLI config only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from esys_gitlog.config import ProfileRules
|
|
12
|
+
|
|
13
|
+
_GITHUB_SSH = re.compile(r"^git@github\.com:(?P<path>.+?)(?:\.git)?$")
|
|
14
|
+
_GITHUB_HTTPS = re.compile(r"^https?://(?:www\.)?github\.com/(?P<path>.+?)(?:\.git)?/?$")
|
|
15
|
+
_GITLAB_SSH = re.compile(r"^git@gitlab\.com:(?P<path>.+?)(?:\.git)?$")
|
|
16
|
+
_GITLAB_HTTPS = re.compile(r"^https?://(?:www\.)?gitlab\.com/(?P<path>.+?)(?:\.git)?/?$")
|
|
17
|
+
_AZURE_HTTPS = re.compile(
|
|
18
|
+
r"^https://(?:[\w.-]+@)?dev\.azure\.com/(?P<org>[^/]+)/(?P<project>[^/]+)/_git/"
|
|
19
|
+
r"(?P<repo>[^/]+?)(?:\.git)?/?$"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def apply_template(template: str, **values: str) -> str:
|
|
24
|
+
result = template
|
|
25
|
+
for key, value in values.items():
|
|
26
|
+
result = result.replace("{" + key + "}", value)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_ref_url(ref_id: str, family: str, rules: ProfileRules) -> str | None:
|
|
31
|
+
template = rules.ref_links.get(family) or rules.ref_links.get("*")
|
|
32
|
+
if not template:
|
|
33
|
+
return None
|
|
34
|
+
return apply_template(template, id=ref_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_commit_link_from_remote(remote_url: str) -> str | None:
|
|
38
|
+
text = remote_url.strip()
|
|
39
|
+
for pattern, fmt in (
|
|
40
|
+
(_GITHUB_SSH, "https://github.com/{path}/commit/{{commit}}"),
|
|
41
|
+
(_GITHUB_HTTPS, "https://github.com/{path}/commit/{{commit}}"),
|
|
42
|
+
(_GITLAB_SSH, "https://gitlab.com/{path}/-/commit/{{commit}}"),
|
|
43
|
+
(_GITLAB_HTTPS, "https://gitlab.com/{path}/-/commit/{{commit}}"),
|
|
44
|
+
):
|
|
45
|
+
match = pattern.match(text)
|
|
46
|
+
if match:
|
|
47
|
+
path = match.group("path").removesuffix(".git")
|
|
48
|
+
return fmt.format(path=path)
|
|
49
|
+
match = _AZURE_HTTPS.match(text)
|
|
50
|
+
if match:
|
|
51
|
+
org = match.group("org")
|
|
52
|
+
project = match.group("project")
|
|
53
|
+
repo = match.group("repo").removesuffix(".git")
|
|
54
|
+
return f"https://dev.azure.com/{org}/{project}/_git/{repo}/commit/{{commit}}"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_origin_url(cwd: Path, *, git_executable: str = "git") -> str | None:
|
|
59
|
+
try:
|
|
60
|
+
completed = subprocess.run(
|
|
61
|
+
[git_executable, "remote", "get-url", "origin"],
|
|
62
|
+
cwd=cwd,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
check=False,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
except OSError:
|
|
68
|
+
return None
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
return None
|
|
71
|
+
url = completed.stdout.strip()
|
|
72
|
+
return url or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_commit_url(
|
|
76
|
+
commit_id: str,
|
|
77
|
+
rules: ProfileRules,
|
|
78
|
+
*,
|
|
79
|
+
cwd: Path | None = None,
|
|
80
|
+
git_executable: str = "git",
|
|
81
|
+
) -> str | None:
|
|
82
|
+
"""Apply ``commitLink`` from resolved preferences (detection runs in ``resolve_config``)."""
|
|
83
|
+
_ = cwd, git_executable # kept for call-site compatibility
|
|
84
|
+
template = rules.commit_link
|
|
85
|
+
if not template:
|
|
86
|
+
return None
|
|
87
|
+
return apply_template(template, commit=commit_id)
|
esys_gitlog/model.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Typed result model for one commit.
|
|
2
|
+
|
|
3
|
+
Claims stay beside the displayed value. Display does not erase evidence.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
Severity = Literal["warning", "error"]
|
|
12
|
+
ClaimSource = Literal["subject", "trailer"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Claim:
|
|
17
|
+
"""One recognized value with provenance."""
|
|
18
|
+
|
|
19
|
+
field: str
|
|
20
|
+
value: str
|
|
21
|
+
source: ClaimSource
|
|
22
|
+
line: int
|
|
23
|
+
column: int
|
|
24
|
+
key: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Diagnostic:
|
|
29
|
+
"""One QA finding."""
|
|
30
|
+
|
|
31
|
+
code: str
|
|
32
|
+
severity: Severity
|
|
33
|
+
field: str
|
|
34
|
+
claims: tuple[Claim, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class DisplayRef:
|
|
39
|
+
"""One displayed reference id with optional autolink."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
family: str | None = None
|
|
43
|
+
url: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class DisplaySelection:
|
|
48
|
+
"""Displayed values for the report."""
|
|
49
|
+
|
|
50
|
+
reason: Literal["subject-precedence", "trailer-precedence"]
|
|
51
|
+
type: str | None = None
|
|
52
|
+
scope: str | None = None
|
|
53
|
+
title: str | None = None
|
|
54
|
+
breaking: bool = False
|
|
55
|
+
refs: tuple[DisplayRef, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class CommitResult:
|
|
60
|
+
"""Interpretation, QA, and display for one commit message."""
|
|
61
|
+
|
|
62
|
+
claims: list[Claim] = field(default_factory=list)
|
|
63
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
64
|
+
display: DisplaySelection | None = None
|
|
65
|
+
conventional_matched: bool = False
|
|
66
|
+
references_matched: bool = False
|
|
67
|
+
signed_off_by: list[str] = field(default_factory=list)
|
|
68
|
+
generic_trailers: list[tuple[str, str]] = field(default_factory=list)
|
|
69
|
+
# Opaque transport / presentation (set by the application layer).
|
|
70
|
+
message: str | None = None
|
|
71
|
+
author_name: str | None = None
|
|
72
|
+
author_email: str | None = None
|
|
73
|
+
authored_date: str | None = None
|
|
74
|
+
commit_url: str | None = None
|
|
75
|
+
|
|
76
|
+
def claims_for(self, field_name: str) -> list[Claim]:
|
|
77
|
+
return [claim for claim in self.claims if claim.field == field_name]
|
|
78
|
+
|
|
79
|
+
def to_report_commit(self, commit_id: str = "") -> dict[str, Any]:
|
|
80
|
+
"""Map to the per-commit report object."""
|
|
81
|
+
diagnostics: list[dict[str, Any]] = []
|
|
82
|
+
for item in self.diagnostics:
|
|
83
|
+
entry: dict[str, Any] = {
|
|
84
|
+
"code": item.code,
|
|
85
|
+
"severity": item.severity,
|
|
86
|
+
"field": item.field,
|
|
87
|
+
"claims": [],
|
|
88
|
+
}
|
|
89
|
+
for claim in item.claims:
|
|
90
|
+
claim_entry: dict[str, Any] = {
|
|
91
|
+
"source": claim.source,
|
|
92
|
+
"value": claim.value,
|
|
93
|
+
"line": claim.line,
|
|
94
|
+
"column": claim.column,
|
|
95
|
+
}
|
|
96
|
+
if claim.key is not None:
|
|
97
|
+
claim_entry["key"] = claim.key
|
|
98
|
+
entry["claims"].append(claim_entry)
|
|
99
|
+
diagnostics.append(entry)
|
|
100
|
+
|
|
101
|
+
display_selection: dict[str, Any] = {}
|
|
102
|
+
if self.display is not None:
|
|
103
|
+
display_selection["reason"] = self.display.reason
|
|
104
|
+
if self.display.type is not None:
|
|
105
|
+
display_selection["type"] = self.display.type
|
|
106
|
+
if self.display.scope is not None:
|
|
107
|
+
display_selection["scope"] = self.display.scope
|
|
108
|
+
if self.display.title is not None:
|
|
109
|
+
display_selection["title"] = self.display.title
|
|
110
|
+
if self.display.breaking:
|
|
111
|
+
display_selection["breaking"] = True
|
|
112
|
+
if self.display.refs:
|
|
113
|
+
display_selection["refs"] = [
|
|
114
|
+
{
|
|
115
|
+
**{"id": ref.id},
|
|
116
|
+
**({"family": ref.family} if ref.family else {}),
|
|
117
|
+
**({"url": ref.url} if ref.url else {}),
|
|
118
|
+
}
|
|
119
|
+
for ref in self.display.refs
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
result: dict[str, Any] = {
|
|
123
|
+
"diagnostics": diagnostics,
|
|
124
|
+
"displaySelection": display_selection,
|
|
125
|
+
}
|
|
126
|
+
if commit_id:
|
|
127
|
+
result["commit"] = commit_id
|
|
128
|
+
if self.commit_url:
|
|
129
|
+
result["commitUrl"] = self.commit_url
|
|
130
|
+
if self.message is not None:
|
|
131
|
+
result["message"] = self.message
|
|
132
|
+
if self.author_name is not None:
|
|
133
|
+
result["authorName"] = self.author_name
|
|
134
|
+
if self.author_email is not None:
|
|
135
|
+
result["authorEmail"] = self.author_email
|
|
136
|
+
if self.authored_date is not None:
|
|
137
|
+
result["authoredDate"] = self.authored_date
|
|
138
|
+
return result
|
esys_gitlog/pager.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Screenful human-output pager (Enter/Space = next page, not next line).
|
|
2
|
+
|
|
3
|
+
Works on Windows (``msvcrt``) and Unix (``termios`` / ``tty``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from typing import TextIO
|
|
11
|
+
|
|
12
|
+
from rich.pager import Pager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_key(stdin: TextIO | None = None) -> str:
|
|
16
|
+
"""Read one keypress without requiring a following Enter.
|
|
17
|
+
|
|
18
|
+
Returns a single character. Enter is ``\\r`` (Windows) or ``\\n`` (Unix).
|
|
19
|
+
``q`` / ``Q`` means quit. Empty string means “ignore / treat as next page”.
|
|
20
|
+
"""
|
|
21
|
+
stream = stdin if stdin is not None else sys.stdin
|
|
22
|
+
|
|
23
|
+
if not hasattr(stream, "isatty") or not stream.isatty():
|
|
24
|
+
# Piped / non-interactive: one line to continue, or EOF to stop.
|
|
25
|
+
line = stream.readline()
|
|
26
|
+
if not line:
|
|
27
|
+
return "q"
|
|
28
|
+
if line.lower().startswith("q"):
|
|
29
|
+
return "q"
|
|
30
|
+
return "\n"
|
|
31
|
+
|
|
32
|
+
if sys.platform == "win32": # pragma: no cover
|
|
33
|
+
import msvcrt
|
|
34
|
+
|
|
35
|
+
char = msvcrt.getwch()
|
|
36
|
+
# Arrow / function keys are a two-char prefix + code.
|
|
37
|
+
if char in ("\x00", "\xe0"):
|
|
38
|
+
msvcrt.getwch()
|
|
39
|
+
return ""
|
|
40
|
+
return char
|
|
41
|
+
|
|
42
|
+
# POSIX TTY raw read — not exercised under non-interactive pytest.
|
|
43
|
+
return _read_key_posix(stream) # pragma: no cover
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_key_posix(stream: TextIO) -> str: # pragma: no cover
|
|
47
|
+
import select
|
|
48
|
+
import termios
|
|
49
|
+
import tty
|
|
50
|
+
|
|
51
|
+
fd = stream.fileno()
|
|
52
|
+
old = termios.tcgetattr(fd)
|
|
53
|
+
try:
|
|
54
|
+
tty.setcbreak(fd)
|
|
55
|
+
char = stream.read(1)
|
|
56
|
+
# Drain a short ESC sequence (arrows) so it does not leak into the next page.
|
|
57
|
+
if char == "\x1b":
|
|
58
|
+
while True:
|
|
59
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
60
|
+
if not ready:
|
|
61
|
+
break
|
|
62
|
+
stream.read(1)
|
|
63
|
+
return ""
|
|
64
|
+
return char
|
|
65
|
+
finally:
|
|
66
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ScreenPager(Pager):
|
|
70
|
+
"""Page by terminal height on Windows and Linux.
|
|
71
|
+
|
|
72
|
+
Enter or Space advances one screen; ``q`` quits.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def show(self, content: str) -> None:
|
|
76
|
+
lines = content.splitlines()
|
|
77
|
+
if not lines:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Leave one row for the ``:`` prompt.
|
|
81
|
+
page_size = max(1, shutil.get_terminal_size((80, 24)).lines - 1)
|
|
82
|
+
stdout = sys.stdout
|
|
83
|
+
index = 0
|
|
84
|
+
total = len(lines)
|
|
85
|
+
|
|
86
|
+
while index < total:
|
|
87
|
+
chunk = lines[index : index + page_size]
|
|
88
|
+
stdout.write("\n".join(chunk))
|
|
89
|
+
stdout.write("\n")
|
|
90
|
+
index += len(chunk)
|
|
91
|
+
if index >= total:
|
|
92
|
+
break
|
|
93
|
+
stdout.write(":")
|
|
94
|
+
stdout.flush()
|
|
95
|
+
try:
|
|
96
|
+
key = _read_key()
|
|
97
|
+
except (EOFError, KeyboardInterrupt, OSError):
|
|
98
|
+
stdout.write("\n")
|
|
99
|
+
break
|
|
100
|
+
# Clear the ``:`` prompt before the next page.
|
|
101
|
+
stdout.write("\r \r")
|
|
102
|
+
stdout.flush()
|
|
103
|
+
if key in ("q", "Q"):
|
|
104
|
+
stdout.write("\n")
|
|
105
|
+
break
|
|
106
|
+
# Enter (\\r / \\n), Space, and other keys → next screenful.
|
esys_gitlog/pipeline.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Compose interpretation, QA, and display for one commit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from esys_gitlog.config import AnalysisConfig, DisplayMode, QaMode, resolve_config
|
|
8
|
+
from esys_gitlog.display import select_display
|
|
9
|
+
from esys_gitlog.interpret import interpret_commit
|
|
10
|
+
from esys_gitlog.model import CommitResult
|
|
11
|
+
from esys_gitlog.qa import evaluate_qa
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def analyze_commit(
|
|
15
|
+
subject: str,
|
|
16
|
+
body: str = "",
|
|
17
|
+
*,
|
|
18
|
+
profile: str | None = None,
|
|
19
|
+
qa: QaMode | None = None,
|
|
20
|
+
display: DisplayMode | None = None,
|
|
21
|
+
config_path: Path | None = None,
|
|
22
|
+
preferences_path: Path | None = None,
|
|
23
|
+
cwd: Path | None = None,
|
|
24
|
+
config: AnalysisConfig | None = None,
|
|
25
|
+
apply_detection: bool = False,
|
|
26
|
+
) -> CommitResult:
|
|
27
|
+
"""Run the pure semantic stages. No Git and no terminal."""
|
|
28
|
+
resolved = (
|
|
29
|
+
config
|
|
30
|
+
if config is not None
|
|
31
|
+
else resolve_config(
|
|
32
|
+
profile=profile,
|
|
33
|
+
qa=qa,
|
|
34
|
+
display=display,
|
|
35
|
+
config_path=config_path,
|
|
36
|
+
preferences_path=preferences_path,
|
|
37
|
+
cwd=cwd,
|
|
38
|
+
apply_detection=apply_detection,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
result = interpret_commit(subject, body, resolved)
|
|
42
|
+
evaluate_qa(result, resolved)
|
|
43
|
+
select_display(result, resolved, subject=subject)
|
|
44
|
+
return result
|