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.
Files changed (37) hide show
  1. pretty_release_notes/__init__.py +38 -0
  2. pretty_release_notes/__main__.py +6 -0
  3. pretty_release_notes/adapters/__init__.py +1 -0
  4. pretty_release_notes/adapters/cli_progress.py +25 -0
  5. pretty_release_notes/api.py +239 -0
  6. pretty_release_notes/core/__init__.py +1 -0
  7. pretty_release_notes/core/config.py +166 -0
  8. pretty_release_notes/core/config_loader.py +221 -0
  9. pretty_release_notes/core/execution.py +74 -0
  10. pretty_release_notes/core/interfaces.py +35 -0
  11. pretty_release_notes/database.py +152 -0
  12. pretty_release_notes/generator.py +317 -0
  13. pretty_release_notes/github_client.py +232 -0
  14. pretty_release_notes/main.py +245 -0
  15. pretty_release_notes/models/__init__.py +15 -0
  16. pretty_release_notes/models/_utils.py +47 -0
  17. pretty_release_notes/models/change.py +34 -0
  18. pretty_release_notes/models/commit.py +75 -0
  19. pretty_release_notes/models/issue.py +17 -0
  20. pretty_release_notes/models/pull_request.py +208 -0
  21. pretty_release_notes/models/release_notes.py +241 -0
  22. pretty_release_notes/models/release_notes_line.py +36 -0
  23. pretty_release_notes/models/repository.py +20 -0
  24. pretty_release_notes/openai_client.py +165 -0
  25. pretty_release_notes/prompt.txt +34 -0
  26. pretty_release_notes/py.typed +0 -0
  27. pretty_release_notes/setup_command.py +336 -0
  28. pretty_release_notes/ui.py +46 -0
  29. pretty_release_notes/web/__init__.py +1 -0
  30. pretty_release_notes/web/app.py +193 -0
  31. pretty_release_notes/web/server.py +11 -0
  32. pretty_release_notes-1.4.0.dist-info/METADATA +335 -0
  33. pretty_release_notes-1.4.0.dist-info/RECORD +37 -0
  34. pretty_release_notes-1.4.0.dist-info/WHEEL +5 -0
  35. pretty_release_notes-1.4.0.dist-info/entry_points.txt +2 -0
  36. pretty_release_notes-1.4.0.dist-info/licenses/LICENSE +7 -0
  37. 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,6 @@
1
+ """Entry point for running pretty_release_notes as a module."""
2
+
3
+ from .main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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
+ )