tinysemver 2.0.7__tar.gz → 2.1.0__tar.gz
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.
- {tinysemver-2.0.7/tinysemver.egg-info → tinysemver-2.1.0}/PKG-INFO +20 -5
- {tinysemver-2.0.7 → tinysemver-2.1.0}/README.md +15 -2
- {tinysemver-2.0.7 → tinysemver-2.1.0}/pyproject.toml +4 -3
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver/tinysemver.py +311 -40
- {tinysemver-2.0.7 → tinysemver-2.1.0/tinysemver.egg-info}/PKG-INFO +20 -5
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver.egg-info/SOURCES.txt +1 -0
- tinysemver-2.1.0/tinysemver.egg-info/requires.txt +2 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/LICENSE +0 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/setup.cfg +0 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver/__init__.py +0 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver.egg-info/dependency_links.txt +0 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver.egg-info/entry_points.txt +0 -0
- {tinysemver-2.0.7 → tinysemver-2.1.0}/tinysemver.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tinysemver
|
|
3
|
-
Version: 2.0
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Semantic-Versioning with LLMs and without 300,000 lines of JS
|
|
5
5
|
Author-email: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>, Guillaume de Rouville <31691250+grouville@users.noreply.github.com>
|
|
6
6
|
License: Apache License
|
|
7
7
|
Version 2.0, January 2004
|
|
@@ -210,7 +210,7 @@ Project-URL: Documentation, https://github.com/ashvardanian/tinysemver/blob/main
|
|
|
210
210
|
Project-URL: Repository, https://github.com/ashvardanian/tinysemver.git
|
|
211
211
|
Project-URL: Issues, https://github.com/ashvardanian/tinysemver/issues
|
|
212
212
|
Project-URL: Changelog, https://github.com/ashvardanian/tinysemver/blob/main/CHANGELOG.md
|
|
213
|
-
Classifier: Development Status ::
|
|
213
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
214
214
|
Classifier: Intended Audience :: Developers
|
|
215
215
|
Classifier: Topic :: Software Development :: Build Tools
|
|
216
216
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
@@ -223,10 +223,12 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
223
223
|
Classifier: Programming Language :: Python :: 3.11
|
|
224
224
|
Description-Content-Type: text/markdown
|
|
225
225
|
License-File: LICENSE
|
|
226
|
+
Requires-Dist: openai>=1.54.4
|
|
227
|
+
Requires-Dist: rich>=13.8.0
|
|
226
228
|
|
|
227
|
-

|
|
229
|
+

|
|
228
230
|
|
|
229
|
-
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file.
|
|
231
|
+
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file, capable of generating changelogs and release notes with the use of OpenAI-compatible LLM APIs, or without them.
|
|
230
232
|
In plain English, if your commit messages look like `feat: add new feature` or `fix: bugfix`, this package will automate releasing new "GIT tags" based on the commit messages.
|
|
231
233
|
Here is how to integrate it into your project CI:
|
|
232
234
|
|
|
@@ -304,6 +306,19 @@ Alternatively, you can just ask for `--help`:
|
|
|
304
306
|
$ tinysemver --help
|
|
305
307
|
```
|
|
306
308
|
|
|
309
|
+
## AI and Rock-n-Roll
|
|
310
|
+
|
|
311
|
+
TinySemVer can leverage a language model to validate the commits and generate clean and infromative release notes.
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
$ tinysemver --verbose \
|
|
315
|
+
--github-repository 'ashvardanian/tinysemver' \
|
|
316
|
+
--openai-base-url 'https://api.groq.com/openai/v1' \
|
|
317
|
+
--openai-api-key 'GET_YOURSELF_A_KEY' \
|
|
318
|
+
--openai-model 'llama-3.2-11b-text-preview' \
|
|
319
|
+
--dry-run
|
|
320
|
+
```
|
|
321
|
+
|
|
307
322
|
## Usage Details for the GitHub CI Action
|
|
308
323
|
|
|
309
324
|
TinySemVer can be easily integrated into your GitHub Actions CI pipeline.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-

|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file.
|
|
3
|
+
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file, capable of generating changelogs and release notes with the use of OpenAI-compatible LLM APIs, or without them.
|
|
4
4
|
In plain English, if your commit messages look like `feat: add new feature` or `fix: bugfix`, this package will automate releasing new "GIT tags" based on the commit messages.
|
|
5
5
|
Here is how to integrate it into your project CI:
|
|
6
6
|
|
|
@@ -78,6 +78,19 @@ Alternatively, you can just ask for `--help`:
|
|
|
78
78
|
$ tinysemver --help
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
## AI and Rock-n-Roll
|
|
82
|
+
|
|
83
|
+
TinySemVer can leverage a language model to validate the commits and generate clean and infromative release notes.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
$ tinysemver --verbose \
|
|
87
|
+
--github-repository 'ashvardanian/tinysemver' \
|
|
88
|
+
--openai-base-url 'https://api.groq.com/openai/v1' \
|
|
89
|
+
--openai-api-key 'GET_YOURSELF_A_KEY' \
|
|
90
|
+
--openai-model 'llama-3.2-11b-text-preview' \
|
|
91
|
+
--dry-run
|
|
92
|
+
```
|
|
93
|
+
|
|
81
94
|
## Usage Details for the GitHub CI Action
|
|
82
95
|
|
|
83
96
|
TinySemVer can be easily integrated into your GitHub Actions CI pipeline.
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tinysemver"
|
|
7
|
-
version = "2.0
|
|
8
|
-
description = "
|
|
7
|
+
version = "2.1.0"
|
|
8
|
+
description = "Semantic-Versioning with LLMs and without 300,000 lines of JS"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
authors = [
|
|
@@ -13,7 +13,7 @@ authors = [
|
|
|
13
13
|
{ name = "Guillaume de Rouville", email = "31691250+grouville@users.noreply.github.com" },
|
|
14
14
|
]
|
|
15
15
|
classifiers = [
|
|
16
|
-
"Development Status ::
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Topic :: Software Development :: Build Tools",
|
|
19
19
|
"License :: OSI Approved :: Apache Software License",
|
|
@@ -25,6 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python :: 3.10",
|
|
26
26
|
"Programming Language :: Python :: 3.11",
|
|
27
27
|
]
|
|
28
|
+
dependencies = ["openai >= 1.54.4", "rich >= 13.8.0"]
|
|
28
29
|
|
|
29
30
|
[project.scripts]
|
|
30
31
|
tinysemver = "tinysemver.tinysemver:main"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""TinySemVer is a tiny
|
|
3
|
+
"""TinySemVer is a tiny but mighty Semantic Versioning tool with AI, Drugs, and Rock-n-Roll.
|
|
4
4
|
|
|
5
5
|
This module traces the commit history of a Git repository after the last tag, and based
|
|
6
6
|
on the commit messages, it determines the type of version bump (major, minor, or patch).
|
|
@@ -15,6 +15,7 @@ Example:
|
|
|
15
15
|
--minor-verbs 'feature,minor,add,new' \
|
|
16
16
|
--patch-verbs 'fix,patch,bug,improve,docs,make' \
|
|
17
17
|
--changelog-file 'CHANGELOG.md' \
|
|
18
|
+
--guide-file 'CONTIBUTING.md' \
|
|
18
19
|
--version-file 'VERSION' \
|
|
19
20
|
--update-version-in 'package.json' '"version": "(.*)"' \
|
|
20
21
|
--update-version-in 'CITATION.cff' '^version: (.*)' \
|
|
@@ -32,6 +33,7 @@ By default, the following conventions are used:
|
|
|
32
33
|
- The repository must be a Git repository.
|
|
33
34
|
- It must contain a "VERSION" and "CHANGELOG.md" files in the root directory.
|
|
34
35
|
- The changelog is append-only - sorted in chronological order.
|
|
36
|
+
- The contibution guide is in the "CONTRIBUTING.md" file.
|
|
35
37
|
|
|
36
38
|
Setting up a new project:
|
|
37
39
|
|
|
@@ -47,13 +49,16 @@ import argparse
|
|
|
47
49
|
import subprocess
|
|
48
50
|
import re
|
|
49
51
|
import os
|
|
50
|
-
from typing import List, Tuple, Literal, Union, Optional
|
|
52
|
+
from typing import List, Tuple, Literal, Union, Optional, NamedTuple
|
|
51
53
|
from datetime import datetime
|
|
52
54
|
import traceback
|
|
53
55
|
|
|
56
|
+
|
|
54
57
|
SemVer = Tuple[int, int, int]
|
|
55
58
|
BumpType = Literal["major", "minor", "patch"]
|
|
56
59
|
PathLike = Union[str, os.PathLike]
|
|
60
|
+
Commit = NamedTuple("Commit", [("hash", str), ("message", str)])
|
|
61
|
+
ChangeDiff = str
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
class NoNewCommitsError(Exception):
|
|
@@ -62,6 +67,69 @@ class NoNewCommitsError(Exception):
|
|
|
62
67
|
pass
|
|
63
68
|
|
|
64
69
|
|
|
70
|
+
class MayContainVulnerability(Warning):
|
|
71
|
+
"""Raised when the commit may contain a vulnerability."""
|
|
72
|
+
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MayContainLogicalBugs(Warning):
|
|
77
|
+
"""Raised when the commit may contain logical bugs."""
|
|
78
|
+
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MayContainBreakingChange(Warning):
|
|
83
|
+
"""Raised when the commit may contain a breaking change."""
|
|
84
|
+
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class MayLackDocumentation(Warning):
|
|
89
|
+
"""Raised when the commit may lack documentation updates."""
|
|
90
|
+
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UnknownCommitWarning(Warning):
|
|
95
|
+
"""Something in the commit doesn't add up."""
|
|
96
|
+
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_console = None # Initialize global console variable
|
|
101
|
+
_console_is_rich = True # Initialize global console type variable
|
|
102
|
+
_openai_client = None # Initialize global OpenAI client variable
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def print_to_console(message: str) -> None:
|
|
106
|
+
"""Print a message to the console."""
|
|
107
|
+
global _console, _console_is_rich
|
|
108
|
+
if _console == None and _console_is_rich:
|
|
109
|
+
try:
|
|
110
|
+
from rich import Console
|
|
111
|
+
|
|
112
|
+
_console = Console()
|
|
113
|
+
except ImportError:
|
|
114
|
+
_console = None
|
|
115
|
+
_console_is_rich = False
|
|
116
|
+
|
|
117
|
+
if _console_is_rich:
|
|
118
|
+
_console.print(message)
|
|
119
|
+
else:
|
|
120
|
+
print(message)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_open_ai_client(base_url: str, api_key: str):
|
|
124
|
+
# Create a global variable for the client
|
|
125
|
+
global _openai_client
|
|
126
|
+
if not _openai_client:
|
|
127
|
+
from openai import OpenAI
|
|
128
|
+
|
|
129
|
+
_openai_client = OpenAI(base_url=base_url, api_key=api_key)
|
|
130
|
+
return _openai_client
|
|
131
|
+
|
|
132
|
+
|
|
65
133
|
def get_last_tag(repository_path: PathLike) -> str:
|
|
66
134
|
"""Retrieve the last Git tag name from the repository."""
|
|
67
135
|
result = subprocess.run(
|
|
@@ -75,7 +143,7 @@ def get_last_tag(repository_path: PathLike) -> str:
|
|
|
75
143
|
return result.stdout.strip().decode("utf-8")
|
|
76
144
|
|
|
77
145
|
|
|
78
|
-
def get_commits_since_tag(repository_path: PathLike, tag: str) ->
|
|
146
|
+
def get_commits_since_tag(repository_path: PathLike, tag: str) -> List[Commit]:
|
|
79
147
|
"""Get commit hashes and messages since the specified Git tag."""
|
|
80
148
|
result = subprocess.run(
|
|
81
149
|
["git", "log", f"{tag}..HEAD", "--no-merges", "--pretty=format:%h:%s"],
|
|
@@ -88,8 +156,22 @@ def get_commits_since_tag(repository_path: PathLike, tag: str) -> Tuple[List[str
|
|
|
88
156
|
|
|
89
157
|
lines = result.stdout.strip().decode("utf-8").split("\n")
|
|
90
158
|
hashes = [line.partition(":")[0] for line in lines if line.strip()]
|
|
91
|
-
|
|
92
|
-
return hashes,
|
|
159
|
+
messages = [line.partition(":")[2] for line in lines if line.strip()]
|
|
160
|
+
return [Commit(h, m) for h, m in zip(hashes, messages)]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_diff_for_commit(repository_path: PathLike, commit_hash: str) -> ChangeDiff:
|
|
164
|
+
"""Retrieve the diff for a specific commit and parse into Change named tuples."""
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["git", "diff", f"{commit_hash}^", commit_hash, "--unified=3"],
|
|
167
|
+
stdout=subprocess.PIPE,
|
|
168
|
+
stderr=subprocess.PIPE,
|
|
169
|
+
cwd=repository_path,
|
|
170
|
+
)
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
raise RuntimeError(f"Failed to retrieve diff for commit: {commit_hash}")
|
|
173
|
+
|
|
174
|
+
return result.stdout.decode("utf-8")
|
|
93
175
|
|
|
94
176
|
|
|
95
177
|
def parse_version(tag: str) -> SemVer:
|
|
@@ -119,27 +201,45 @@ def normalize_verbs(verbs: Union[str, List[str]], defaults: List[str]) -> List[s
|
|
|
119
201
|
|
|
120
202
|
|
|
121
203
|
def group_commits(
|
|
122
|
-
commits: List[
|
|
204
|
+
commits: List[Commit],
|
|
123
205
|
major_verbs: List[str],
|
|
124
206
|
minor_verbs: List[str],
|
|
125
207
|
patch_verbs: List[str],
|
|
126
|
-
) -> Tuple[List[
|
|
208
|
+
) -> Tuple[List[Commit], List[Commit], List[Commit]]:
|
|
127
209
|
"""Group commits into major, minor, and patch categories based on keywords to simplify future `BumpType` resolution."""
|
|
128
210
|
major_commits = []
|
|
129
211
|
minor_commits = []
|
|
130
212
|
patch_commits = []
|
|
131
213
|
|
|
132
214
|
for commit in commits:
|
|
133
|
-
if any(commit_starts_with_verb(commit, verb) for verb in major_verbs):
|
|
215
|
+
if any(commit_starts_with_verb(commit.message, verb) for verb in major_verbs):
|
|
134
216
|
major_commits.append(commit)
|
|
135
|
-
if any(commit_starts_with_verb(commit, verb) for verb in minor_verbs):
|
|
217
|
+
if any(commit_starts_with_verb(commit.message, verb) for verb in minor_verbs):
|
|
136
218
|
minor_commits.append(commit)
|
|
137
|
-
if any(commit_starts_with_verb(commit, verb) for verb in patch_verbs):
|
|
219
|
+
if any(commit_starts_with_verb(commit.message, verb) for verb in patch_verbs):
|
|
138
220
|
patch_commits.append(commit)
|
|
139
221
|
|
|
140
222
|
return major_commits, minor_commits, patch_commits
|
|
141
223
|
|
|
142
224
|
|
|
225
|
+
def convert_commits_to_message(
|
|
226
|
+
major_commits: List[Commit],
|
|
227
|
+
minor_commits: List[Commit],
|
|
228
|
+
patch_commits: List[Commit],
|
|
229
|
+
) -> str:
|
|
230
|
+
"""Turns the different commits (major, minor, patch) into a single message."""
|
|
231
|
+
message = ""
|
|
232
|
+
|
|
233
|
+
if len(major_commits):
|
|
234
|
+
message += f"\n### Major\n\n" + "\n".join(f"- {c.message} ({c.hash})" for c in major_commits) + "\n"
|
|
235
|
+
if len(minor_commits):
|
|
236
|
+
message += f"\n### Minor\n\n" + "\n".join(f"- {c.message} ({c.hash})" for c in minor_commits) + "\n"
|
|
237
|
+
if len(patch_commits):
|
|
238
|
+
message += f"\n### Patch\n\n" + "\n".join(f"- {c.message} ({c.hash})" for c in patch_commits) + "\n"
|
|
239
|
+
|
|
240
|
+
return message
|
|
241
|
+
|
|
242
|
+
|
|
143
243
|
def bump_version(version: SemVer, bump_type: BumpType) -> SemVer:
|
|
144
244
|
"""Bump the version based on the specified bump type (major, minor, patch)."""
|
|
145
245
|
major, minor, patch = version
|
|
@@ -162,6 +262,9 @@ def create_tag(
|
|
|
162
262
|
github_repository: Optional[str] = None,
|
|
163
263
|
push: bool = False,
|
|
164
264
|
create_release: bool = False,
|
|
265
|
+
major_commits: List[Commit] = None,
|
|
266
|
+
minor_commits: List[Commit] = None,
|
|
267
|
+
patch_commits: List[Commit] = None,
|
|
165
268
|
) -> None:
|
|
166
269
|
"""Create a new Git tag and optionally push it to a remote GitHub repository."""
|
|
167
270
|
|
|
@@ -172,7 +275,10 @@ def create_tag(
|
|
|
172
275
|
env["GIT_AUTHOR_NAME"] = user_name
|
|
173
276
|
env["GIT_AUTHOR_EMAIL"] = user_email
|
|
174
277
|
env["GITHUB_TOKEN"] = github_token
|
|
278
|
+
|
|
175
279
|
message = f"Release: {tag} [skip ci]"
|
|
280
|
+
message += convert_commits_to_message(major_commits or [], minor_commits or [], patch_commits or [])
|
|
281
|
+
|
|
176
282
|
subprocess.run(["git", "add", "-A"], cwd=repository_path)
|
|
177
283
|
subprocess.run(["git", "commit", "-m", message], cwd=repository_path, env=env)
|
|
178
284
|
|
|
@@ -186,7 +292,7 @@ def create_tag(
|
|
|
186
292
|
).stdout.strip()
|
|
187
293
|
|
|
188
294
|
subprocess.run(["git", "tag", "-a", tag, "-m", message, new_commit_sha], cwd=repository_path, env=env)
|
|
189
|
-
|
|
295
|
+
print_to_console(f"[bold green]Created new tag:[/bold green] {tag}")
|
|
190
296
|
if push:
|
|
191
297
|
url = None
|
|
192
298
|
if github_token and github_repository:
|
|
@@ -197,11 +303,6 @@ def create_tag(
|
|
|
197
303
|
assert github_repository and not github_token, "You can't provide the GitHub token without the repository"
|
|
198
304
|
url = f"https://github.com/{github_repository}"
|
|
199
305
|
|
|
200
|
-
# Pull the latest changes from the remote repository
|
|
201
|
-
# pull_result = subprocess.run(["git", "pull", "--merge", url], cwd=repository_path, env=env)
|
|
202
|
-
# if pull_result.returncode != 0:
|
|
203
|
-
# raise RuntimeError("Failed to pull the latest changes from the remote repository")
|
|
204
|
-
|
|
205
306
|
# Push both commits and the tag
|
|
206
307
|
push_result = subprocess.run(
|
|
207
308
|
["git", "push", url, f"{new_commit_sha}:{default_branch}"],
|
|
@@ -211,15 +312,15 @@ def create_tag(
|
|
|
211
312
|
)
|
|
212
313
|
if push_result.returncode != 0:
|
|
213
314
|
raise RuntimeError(
|
|
214
|
-
f"Failed to push the new commits to the remote repository: '{url}' with error: {push_result.stderr}"
|
|
315
|
+
f"Failed to push the new commits to the remote repository: '{url}' with error: {push_result.stderr.decode('utf-8')}"
|
|
215
316
|
)
|
|
216
317
|
|
|
217
318
|
push_result = subprocess.run(["git", "push", url, "--tag"], cwd=repository_path, capture_output=True, env=env)
|
|
218
319
|
if push_result.returncode != 0:
|
|
219
320
|
raise RuntimeError(
|
|
220
|
-
f"Failed to push the new tag to the remote repository: '{url}' with error: {push_result.stderr}"
|
|
321
|
+
f"Failed to push the new tag to the remote repository: '{url}' with error: {push_result.stderr.decode('utf-8')}"
|
|
221
322
|
)
|
|
222
|
-
|
|
323
|
+
print_to_console(f"[bold green]Pushed to:[/bold green] {url}")
|
|
223
324
|
|
|
224
325
|
# Create a release using GitHub CLI if available
|
|
225
326
|
if create_release and github_repository:
|
|
@@ -249,13 +350,15 @@ def create_tag(
|
|
|
249
350
|
)
|
|
250
351
|
|
|
251
352
|
if release_result.returncode == 0:
|
|
252
|
-
|
|
353
|
+
print_to_console(f"[bold green]Created GitHub release for tag:[/bold green] {tag}")
|
|
253
354
|
else:
|
|
254
|
-
|
|
355
|
+
print_to_console(
|
|
356
|
+
f"[bold red]Failed to create GitHub release:[/bold red] {release_result.stderr.strip()}"
|
|
357
|
+
)
|
|
255
358
|
except subprocess.CalledProcessError:
|
|
256
|
-
|
|
359
|
+
print_to_console("[bold yellow]GitHub CLI not available. Skipping release creation.[/bold yellow]")
|
|
257
360
|
except Exception as e:
|
|
258
|
-
|
|
361
|
+
print_to_console(f"[bold red]An error occurred while creating the release:[/bold red] {str(e)}")
|
|
259
362
|
|
|
260
363
|
|
|
261
364
|
def patch_with_regex(
|
|
@@ -282,8 +385,7 @@ def patch_with_regex(
|
|
|
282
385
|
)
|
|
283
386
|
return updated
|
|
284
387
|
|
|
285
|
-
#
|
|
286
|
-
# the ^ and $ anchors match the start and end of the whole string.
|
|
388
|
+
# Compile the regex pattern with multiline support
|
|
287
389
|
regex_pattern = re.compile(regex_pattern, re.MULTILINE)
|
|
288
390
|
matches = list(re.finditer(regex_pattern, old_content))
|
|
289
391
|
new_content = re.sub(regex_pattern, replace_first_group, old_content, 1)
|
|
@@ -297,13 +399,120 @@ def patch_with_regex(
|
|
|
297
399
|
new_slice = re.sub(regex_pattern, replace_first_group, old_slice, 1)
|
|
298
400
|
|
|
299
401
|
if verbose:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
402
|
+
print_to_console(f"[bold cyan]Will update file:[/bold cyan] {file_path}:{match_line}")
|
|
403
|
+
print_to_console(f"[red]- {old_slice}[/red]")
|
|
404
|
+
print_to_console(f"[green]+ {new_slice}[/green]")
|
|
303
405
|
|
|
304
406
|
if not dry_run:
|
|
305
407
|
with open(file_path, "w") as file:
|
|
306
408
|
file.write(new_content)
|
|
409
|
+
if verbose:
|
|
410
|
+
print_to_console(f"[bold green]File updated successfully:[/bold green] {file_path}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def validate_commit_with_llms(
|
|
414
|
+
base_url: str,
|
|
415
|
+
api_key: str,
|
|
416
|
+
model: Optional[str],
|
|
417
|
+
commit: Commit,
|
|
418
|
+
change: ChangeDiff,
|
|
419
|
+
) -> Optional[Warning]:
|
|
420
|
+
prompt = f"""
|
|
421
|
+
You are a professional coding assistant helping me to validate a commit message and its changes.
|
|
422
|
+
|
|
423
|
+
1. Check if the changed code is likely to introduce logical bugs.
|
|
424
|
+
2. Check if the commit message is likely to contain a vulnerability.
|
|
425
|
+
3. Check if it contains a breaking change that is likely to affect users.
|
|
426
|
+
4. Check if the documentation wasn't updated for the changes made.
|
|
427
|
+
|
|
428
|
+
Don't let the commit message fool you, just look at the changes made in the code.
|
|
429
|
+
Reply by choosing any number from 0 to 4 and describe the issue, if anything is found.
|
|
430
|
+
|
|
431
|
+
0. No issues found.
|
|
432
|
+
1. The commit may introduce logical bugs, such as the overflow in ...
|
|
433
|
+
2. The commit may contain a vulnerability, such as the SQL injection in ...
|
|
434
|
+
3. The commit may contain a breaking change, such as the removal of ...
|
|
435
|
+
4. The commit may lack documentation updates for the changes made in function ...
|
|
436
|
+
"""
|
|
437
|
+
message = f"""
|
|
438
|
+
#{commit.hash}: {commit.message}
|
|
439
|
+
|
|
440
|
+
Changes:
|
|
441
|
+
|
|
442
|
+
{change}
|
|
443
|
+
"""
|
|
444
|
+
client = get_open_ai_client(base_url=base_url, api_key=api_key)
|
|
445
|
+
response = client.chat.completions.create(
|
|
446
|
+
messages=[
|
|
447
|
+
{"role": "system", "content": prompt},
|
|
448
|
+
{"role": "user", "content": message},
|
|
449
|
+
],
|
|
450
|
+
max_tokens=256,
|
|
451
|
+
model=model,
|
|
452
|
+
stream=False,
|
|
453
|
+
)
|
|
454
|
+
response_text = response.choices[0].message.content
|
|
455
|
+
if response_text.startswith("0."):
|
|
456
|
+
return None
|
|
457
|
+
if response_text.startswith("1."):
|
|
458
|
+
return MayContainLogicalBugs(response_text)
|
|
459
|
+
if response_text.startswith("2."):
|
|
460
|
+
return MayContainVulnerability(response_text)
|
|
461
|
+
if response_text.startswith("3."):
|
|
462
|
+
return MayContainBreakingChange(response_text)
|
|
463
|
+
if response_text.startswith("4."):
|
|
464
|
+
return MayLackDocumentation(response_text)
|
|
465
|
+
return UnknownCommitWarning(response_text)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def aggregate_release_notes_with_llms(
|
|
469
|
+
base_url: str,
|
|
470
|
+
api_key: str,
|
|
471
|
+
model: Optional[str],
|
|
472
|
+
github_repository: str,
|
|
473
|
+
commits: List[Commit],
|
|
474
|
+
changes: List[ChangeDiff],
|
|
475
|
+
) -> str:
|
|
476
|
+
|
|
477
|
+
commits_with_hashes = "\n".join(f"- {commit.hash}: {commit.message}" for commit in commits)
|
|
478
|
+
prompt = f"""
|
|
479
|
+
You are a release notes generator for an advanced software project.
|
|
480
|
+
|
|
481
|
+
Aggregate the release notes for the upcoming version based on the commits and their
|
|
482
|
+
changes replying in a Github-flavored Markdown format.
|
|
483
|
+
|
|
484
|
+
- Mention the new features, improvements, and bug fixes.
|
|
485
|
+
- Warn about potential breaking changes and vulnerabilities.
|
|
486
|
+
- Tag people and teams responsible for the changes.
|
|
487
|
+
- For the most important commits provide a link to the issue or pull request and
|
|
488
|
+
a URL to that commit, like [commit](https://github.com/{github_repository}/commit/...).
|
|
489
|
+
- Don't guess anything, only use the information from the commits and their changes.
|
|
490
|
+
- Use inline math notation, like $\\beta=1$ or math blocks like the following, when needed:
|
|
491
|
+
|
|
492
|
+
```math
|
|
493
|
+
\\S_i(A, B, \\alpha, \\beta) = \\alpha \\cdot A_i + \\beta \\cdot B_i
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
- Use alerting quotes, like: [!CAUTION] or [!TIP], when needed.
|
|
497
|
+
"""
|
|
498
|
+
header_message = f"""Commits:
|
|
499
|
+
|
|
500
|
+
{commits_with_hashes}
|
|
501
|
+
"""
|
|
502
|
+
changes_messages = [f"#{commit.hash}: {commit.message}\n{change}" for commit, change in zip(commits, changes)]
|
|
503
|
+
|
|
504
|
+
client = OpenAI(base_url=base_url, api_key=api_key)
|
|
505
|
+
response = client.chat.completions.create(
|
|
506
|
+
messages=[
|
|
507
|
+
{"role": "system", "content": prompt},
|
|
508
|
+
{"role": "user", "content": header_message},
|
|
509
|
+
*[{"role": "user", "content": message} for message in changes_messages],
|
|
510
|
+
],
|
|
511
|
+
max_tokens=2048,
|
|
512
|
+
model=model,
|
|
513
|
+
stream=False,
|
|
514
|
+
)
|
|
515
|
+
return response.choices[0].message.content
|
|
307
516
|
|
|
308
517
|
|
|
309
518
|
def bump(
|
|
@@ -325,6 +534,9 @@ def bump(
|
|
|
325
534
|
push: bool = True,
|
|
326
535
|
github_token: Optional[str] = None,
|
|
327
536
|
github_repository: Optional[str] = None,
|
|
537
|
+
openai_base_url: str = "https://api.openai.com",
|
|
538
|
+
openai_api_key: Optional[str] = None,
|
|
539
|
+
openai_model: Optional[str] = None,
|
|
328
540
|
default_branch: str = "main",
|
|
329
541
|
create_release: bool = False,
|
|
330
542
|
) -> SemVer:
|
|
@@ -350,6 +562,9 @@ def bump(
|
|
|
350
562
|
push (bool): If True, pushes the changes to the remote GitHub repository. Defaults to True.
|
|
351
563
|
github_token (Optional[str]): The GitHub token for authentication. If None, it attempts to use the GH_TOKEN environment variable. Defaults to None.
|
|
352
564
|
github_repository (Optional[str]): The GitHub repository in 'owner/repo' format. If None, it attempts to use the GH_REPOSITORY environment variable. Defaults to None.
|
|
565
|
+
openai_base_url (str): The OpenAI API base URL. Defaults to "https://api.openai.com".
|
|
566
|
+
openai_api_key (Optional[str]): The OpenAI API key. Defaults to None.
|
|
567
|
+
openai_model (Optional[str]): The OpenAI model to use for text generation. Defaults to None.
|
|
353
568
|
default_branch (str): The default branch to push the changes to. Defaults to "main".
|
|
354
569
|
|
|
355
570
|
Returns:
|
|
@@ -408,19 +623,19 @@ def bump(
|
|
|
408
623
|
if verbose:
|
|
409
624
|
print(f"Current version: {current_version[0]}.{current_version[1]}.{current_version[2]}")
|
|
410
625
|
|
|
411
|
-
|
|
412
|
-
if not len(
|
|
626
|
+
commits = get_commits_since_tag(repository_path, last_tag)
|
|
627
|
+
if not len(commits):
|
|
413
628
|
raise NoNewCommitsError(f"No new commits since the last {last_tag} tag")
|
|
414
629
|
|
|
415
630
|
if verbose:
|
|
416
|
-
print(f"? Commits since last tag: {len(
|
|
417
|
-
for hash, commit in
|
|
631
|
+
print(f"? Commits since last tag: {len(commits)}")
|
|
632
|
+
for hash, commit in commits:
|
|
418
633
|
print(f"# {hash}: {commit}")
|
|
419
634
|
|
|
420
|
-
major_commits, minor_commits, patch_commits = group_commits(
|
|
635
|
+
major_commits, minor_commits, patch_commits = group_commits(commits, major_verbs, minor_verbs, patch_verbs)
|
|
421
636
|
assert (
|
|
422
637
|
len(major_commits) + len(minor_commits) + len(patch_commits)
|
|
423
|
-
), "No commit categories found to bump the version: " + ", ".join(
|
|
638
|
+
), "No commit categories found to bump the version: " + ", ".join(map(lambda c: c[1], commits))
|
|
424
639
|
|
|
425
640
|
if len(major_commits):
|
|
426
641
|
bump_type = "major"
|
|
@@ -439,17 +654,14 @@ def bump(
|
|
|
439
654
|
if changelog_file:
|
|
440
655
|
now = datetime.now()
|
|
441
656
|
changes = f"\n## {now:%B %d, %Y}: v{new_version_str}\n"
|
|
442
|
-
|
|
443
|
-
changes += f"\n### Major\n\n" + "\n".join(f"- {c}" for c in major_commits) + "\n"
|
|
444
|
-
if len(minor_commits):
|
|
445
|
-
changes += f"\n### Minor\n\n" + "\n".join(f"- {c}" for c in minor_commits) + "\n"
|
|
446
|
-
if len(patch_commits):
|
|
447
|
-
changes += f"\n### Patch\n\n" + "\n".join(f"- {c}" for c in patch_commits) + "\n"
|
|
657
|
+
changes += convert_commits_to_message(major_commits, minor_commits, patch_commits)
|
|
448
658
|
|
|
449
659
|
print(f"Will update file: {changelog_file}")
|
|
450
660
|
if verbose:
|
|
451
661
|
changes_lines = changes.count("\n") + 1
|
|
452
662
|
print(f"? Appending {changes_lines} lines")
|
|
663
|
+
for line in changes.split("\n"):
|
|
664
|
+
print(f"+ {line}")
|
|
453
665
|
|
|
454
666
|
if not dry_run:
|
|
455
667
|
with open(changelog_file, "a") as file:
|
|
@@ -468,6 +680,43 @@ def bump(
|
|
|
468
680
|
for file_path, regex_pattern in update_patch_version_in:
|
|
469
681
|
patch_with_regex(file_path, regex_pattern, str(new_version[2]), dry_run=dry_run, verbose=verbose)
|
|
470
682
|
|
|
683
|
+
# Now log the potential issues with the commits
|
|
684
|
+
if openai_api_key:
|
|
685
|
+
warnings_commits = []
|
|
686
|
+
warnings = []
|
|
687
|
+
changes = [get_diff_for_commit(repository_path, commit.hash) for commit in commits]
|
|
688
|
+
for commit, change in zip(commits, changes):
|
|
689
|
+
try:
|
|
690
|
+
warning = validate_commit_with_llms(
|
|
691
|
+
base_url=openai_base_url,
|
|
692
|
+
api_key=openai_api_key,
|
|
693
|
+
model=openai_model,
|
|
694
|
+
commit=commit,
|
|
695
|
+
change=change,
|
|
696
|
+
)
|
|
697
|
+
if warning:
|
|
698
|
+
warnings_commits.append(commit)
|
|
699
|
+
warnings.append(warning)
|
|
700
|
+
except Exception as e:
|
|
701
|
+
print(f"Failed to validate commit: {commit.hash} with error: {str(e)}")
|
|
702
|
+
traceback.print_exc()
|
|
703
|
+
|
|
704
|
+
if len(warnings):
|
|
705
|
+
print("## Potential issues")
|
|
706
|
+
for commit, warning in zip(warnings_commits, warnings):
|
|
707
|
+
print(f"- Commit #{commit.hash}: {warning}")
|
|
708
|
+
|
|
709
|
+
release_notes = aggregate_release_notes_with_llms(
|
|
710
|
+
base_url=openai_base_url,
|
|
711
|
+
api_key=openai_api_key,
|
|
712
|
+
model=openai_model,
|
|
713
|
+
github_repository=github_repository,
|
|
714
|
+
commits=commits,
|
|
715
|
+
changes=changes,
|
|
716
|
+
)
|
|
717
|
+
print("## Generated release notes:")
|
|
718
|
+
print(release_notes)
|
|
719
|
+
|
|
471
720
|
if not dry_run:
|
|
472
721
|
create_tag(
|
|
473
722
|
repository_path=repository_path,
|
|
@@ -479,6 +728,9 @@ def bump(
|
|
|
479
728
|
github_repository=github_repository,
|
|
480
729
|
push=push,
|
|
481
730
|
create_release=create_release,
|
|
731
|
+
major_commits=major_commits,
|
|
732
|
+
minor_commits=minor_commits,
|
|
733
|
+
patch_commits=patch_commits,
|
|
482
734
|
)
|
|
483
735
|
|
|
484
736
|
|
|
@@ -582,6 +834,19 @@ def main():
|
|
|
582
834
|
"--github-repository",
|
|
583
835
|
help="GitHub repository in the 'owner/repo' format, if not set will use GH_REPOSITORY env var",
|
|
584
836
|
)
|
|
837
|
+
parser.add_argument(
|
|
838
|
+
"--openai-base-url",
|
|
839
|
+
default="https://api.openai.com",
|
|
840
|
+
help="OpenAI API base URL",
|
|
841
|
+
)
|
|
842
|
+
parser.add_argument(
|
|
843
|
+
"--openai-api-key",
|
|
844
|
+
help="OpenAI API key for text generation",
|
|
845
|
+
)
|
|
846
|
+
parser.add_argument(
|
|
847
|
+
"--openai-model",
|
|
848
|
+
help="OpenAI model to use for text generation (e.g., 'text-davinci-003' or 'text-davinci-002')",
|
|
849
|
+
)
|
|
585
850
|
parser.add_argument(
|
|
586
851
|
"--create-release",
|
|
587
852
|
action="store_true",
|
|
@@ -629,6 +894,9 @@ def main():
|
|
|
629
894
|
args.git_user_email = os.environ.get("TINYSEMVER_GIT_USER_EMAIL", "tinysemver@ashvardanian.com")
|
|
630
895
|
args.github_token = os.environ.get("GITHUB_TOKEN")
|
|
631
896
|
args.github_repository = os.environ.get("GITHUB_REPOSITORY")
|
|
897
|
+
args.openai_base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com")
|
|
898
|
+
args.openai_api_key = os.environ.get("OPENAI_API_KEY")
|
|
899
|
+
args.openai_model = os.environ.get("OPENAI_MODEL")
|
|
632
900
|
args.create_release = os.environ.get("TINYSEMVER_CREATE_RELEASE", "").lower() == "true"
|
|
633
901
|
|
|
634
902
|
# It's common for a CI pipeline to have multiple broken settings or missing files.
|
|
@@ -657,6 +925,9 @@ def main():
|
|
|
657
925
|
git_user_email=args.git_user_email,
|
|
658
926
|
github_token=args.github_token,
|
|
659
927
|
github_repository=args.github_repository,
|
|
928
|
+
openai_base_url=args.openai_base_url,
|
|
929
|
+
openai_api_key=args.openai_api_key,
|
|
930
|
+
openai_model=args.openai_model,
|
|
660
931
|
push=args.push,
|
|
661
932
|
create_release=args.create_release,
|
|
662
933
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tinysemver
|
|
3
|
-
Version: 2.0
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Semantic-Versioning with LLMs and without 300,000 lines of JS
|
|
5
5
|
Author-email: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>, Guillaume de Rouville <31691250+grouville@users.noreply.github.com>
|
|
6
6
|
License: Apache License
|
|
7
7
|
Version 2.0, January 2004
|
|
@@ -210,7 +210,7 @@ Project-URL: Documentation, https://github.com/ashvardanian/tinysemver/blob/main
|
|
|
210
210
|
Project-URL: Repository, https://github.com/ashvardanian/tinysemver.git
|
|
211
211
|
Project-URL: Issues, https://github.com/ashvardanian/tinysemver/issues
|
|
212
212
|
Project-URL: Changelog, https://github.com/ashvardanian/tinysemver/blob/main/CHANGELOG.md
|
|
213
|
-
Classifier: Development Status ::
|
|
213
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
214
214
|
Classifier: Intended Audience :: Developers
|
|
215
215
|
Classifier: Topic :: Software Development :: Build Tools
|
|
216
216
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
@@ -223,10 +223,12 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
223
223
|
Classifier: Programming Language :: Python :: 3.11
|
|
224
224
|
Description-Content-Type: text/markdown
|
|
225
225
|
License-File: LICENSE
|
|
226
|
+
Requires-Dist: openai>=1.54.4
|
|
227
|
+
Requires-Dist: rich>=13.8.0
|
|
226
228
|
|
|
227
|
-

|
|
229
|
+

|
|
228
230
|
|
|
229
|
-
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file.
|
|
231
|
+
__TinySemVer__ is a minimalistic [Semantic Versioning](https://semver.org/) package for projects following [Conventional Commits](https://www.conventionalcommits.org/) in a single short Python file, capable of generating changelogs and release notes with the use of OpenAI-compatible LLM APIs, or without them.
|
|
230
232
|
In plain English, if your commit messages look like `feat: add new feature` or `fix: bugfix`, this package will automate releasing new "GIT tags" based on the commit messages.
|
|
231
233
|
Here is how to integrate it into your project CI:
|
|
232
234
|
|
|
@@ -304,6 +306,19 @@ Alternatively, you can just ask for `--help`:
|
|
|
304
306
|
$ tinysemver --help
|
|
305
307
|
```
|
|
306
308
|
|
|
309
|
+
## AI and Rock-n-Roll
|
|
310
|
+
|
|
311
|
+
TinySemVer can leverage a language model to validate the commits and generate clean and infromative release notes.
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
$ tinysemver --verbose \
|
|
315
|
+
--github-repository 'ashvardanian/tinysemver' \
|
|
316
|
+
--openai-base-url 'https://api.groq.com/openai/v1' \
|
|
317
|
+
--openai-api-key 'GET_YOURSELF_A_KEY' \
|
|
318
|
+
--openai-model 'llama-3.2-11b-text-preview' \
|
|
319
|
+
--dry-run
|
|
320
|
+
```
|
|
321
|
+
|
|
307
322
|
## Usage Details for the GitHub CI Action
|
|
308
323
|
|
|
309
324
|
TinySemVer can be easily integrated into your GitHub Actions CI pipeline.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|