Skip to content

Commit ac880c8

Browse files
🔖 bump version 0.2.1 -> 0.3.0 (#26)
* add bump command * update command to get url * fix message for changelog * grab latest changes * add dry run * add release command * add force run to some commands * fix quotes? * update CHANGELOG for version 0.3.0 * 🔖 bump version 0.2.1 -> 0.3.0
1 parent 947caea commit ac880c8

File tree

7 files changed

+268
-4
lines changed

7 files changed

+268
-4
lines changed

Diff for: .bin/bump.py

+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
#!/usr/bin/env -S uv run --quiet
2+
# /// script
3+
# requires-python = ">=3.13"
4+
# dependencies = [
5+
# "bumpver",
6+
# "typer",
7+
# ]
8+
# ///
9+
from __future__ import annotations
10+
11+
import re
12+
import subprocess
13+
import sys
14+
from enum import Enum
15+
from pathlib import Path
16+
from typing import Annotated
17+
from typing import Any
18+
19+
import typer
20+
from typer import Option
21+
22+
23+
class CommandRunner:
24+
def __init__(self, dry_run: bool = False):
25+
self.dry_run = dry_run
26+
27+
def _quote_arg(self, arg: str) -> str:
28+
if " " in arg and not (arg.startswith('"') or arg.startswith("'")):
29+
return f"'{arg}'"
30+
return arg
31+
32+
def _build_command_args(self, **params: Any) -> str:
33+
args = []
34+
for key, value in params.items():
35+
key = key.replace("_", "-")
36+
if isinstance(value, bool) and value:
37+
args.append(f"--{key}")
38+
elif value is not None:
39+
args.extend([f"--{key}", self._quote_arg(str(value))])
40+
return " ".join(args)
41+
42+
def run(
43+
self, cmd: str, name: str, *args: str, force_run: bool = False, **params: Any
44+
) -> str:
45+
command_parts = [cmd, name]
46+
command_parts.extend(self._quote_arg(arg) for arg in args)
47+
if params:
48+
command_parts.append(self._build_command_args(**params))
49+
command = " ".join(command_parts)
50+
print(
51+
f"would run command: {command}"
52+
if self.dry_run and not force_run
53+
else f"running command: {command}"
54+
)
55+
56+
if self.dry_run and not force_run:
57+
return ""
58+
59+
success, output = self._run_command(command)
60+
if not success:
61+
print(f"{cmd} failed: {output}", file=sys.stderr)
62+
raise typer.Exit(1)
63+
return output
64+
65+
def _run_command(self, command: str) -> tuple[bool, str]:
66+
try:
67+
output = subprocess.check_output(
68+
command, shell=True, text=True, stderr=subprocess.STDOUT
69+
).strip()
70+
return True, output
71+
except subprocess.CalledProcessError as e:
72+
return False, e.output
73+
74+
75+
_runner: CommandRunner | None = None
76+
77+
78+
def run(cmd: str, name: str, *args: str, **params: Any) -> str:
79+
if _runner is None:
80+
raise RuntimeError("CommandRunner not initialized. Call init_runner first.")
81+
return _runner.run(cmd, name, *args, **params)
82+
83+
84+
def init_runner(dry_run: bool = False) -> None:
85+
global _runner
86+
_runner = CommandRunner(dry_run)
87+
88+
89+
def get_current_version():
90+
tags = run("git", "tag", "--sort=-creatordate", force_run=True).splitlines()
91+
return tags[0] if tags else ""
92+
93+
94+
def get_new_version(version: Version, tag: Tag | None = None) -> str:
95+
output = run(
96+
"bumpver", "update", dry=True, tag=tag, force_run=True, **{version: True}
97+
)
98+
if match := re.search(r"New Version: (.+)", output):
99+
return match.group(1)
100+
return typer.prompt("Failed to get new version. Enter manually")
101+
102+
103+
def update_changelog(new_version: str) -> None:
104+
repo_url = run("git", "remote", "get-url", "origin").strip().replace(".git", "")
105+
changelog = Path("CHANGELOG.md")
106+
content = changelog.read_text()
107+
108+
content = re.sub(
109+
r"## \[Unreleased\]",
110+
f"## [{new_version}]",
111+
content,
112+
count=1,
113+
)
114+
content = re.sub(
115+
rf"## \[{new_version}\]",
116+
f"## [Unreleased]\n\n## [{new_version}]",
117+
content,
118+
count=1,
119+
)
120+
content += f"[{new_version}]: {repo_url}/releases/tag/v{new_version}\n"
121+
content = re.sub(
122+
r"\[unreleased\]: .*\n",
123+
f"[unreleased]: {repo_url}/compare/v{new_version}...HEAD\n",
124+
content,
125+
count=1,
126+
)
127+
128+
changelog.write_text(content)
129+
run("git", "add", ".")
130+
run("git", "commit", "-m", f"update CHANGELOG for version {new_version}")
131+
132+
133+
cli = typer.Typer()
134+
135+
136+
class Version(str, Enum):
137+
MAJOR = "major"
138+
MINOR = "minor"
139+
PATCH = "patch"
140+
141+
142+
class Tag(str, Enum):
143+
DEV = "dev"
144+
ALPHA = "alpha"
145+
BETA = "beta"
146+
RC = "rc"
147+
FINAL = "final"
148+
149+
150+
@cli.command()
151+
def version(
152+
version: Annotated[
153+
Version, Option("--version", "-v", help="The tag to add to the new version")
154+
],
155+
tag: Annotated[Tag, Option("--tag", "-t", help="The tag to add to the new version")]
156+
| None = None,
157+
dry_run: Annotated[
158+
bool, Option("--dry-run", "-d", help="Show commands without executing")
159+
] = False,
160+
):
161+
init_runner(dry_run)
162+
163+
current_version = get_current_version()
164+
changes = run(
165+
"git",
166+
"log",
167+
f"{current_version}..HEAD",
168+
"--pretty=format:- `%h`: %s",
169+
"--reverse",
170+
force_run=True,
171+
)
172+
173+
new_version = get_new_version(version, tag)
174+
release_branch = f"release-v{new_version}"
175+
176+
run("git", "checkout", "-b", release_branch)
177+
run("bumpver", "update", tag=tag, **{version: True})
178+
update_changelog(new_version)
179+
180+
run("git", "push", "--set-upstream", "origin", release_branch)
181+
title = run("git", "log", "-1", "--pretty=%s")
182+
run(
183+
"gh",
184+
"pr",
185+
"create",
186+
"--base",
187+
"main",
188+
"--head",
189+
release_branch,
190+
"--title",
191+
title,
192+
"--body",
193+
changes,
194+
)
195+
196+
197+
@cli.command()
198+
def release(
199+
dry_run: Annotated[
200+
bool, Option("--dry-run", "-d", help="Show commands without executing")
201+
] = False,
202+
force: Annotated[bool, Option("--force", "-f", help="Skip safety checks")] = False,
203+
):
204+
init_runner(dry_run)
205+
206+
current_branch = run("git", "branch", "--show-current", force_run=True).strip()
207+
if current_branch != "main" and not force:
208+
print(
209+
f"Must be on main branch to create release (currently on {current_branch})"
210+
)
211+
raise typer.Exit(1)
212+
213+
if run("git", "status", "--porcelain") and not force:
214+
print("Working directory is not clean. Commit or stash changes first.")
215+
raise typer.Exit(1)
216+
217+
run("git", "fetch", "origin", "main")
218+
local_sha = run("git", "rev-parse", "@").strip()
219+
remote_sha = run("git", "rev-parse", "@{u}").strip()
220+
if local_sha != remote_sha and not force:
221+
print("Local main is not up to date with remote. Pull changes first.")
222+
raise typer.Exit(1)
223+
224+
current_version = get_current_version()
225+
226+
try:
227+
run("gh", "release", "view", f"v{current_version}")
228+
if not force:
229+
print(f"Release v{current_version} already exists!")
230+
raise typer.Exit(1)
231+
except Exception:
232+
pass
233+
234+
if not force and not dry_run:
235+
typer.confirm(f"Create release v{current_version}?", abort=True)
236+
237+
run("gh", "release", "create", f"v{current_version}", "--generate-notes")
238+
239+
240+
if __name__ == "__main__":
241+
cli()

Diff for: .just/project.just

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
set unstable := true
2+
3+
justfile := justfile_directory() + "/.just/project.just"
4+
5+
[private]
6+
default:
7+
@just --list --justfile {{ justfile }}
8+
9+
[private]
10+
fmt:
11+
@just --fmt --justfile {{ justfile }}
12+
13+
[no-cd]
14+
@bump *ARGS:
15+
{{ justfile_directory() }}/.bin/bump.py version {{ ARGS }}
16+
17+
[no-cd]
18+
@release *ARGS:
19+
{{ justfile_directory() }}/.bin/bump.py release {{ ARGS }}

Diff for: CHANGELOG.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
## [0.3.0]
22+
2123
### Added
2224

2325
- Added `SyncGitHubAPI`, a synchronous implementation of `gidgethub.abc.GitHubAPI` for Django applications running under WSGI. Maintains the familiar gidgethub interface without requiring async/await.
@@ -58,7 +60,8 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
5860

5961
- Josh Thomas <[email protected]> (maintainer)
6062

61-
[unreleased]: https://github.com/joshuadavidthomas/django-github-app/compare/v0.2.1...HEAD
63+
[unreleased]: https://github.com/joshuadavidthomas/django-github-app/compare/v0.3.0...HEAD
6264
[0.1.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.1.0
6365
[0.2.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.2.0
6466
[0.2.1]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.2.1
67+
[0.3.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.3.0

Diff for: Justfile

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ set dotenv-load := true
22
set unstable := true
33

44
mod docs ".just/documentation.just"
5+
mod project ".just/project.just"
56

67
[private]
78
default:

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Source = "https://github.com/joshuadavidthomas/django-github-app"
7070
[tool.bumpver]
7171
commit = true
7272
commit_message = ":bookmark: bump version {old_version} -> {new_version}"
73-
current_version = "0.2.1"
73+
current_version = "0.3.0"
7474
push = false # set to false for CI
7575
tag = false
7676
version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]"

Diff for: src/django_github_app/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from __future__ import annotations
22

3-
__version__ = "0.2.1"
3+
__version__ = "0.3.0"

Diff for: tests/test_version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55

66
def test_version():
7-
assert __version__ == "0.2.1"
7+
assert __version__ == "0.3.0"

0 commit comments

Comments
 (0)