releez 0.2.2__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.
- releez/__init__.py +1 -0
- releez/artifact_version.py +111 -0
- releez/cli.py +559 -0
- releez/cliff.py +186 -0
- releez/errors.py +231 -0
- releez/git_repo.py +232 -0
- releez/github.py +145 -0
- releez/process.py +52 -0
- releez/release.py +241 -0
- releez/settings.py +101 -0
- releez/subapps/__init__.py +5 -0
- releez/subapps/changelog.py +87 -0
- releez/utils.py +44 -0
- releez/version_tags.py +69 -0
- releez-0.2.2.dist-info/METADATA +225 -0
- releez-0.2.2.dist-info/RECORD +18 -0
- releez-0.2.2.dist-info/WHEEL +4 -0
- releez-0.2.2.dist-info/entry_points.txt +3 -0
releez/github.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from releez.errors import InvalidGitHubRemoteError, MissingGitHubDependencyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PullRequest:
|
|
13
|
+
"""A minimal representation of a created GitHub pull request.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
url: The PR URL.
|
|
17
|
+
number: The PR number.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
url: str
|
|
21
|
+
number: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PullRequestCreateRequest:
|
|
26
|
+
"""Parameters for creating a GitHub pull request.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
remote_url: The git remote URL used to infer the GitHub repo.
|
|
30
|
+
token: GitHub token used for authentication.
|
|
31
|
+
base: The base branch for the PR.
|
|
32
|
+
head: The head branch for the PR.
|
|
33
|
+
title: The PR title.
|
|
34
|
+
body: The PR body.
|
|
35
|
+
labels: Labels to add to the PR.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
remote_url: str
|
|
39
|
+
token: str
|
|
40
|
+
base: str
|
|
41
|
+
head: str
|
|
42
|
+
title: str
|
|
43
|
+
body: str
|
|
44
|
+
labels: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_SCP_SSH_RE = re.compile(
|
|
48
|
+
r'^git@(?P<host>[^:]+):(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
|
|
49
|
+
)
|
|
50
|
+
_SSH_URL_RE = re.compile(
|
|
51
|
+
r'^ssh://git@(?P<host>[^/]+)/(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
|
|
52
|
+
)
|
|
53
|
+
_HTTPS_RE = re.compile(
|
|
54
|
+
r'^https?://(?P<host>[^/]+)/(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _github_api_base_url_from_env() -> str | None:
|
|
59
|
+
api_url = os.getenv('RELEEZ_GITHUB_API_URL') or os.getenv('GITHUB_API_URL')
|
|
60
|
+
if api_url:
|
|
61
|
+
return api_url.rstrip('/')
|
|
62
|
+
|
|
63
|
+
server_url = os.getenv('RELEEZ_GITHUB_SERVER_URL') or os.getenv(
|
|
64
|
+
'GITHUB_SERVER_URL',
|
|
65
|
+
)
|
|
66
|
+
if not server_url:
|
|
67
|
+
return None
|
|
68
|
+
return f'{server_url.rstrip("/")}/api/v3'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _allowed_github_hosts_from_env() -> set[str]:
|
|
72
|
+
hosts = {'github.com'}
|
|
73
|
+
|
|
74
|
+
for var in (
|
|
75
|
+
'RELEEZ_GITHUB_SERVER_URL',
|
|
76
|
+
'GITHUB_SERVER_URL',
|
|
77
|
+
'RELEEZ_GITHUB_API_URL',
|
|
78
|
+
'GITHUB_API_URL',
|
|
79
|
+
):
|
|
80
|
+
raw = os.getenv(var)
|
|
81
|
+
if not raw:
|
|
82
|
+
continue
|
|
83
|
+
parsed = urlparse(raw)
|
|
84
|
+
if parsed.hostname:
|
|
85
|
+
hosts.add(parsed.hostname)
|
|
86
|
+
continue
|
|
87
|
+
# allow plain host values (not URLs)
|
|
88
|
+
hosts.add(raw.strip().rstrip('/'))
|
|
89
|
+
|
|
90
|
+
return hosts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_github_full_name(remote_url: str) -> str:
|
|
94
|
+
remote_url = remote_url.strip()
|
|
95
|
+
for regex in (_SCP_SSH_RE, _SSH_URL_RE, _HTTPS_RE):
|
|
96
|
+
m = regex.match(remote_url)
|
|
97
|
+
if m:
|
|
98
|
+
host = m.group('host')
|
|
99
|
+
if host not in _allowed_github_hosts_from_env():
|
|
100
|
+
raise InvalidGitHubRemoteError(remote_url)
|
|
101
|
+
full_name = m.group('full')
|
|
102
|
+
if full_name.endswith('.git'):
|
|
103
|
+
full_name = full_name.removesuffix('.git')
|
|
104
|
+
return full_name
|
|
105
|
+
raise InvalidGitHubRemoteError(remote_url)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_pull_request(request: PullRequestCreateRequest) -> PullRequest:
|
|
109
|
+
"""Create a GitHub pull request.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
request: The parameters needed to create the pull request.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The created PR URL and number.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
MissingGitHubDependencyError: If PyGithub is not installed.
|
|
119
|
+
InvalidGitHubRemoteError: If the remote URL cannot be mapped to a GitHub repo.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
from github import Github # noqa: PLC0415
|
|
123
|
+
except ImportError as exc:
|
|
124
|
+
raise MissingGitHubDependencyError from exc
|
|
125
|
+
|
|
126
|
+
full_name = _parse_github_full_name(request.remote_url)
|
|
127
|
+
base_url = _github_api_base_url_from_env()
|
|
128
|
+
gh = (
|
|
129
|
+
Github(
|
|
130
|
+
login_or_token=request.token,
|
|
131
|
+
base_url=base_url,
|
|
132
|
+
)
|
|
133
|
+
if base_url
|
|
134
|
+
else Github(request.token)
|
|
135
|
+
)
|
|
136
|
+
repo = gh.get_repo(full_name)
|
|
137
|
+
pr = repo.create_pull(
|
|
138
|
+
title=request.title,
|
|
139
|
+
body=request.body,
|
|
140
|
+
base=request.base,
|
|
141
|
+
head=request.head,
|
|
142
|
+
)
|
|
143
|
+
if request.labels:
|
|
144
|
+
pr.add_to_labels(*request.labels)
|
|
145
|
+
return PullRequest(url=pr.html_url, number=pr.number)
|
releez/process.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from releez.errors import ExternalCommandError, MissingCliError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_checked(
|
|
14
|
+
args: Sequence[str],
|
|
15
|
+
*,
|
|
16
|
+
cwd: Path | None = None,
|
|
17
|
+
capture_stdout: bool = True,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Run a command and raise a ReleezError on failure.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
args: The command and arguments to execute.
|
|
23
|
+
cwd: Optional working directory for the command.
|
|
24
|
+
capture_stdout: If false, stdout is not captured.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The stripped stdout of the command.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
MissingCliError: If the executable is not found.
|
|
31
|
+
ExternalCommandError: If the command exits non-zero.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
res = subprocess.run( # noqa: S603
|
|
35
|
+
list(args),
|
|
36
|
+
cwd=cwd,
|
|
37
|
+
check=True,
|
|
38
|
+
text=True,
|
|
39
|
+
stdout=subprocess.PIPE if capture_stdout else None,
|
|
40
|
+
stderr=subprocess.PIPE,
|
|
41
|
+
)
|
|
42
|
+
except FileNotFoundError as exc:
|
|
43
|
+
raise MissingCliError(args[0]) from exc
|
|
44
|
+
except subprocess.CalledProcessError as exc:
|
|
45
|
+
stderr = (exc.stderr or '').strip()
|
|
46
|
+
raise ExternalCommandError(
|
|
47
|
+
cmd_args=args,
|
|
48
|
+
returncode=exc.returncode,
|
|
49
|
+
stderr=stderr,
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
return (res.stdout or '').strip()
|
releez/release.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from git import Repo
|
|
10
|
+
|
|
11
|
+
from releez.cliff import GitCliff, GitCliffBump
|
|
12
|
+
from releez.errors import (
|
|
13
|
+
ChangelogFormatCommandRequiredError,
|
|
14
|
+
GitHubTokenRequiredError,
|
|
15
|
+
GitRemoteUrlRequiredError,
|
|
16
|
+
)
|
|
17
|
+
from releez.git_repo import (
|
|
18
|
+
checkout_remote_branch,
|
|
19
|
+
commit_file,
|
|
20
|
+
create_and_checkout_branch,
|
|
21
|
+
ensure_clean,
|
|
22
|
+
fetch,
|
|
23
|
+
open_repo,
|
|
24
|
+
push_set_upstream,
|
|
25
|
+
)
|
|
26
|
+
from releez.github import PullRequestCreateRequest, create_pull_request
|
|
27
|
+
from releez.utils import resolve_changelog_path, run_changelog_formatter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class StartReleaseResult:
|
|
32
|
+
"""Result of starting a release.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
version: The computed next version.
|
|
36
|
+
release_notes_markdown: The generated release notes markdown.
|
|
37
|
+
release_branch: The created release branch, or None in dry-run mode.
|
|
38
|
+
pr_url: The created PR URL, or None if not created.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
version: str
|
|
42
|
+
release_notes_markdown: str
|
|
43
|
+
release_branch: str | None
|
|
44
|
+
pr_url: str | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class StartReleaseInput:
|
|
49
|
+
"""Inputs for starting a release.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
bump: Bump mode for git-cliff.
|
|
53
|
+
version_override: Override the computed next version.
|
|
54
|
+
base_branch: Base branch for the release PR.
|
|
55
|
+
remote_name: Remote name to use.
|
|
56
|
+
labels: Labels to add to the PR.
|
|
57
|
+
title_prefix: Prefix for PR title / commit message.
|
|
58
|
+
changelog_path: Changelog file to prepend to.
|
|
59
|
+
run_changelog_format: If true, run the configured changelog formatter before commit.
|
|
60
|
+
changelog_format_cmd: Optional argv list to run for formatting (overrides config).
|
|
61
|
+
create_pr: If true, create a GitHub pull request.
|
|
62
|
+
github_token: GitHub token for PR creation.
|
|
63
|
+
dry_run: If true, do not modify the repo; just output version and notes.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
bump: GitCliffBump
|
|
67
|
+
version_override: str | None
|
|
68
|
+
base_branch: str
|
|
69
|
+
remote_name: str
|
|
70
|
+
labels: list[str]
|
|
71
|
+
title_prefix: str
|
|
72
|
+
changelog_path: str
|
|
73
|
+
run_changelog_format: bool
|
|
74
|
+
changelog_format_cmd: list[str] | None
|
|
75
|
+
create_pr: bool
|
|
76
|
+
github_token: str | None
|
|
77
|
+
dry_run: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class _MaybeCreatePullRequestInput:
|
|
82
|
+
"""Inputs for optionally creating a pull request.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
create_pr: If true, create a GitHub pull request.
|
|
86
|
+
github_token: GitHub token for PR creation.
|
|
87
|
+
remote_name: Remote name used to infer the repo URL.
|
|
88
|
+
base_branch: The base branch for the PR.
|
|
89
|
+
head_branch: The head branch for the PR.
|
|
90
|
+
title: The PR title.
|
|
91
|
+
body: The PR body.
|
|
92
|
+
labels: Labels to add to the PR.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
create_pr: bool
|
|
96
|
+
github_token: str | None
|
|
97
|
+
remote_name: str
|
|
98
|
+
base_branch: str
|
|
99
|
+
head_branch: str
|
|
100
|
+
title: str
|
|
101
|
+
body: str
|
|
102
|
+
labels: list[str]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _maybe_create_pull_request(
|
|
106
|
+
*,
|
|
107
|
+
repo: Repo,
|
|
108
|
+
pr_input: _MaybeCreatePullRequestInput,
|
|
109
|
+
) -> str | None:
|
|
110
|
+
if not pr_input.create_pr:
|
|
111
|
+
return None
|
|
112
|
+
if not pr_input.github_token:
|
|
113
|
+
raise GitHubTokenRequiredError
|
|
114
|
+
|
|
115
|
+
remote_url = repo.remotes[pr_input.remote_name].url
|
|
116
|
+
if not remote_url:
|
|
117
|
+
raise GitRemoteUrlRequiredError(pr_input.remote_name)
|
|
118
|
+
|
|
119
|
+
request = PullRequestCreateRequest(
|
|
120
|
+
remote_url=remote_url,
|
|
121
|
+
token=pr_input.github_token,
|
|
122
|
+
base=pr_input.base_branch,
|
|
123
|
+
head=pr_input.head_branch,
|
|
124
|
+
title=pr_input.title,
|
|
125
|
+
body=pr_input.body,
|
|
126
|
+
labels=pr_input.labels,
|
|
127
|
+
)
|
|
128
|
+
pr = create_pull_request(request)
|
|
129
|
+
return pr.url
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_release_version(
|
|
133
|
+
*,
|
|
134
|
+
cliff: GitCliff,
|
|
135
|
+
release_input: StartReleaseInput,
|
|
136
|
+
) -> str:
|
|
137
|
+
if release_input.version_override is not None:
|
|
138
|
+
return release_input.version_override
|
|
139
|
+
return cliff.compute_next_version(bump=release_input.bump)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _format_changelog_if_requested(
|
|
143
|
+
*,
|
|
144
|
+
repo_root: Path,
|
|
145
|
+
changelog_path: Path,
|
|
146
|
+
release_input: StartReleaseInput,
|
|
147
|
+
) -> None:
|
|
148
|
+
if not release_input.run_changelog_format:
|
|
149
|
+
return
|
|
150
|
+
if not release_input.changelog_format_cmd:
|
|
151
|
+
raise ChangelogFormatCommandRequiredError
|
|
152
|
+
run_changelog_formatter(
|
|
153
|
+
changelog_path=changelog_path,
|
|
154
|
+
repo_root=repo_root,
|
|
155
|
+
changelog_format_cmd=release_input.changelog_format_cmd,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def start_release(
|
|
160
|
+
release_input: StartReleaseInput,
|
|
161
|
+
) -> StartReleaseResult:
|
|
162
|
+
"""Start a release.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
release_input: The input parameters for starting the release.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The version, release notes, and (if created) branch/PR details.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ReleezError: If a release step fails (git, git-cliff, or GitHub).
|
|
172
|
+
"""
|
|
173
|
+
repo, info = open_repo()
|
|
174
|
+
ensure_clean(repo)
|
|
175
|
+
fetch(repo, remote_name=release_input.remote_name)
|
|
176
|
+
|
|
177
|
+
cliff = GitCliff(repo_root=info.root)
|
|
178
|
+
if not release_input.dry_run:
|
|
179
|
+
checkout_remote_branch(
|
|
180
|
+
repo,
|
|
181
|
+
remote_name=release_input.remote_name,
|
|
182
|
+
branch=release_input.base_branch,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
version = _resolve_release_version(cliff=cliff, release_input=release_input)
|
|
186
|
+
notes = cliff.generate_unreleased_notes(version=version)
|
|
187
|
+
|
|
188
|
+
if release_input.dry_run:
|
|
189
|
+
return StartReleaseResult(
|
|
190
|
+
version=version,
|
|
191
|
+
release_notes_markdown=notes,
|
|
192
|
+
release_branch=None,
|
|
193
|
+
pr_url=None,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
release_branch = f'release/{version}'
|
|
197
|
+
create_and_checkout_branch(repo, name=release_branch)
|
|
198
|
+
|
|
199
|
+
changelog = resolve_changelog_path(
|
|
200
|
+
changelog_path=release_input.changelog_path,
|
|
201
|
+
repo_root=info.root,
|
|
202
|
+
)
|
|
203
|
+
cliff.prepend_to_changelog(version=version, changelog_path=changelog)
|
|
204
|
+
_format_changelog_if_requested(
|
|
205
|
+
repo_root=info.root,
|
|
206
|
+
changelog_path=changelog,
|
|
207
|
+
release_input=release_input,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
commit_file(
|
|
211
|
+
repo,
|
|
212
|
+
path=changelog,
|
|
213
|
+
message=f'{release_input.title_prefix}{version}',
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
push_set_upstream(
|
|
217
|
+
repo,
|
|
218
|
+
remote_name=release_input.remote_name,
|
|
219
|
+
branch=release_branch,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
pr_url = _maybe_create_pull_request(
|
|
223
|
+
repo=repo,
|
|
224
|
+
pr_input=_MaybeCreatePullRequestInput(
|
|
225
|
+
create_pr=release_input.create_pr,
|
|
226
|
+
github_token=release_input.github_token,
|
|
227
|
+
remote_name=release_input.remote_name,
|
|
228
|
+
base_branch=release_input.base_branch,
|
|
229
|
+
head_branch=release_branch,
|
|
230
|
+
title=f'{release_input.title_prefix}{version}',
|
|
231
|
+
body=notes,
|
|
232
|
+
labels=release_input.labels,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return StartReleaseResult(
|
|
237
|
+
version=version,
|
|
238
|
+
release_notes_markdown=notes,
|
|
239
|
+
release_branch=release_branch,
|
|
240
|
+
pr_url=pr_url,
|
|
241
|
+
)
|
releez/settings.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import AliasChoices, AliasGenerator, BaseModel, ConfigDict, Field
|
|
4
|
+
from pydantic_settings import (
|
|
5
|
+
BaseSettings,
|
|
6
|
+
PydanticBaseSettingsSource,
|
|
7
|
+
PyprojectTomlConfigSettingsSource,
|
|
8
|
+
SettingsConfigDict,
|
|
9
|
+
TomlConfigSettingsSource,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from releez.version_tags import AliasVersions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _to_kebab(name: str) -> str:
|
|
16
|
+
"""Convert snake_case to kebab-case."""
|
|
17
|
+
return name.replace('_', '-')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validation_alias(name: str) -> AliasChoices:
|
|
21
|
+
"""Accept both snake_case and kebab-case for settings keys.
|
|
22
|
+
|
|
23
|
+
`AliasChoices` order matters: we list the snake_case field name first so if both
|
|
24
|
+
variants are present (e.g. env var + pyproject), the env var wins.
|
|
25
|
+
"""
|
|
26
|
+
return AliasChoices(name, _to_kebab(name))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ALIASES = AliasGenerator(
|
|
30
|
+
validation_alias=_validation_alias,
|
|
31
|
+
serialization_alias=_to_kebab,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReleezHooks(BaseModel):
|
|
36
|
+
"""Hook-related configuration.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
changelog_format: Optional argv list used to format the changelog (e.g.
|
|
40
|
+
["dprint", "fmt", "{changelog}"]).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(
|
|
44
|
+
alias_generator=_ALIASES,
|
|
45
|
+
populate_by_name=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
changelog_format: list[str] | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReleezSettings(BaseSettings):
|
|
52
|
+
"""Settings loaded from CLI args, env vars, and config files.
|
|
53
|
+
|
|
54
|
+
Precedence (highest first):
|
|
55
|
+
1. Explicit init kwargs (CLI layer)
|
|
56
|
+
2. RELEEZ_* env vars
|
|
57
|
+
3. releez.toml
|
|
58
|
+
4. pyproject.toml ([tool.releez])
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = SettingsConfigDict(
|
|
62
|
+
env_prefix='RELEEZ_',
|
|
63
|
+
env_nested_delimiter='__',
|
|
64
|
+
extra='ignore',
|
|
65
|
+
pyproject_toml_table_header=('tool', 'releez'),
|
|
66
|
+
alias_generator=_ALIASES,
|
|
67
|
+
populate_by_name=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
base_branch: str = 'master'
|
|
71
|
+
git_remote: str = 'origin'
|
|
72
|
+
pr_labels: str = 'release'
|
|
73
|
+
pr_title_prefix: str = 'chore(release): '
|
|
74
|
+
changelog_path: str = 'CHANGELOG.md'
|
|
75
|
+
create_pr: bool = False
|
|
76
|
+
run_changelog_format: bool = False
|
|
77
|
+
alias_versions: AliasVersions = AliasVersions.none
|
|
78
|
+
hooks: ReleezHooks = Field(default_factory=ReleezHooks)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def settings_customise_sources(
|
|
82
|
+
cls,
|
|
83
|
+
settings_cls: type[BaseSettings],
|
|
84
|
+
init_settings: PydanticBaseSettingsSource,
|
|
85
|
+
env_settings: PydanticBaseSettingsSource,
|
|
86
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
87
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
88
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
89
|
+
"""Customize settings sources for Releez."""
|
|
90
|
+
_ = (dotenv_settings, file_secret_settings)
|
|
91
|
+
releez_toml = TomlConfigSettingsSource(
|
|
92
|
+
settings_cls,
|
|
93
|
+
toml_file='releez.toml',
|
|
94
|
+
)
|
|
95
|
+
pyproject_toml = PyprojectTomlConfigSettingsSource(settings_cls)
|
|
96
|
+
return (
|
|
97
|
+
init_settings,
|
|
98
|
+
env_settings,
|
|
99
|
+
releez_toml,
|
|
100
|
+
pyproject_toml,
|
|
101
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from releez.cliff import GitCliff
|
|
11
|
+
from releez.errors import ChangelogFormatCommandRequiredError, ReleezError
|
|
12
|
+
from releez.git_repo import open_repo
|
|
13
|
+
from releez.utils import resolve_changelog_path, run_changelog_formatter
|
|
14
|
+
|
|
15
|
+
changelog_app = typer.Typer(help='Changelog utilities.')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_changelog_formatter_with_message(
|
|
19
|
+
*,
|
|
20
|
+
changelog_path: Path,
|
|
21
|
+
repo_root: Path,
|
|
22
|
+
changelog_format_cmd: list[str],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Run the changelog formatter command and print success message."""
|
|
25
|
+
run_changelog_formatter(
|
|
26
|
+
changelog_path=changelog_path,
|
|
27
|
+
repo_root=repo_root,
|
|
28
|
+
changelog_format_cmd=changelog_format_cmd,
|
|
29
|
+
)
|
|
30
|
+
typer.secho(
|
|
31
|
+
'✓ Ran changelog format hook',
|
|
32
|
+
fg=typer.colors.GREEN,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@changelog_app.command('regenerate')
|
|
37
|
+
def changelog_regenerate(
|
|
38
|
+
*,
|
|
39
|
+
changelog_path: Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Option(
|
|
42
|
+
'--changelog-path',
|
|
43
|
+
help='Path to the changelog file.',
|
|
44
|
+
show_default=True,
|
|
45
|
+
),
|
|
46
|
+
] = 'CHANGELOG.md',
|
|
47
|
+
run_changelog_format: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option(
|
|
50
|
+
'--run-changelog-format',
|
|
51
|
+
help='Run the configured changelog formatter after regeneration.',
|
|
52
|
+
show_default=True,
|
|
53
|
+
),
|
|
54
|
+
] = False,
|
|
55
|
+
changelog_format_cmd: Annotated[
|
|
56
|
+
list[str] | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
'--changelog-format-cmd',
|
|
59
|
+
help='Override changelog format command argv (repeatable).',
|
|
60
|
+
show_default=False,
|
|
61
|
+
),
|
|
62
|
+
] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Regenerate the full changelog from git history."""
|
|
65
|
+
try:
|
|
66
|
+
if run_changelog_format and not changelog_format_cmd:
|
|
67
|
+
raise ChangelogFormatCommandRequiredError
|
|
68
|
+
|
|
69
|
+
_, info = open_repo()
|
|
70
|
+
changelog = resolve_changelog_path(changelog_path, info.root)
|
|
71
|
+
|
|
72
|
+
cliff = GitCliff(repo_root=info.root)
|
|
73
|
+
cliff.regenerate_changelog(changelog_path=changelog)
|
|
74
|
+
typer.secho(
|
|
75
|
+
f'✓ Regenerated changelog: {changelog}',
|
|
76
|
+
fg=typer.colors.GREEN,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if run_changelog_format and changelog_format_cmd:
|
|
80
|
+
_run_changelog_formatter_with_message(
|
|
81
|
+
changelog_path=changelog,
|
|
82
|
+
repo_root=info.root,
|
|
83
|
+
changelog_format_cmd=changelog_format_cmd,
|
|
84
|
+
)
|
|
85
|
+
except ReleezError as exc:
|
|
86
|
+
typer.secho(str(exc), err=True, fg=typer.colors.RED)
|
|
87
|
+
raise typer.Exit(code=1) from exc
|
releez/utils.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from releez.errors import ChangelogNotFoundError
|
|
6
|
+
from releez.process import run_checked
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_changelog_path(changelog_path: str, repo_root: Path) -> Path:
|
|
10
|
+
"""Resolve the changelog path relative to the repo root.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
changelog_path: Path to the changelog file (absolute or relative).
|
|
14
|
+
repo_root: Root directory of the git repository.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The resolved absolute path to the changelog.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ChangelogNotFoundError: If the changelog file doesn't exist.
|
|
21
|
+
"""
|
|
22
|
+
changelog = Path(changelog_path)
|
|
23
|
+
if not changelog.is_absolute():
|
|
24
|
+
changelog = repo_root / changelog
|
|
25
|
+
if not changelog.exists():
|
|
26
|
+
raise ChangelogNotFoundError(changelog)
|
|
27
|
+
return changelog
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_changelog_formatter(
|
|
31
|
+
*,
|
|
32
|
+
changelog_path: Path,
|
|
33
|
+
repo_root: Path,
|
|
34
|
+
changelog_format_cmd: list[str],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Run the changelog formatter command.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
changelog_path: Path to the changelog file to format.
|
|
40
|
+
repo_root: Root directory to run the command from.
|
|
41
|
+
changelog_format_cmd: Command argv list with optional {changelog} placeholder.
|
|
42
|
+
"""
|
|
43
|
+
cmd = [arg.replace('{changelog}', str(changelog_path)) for arg in changelog_format_cmd]
|
|
44
|
+
run_checked(cmd, cwd=repo_root, capture_stdout=False)
|