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/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()