pretty-release-notes 1.4.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.
- pretty_release_notes/__init__.py +38 -0
- pretty_release_notes/__main__.py +6 -0
- pretty_release_notes/adapters/__init__.py +1 -0
- pretty_release_notes/adapters/cli_progress.py +25 -0
- pretty_release_notes/api.py +239 -0
- pretty_release_notes/core/__init__.py +1 -0
- pretty_release_notes/core/config.py +166 -0
- pretty_release_notes/core/config_loader.py +221 -0
- pretty_release_notes/core/execution.py +74 -0
- pretty_release_notes/core/interfaces.py +35 -0
- pretty_release_notes/database.py +152 -0
- pretty_release_notes/generator.py +317 -0
- pretty_release_notes/github_client.py +232 -0
- pretty_release_notes/main.py +245 -0
- pretty_release_notes/models/__init__.py +15 -0
- pretty_release_notes/models/_utils.py +47 -0
- pretty_release_notes/models/change.py +34 -0
- pretty_release_notes/models/commit.py +75 -0
- pretty_release_notes/models/issue.py +17 -0
- pretty_release_notes/models/pull_request.py +208 -0
- pretty_release_notes/models/release_notes.py +241 -0
- pretty_release_notes/models/release_notes_line.py +36 -0
- pretty_release_notes/models/repository.py +20 -0
- pretty_release_notes/openai_client.py +165 -0
- pretty_release_notes/prompt.txt +34 -0
- pretty_release_notes/py.typed +0 -0
- pretty_release_notes/setup_command.py +336 -0
- pretty_release_notes/ui.py +46 -0
- pretty_release_notes/web/__init__.py +1 -0
- pretty_release_notes/web/app.py +193 -0
- pretty_release_notes/web/server.py +11 -0
- pretty_release_notes-1.4.0.dist-info/METADATA +335 -0
- pretty_release_notes-1.4.0.dist-info/RECORD +37 -0
- pretty_release_notes-1.4.0.dist-info/WHEEL +5 -0
- pretty_release_notes-1.4.0.dist-info/entry_points.txt +2 -0
- pretty_release_notes-1.4.0.dist-info/licenses/LICENSE +7 -0
- pretty_release_notes-1.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pretty Release Notes - Transform GitHub release notes with AI."""
|
|
2
|
+
|
|
3
|
+
# Public API exports for library usage
|
|
4
|
+
from .api import ReleaseNotesBuilder, ReleaseNotesClient
|
|
5
|
+
from .core.config import (
|
|
6
|
+
DatabaseConfig,
|
|
7
|
+
FilterConfig,
|
|
8
|
+
GitHubConfig,
|
|
9
|
+
LLMConfig,
|
|
10
|
+
OpenAIConfig,
|
|
11
|
+
ReleaseNotesConfig,
|
|
12
|
+
)
|
|
13
|
+
from .core.interfaces import (
|
|
14
|
+
CompositeProgressReporter,
|
|
15
|
+
NullProgressReporter,
|
|
16
|
+
ProgressEvent,
|
|
17
|
+
ProgressReporter,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "1.4.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
# Client classes
|
|
24
|
+
"ReleaseNotesBuilder",
|
|
25
|
+
"ReleaseNotesClient",
|
|
26
|
+
# Configuration
|
|
27
|
+
"ReleaseNotesConfig",
|
|
28
|
+
"GitHubConfig",
|
|
29
|
+
"LLMConfig",
|
|
30
|
+
"OpenAIConfig",
|
|
31
|
+
"DatabaseConfig",
|
|
32
|
+
"FilterConfig",
|
|
33
|
+
# Progress reporting
|
|
34
|
+
"ProgressReporter",
|
|
35
|
+
"ProgressEvent",
|
|
36
|
+
"NullProgressReporter",
|
|
37
|
+
"CompositeProgressReporter",
|
|
38
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters for integrating core abstractions with external systems."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CLI adapter for progress reporting."""
|
|
2
|
+
|
|
3
|
+
from ..core.interfaces import ProgressEvent, ProgressReporter
|
|
4
|
+
from ..ui import CLI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CLIProgressReporter(ProgressReporter):
|
|
8
|
+
"""Adapt ProgressReporter interface to existing CLI class."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, cli: CLI):
|
|
11
|
+
self.cli = cli
|
|
12
|
+
|
|
13
|
+
def report(self, event: ProgressEvent) -> None:
|
|
14
|
+
"""Route progress events to appropriate CLI methods."""
|
|
15
|
+
if event.type == "markdown":
|
|
16
|
+
self.cli.show_markdown_text(event.message)
|
|
17
|
+
elif event.type == "success":
|
|
18
|
+
self.cli.show_success(event.message)
|
|
19
|
+
elif event.type == "error":
|
|
20
|
+
self.cli.show_error(event.message)
|
|
21
|
+
elif event.type == "info":
|
|
22
|
+
self.cli.show_markdown_text(event.message)
|
|
23
|
+
elif event.type == "release_notes":
|
|
24
|
+
heading = event.metadata.get("heading", "Release Notes") if event.metadata else "Release Notes"
|
|
25
|
+
self.cli.show_release_notes(heading, event.message)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""High-level API for library usage of pretty_release_notes."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .core.config import (
|
|
6
|
+
DatabaseConfig,
|
|
7
|
+
FilterConfig,
|
|
8
|
+
GitHubConfig,
|
|
9
|
+
GroupingConfig,
|
|
10
|
+
LLMConfig,
|
|
11
|
+
ReleaseNotesConfig,
|
|
12
|
+
)
|
|
13
|
+
from .core.interfaces import NullProgressReporter, ProgressReporter
|
|
14
|
+
from .generator import ReleaseNotesGenerator
|
|
15
|
+
from .openai_client import DEFAULT_MODEL, ReasoningEffort
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReleaseNotesClient:
|
|
19
|
+
"""High-level client for generating release notes."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
config: ReleaseNotesConfig,
|
|
24
|
+
progress_reporter: ProgressReporter | None = None,
|
|
25
|
+
):
|
|
26
|
+
self.config = config
|
|
27
|
+
self.progress_reporter = progress_reporter or NullProgressReporter()
|
|
28
|
+
|
|
29
|
+
def generate_release_notes(
|
|
30
|
+
self,
|
|
31
|
+
owner: str,
|
|
32
|
+
repo: str,
|
|
33
|
+
tag: str,
|
|
34
|
+
previous_tag_name: str | None = None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Generate release notes for a repository and tag.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
owner: Repository owner
|
|
40
|
+
repo: Repository name
|
|
41
|
+
tag: Git tag for the release
|
|
42
|
+
previous_tag_name: Optional previous tag to use as starting point
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Formatted release notes as markdown
|
|
46
|
+
"""
|
|
47
|
+
generator = ReleaseNotesGenerator(self.config, self.progress_reporter)
|
|
48
|
+
generator.initialize_repository(owner, repo)
|
|
49
|
+
return generator.generate(tag, previous_tag_name)
|
|
50
|
+
|
|
51
|
+
def update_github_release(
|
|
52
|
+
self,
|
|
53
|
+
owner: str,
|
|
54
|
+
repo: str,
|
|
55
|
+
tag: str,
|
|
56
|
+
notes: str,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Update release notes on GitHub.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
owner: Repository owner
|
|
62
|
+
repo: Repository name
|
|
63
|
+
tag: Git tag for the release
|
|
64
|
+
notes: New release notes content
|
|
65
|
+
"""
|
|
66
|
+
generator = ReleaseNotesGenerator(self.config, self.progress_reporter)
|
|
67
|
+
generator.initialize_repository(owner, repo)
|
|
68
|
+
generator.update_on_github(notes, tag)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ReleaseNotesBuilder:
|
|
72
|
+
"""Builder pattern for constructing ReleaseNotesClient."""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self._github_token = None
|
|
76
|
+
self._llm_key = None
|
|
77
|
+
self._llm_model = DEFAULT_MODEL
|
|
78
|
+
self._max_patch_size = 10000
|
|
79
|
+
self._reasoning_effort = None
|
|
80
|
+
self._db_type = "sqlite"
|
|
81
|
+
self._db_name = "stored_lines"
|
|
82
|
+
self._db_read_enabled = True
|
|
83
|
+
self._db_write_enabled = True
|
|
84
|
+
self._exclude_types = set()
|
|
85
|
+
self._exclude_labels = set()
|
|
86
|
+
self._exclude_authors = set()
|
|
87
|
+
self._group_by_type = True
|
|
88
|
+
self._type_headings = {}
|
|
89
|
+
self._other_heading = "Other Changes"
|
|
90
|
+
self._prompt_path = Path("prompt.txt")
|
|
91
|
+
self._force_use_commits = False
|
|
92
|
+
self._progress_reporter = None
|
|
93
|
+
|
|
94
|
+
def with_github_token(self, token: str) -> "ReleaseNotesBuilder":
|
|
95
|
+
"""Set GitHub authentication token."""
|
|
96
|
+
self._github_token = token
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def with_llm(
|
|
100
|
+
self,
|
|
101
|
+
api_key: str,
|
|
102
|
+
model: str = DEFAULT_MODEL,
|
|
103
|
+
max_patch_size: int = 10000,
|
|
104
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
105
|
+
) -> "ReleaseNotesBuilder":
|
|
106
|
+
"""Set LLM configuration."""
|
|
107
|
+
self._llm_key = api_key
|
|
108
|
+
self._llm_model = model
|
|
109
|
+
self._max_patch_size = max_patch_size
|
|
110
|
+
self._reasoning_effort = reasoning_effort
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def with_openai(
|
|
114
|
+
self,
|
|
115
|
+
api_key: str,
|
|
116
|
+
model: str = DEFAULT_MODEL,
|
|
117
|
+
max_patch_size: int = 10000,
|
|
118
|
+
reasoning_effort: ReasoningEffort | None = None,
|
|
119
|
+
) -> "ReleaseNotesBuilder":
|
|
120
|
+
"""Backward-compatible alias for with_llm()."""
|
|
121
|
+
return self.with_llm(
|
|
122
|
+
api_key=api_key,
|
|
123
|
+
model=model,
|
|
124
|
+
max_patch_size=max_patch_size,
|
|
125
|
+
reasoning_effort=reasoning_effort,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def with_database(
|
|
129
|
+
self,
|
|
130
|
+
db_type: str = "sqlite",
|
|
131
|
+
db_name: str = "stored_lines",
|
|
132
|
+
no_read: bool = False,
|
|
133
|
+
no_write: bool = False,
|
|
134
|
+
) -> "ReleaseNotesBuilder":
|
|
135
|
+
"""Configure database for caching generated summaries."""
|
|
136
|
+
self._db_type = db_type
|
|
137
|
+
self._db_name = db_name
|
|
138
|
+
self._db_read_enabled = not no_read
|
|
139
|
+
self._db_write_enabled = not no_write
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def with_filters(
|
|
143
|
+
self,
|
|
144
|
+
exclude_types: set[str] | None = None,
|
|
145
|
+
exclude_labels: set[str] | None = None,
|
|
146
|
+
exclude_authors: set[str] | None = None,
|
|
147
|
+
) -> "ReleaseNotesBuilder":
|
|
148
|
+
"""Set filters for excluding specific types, labels, or authors."""
|
|
149
|
+
if exclude_types:
|
|
150
|
+
self._exclude_types = exclude_types
|
|
151
|
+
if exclude_labels:
|
|
152
|
+
self._exclude_labels = exclude_labels
|
|
153
|
+
if exclude_authors:
|
|
154
|
+
self._exclude_authors = exclude_authors
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def with_grouping(
|
|
158
|
+
self,
|
|
159
|
+
group_by_type: bool = True,
|
|
160
|
+
type_headings: dict[str, str] | None = None,
|
|
161
|
+
other_heading: str = "Other Changes",
|
|
162
|
+
) -> "ReleaseNotesBuilder":
|
|
163
|
+
"""Configure output grouping by conventional commit type.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
group_by_type: Enable grouping by type
|
|
167
|
+
type_headings: Custom mapping of types to section headings
|
|
168
|
+
other_heading: Heading for uncategorized changes
|
|
169
|
+
"""
|
|
170
|
+
self._group_by_type = group_by_type
|
|
171
|
+
if type_headings:
|
|
172
|
+
self._type_headings = type_headings
|
|
173
|
+
self._other_heading = other_heading
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def with_prompt_file(self, path: Path) -> "ReleaseNotesBuilder":
|
|
177
|
+
"""Set custom prompt file path."""
|
|
178
|
+
self._prompt_path = path
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def with_force_commits(self, force: bool = True) -> "ReleaseNotesBuilder":
|
|
182
|
+
"""Force using commits even when PRs are available."""
|
|
183
|
+
self._force_use_commits = force
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def with_progress_reporter(self, reporter: ProgressReporter) -> "ReleaseNotesBuilder":
|
|
187
|
+
"""Set custom progress reporter."""
|
|
188
|
+
self._progress_reporter = reporter
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def _build_grouping_config(self) -> GroupingConfig:
|
|
192
|
+
"""Build grouping config, preserving defaults when no custom headings are set."""
|
|
193
|
+
if self._type_headings:
|
|
194
|
+
return GroupingConfig(
|
|
195
|
+
group_by_type=self._group_by_type,
|
|
196
|
+
type_headings=self._type_headings,
|
|
197
|
+
other_heading=self._other_heading,
|
|
198
|
+
)
|
|
199
|
+
return GroupingConfig(
|
|
200
|
+
group_by_type=self._group_by_type,
|
|
201
|
+
other_heading=self._other_heading,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def build(self) -> ReleaseNotesClient:
|
|
205
|
+
"""Build the client with configured options.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ValueError: If required configuration is missing
|
|
209
|
+
"""
|
|
210
|
+
if not self._github_token:
|
|
211
|
+
raise ValueError("GitHub token is required")
|
|
212
|
+
if not self._llm_key:
|
|
213
|
+
raise ValueError("LLM API key is required")
|
|
214
|
+
|
|
215
|
+
config = ReleaseNotesConfig(
|
|
216
|
+
github=GitHubConfig(token=self._github_token),
|
|
217
|
+
llm=LLMConfig(
|
|
218
|
+
api_key=self._llm_key,
|
|
219
|
+
model=self._llm_model,
|
|
220
|
+
max_patch_size=self._max_patch_size,
|
|
221
|
+
reasoning_effort=self._reasoning_effort,
|
|
222
|
+
),
|
|
223
|
+
database=DatabaseConfig(
|
|
224
|
+
type=self._db_type,
|
|
225
|
+
name=self._db_name,
|
|
226
|
+
read_enabled=self._db_read_enabled,
|
|
227
|
+
write_enabled=self._db_write_enabled,
|
|
228
|
+
),
|
|
229
|
+
filters=FilterConfig(
|
|
230
|
+
exclude_change_types=self._exclude_types,
|
|
231
|
+
exclude_change_labels=self._exclude_labels,
|
|
232
|
+
exclude_authors=self._exclude_authors,
|
|
233
|
+
),
|
|
234
|
+
grouping=self._build_grouping_config(),
|
|
235
|
+
prompt_path=self._prompt_path,
|
|
236
|
+
force_use_commits=self._force_use_commits,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return ReleaseNotesClient(config, self._progress_reporter)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core abstractions for pretty_release_notes."""
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..openai_client import DEFAULT_MODEL, ReasoningEffort, normalize_reasoning_effort
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class GitHubConfig:
|
|
9
|
+
token: str
|
|
10
|
+
owner: str | None = None
|
|
11
|
+
|
|
12
|
+
def __post_init__(self):
|
|
13
|
+
if not self.token:
|
|
14
|
+
raise ValueError("GitHub token is required")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LLMConfig:
|
|
19
|
+
api_key: str
|
|
20
|
+
model: str = DEFAULT_MODEL
|
|
21
|
+
max_patch_size: int = 10000
|
|
22
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
if not self.api_key:
|
|
26
|
+
raise ValueError("LLM API key is required")
|
|
27
|
+
self.reasoning_effort = normalize_reasoning_effort(self.reasoning_effort)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
OpenAIConfig = LLMConfig
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DatabaseConfig:
|
|
35
|
+
"""Database configuration for caching release note summaries.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
type: Database backend type ("csv" or "sqlite")
|
|
39
|
+
name: Database filename (without extension). If relative, stores in ~/.pretty-release-notes/
|
|
40
|
+
If absolute path, stores at that exact location.
|
|
41
|
+
read_enabled: Whether to read cached summaries (CLI override; not in config files)
|
|
42
|
+
write_enabled: Whether to persist new summaries (CLI override; not in config files)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
type: str = "sqlite"
|
|
46
|
+
name: str = "stored_lines"
|
|
47
|
+
read_enabled: bool = True
|
|
48
|
+
write_enabled: bool = True
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
type: str = "sqlite",
|
|
53
|
+
name: str = "stored_lines",
|
|
54
|
+
read_enabled: bool = True,
|
|
55
|
+
write_enabled: bool = True,
|
|
56
|
+
enabled: bool | None = None,
|
|
57
|
+
):
|
|
58
|
+
self.type = type
|
|
59
|
+
self.name = name
|
|
60
|
+
if enabled is not None:
|
|
61
|
+
self.read_enabled = enabled
|
|
62
|
+
self.write_enabled = enabled
|
|
63
|
+
else:
|
|
64
|
+
self.read_enabled = read_enabled
|
|
65
|
+
self.write_enabled = write_enabled
|
|
66
|
+
if self.type not in ("csv", "sqlite"):
|
|
67
|
+
raise ValueError(f"Invalid database type: {self.type}")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def enabled(self) -> bool:
|
|
71
|
+
return self.read_enabled and self.write_enabled
|
|
72
|
+
|
|
73
|
+
@enabled.setter
|
|
74
|
+
def enabled(self, value: bool) -> None:
|
|
75
|
+
self.read_enabled = value
|
|
76
|
+
self.write_enabled = value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class FilterConfig:
|
|
81
|
+
exclude_change_types: set[str] = field(default_factory=set)
|
|
82
|
+
exclude_change_labels: set[str] = field(default_factory=set)
|
|
83
|
+
exclude_authors: set[str] = field(default_factory=set)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class GroupingConfig:
|
|
88
|
+
"""Configuration for grouping release notes output."""
|
|
89
|
+
|
|
90
|
+
group_by_type: bool = True
|
|
91
|
+
type_headings: dict[str, str] = field(
|
|
92
|
+
default_factory=lambda: {
|
|
93
|
+
"feat": "Features",
|
|
94
|
+
"fix": "Bug Fixes",
|
|
95
|
+
"perf": "Performance Improvements",
|
|
96
|
+
"docs": "Documentation",
|
|
97
|
+
"refactor": "Code Refactoring",
|
|
98
|
+
"test": "Tests",
|
|
99
|
+
"build": "Build System",
|
|
100
|
+
"ci": "CI/CD",
|
|
101
|
+
"chore": "Chores",
|
|
102
|
+
"style": "Style",
|
|
103
|
+
"revert": "Reverts",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
other_heading: str = "Other Changes"
|
|
107
|
+
breaking_changes_heading: str = "Breaking Changes"
|
|
108
|
+
|
|
109
|
+
def get_heading(self, type_name: str | None) -> str:
|
|
110
|
+
"""Get the section heading for a given type."""
|
|
111
|
+
if not type_name:
|
|
112
|
+
return self.other_heading
|
|
113
|
+
return self.type_headings.get(type_name, self.other_heading)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_default_prompt_path() -> Path:
|
|
117
|
+
"""Get the default prompt.txt path from the package directory."""
|
|
118
|
+
# Get the directory where this config.py file is located
|
|
119
|
+
package_dir = Path(__file__).parent.parent
|
|
120
|
+
return package_dir / "prompt.txt"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(init=False)
|
|
124
|
+
class ReleaseNotesConfig:
|
|
125
|
+
github: GitHubConfig
|
|
126
|
+
llm: LLMConfig
|
|
127
|
+
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
|
128
|
+
filters: FilterConfig = field(default_factory=FilterConfig)
|
|
129
|
+
grouping: GroupingConfig = field(default_factory=GroupingConfig)
|
|
130
|
+
prompt_path: Path = field(default_factory=_get_default_prompt_path)
|
|
131
|
+
force_use_commits: bool = False
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
github: GitHubConfig,
|
|
136
|
+
llm: LLMConfig | None = None,
|
|
137
|
+
openai: LLMConfig | None = None,
|
|
138
|
+
database: DatabaseConfig | None = None,
|
|
139
|
+
filters: FilterConfig | None = None,
|
|
140
|
+
grouping: GroupingConfig | None = None,
|
|
141
|
+
prompt_path: Path | None = None,
|
|
142
|
+
force_use_commits: bool = False,
|
|
143
|
+
):
|
|
144
|
+
if llm is not None and openai is not None and llm != openai:
|
|
145
|
+
raise ValueError("Pass either llm or openai configuration, not both")
|
|
146
|
+
|
|
147
|
+
resolved_llm = llm or openai
|
|
148
|
+
if resolved_llm is None:
|
|
149
|
+
raise ValueError("LLM configuration is required")
|
|
150
|
+
|
|
151
|
+
self.github = github
|
|
152
|
+
self.llm = resolved_llm
|
|
153
|
+
self.database = database if database is not None else DatabaseConfig()
|
|
154
|
+
self.filters = filters if filters is not None else FilterConfig()
|
|
155
|
+
self.grouping = grouping if grouping is not None else GroupingConfig()
|
|
156
|
+
self.prompt_path = prompt_path if prompt_path is not None else _get_default_prompt_path()
|
|
157
|
+
self.force_use_commits = force_use_commits
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def openai(self) -> LLMConfig:
|
|
161
|
+
"""Backward-compatible alias for llm configuration."""
|
|
162
|
+
return self.llm
|
|
163
|
+
|
|
164
|
+
@openai.setter
|
|
165
|
+
def openai(self, value: LLMConfig) -> None:
|
|
166
|
+
self.llm = value
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dotenv import dotenv_values
|
|
7
|
+
|
|
8
|
+
from ..openai_client import DEFAULT_MODEL
|
|
9
|
+
from .config import (
|
|
10
|
+
DatabaseConfig,
|
|
11
|
+
FilterConfig,
|
|
12
|
+
GitHubConfig,
|
|
13
|
+
GroupingConfig,
|
|
14
|
+
LLMConfig,
|
|
15
|
+
ReleaseNotesConfig,
|
|
16
|
+
_get_default_prompt_path,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigLoader(ABC):
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def load(self) -> ReleaseNotesConfig:
|
|
23
|
+
"""Load configuration from source."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DictConfigLoader(ConfigLoader):
|
|
28
|
+
"""Load from dictionary (for programmatic usage)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config_dict: dict[str, Any]):
|
|
31
|
+
self.config_dict = config_dict
|
|
32
|
+
|
|
33
|
+
def load(self) -> ReleaseNotesConfig:
|
|
34
|
+
llm_api_key = self.config_dict.get("llm_api_key", self.config_dict.get("openai_api_key"))
|
|
35
|
+
if llm_api_key is None:
|
|
36
|
+
raise KeyError("llm_api_key")
|
|
37
|
+
|
|
38
|
+
return ReleaseNotesConfig(
|
|
39
|
+
github=GitHubConfig(
|
|
40
|
+
token=self.config_dict["github_token"],
|
|
41
|
+
owner=self.config_dict.get("github_owner"),
|
|
42
|
+
),
|
|
43
|
+
llm=LLMConfig(
|
|
44
|
+
api_key=llm_api_key,
|
|
45
|
+
model=self.config_dict.get("llm_model", self.config_dict.get("openai_model", DEFAULT_MODEL)),
|
|
46
|
+
max_patch_size=self.config_dict.get("max_patch_size", 10000),
|
|
47
|
+
reasoning_effort=self.config_dict.get(
|
|
48
|
+
"llm_reasoning_effort",
|
|
49
|
+
self.config_dict.get(
|
|
50
|
+
"openai_reasoning_effort",
|
|
51
|
+
self.config_dict.get("reasoning_effort"),
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
database=DatabaseConfig(
|
|
56
|
+
type=self.config_dict.get("db_type", "sqlite"),
|
|
57
|
+
name=self.config_dict.get("db_name", "stored_lines"),
|
|
58
|
+
enabled=self.config_dict.get("use_db", True),
|
|
59
|
+
),
|
|
60
|
+
filters=FilterConfig(
|
|
61
|
+
exclude_change_types=set(self.config_dict.get("exclude_types", [])),
|
|
62
|
+
exclude_change_labels=set(self.config_dict.get("exclude_labels", [])),
|
|
63
|
+
exclude_authors=set(self.config_dict.get("exclude_authors", [])),
|
|
64
|
+
),
|
|
65
|
+
grouping=GroupingConfig(
|
|
66
|
+
group_by_type=self.config_dict.get("group_by_type", True),
|
|
67
|
+
**(
|
|
68
|
+
{}
|
|
69
|
+
if "type_headings" not in self.config_dict
|
|
70
|
+
else {"type_headings": self.config_dict["type_headings"]}
|
|
71
|
+
),
|
|
72
|
+
**(
|
|
73
|
+
{}
|
|
74
|
+
if "other_heading" not in self.config_dict
|
|
75
|
+
else {"other_heading": self.config_dict["other_heading"]}
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
prompt_path=Path(self.config_dict["prompt_path"])
|
|
79
|
+
if "prompt_path" in self.config_dict
|
|
80
|
+
else _get_default_prompt_path(),
|
|
81
|
+
force_use_commits=self.config_dict.get("force_use_commits", False),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EnvConfigLoader(ConfigLoader):
|
|
86
|
+
"""Load from .env file (backward compatibility)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, env_path: str = ".env"):
|
|
89
|
+
self.env_path = env_path
|
|
90
|
+
|
|
91
|
+
def load(self) -> ReleaseNotesConfig:
|
|
92
|
+
config = dotenv_values(self.env_path)
|
|
93
|
+
|
|
94
|
+
# Required fields
|
|
95
|
+
github_token = config["GH_TOKEN"]
|
|
96
|
+
llm_key = config.get("LLM_API_KEY") or config.get("OPENAI_API_KEY")
|
|
97
|
+
|
|
98
|
+
# Ensure github_token and llm_key are not None
|
|
99
|
+
if github_token is None:
|
|
100
|
+
raise ValueError("GH_TOKEN is required in .env file")
|
|
101
|
+
if llm_key is None:
|
|
102
|
+
raise ValueError("LLM_API_KEY is required in .env file")
|
|
103
|
+
|
|
104
|
+
return ReleaseNotesConfig(
|
|
105
|
+
github=GitHubConfig(token=github_token, owner=config.get("DEFAULT_OWNER")),
|
|
106
|
+
llm=LLMConfig(
|
|
107
|
+
api_key=llm_key,
|
|
108
|
+
model=config.get("LLM_MODEL") or config.get("OPENAI_MODEL") or DEFAULT_MODEL,
|
|
109
|
+
max_patch_size=int(config.get("MAX_PATCH_SIZE") or "10000"),
|
|
110
|
+
reasoning_effort=config.get("LLM_REASONING_EFFORT") or config.get("OPENAI_REASONING_EFFORT"),
|
|
111
|
+
),
|
|
112
|
+
database=DatabaseConfig(
|
|
113
|
+
type=config.get("DB_TYPE") or "sqlite",
|
|
114
|
+
name=config.get("DB_NAME") or "stored_lines",
|
|
115
|
+
enabled=True,
|
|
116
|
+
),
|
|
117
|
+
filters=FilterConfig(
|
|
118
|
+
exclude_change_types=self._parse_set(config.get("EXCLUDE_PR_TYPES") or ""),
|
|
119
|
+
exclude_change_labels=self._parse_set(config.get("EXCLUDE_PR_LABELS") or ""),
|
|
120
|
+
exclude_authors=self._parse_set(config.get("EXCLUDE_AUTHORS") or ""),
|
|
121
|
+
),
|
|
122
|
+
grouping=GroupingConfig(
|
|
123
|
+
group_by_type=(config.get("GROUP_BY_TYPE") or "true").lower() == "true",
|
|
124
|
+
),
|
|
125
|
+
prompt_path=(
|
|
126
|
+
Path(config["PROMPT_PATH"])
|
|
127
|
+
if "PROMPT_PATH" in config and config["PROMPT_PATH"] is not None
|
|
128
|
+
else _get_default_prompt_path()
|
|
129
|
+
),
|
|
130
|
+
force_use_commits=(config.get("FORCE_USE_COMMITS") or "false").lower() == "true",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _parse_set(self, value: str) -> set[str]:
|
|
134
|
+
return set(value.split(",")) if value else set()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TomlConfigLoader(ConfigLoader):
|
|
138
|
+
"""Load from TOML file (default config format)."""
|
|
139
|
+
|
|
140
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".pretty-release-notes" / "config.toml"
|
|
141
|
+
|
|
142
|
+
def __init__(self, config_path: Path | str | None = None):
|
|
143
|
+
"""Initialize TOML config loader.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
config_path: Path to config file. If None, uses DEFAULT_CONFIG_PATH.
|
|
147
|
+
"""
|
|
148
|
+
if config_path is None:
|
|
149
|
+
self.config_path = self.DEFAULT_CONFIG_PATH
|
|
150
|
+
else:
|
|
151
|
+
self.config_path = Path(config_path)
|
|
152
|
+
|
|
153
|
+
def load(self) -> ReleaseNotesConfig:
|
|
154
|
+
"""Load configuration from TOML file.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
FileNotFoundError: If config file doesn't exist
|
|
158
|
+
ValueError: If required fields are missing or invalid
|
|
159
|
+
"""
|
|
160
|
+
if not self.config_path.exists():
|
|
161
|
+
raise FileNotFoundError(
|
|
162
|
+
f"Config file not found at {self.config_path}. "
|
|
163
|
+
f"Create it with the required fields or use --config-path to specify a different location."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
with open(self.config_path, "rb") as f:
|
|
167
|
+
config = tomllib.load(f)
|
|
168
|
+
|
|
169
|
+
# Extract nested sections with defaults
|
|
170
|
+
github_config = config.get("github", {})
|
|
171
|
+
llm_config = {**config.get("openai", {}), **config.get("llm", {})}
|
|
172
|
+
database_config = config.get("database", {})
|
|
173
|
+
filters_config = config.get("filters", {})
|
|
174
|
+
grouping_config = config.get("grouping", {})
|
|
175
|
+
|
|
176
|
+
# Required fields
|
|
177
|
+
github_token = github_config.get("token")
|
|
178
|
+
llm_key = llm_config.get("api_key")
|
|
179
|
+
|
|
180
|
+
if not github_token:
|
|
181
|
+
raise ValueError("github.token is required in config file")
|
|
182
|
+
if not llm_key:
|
|
183
|
+
raise ValueError("llm.api_key is required in config file")
|
|
184
|
+
|
|
185
|
+
return ReleaseNotesConfig(
|
|
186
|
+
github=GitHubConfig(
|
|
187
|
+
token=github_token,
|
|
188
|
+
owner=github_config.get("owner"),
|
|
189
|
+
),
|
|
190
|
+
llm=LLMConfig(
|
|
191
|
+
api_key=llm_key,
|
|
192
|
+
model=llm_config.get("model", DEFAULT_MODEL),
|
|
193
|
+
max_patch_size=llm_config.get("max_patch_size", 10000),
|
|
194
|
+
reasoning_effort=llm_config.get("reasoning_effort"),
|
|
195
|
+
),
|
|
196
|
+
database=DatabaseConfig(
|
|
197
|
+
type=database_config.get("type", "sqlite"),
|
|
198
|
+
name=database_config.get("name", "stored_lines"),
|
|
199
|
+
enabled=database_config.get("enabled", True),
|
|
200
|
+
),
|
|
201
|
+
filters=FilterConfig(
|
|
202
|
+
exclude_change_types=set(filters_config.get("exclude_change_types", [])),
|
|
203
|
+
exclude_change_labels=set(filters_config.get("exclude_change_labels", [])),
|
|
204
|
+
exclude_authors=set(filters_config.get("exclude_authors", [])),
|
|
205
|
+
),
|
|
206
|
+
grouping=GroupingConfig(
|
|
207
|
+
group_by_type=grouping_config.get("group_by_type", True),
|
|
208
|
+
**(
|
|
209
|
+
{}
|
|
210
|
+
if "type_headings" not in grouping_config
|
|
211
|
+
else {"type_headings": grouping_config["type_headings"]}
|
|
212
|
+
),
|
|
213
|
+
**(
|
|
214
|
+
{}
|
|
215
|
+
if "other_heading" not in grouping_config
|
|
216
|
+
else {"other_heading": grouping_config["other_heading"]}
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
prompt_path=Path(config["prompt_path"]) if "prompt_path" in config else _get_default_prompt_path(),
|
|
220
|
+
force_use_commits=config.get("force_use_commits", False),
|
|
221
|
+
)
|