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/config.py ADDED
@@ -0,0 +1,202 @@
1
+ """Resolved analysis configuration for one run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field, replace
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from esys_gitlog.detection import detect_links
11
+ from esys_gitlog.preferences import (
12
+ Preferences,
13
+ empty_preferences,
14
+ fill_link_gaps,
15
+ load_layered_preferences,
16
+ merge_missing_links_into_user_file,
17
+ resolved_ref_id_width,
18
+ resolved_show_author,
19
+ resolved_show_body,
20
+ resolved_show_conventional,
21
+ resolved_show_date,
22
+ resolved_show_id,
23
+ resolved_type_width,
24
+ )
25
+ from esys_gitlog.tool_paths import profile_search_roots_high_to_low
26
+
27
+ QaMode = Literal["strict", "permissive"]
28
+ DisplayMode = Literal["subject-precedence", "trailer-precedence"]
29
+
30
+ DEFAULT_TYPES: tuple[str, ...] = (
31
+ "adr",
32
+ "build",
33
+ "chore",
34
+ "ci",
35
+ "docs",
36
+ "feat",
37
+ "fix",
38
+ "perf",
39
+ "refactor",
40
+ "revert",
41
+ "style",
42
+ "test",
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ProfileRules:
48
+ """Active profile rules for interpretation and QA."""
49
+
50
+ name: str
51
+ conventional: bool = True
52
+ conventional_locations: tuple[str, ...] = ("title",)
53
+ references: bool = True
54
+ reference_locations: tuple[str, ...] = ("refs",)
55
+ identifier_family: str = "jira-id"
56
+ signed_off_by: bool = True
57
+ types: tuple[str, ...] = DEFAULT_TYPES
58
+ reference_keys: tuple[str, ...] = ("Refs",)
59
+ # Populated from preference ``links`` at resolve time (not from profile JSON).
60
+ ref_links: dict[str, str] = field(default_factory=dict)
61
+ commit_link: str | None = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AnalysisConfig:
66
+ """Fully resolved settings for one analysis."""
67
+
68
+ profile: ProfileRules
69
+ qa: QaMode = "strict"
70
+ display: DisplayMode = "subject-precedence"
71
+ preferences: Preferences = field(default_factory=empty_preferences)
72
+ # Human history: fixed-width refs / minimum Type(Scope) columns.
73
+ ref_id_width: int = 10
74
+ type_width: int = 10
75
+ show_author: bool = False
76
+ show_date: bool = False
77
+ show_body: bool = False
78
+ show_id: bool = True
79
+ show_conventional: bool = True
80
+
81
+
82
+ def _default_profile() -> ProfileRules:
83
+ return ProfileRules(name="default")
84
+
85
+
86
+ def _trailer_profile() -> ProfileRules:
87
+ return ProfileRules(
88
+ name="trailer",
89
+ conventional_locations=("trailer",),
90
+ )
91
+
92
+
93
+ KNOWN_PROFILES: dict[str, ProfileRules] = {
94
+ "default": _default_profile(),
95
+ "trailer": _trailer_profile(),
96
+ }
97
+
98
+
99
+ def _profile_from_object(data: dict[str, Any], *, name: str = "custom") -> ProfileRules:
100
+ conventional_locations = data.get("conventionalLocations", ["title"])
101
+ reference_locations = data.get("referenceLocations", ["refs"])
102
+ types = data.get("types", list(DEFAULT_TYPES))
103
+ reference_keys = data.get("referenceKeys", ["Refs"])
104
+ # Legacy profile link keys are ignored (CADR-0012); prefer preference files.
105
+ return ProfileRules(
106
+ name=name,
107
+ conventional=bool(data.get("conventional", True)),
108
+ conventional_locations=tuple(conventional_locations),
109
+ references=bool(data.get("references", True)),
110
+ reference_locations=tuple(reference_locations),
111
+ identifier_family=str(data.get("identifierFamily", "jira-id")),
112
+ signed_off_by=bool(data.get("signedOffBy", True)),
113
+ types=tuple(types),
114
+ reference_keys=tuple(reference_keys),
115
+ )
116
+
117
+
118
+ def load_profile_file(path: Path) -> ProfileRules:
119
+ """Load a custom profile object from a JSON file."""
120
+ payload = json.loads(path.read_text(encoding="utf-8"))
121
+ if not isinstance(payload, dict):
122
+ raise ValueError("profile config must be a JSON object")
123
+ return _profile_from_object(payload, name=str(payload.get("name", "custom")))
124
+
125
+
126
+ def resolve_named_profile(
127
+ name: str,
128
+ *,
129
+ cwd: Path,
130
+ config_path: Path | None = None,
131
+ ) -> ProfileRules:
132
+ """Resolve built-in, ``--config`` file, or layered ``profiles/<name>.json``."""
133
+ if config_path is not None:
134
+ return load_profile_file(config_path)
135
+ if name in KNOWN_PROFILES:
136
+ return KNOWN_PROFILES[name]
137
+ for root in profile_search_roots_high_to_low(cwd):
138
+ path = root / "profiles" / f"{name}.json"
139
+ if path.is_file():
140
+ return load_profile_file(path)
141
+ raise ValueError(f"unknown profile: {name}")
142
+
143
+
144
+ def resolve_config(
145
+ *,
146
+ profile: str | None = None,
147
+ qa: QaMode | None = None,
148
+ display: DisplayMode | None = None,
149
+ config_path: Path | None = None,
150
+ preferences_path: Path | None = None,
151
+ cwd: Path | None = None,
152
+ apply_detection: bool = True,
153
+ persist_detection: bool | None = None,
154
+ ) -> AnalysisConfig:
155
+ """Resolve preferences, named profile, and optional link detection."""
156
+ root = (cwd or Path.cwd()).resolve()
157
+ prefs = load_layered_preferences(root, preferences_path=preferences_path)
158
+
159
+ if apply_detection:
160
+ discovered = detect_links(root, detection=prefs.detection)
161
+ prefs = fill_link_gaps(prefs, discovered)
162
+ should_persist = (
163
+ persist_detection if persist_detection is not None else prefs.detection.resolved()[2]
164
+ )
165
+ if should_persist and (discovered.ref_links or discovered.commit_link is not None):
166
+ merge_missing_links_into_user_file(discovered=discovered)
167
+
168
+ profile_name = profile or prefs.defaults.profile or "default"
169
+ qa_mode: QaMode = qa or prefs.defaults.qa or "strict"
170
+ rules = resolve_named_profile(
171
+ profile_name,
172
+ cwd=root,
173
+ config_path=config_path,
174
+ )
175
+ rules = replace(
176
+ rules,
177
+ ref_links=dict(prefs.links.ref_links),
178
+ commit_link=prefs.links.commit_link,
179
+ )
180
+
181
+ if display is not None:
182
+ display_mode = display
183
+ elif prefs.defaults.display is not None:
184
+ display_mode = prefs.defaults.display
185
+ elif "trailer" in rules.conventional_locations and "title" not in rules.conventional_locations:
186
+ display_mode = "trailer-precedence"
187
+ else:
188
+ display_mode = "subject-precedence"
189
+
190
+ return AnalysisConfig(
191
+ profile=rules,
192
+ qa=qa_mode,
193
+ display=display_mode,
194
+ preferences=prefs,
195
+ ref_id_width=resolved_ref_id_width(prefs.defaults),
196
+ type_width=resolved_type_width(prefs.defaults),
197
+ show_author=resolved_show_author(prefs.defaults),
198
+ show_date=resolved_show_date(prefs.defaults),
199
+ show_body=resolved_show_body(prefs.defaults),
200
+ show_id=resolved_show_id(prefs.defaults),
201
+ show_conventional=resolved_show_conventional(prefs.defaults),
202
+ )
@@ -0,0 +1,145 @@
1
+ """Optional local CLI / git-remote link-base detection (SADR-0010)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from esys_gitlog.links import detect_commit_link_from_remote, read_origin_url
12
+ from esys_gitlog.preferences import DetectionSettings, LinkPreferences
13
+
14
+ _JIRA_SERVER = re.compile(r"""(?mx)^\s*(?:server|endpoint|url)\s*[:=]\s*["']?(https?://[^\s"']+)""")
15
+
16
+
17
+ def _run_text(argv: list[str]) -> str | None:
18
+ executable = shutil.which(argv[0])
19
+ if not executable:
20
+ return None
21
+ try:
22
+ completed = subprocess.run(
23
+ [executable, *argv[1:]],
24
+ capture_output=True,
25
+ check=False,
26
+ text=True,
27
+ timeout=5,
28
+ )
29
+ except (OSError, subprocess.TimeoutExpired):
30
+ return None
31
+ if completed.returncode != 0:
32
+ return None
33
+ text = (completed.stdout or "").strip()
34
+ return text or None
35
+
36
+
37
+ def _browse_template(base: str) -> str:
38
+ return base.rstrip("/") + "/browse/{id}"
39
+
40
+
41
+ def detect_jira_ref_link() -> str | None:
42
+ """Return a ``jira-id`` browse template from local jira CLI config if possible."""
43
+ host = _run_text(["jira", "me", "--plain"]) # may fail; try config files next
44
+ # Prefer config files — avoid network. `jira me` can hit the API.
45
+ for candidate in _jira_config_candidates():
46
+ if not candidate.is_file():
47
+ continue
48
+ text = candidate.read_text(encoding="utf-8", errors="replace")
49
+ match = _JIRA_SERVER.search(text)
50
+ if match:
51
+ return _browse_template(match.group(1))
52
+ # Some jira CLIs expose server without network:
53
+ for argv in (
54
+ ["jira", "config", "get", "server"],
55
+ ["acli", "jira", "config", "get", "site"],
56
+ ):
57
+ value = _run_text(list(argv))
58
+ if value and value.startswith("http"):
59
+ return _browse_template(value)
60
+ _ = host # unused; kept for future plain local probes
61
+ return None
62
+
63
+
64
+ def _jira_config_candidates() -> list[Path]:
65
+ home = Path.home()
66
+ return [
67
+ home / ".config" / ".jira" / ".config.yml",
68
+ home / ".jira" / ".config.yml",
69
+ home / ".config" / "jira" / "config.yml",
70
+ ]
71
+
72
+
73
+ def detect_github_commit_link(*, cwd: Path | None = None) -> str | None:
74
+ """Prefer ``gh`` host config; fall back to origin remote when appropriate."""
75
+ host = _run_text(["gh", "config", "get", "host"])
76
+ if host and "github" in host:
77
+ # Repo-specific commit URL still needs remote path; leave to git remote.
78
+ return None
79
+ # ``gh repo view --json url`` needs network; skip.
80
+ _ = cwd
81
+ return None
82
+
83
+
84
+ def detect_gitlab_commit_link() -> str | None:
85
+ host = _run_text(["glab", "config", "get", "host"])
86
+ if host and host.startswith("http"):
87
+ # Project path still needs git remote; host alone is not a commit template.
88
+ return None
89
+ return None
90
+
91
+
92
+ def detect_links(
93
+ cwd: Path,
94
+ *,
95
+ detection: DetectionSettings | None = None,
96
+ git_executable: str = "git",
97
+ ) -> LinkPreferences:
98
+ """Discover link templates for unset preference gaps (best-effort, local)."""
99
+ settings = detection or DetectionSettings()
100
+ cli_on, git_on, _persist = settings.resolved()
101
+ ref_links: dict[str, str] = {}
102
+ commit_link: str | None = None
103
+
104
+ if cli_on:
105
+ jira = detect_jira_ref_link()
106
+ if jira:
107
+ ref_links["jira-id"] = jira
108
+ # gh/glab currently contribute via git remote for commit URLs.
109
+ detect_github_commit_link(cwd=cwd)
110
+ detect_gitlab_commit_link()
111
+
112
+ if git_on:
113
+ remote = read_origin_url(cwd, git_executable=git_executable)
114
+ if remote:
115
+ commit_link = detect_commit_link_from_remote(remote)
116
+
117
+ return LinkPreferences(ref_links=ref_links, commit_link=commit_link)
118
+
119
+
120
+ def read_gh_host_file() -> str | None:
121
+ """Test helper: parse ``gh`` hosts.yml for a github.com entry."""
122
+ path = Path.home() / ".config" / "gh" / "hosts.yml"
123
+ if not path.is_file():
124
+ return None
125
+ try:
126
+ # Avoid requiring PyYAML; look for github.com key.
127
+ text = path.read_text(encoding="utf-8", errors="replace")
128
+ except OSError:
129
+ return None
130
+ if "github.com:" in text or "github.com" in text:
131
+ return "https://github.com"
132
+ return None
133
+
134
+
135
+ def dump_detection_debug() -> str:
136
+ """Return a short JSON summary of what would be detected (for CLI)."""
137
+ return json.dumps(
138
+ {
139
+ "jiraConfigCandidates": [str(p) for p in _jira_config_candidates()],
140
+ "ghOnPath": bool(shutil.which("gh")),
141
+ "glabOnPath": bool(shutil.which("glab")),
142
+ "jiraOnPath": bool(shutil.which("jira")),
143
+ },
144
+ indent=2,
145
+ )
esys_gitlog/display.py ADDED
@@ -0,0 +1,82 @@
1
+ """Display selection. Does not erase claims or diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from esys_gitlog.config import AnalysisConfig
8
+ from esys_gitlog.identifiers import matches_family, ref_identity_key
9
+ from esys_gitlog.interpret import title_pattern
10
+ from esys_gitlog.links import resolve_ref_url
11
+ from esys_gitlog.model import CommitResult, DisplayRef, DisplaySelection
12
+
13
+ _SUFFIX_REF = re.compile(r"(?:\s+|\[)(?P<token>[A-Za-z][A-Za-z0-9._-]*-\d+)\]?\s*$")
14
+ _PREFIX_REF = re.compile(r"^(?:\[)?(?P<token>[A-Za-z][A-Za-z0-9._-]*-\d+)(?:\])?(?::|\s+)\s*")
15
+
16
+
17
+ def _pick_single(
18
+ result: CommitResult,
19
+ field_name: str,
20
+ reason: str,
21
+ ) -> str | None:
22
+ claims = result.claims_for(field_name)
23
+ subject = [claim for claim in claims if claim.source == "subject"]
24
+ trailer = [claim for claim in claims if claim.source == "trailer"]
25
+ if reason == "trailer-precedence":
26
+ if trailer:
27
+ return trailer[0].value
28
+ if subject:
29
+ return subject[0].value
30
+ return None
31
+ if subject:
32
+ return subject[0].value
33
+ if trailer:
34
+ return trailer[0].value
35
+ return None
36
+
37
+
38
+ def _cleaned_title(subject: str, config: AnalysisConfig) -> str:
39
+ line = subject.splitlines()[0] if subject else ""
40
+ rules = config.profile
41
+ if rules.conventional and "title" in rules.conventional_locations:
42
+ match = title_pattern(rules.types).fullmatch(line)
43
+ if match is not None:
44
+ return match.group("description")
45
+ if rules.references:
46
+ stripped = _PREFIX_REF.sub("", line, count=1)
47
+ stripped = _SUFFIX_REF.sub("", stripped)
48
+ return stripped.strip() or line
49
+ return line
50
+
51
+
52
+ def _display_refs(result: CommitResult, config: AnalysisConfig) -> tuple[DisplayRef, ...]:
53
+ seen: set[str] = set()
54
+ refs: list[DisplayRef] = []
55
+ family = config.profile.identifier_family
56
+ for claim in result.claims_for("refs"):
57
+ key = ref_identity_key(claim.value)
58
+ if key in seen:
59
+ continue
60
+ seen.add(key)
61
+ claim_family = family if matches_family(claim.value, family) else family
62
+ url = resolve_ref_url(claim.value, family, config.profile)
63
+ refs.append(DisplayRef(id=claim.value, family=claim_family, url=url))
64
+ return tuple(refs)
65
+
66
+
67
+ def select_display(
68
+ result: CommitResult,
69
+ config: AnalysisConfig,
70
+ *,
71
+ subject: str = "",
72
+ ) -> None:
73
+ """Choose displayed values without inventing claims."""
74
+ reason = config.display
75
+ result.display = DisplaySelection(
76
+ reason=reason,
77
+ type=_pick_single(result, "type", reason),
78
+ scope=_pick_single(result, "scope", reason),
79
+ title=_cleaned_title(subject, config) or None,
80
+ breaking=len(result.claims_for("breaking")) > 0,
81
+ refs=_display_refs(result, config),
82
+ )