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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tinysemver
3
- Version: 2.0.7
4
- Summary: Tiny Semantic Versioning (SemVer) library, that doesn't depend on 300K lines of JavaScript
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 :: 4 - Beta
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
- ![](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
229
+ ![TinySemVer Banner](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
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
- ![](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
1
+ ![TinySemVer Banner](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
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.7"
8
- description = "Tiny Semantic Versioning (SemVer) library, that doesn't depend on 300K lines of JavaScript"
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 :: 4 - Beta",
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 Python script that helps you manage your project's versioning.
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) -> Tuple[List[str], List[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
- commits = [line.partition(":")[2] for line in lines if line.strip()]
92
- return hashes, commits
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[str],
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[str], List[str], List[str]]:
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
- print(f"Created new tag: {tag}")
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
- print(f"Pushed to: {url}")
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
- print(f"Created GitHub release for tag: {tag}")
353
+ print_to_console(f"[bold green]Created GitHub release for tag:[/bold green] {tag}")
253
354
  else:
254
- print(f"Failed to create GitHub release: {release_result.stderr}")
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
- print("GitHub CLI not available. Skipping release creation.")
359
+ print_to_console("[bold yellow]GitHub CLI not available. Skipping release creation.[/bold yellow]")
257
360
  except Exception as e:
258
- print(f"An error occurred while creating the release: {str(e)}")
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
- # Without using the re.MULTILINE flag,
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
- print(f"Will update file: {file_path}:{match_line}")
301
- print(f"- {old_slice}")
302
- print(f"+ {new_slice}")
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
- commits_hashes, commits_messages = get_commits_since_tag(repository_path, last_tag)
412
- if not len(commits_hashes):
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(commits_hashes)}")
417
- for hash, commit in zip(commits_hashes, commits_messages):
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(commits_messages, major_verbs, minor_verbs, patch_verbs)
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(commits_messages)
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
- if len(major_commits):
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.7
4
- Summary: Tiny Semantic Versioning (SemVer) library, that doesn't depend on 300K lines of JavaScript
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 :: 4 - Beta
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
- ![](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
229
+ ![TinySemVer Banner](https://github.com/ashvardanian/ashvardanian/blob/master/repositories/TinySemVer.jpg)
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.
@@ -9,4 +9,5 @@ tinysemver.egg-info/PKG-INFO
9
9
  tinysemver.egg-info/SOURCES.txt
10
10
  tinysemver.egg-info/dependency_links.txt
11
11
  tinysemver.egg-info/entry_points.txt
12
+ tinysemver.egg-info/requires.txt
12
13
  tinysemver.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ openai>=1.54.4
2
+ rich>=13.8.0
File without changes
File without changes