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