patchnote 0.1.1__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.
- patchnote/__init__.py +7 -0
- patchnote/__main__.py +7 -0
- patchnote/action_entry.py +148 -0
- patchnote/ai/__init__.py +126 -0
- patchnote/ai/cache.py +40 -0
- patchnote/ai/client.py +89 -0
- patchnote/ai/prompts.py +71 -0
- patchnote/ai/validate.py +87 -0
- patchnote/check.py +67 -0
- patchnote/classify.py +353 -0
- patchnote/cli.py +690 -0
- patchnote/config.py +461 -0
- patchnote/conventional.py +197 -0
- patchnote/filters.py +199 -0
- patchnote/github.py +388 -0
- patchnote/gitlog.py +201 -0
- patchnote/model.py +228 -0
- patchnote/py.typed +0 -0
- patchnote/render/__init__.py +17 -0
- patchnote/render/changelog_file.py +138 -0
- patchnote/render/github_release.py +36 -0
- patchnote/render/json.py +12 -0
- patchnote/render/markdown.py +185 -0
- patchnote/render/templates/changelog.md.j2 +43 -0
- patchnote-0.1.1.dist-info/METADATA +291 -0
- patchnote-0.1.1.dist-info/RECORD +29 -0
- patchnote-0.1.1.dist-info/WHEEL +4 -0
- patchnote-0.1.1.dist-info/entry_points.txt +2 -0
- patchnote-0.1.1.dist-info/licenses/LICENSE +21 -0
patchnote/cli.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""Typer CLI for Patchnote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.theme import Theme
|
|
14
|
+
|
|
15
|
+
from patchnote import __version__
|
|
16
|
+
from patchnote.ai import apply_ai, default_cache
|
|
17
|
+
from patchnote.ai.client import LLMClient
|
|
18
|
+
from patchnote.check import run_check
|
|
19
|
+
from patchnote.classify import build_changelog
|
|
20
|
+
from patchnote.config import Config, load_config, parse_config, write_init_config
|
|
21
|
+
from patchnote.github import (
|
|
22
|
+
GitHubClient,
|
|
23
|
+
enrich_or_empty,
|
|
24
|
+
parse_github_nwo,
|
|
25
|
+
repository_http_url,
|
|
26
|
+
resolve_token,
|
|
27
|
+
)
|
|
28
|
+
from patchnote.github import (
|
|
29
|
+
compare_url as gh_compare_url,
|
|
30
|
+
)
|
|
31
|
+
from patchnote.gitlog import (
|
|
32
|
+
GitRunner,
|
|
33
|
+
create_tag,
|
|
34
|
+
discover_repo,
|
|
35
|
+
list_commits,
|
|
36
|
+
list_tags,
|
|
37
|
+
ref_exists,
|
|
38
|
+
remote_url,
|
|
39
|
+
tag_date,
|
|
40
|
+
)
|
|
41
|
+
from patchnote.gitlog import (
|
|
42
|
+
push_tag as git_push_tag,
|
|
43
|
+
)
|
|
44
|
+
from patchnote.model import (
|
|
45
|
+
AiMode,
|
|
46
|
+
Changelog,
|
|
47
|
+
CheckViolationError,
|
|
48
|
+
ConfigError,
|
|
49
|
+
GitError,
|
|
50
|
+
OutputFormat,
|
|
51
|
+
PatchnoteError,
|
|
52
|
+
Style,
|
|
53
|
+
UsageError,
|
|
54
|
+
)
|
|
55
|
+
from patchnote.render import render
|
|
56
|
+
from patchnote.render.changelog_file import write_prepend
|
|
57
|
+
from patchnote.render.github_release import render_github_release
|
|
58
|
+
|
|
59
|
+
LAST_RENDERED: str = ""
|
|
60
|
+
LAST_CHANGELOG: Changelog | None = None
|
|
61
|
+
|
|
62
|
+
theme = Theme({"warning": "yellow", "error": "red", "ok": "green"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _console(no_color: bool, stderr: bool = False) -> Console:
|
|
66
|
+
return Console(theme=theme, no_color=no_color, stderr=stderr, highlight=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AppState:
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.config_path: Path | None = None
|
|
72
|
+
self.repo: Path = Path.cwd()
|
|
73
|
+
self.no_color: bool = False
|
|
74
|
+
self.verbose: bool = False
|
|
75
|
+
self.console: Console = _console(False)
|
|
76
|
+
self.err: Console = _console(False, stderr=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
state = AppState()
|
|
80
|
+
app = typer.Typer(
|
|
81
|
+
name="patchnote",
|
|
82
|
+
help="Generate changelogs and release notes from git history and pull requests.",
|
|
83
|
+
no_args_is_help=True,
|
|
84
|
+
add_completion=False,
|
|
85
|
+
pretty_exceptions_enable=False,
|
|
86
|
+
pretty_exceptions_show_locals=False,
|
|
87
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _version_callback(value: bool) -> None:
|
|
92
|
+
if value:
|
|
93
|
+
typer.echo(f"patchnote {__version__}")
|
|
94
|
+
raise typer.Exit(0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.callback()
|
|
98
|
+
def main(
|
|
99
|
+
config: Path | None = typer.Option(
|
|
100
|
+
None, "--config", help="Path to patchnote.yaml", exists=False, dir_okay=False
|
|
101
|
+
),
|
|
102
|
+
repo: Path | None = typer.Option(
|
|
103
|
+
None, "--repo", help="Path to the git repository", file_okay=False
|
|
104
|
+
),
|
|
105
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colours"),
|
|
106
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose diagnostics"),
|
|
107
|
+
version: bool = typer.Option(
|
|
108
|
+
False,
|
|
109
|
+
"--version",
|
|
110
|
+
callback=_version_callback,
|
|
111
|
+
is_eager=True,
|
|
112
|
+
help="Show version and exit",
|
|
113
|
+
),
|
|
114
|
+
) -> None:
|
|
115
|
+
state.config_path = config
|
|
116
|
+
state.repo = repo or Path.cwd()
|
|
117
|
+
state.no_color = no_color or not sys.stdout.isatty()
|
|
118
|
+
state.verbose = verbose
|
|
119
|
+
state.console = _console(state.no_color)
|
|
120
|
+
state.err = _console(state.no_color, stderr=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def warn(message: str) -> None:
|
|
124
|
+
state.err.print(f"[warning]warning:[/warning] {message}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def die(exc: BaseException) -> None:
|
|
128
|
+
if isinstance(exc, CheckViolationError):
|
|
129
|
+
for line in exc.violations:
|
|
130
|
+
state.err.print(f"[error]{line}[/error]")
|
|
131
|
+
state.err.print(f"[error]{exc.message}[/error]")
|
|
132
|
+
raise typer.Exit(exc.exit_code)
|
|
133
|
+
if isinstance(exc, PatchnoteError):
|
|
134
|
+
state.err.print(f"[error]error:[/error] {exc.message}")
|
|
135
|
+
raise typer.Exit(exc.exit_code)
|
|
136
|
+
if state.verbose:
|
|
137
|
+
raise exc
|
|
138
|
+
state.err.print(f"error: {type(exc).__name__}; use --verbose for details", markup=False)
|
|
139
|
+
raise typer.Exit(3)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_runtime_config() -> tuple[Config, Path, GitRunner]:
|
|
143
|
+
try:
|
|
144
|
+
repo = discover_repo(state.repo)
|
|
145
|
+
except GitError:
|
|
146
|
+
# init does not need a repo; other commands do. Re-raise later.
|
|
147
|
+
repo = state.repo.resolve()
|
|
148
|
+
runner = GitRunner(repo)
|
|
149
|
+
try:
|
|
150
|
+
cfg = load_config(repo, state.config_path)
|
|
151
|
+
except ConfigError as exc:
|
|
152
|
+
die(exc)
|
|
153
|
+
raise
|
|
154
|
+
return cfg, repo, runner
|
|
155
|
+
runner = GitRunner(repo)
|
|
156
|
+
try:
|
|
157
|
+
cfg = load_config(repo, state.config_path)
|
|
158
|
+
except ConfigError as exc:
|
|
159
|
+
die(exc)
|
|
160
|
+
raise
|
|
161
|
+
return cfg, repo, runner
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_range(
|
|
165
|
+
runner: GitRunner,
|
|
166
|
+
config: Config,
|
|
167
|
+
from_ref: str | None,
|
|
168
|
+
to_ref: str | None,
|
|
169
|
+
) -> tuple[str | None, str, str | None]:
|
|
170
|
+
to_name = to_ref or "HEAD"
|
|
171
|
+
if not ref_exists(runner, to_name):
|
|
172
|
+
raise UsageError(f"Unknown git ref: {to_name}")
|
|
173
|
+
prev: str | None
|
|
174
|
+
if from_ref:
|
|
175
|
+
if not ref_exists(runner, from_ref):
|
|
176
|
+
raise UsageError(f"Unknown git ref: {from_ref}")
|
|
177
|
+
prev = from_ref
|
|
178
|
+
else:
|
|
179
|
+
prev = None
|
|
180
|
+
for candidate in list_tags(runner, config.tag_pattern):
|
|
181
|
+
if to_name != "HEAD" and candidate == to_name:
|
|
182
|
+
continue
|
|
183
|
+
if runner.run_ok(["merge-base", "--is-ancestor", candidate, to_name]):
|
|
184
|
+
prev = candidate
|
|
185
|
+
break
|
|
186
|
+
return prev, to_name, prev
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def detect_repo_slug(runner: GitRunner, config: Config) -> tuple[str, str] | None:
|
|
190
|
+
if config.repository:
|
|
191
|
+
parsed = parse_github_nwo(config.repository)
|
|
192
|
+
if parsed:
|
|
193
|
+
return parsed
|
|
194
|
+
env_slug = os.environ.get("GITHUB_REPOSITORY")
|
|
195
|
+
if env_slug:
|
|
196
|
+
parsed = parse_github_nwo(env_slug)
|
|
197
|
+
if parsed:
|
|
198
|
+
return parsed
|
|
199
|
+
url = remote_url(runner)
|
|
200
|
+
if url:
|
|
201
|
+
return parse_github_nwo(url)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def version_from_ref(to_ref: str, explicit: str | None) -> str | None:
|
|
206
|
+
if explicit:
|
|
207
|
+
return (
|
|
208
|
+
explicit.lstrip("v")
|
|
209
|
+
if explicit.startswith("v") and explicit[1:2].isdigit()
|
|
210
|
+
else explicit
|
|
211
|
+
)
|
|
212
|
+
if to_ref != "HEAD" and to_ref.startswith("v") and to_ref[1:2].isdigit():
|
|
213
|
+
return to_ref[1:]
|
|
214
|
+
if to_ref != "HEAD":
|
|
215
|
+
return to_ref
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def tag_for_version(version: str) -> str:
|
|
220
|
+
if version.startswith("v"):
|
|
221
|
+
return version
|
|
222
|
+
return f"v{version}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def confirm_ai(config: Config, mode: AiMode, *, yes: bool, include_bodies: bool) -> bool:
|
|
226
|
+
endpoint = config.ai.base_url
|
|
227
|
+
model = config.ai.model
|
|
228
|
+
bodies = (
|
|
229
|
+
"titles, summaries, and PR bodies" if include_bodies else "titles and commit subjects only"
|
|
230
|
+
)
|
|
231
|
+
notice = (
|
|
232
|
+
f"Patchnote is about to send classified changelog entries ({bodies}) "
|
|
233
|
+
f"to {endpoint} (model {model}). "
|
|
234
|
+
"API keys are read from the environment and are never logged. "
|
|
235
|
+
"Commit messages are treated as untrusted data."
|
|
236
|
+
)
|
|
237
|
+
state.err.print(f"[warning]{notice}[/warning]")
|
|
238
|
+
if yes:
|
|
239
|
+
return True
|
|
240
|
+
if not sys.stdin.isatty():
|
|
241
|
+
raise UsageError("Refusing to call the LLM non-interactively without --yes")
|
|
242
|
+
return typer.confirm("Proceed?", default=False)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def make_github_client(
|
|
246
|
+
config: Config,
|
|
247
|
+
slug: tuple[str, str] | None,
|
|
248
|
+
token: str | None,
|
|
249
|
+
http: httpx.Client,
|
|
250
|
+
) -> GitHubClient | None:
|
|
251
|
+
if slug is None:
|
|
252
|
+
return None
|
|
253
|
+
owner, repo = slug
|
|
254
|
+
return GitHubClient(
|
|
255
|
+
http,
|
|
256
|
+
token,
|
|
257
|
+
owner,
|
|
258
|
+
repo,
|
|
259
|
+
api_url=config.github.api_url,
|
|
260
|
+
graphql_url=config.github.graphql_url,
|
|
261
|
+
warn=warn,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def generate_changelog(
|
|
266
|
+
*,
|
|
267
|
+
from_ref: str | None,
|
|
268
|
+
to_ref: str | None,
|
|
269
|
+
version: str | None,
|
|
270
|
+
style: Style | None,
|
|
271
|
+
include_all: bool,
|
|
272
|
+
token: str | None,
|
|
273
|
+
ai_mode: AiMode,
|
|
274
|
+
yes: bool,
|
|
275
|
+
ai_include_bodies: bool,
|
|
276
|
+
http: httpx.Client | None = None,
|
|
277
|
+
clock: date | None = None,
|
|
278
|
+
) -> Changelog:
|
|
279
|
+
config, repo, runner = load_runtime_config()
|
|
280
|
+
if not runner.run_ok(["rev-parse", "--is-inside-work-tree"]):
|
|
281
|
+
raise GitError(f"{repo} is not a git repository")
|
|
282
|
+
if style is not None:
|
|
283
|
+
config = config.model_copy(update={"style": style})
|
|
284
|
+
# Rebuild default sections for the new style if the user didn't custom-set them.
|
|
285
|
+
if style is Style.CONVENTIONAL and set(config.sections) == {
|
|
286
|
+
"Added",
|
|
287
|
+
"Changed",
|
|
288
|
+
"Deprecated",
|
|
289
|
+
"Removed",
|
|
290
|
+
"Fixed",
|
|
291
|
+
"Security",
|
|
292
|
+
}:
|
|
293
|
+
config = parse_config(
|
|
294
|
+
config.model_dump(mode="python") | {"style": "conventional", "sections": {}}
|
|
295
|
+
)
|
|
296
|
+
prev, to_name, _ = resolve_range(runner, config, from_ref, to_ref)
|
|
297
|
+
commits = list_commits(runner, prev, to_name)
|
|
298
|
+
slug = detect_repo_slug(runner, config)
|
|
299
|
+
token_value = resolve_token(config, token)
|
|
300
|
+
owns_http = http is None
|
|
301
|
+
client_http = http or httpx.Client()
|
|
302
|
+
try:
|
|
303
|
+
# No token → no network. Public unauthenticated GitHub calls are skipped
|
|
304
|
+
# so a missing GITHUB_TOKEN never surprises CI with rate limits.
|
|
305
|
+
gh = make_github_client(config, slug, token_value, client_http) if token_value else None
|
|
306
|
+
pr_by_sha = enrich_or_empty(commits, gh, warn=warn) if gh is not None else {}
|
|
307
|
+
first_time: set[str] = set()
|
|
308
|
+
if gh is not None and config.mark_first_time_contributors and token_value:
|
|
309
|
+
logins: set[str] = set()
|
|
310
|
+
current_prs: set[int] = set()
|
|
311
|
+
for pulls in pr_by_sha.values():
|
|
312
|
+
for pr in pulls:
|
|
313
|
+
if pr.author:
|
|
314
|
+
logins.add(pr.author)
|
|
315
|
+
current_prs.add(pr.number)
|
|
316
|
+
if logins:
|
|
317
|
+
try:
|
|
318
|
+
first_time = gh.first_time_contributors(logins, current_prs)
|
|
319
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
320
|
+
warn("Could not determine first-time contributors; keeping commit notes")
|
|
321
|
+
finally:
|
|
322
|
+
if owns_http:
|
|
323
|
+
client_http.close()
|
|
324
|
+
|
|
325
|
+
repo_url = None
|
|
326
|
+
cmp_url = None
|
|
327
|
+
if slug is not None:
|
|
328
|
+
owner, name = slug
|
|
329
|
+
repo_url = repository_http_url(owner, name)
|
|
330
|
+
right = tag_for_version(version) if version else to_name
|
|
331
|
+
if prev:
|
|
332
|
+
cmp_url = gh_compare_url(owner, name, prev, right)
|
|
333
|
+
|
|
334
|
+
resolved_version = version_from_ref(
|
|
335
|
+
to_name if to_ref else (to_name if to_name != "HEAD" else "HEAD"), version
|
|
336
|
+
)
|
|
337
|
+
if to_name == "HEAD" and version is None:
|
|
338
|
+
resolved_version = None
|
|
339
|
+
elif version:
|
|
340
|
+
resolved_version = (
|
|
341
|
+
version[1:]
|
|
342
|
+
if version.startswith("v") and len(version) > 1 and version[1].isdigit()
|
|
343
|
+
else version
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
when = clock
|
|
347
|
+
if when is None and to_name != "HEAD" and ref_exists(runner, to_name):
|
|
348
|
+
when = tag_date(runner, to_name)
|
|
349
|
+
changelog = build_changelog(
|
|
350
|
+
commits,
|
|
351
|
+
config,
|
|
352
|
+
from_ref=prev or "",
|
|
353
|
+
to_ref=to_name,
|
|
354
|
+
version=resolved_version,
|
|
355
|
+
today=when,
|
|
356
|
+
pr_by_sha=pr_by_sha,
|
|
357
|
+
include_all=include_all,
|
|
358
|
+
repository_url=repo_url,
|
|
359
|
+
compare_url=cmp_url,
|
|
360
|
+
previous_tag=prev,
|
|
361
|
+
first_time_logins=first_time or None,
|
|
362
|
+
tag_name=tag_for_version(resolved_version) if resolved_version else None,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if ai_mode is not AiMode.NONE:
|
|
366
|
+
include_bodies = ai_include_bodies or config.ai.include_bodies
|
|
367
|
+
config.ai = config.ai.model_copy(
|
|
368
|
+
update={
|
|
369
|
+
"base_url": os.environ.get("PATCHNOTE_AI_BASE_URL") or config.ai.base_url,
|
|
370
|
+
"model": os.environ.get("PATCHNOTE_AI_MODEL") or config.ai.model,
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
if confirm_ai(config, ai_mode, yes=yes, include_bodies=include_bodies):
|
|
374
|
+
api_key = os.environ.get(config.ai.api_key_env)
|
|
375
|
+
# Also accept a generic override used by the GitHub Action.
|
|
376
|
+
api_key = os.environ.get("PATCHNOTE_AI_API_KEY") or api_key
|
|
377
|
+
base_url = os.environ.get("PATCHNOTE_AI_BASE_URL") or config.ai.base_url
|
|
378
|
+
model = os.environ.get("PATCHNOTE_AI_MODEL") or config.ai.model
|
|
379
|
+
ai_cfg = config.ai.model_copy(update={"base_url": base_url, "model": model})
|
|
380
|
+
cache_dir = Path(ai_cfg.cache_dir)
|
|
381
|
+
if not cache_dir.is_absolute():
|
|
382
|
+
cache_dir = repo / cache_dir
|
|
383
|
+
owns = http is None
|
|
384
|
+
http_client = http or httpx.Client()
|
|
385
|
+
try:
|
|
386
|
+
llm = LLMClient(
|
|
387
|
+
http_client,
|
|
388
|
+
base_url=ai_cfg.base_url,
|
|
389
|
+
api_key=api_key,
|
|
390
|
+
timeout=ai_cfg.timeout_seconds,
|
|
391
|
+
temperature=ai_cfg.temperature,
|
|
392
|
+
)
|
|
393
|
+
changelog = apply_ai(
|
|
394
|
+
changelog,
|
|
395
|
+
ai_cfg,
|
|
396
|
+
ai_mode,
|
|
397
|
+
llm,
|
|
398
|
+
default_cache(cache_dir),
|
|
399
|
+
include_bodies=include_bodies,
|
|
400
|
+
warn=warn,
|
|
401
|
+
)
|
|
402
|
+
finally:
|
|
403
|
+
if owns:
|
|
404
|
+
http_client.close()
|
|
405
|
+
return changelog
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def emit(
|
|
409
|
+
changelog: Changelog,
|
|
410
|
+
config: Config,
|
|
411
|
+
fmt: OutputFormat,
|
|
412
|
+
output: Path | None,
|
|
413
|
+
prepend: bool,
|
|
414
|
+
dry_run: bool,
|
|
415
|
+
) -> str:
|
|
416
|
+
global LAST_RENDERED, LAST_CHANGELOG
|
|
417
|
+
LAST_CHANGELOG = changelog
|
|
418
|
+
text = render(changelog, config, fmt)
|
|
419
|
+
LAST_RENDERED = text
|
|
420
|
+
if dry_run:
|
|
421
|
+
typer.echo(text, nl=not text.endswith("\n"))
|
|
422
|
+
return text
|
|
423
|
+
if output is not None:
|
|
424
|
+
if prepend:
|
|
425
|
+
if fmt is not OutputFormat.MARKDOWN:
|
|
426
|
+
raise UsageError("--prepend is only supported with --format markdown")
|
|
427
|
+
write_prepend(output, changelog, config)
|
|
428
|
+
state.err.print(f"[ok]updated[/ok] {output}")
|
|
429
|
+
else:
|
|
430
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
output.write_text(text, encoding="utf-8")
|
|
432
|
+
state.err.print(f"[ok]wrote[/ok] {output}")
|
|
433
|
+
return text
|
|
434
|
+
typer.echo(text, nl=not text.endswith("\n"))
|
|
435
|
+
return text
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.command()
|
|
439
|
+
def init(
|
|
440
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing patchnote.yaml"),
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Write a commented patchnote.yaml in the current directory."""
|
|
443
|
+
try:
|
|
444
|
+
path = write_init_config(Path.cwd(), force=force)
|
|
445
|
+
except ConfigError as exc:
|
|
446
|
+
die(exc)
|
|
447
|
+
return
|
|
448
|
+
state.err.print(f"[ok]wrote[/ok] {path}")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@app.command()
|
|
452
|
+
def generate(
|
|
453
|
+
from_ref: str | None = typer.Option(
|
|
454
|
+
None, "--from", help="Start ref (exclusive). Default: latest tag"
|
|
455
|
+
),
|
|
456
|
+
to_ref: str | None = typer.Option(None, "--to", help="End ref (inclusive). Default: HEAD"),
|
|
457
|
+
version: str | None = typer.Option(None, "--version", help="Version heading, e.g. 1.4.0"),
|
|
458
|
+
format_name: OutputFormat = typer.Option(
|
|
459
|
+
OutputFormat.MARKDOWN, "--format", help="Output format", case_sensitive=False
|
|
460
|
+
),
|
|
461
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Write to this file"),
|
|
462
|
+
ai: AiMode = typer.Option(
|
|
463
|
+
AiMode.NONE, "--ai", help="Optional LLM rewrite", case_sensitive=False
|
|
464
|
+
),
|
|
465
|
+
style: Style | None = typer.Option(
|
|
466
|
+
None, "--style", help="Grouping style", case_sensitive=False
|
|
467
|
+
),
|
|
468
|
+
include_all: bool = typer.Option(False, "--include-all", help="Do not skip chore/ci/test/docs"),
|
|
469
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print without writing --output"),
|
|
470
|
+
prepend: bool = typer.Option(
|
|
471
|
+
False, "--prepend", help="Insert into an existing Keep a Changelog file"
|
|
472
|
+
),
|
|
473
|
+
token: str | None = typer.Option(
|
|
474
|
+
None, "--token", help="GitHub token (else GITHUB_TOKEN)", envvar=None
|
|
475
|
+
),
|
|
476
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt before calling an LLM"),
|
|
477
|
+
ai_include_bodies: bool = typer.Option(
|
|
478
|
+
False, "--ai-include-bodies", help="Opt in to sending PR bodies to the LLM"
|
|
479
|
+
),
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Generate a changelog for a git revision range."""
|
|
482
|
+
try:
|
|
483
|
+
changelog = generate_changelog(
|
|
484
|
+
from_ref=from_ref,
|
|
485
|
+
to_ref=to_ref,
|
|
486
|
+
version=version,
|
|
487
|
+
style=style,
|
|
488
|
+
include_all=include_all,
|
|
489
|
+
token=token,
|
|
490
|
+
ai_mode=ai,
|
|
491
|
+
yes=yes,
|
|
492
|
+
ai_include_bodies=ai_include_bodies,
|
|
493
|
+
)
|
|
494
|
+
config, _, _ = load_runtime_config()
|
|
495
|
+
if style is not None:
|
|
496
|
+
config = config.model_copy(update={"style": style})
|
|
497
|
+
emit(changelog, config, format_name, output, prepend, dry_run)
|
|
498
|
+
except PatchnoteError as exc:
|
|
499
|
+
die(exc)
|
|
500
|
+
except typer.Exit:
|
|
501
|
+
raise
|
|
502
|
+
except Exception as exc:
|
|
503
|
+
die(exc)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@app.command()
|
|
507
|
+
def preview(
|
|
508
|
+
include_all: bool = typer.Option(False, "--include-all"),
|
|
509
|
+
style: Style | None = typer.Option(None, "--style", case_sensitive=False),
|
|
510
|
+
ai: AiMode = typer.Option(AiMode.NONE, "--ai", case_sensitive=False),
|
|
511
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Show notes for HEAD since the last tag without writing anything."""
|
|
514
|
+
try:
|
|
515
|
+
changelog = generate_changelog(
|
|
516
|
+
from_ref=None,
|
|
517
|
+
to_ref="HEAD",
|
|
518
|
+
version=None,
|
|
519
|
+
style=style,
|
|
520
|
+
include_all=include_all,
|
|
521
|
+
token=None,
|
|
522
|
+
ai_mode=ai,
|
|
523
|
+
yes=yes,
|
|
524
|
+
ai_include_bodies=False,
|
|
525
|
+
)
|
|
526
|
+
config, _, _ = load_runtime_config()
|
|
527
|
+
emit(changelog, config, OutputFormat.MARKDOWN, None, False, True)
|
|
528
|
+
except PatchnoteError as exc:
|
|
529
|
+
die(exc)
|
|
530
|
+
except typer.Exit:
|
|
531
|
+
raise
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
die(exc)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@app.command()
|
|
537
|
+
def check(
|
|
538
|
+
from_ref: str | None = typer.Option(None, "--from"),
|
|
539
|
+
to_ref: str | None = typer.Option(None, "--to"),
|
|
540
|
+
token: str | None = typer.Option(None, "--token"),
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Lint commit messages in a range. Exit 1 when any violate the convention."""
|
|
543
|
+
try:
|
|
544
|
+
config, repo, runner = load_runtime_config()
|
|
545
|
+
if not runner.run_ok(["rev-parse", "--is-inside-work-tree"]):
|
|
546
|
+
raise GitError(f"{repo} is not a git repository")
|
|
547
|
+
prev, to_name, _ = resolve_range(runner, config, from_ref, to_ref)
|
|
548
|
+
commits = list_commits(runner, prev, to_name)
|
|
549
|
+
slug = detect_repo_slug(runner, config)
|
|
550
|
+
token_value = resolve_token(config, token)
|
|
551
|
+
pr_by_sha = {}
|
|
552
|
+
if slug is not None and token_value:
|
|
553
|
+
with httpx.Client() as http:
|
|
554
|
+
gh = make_github_client(config, slug, token_value, http)
|
|
555
|
+
pr_by_sha = enrich_or_empty(commits, gh, warn=warn)
|
|
556
|
+
run_check(commits, config, pr_by_sha=pr_by_sha)
|
|
557
|
+
state.err.print(f"[ok]ok[/ok] {len(commits)} commit(s) match the convention")
|
|
558
|
+
except CheckViolationError as exc:
|
|
559
|
+
die(exc)
|
|
560
|
+
except PatchnoteError as exc:
|
|
561
|
+
die(exc)
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
die(exc)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@app.command()
|
|
567
|
+
def release(
|
|
568
|
+
version: str = typer.Option(..., "--version", help="Version to release, e.g. 1.4.0"),
|
|
569
|
+
tag: bool = typer.Option(False, "--tag", help="Create an annotated git tag"),
|
|
570
|
+
push_tag: bool = typer.Option(False, "--push-tag", help="Push the tag to origin"),
|
|
571
|
+
push_release: bool = typer.Option(
|
|
572
|
+
False, "--push-release", help="Create or update a GitHub Release"
|
|
573
|
+
),
|
|
574
|
+
publish: bool = typer.Option(
|
|
575
|
+
False, "--publish", help="Publish the GitHub Release (otherwise it stays a draft)"
|
|
576
|
+
),
|
|
577
|
+
from_ref: str | None = typer.Option(None, "--from"),
|
|
578
|
+
to_ref: str | None = typer.Option(None, "--to"),
|
|
579
|
+
token: str | None = typer.Option(None, "--token"),
|
|
580
|
+
include_all: bool = typer.Option(False, "--include-all"),
|
|
581
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Generate notes and optionally tag / open a GitHub Release draft."""
|
|
584
|
+
try:
|
|
585
|
+
_, _, release_runner = load_runtime_config()
|
|
586
|
+
existing_tag = tag_for_version(version)
|
|
587
|
+
release_to = to_ref or (
|
|
588
|
+
existing_tag if ref_exists(release_runner, existing_tag) else "HEAD"
|
|
589
|
+
)
|
|
590
|
+
changelog = generate_changelog(
|
|
591
|
+
from_ref=from_ref,
|
|
592
|
+
to_ref=release_to,
|
|
593
|
+
version=version,
|
|
594
|
+
style=None,
|
|
595
|
+
include_all=include_all,
|
|
596
|
+
token=token,
|
|
597
|
+
ai_mode=AiMode.NONE,
|
|
598
|
+
yes=yes,
|
|
599
|
+
ai_include_bodies=False,
|
|
600
|
+
)
|
|
601
|
+
config, _, runner = load_runtime_config()
|
|
602
|
+
body = render_github_release(changelog, config)
|
|
603
|
+
typer.echo(body, nl=False)
|
|
604
|
+
tag_name = tag_for_version(version)
|
|
605
|
+
if tag:
|
|
606
|
+
if not ref_exists(runner, tag_name):
|
|
607
|
+
create_tag(runner, tag_name, f"Release {tag_name}")
|
|
608
|
+
state.err.print(f"[ok]tagged[/ok] {tag_name}")
|
|
609
|
+
else:
|
|
610
|
+
warn(f"tag {tag_name} already exists")
|
|
611
|
+
if push_tag:
|
|
612
|
+
if not tag and not ref_exists(runner, tag_name):
|
|
613
|
+
raise UsageError("--push-tag requires --tag (or an existing tag)")
|
|
614
|
+
git_push_tag(runner, tag_name)
|
|
615
|
+
state.err.print(f"[ok]pushed tag[/ok] {tag_name}")
|
|
616
|
+
if push_release:
|
|
617
|
+
url = _push_github_release(
|
|
618
|
+
config, runner, token, tag_name, version, body, draft=not publish
|
|
619
|
+
)
|
|
620
|
+
state.err.print(f"[ok]release[/ok] {url}")
|
|
621
|
+
except PatchnoteError as exc:
|
|
622
|
+
die(exc)
|
|
623
|
+
except Exception as exc:
|
|
624
|
+
die(exc)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _push_github_release(
|
|
628
|
+
config: Config,
|
|
629
|
+
runner: GitRunner,
|
|
630
|
+
token: str | None,
|
|
631
|
+
tag_name: str,
|
|
632
|
+
version: str,
|
|
633
|
+
body: str,
|
|
634
|
+
*,
|
|
635
|
+
draft: bool,
|
|
636
|
+
) -> str:
|
|
637
|
+
slug = detect_repo_slug(runner, config)
|
|
638
|
+
if slug is None:
|
|
639
|
+
raise UsageError("Cannot determine GitHub repository (set repository: in config)")
|
|
640
|
+
token_value = resolve_token(config, token)
|
|
641
|
+
if not token_value:
|
|
642
|
+
raise UsageError("A GitHub token is required to create a release")
|
|
643
|
+
with httpx.Client() as http:
|
|
644
|
+
gh = GitHubClient(
|
|
645
|
+
http,
|
|
646
|
+
token_value,
|
|
647
|
+
slug[0],
|
|
648
|
+
slug[1],
|
|
649
|
+
api_url=config.github.api_url,
|
|
650
|
+
graphql_url=config.github.graphql_url,
|
|
651
|
+
warn=warn,
|
|
652
|
+
)
|
|
653
|
+
data = gh.create_or_update_release(
|
|
654
|
+
tag=tag_name,
|
|
655
|
+
name=f"v{version}" if not version.startswith("v") else version,
|
|
656
|
+
body=body,
|
|
657
|
+
draft=draft,
|
|
658
|
+
)
|
|
659
|
+
return str(data.get("html_url") or "")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def run_release_from_env(env: dict[str, str], changelog_text: str) -> str | None:
|
|
663
|
+
"""Used by the GitHub Action to create a release without shell interpolation."""
|
|
664
|
+
version = env.get("PATCHNOTE_VERSION") or env.get("GITHUB_REF_NAME") or ""
|
|
665
|
+
if version.startswith("refs/tags/"):
|
|
666
|
+
version = version.removeprefix("refs/tags/")
|
|
667
|
+
if not version:
|
|
668
|
+
raise UsageError("create-release requires a version or a tag ref")
|
|
669
|
+
tag_name = version if version.startswith("v") else f"v{version.lstrip('v')}"
|
|
670
|
+
is_draft = True
|
|
671
|
+
publish = env.get("PATCHNOTE_PUBLISH", "")
|
|
672
|
+
if publish.strip().lower() in {"1", "true", "yes", "on"}:
|
|
673
|
+
is_draft = False
|
|
674
|
+
token = env.get("PATCHNOTE_TOKEN") or env.get("GITHUB_TOKEN")
|
|
675
|
+
config, _, runner = load_runtime_config()
|
|
676
|
+
body = changelog_text
|
|
677
|
+
if LAST_CHANGELOG is not None:
|
|
678
|
+
body = render_github_release(LAST_CHANGELOG, config)
|
|
679
|
+
return _push_github_release(config, runner, token, tag_name, version, body, draft=is_draft)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def run() -> None:
|
|
683
|
+
try:
|
|
684
|
+
app()
|
|
685
|
+
except PatchnoteError as exc:
|
|
686
|
+
die(exc)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
if __name__ == "__main__":
|
|
690
|
+
run()
|