releaseguru 0.1.0__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.
- releaseguru/__init__.py +3 -0
- releaseguru/__main__.py +5 -0
- releaseguru/cli.py +368 -0
- releaseguru/providers.py +105 -0
- releaseguru-0.1.0.dist-info/METADATA +303 -0
- releaseguru-0.1.0.dist-info/RECORD +9 -0
- releaseguru-0.1.0.dist-info/WHEEL +4 -0
- releaseguru-0.1.0.dist-info/entry_points.txt +2 -0
- releaseguru-0.1.0.dist-info/licenses/LICENSE +21 -0
releaseguru/__init__.py
ADDED
releaseguru/__main__.py
ADDED
releaseguru/cli.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import requests
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from .providers import PROVIDERS, auto_detect_provider
|
|
13
|
+
|
|
14
|
+
load_dotenv()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ReleaseResult:
|
|
19
|
+
version: str
|
|
20
|
+
bump_type: str
|
|
21
|
+
release_brief: str
|
|
22
|
+
release_notes: str
|
|
23
|
+
commit_count: int
|
|
24
|
+
from_ref: str | None
|
|
25
|
+
to_ref: str
|
|
26
|
+
provider: str
|
|
27
|
+
published_url: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
["git", *args],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
check=check,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_commits(from_ref: str | None, to_ref: str) -> list[str]:
|
|
40
|
+
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
41
|
+
result = run_git(["log", range_spec, "--pretty=format:%s (%an)"])
|
|
42
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_latest_tag() -> str | None:
|
|
46
|
+
result = run_git(["describe", "--tags", "--abbrev=0"], check=False)
|
|
47
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_github_repo_slug() -> str | None:
|
|
51
|
+
result = run_git(["remote", "get-url", "origin"], check=False)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
match = re.search(r"github\.com[:/]([^/]+)/([^.]+)(?:\.git)?", result.stdout.strip())
|
|
56
|
+
if not match:
|
|
57
|
+
return None
|
|
58
|
+
return f"{match.group(1)}/{match.group(2)}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def tag_exists(tag_name: str) -> bool:
|
|
62
|
+
return run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tag_name}"], check=False).returncode == 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_and_push_tag(tag_name: str, dry_run: bool) -> None:
|
|
66
|
+
if dry_run:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if not tag_exists(tag_name):
|
|
70
|
+
run_git(["tag", "-a", tag_name, "-m", f"Release {tag_name}"])
|
|
71
|
+
|
|
72
|
+
run_git(["push", "origin", tag_name])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def bump_version(version: str, bump_type: str) -> str:
|
|
76
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", version)
|
|
77
|
+
if not match:
|
|
78
|
+
return f"{version}-{bump_type}-bump"
|
|
79
|
+
|
|
80
|
+
major, minor, patch = map(int, match.groups())
|
|
81
|
+
if bump_type == "major":
|
|
82
|
+
major += 1
|
|
83
|
+
minor = 0
|
|
84
|
+
patch = 0
|
|
85
|
+
elif bump_type == "minor":
|
|
86
|
+
minor += 1
|
|
87
|
+
patch = 0
|
|
88
|
+
else:
|
|
89
|
+
patch += 1
|
|
90
|
+
|
|
91
|
+
prefix = "v" if version.lower().startswith("v") else ""
|
|
92
|
+
return f"{prefix}{major}.{minor}.{patch}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_prompt(commits: list[str], current_version: str) -> str:
|
|
96
|
+
lines = "\n".join(f"- {commit}" for commit in commits)
|
|
97
|
+
return f"""You are a release assistant for software teams.
|
|
98
|
+
|
|
99
|
+
Analyze the git commits and prepare a release brief for humans. Help the team decide what this release is, what matters most, what needs attention, and what can be published.
|
|
100
|
+
|
|
101
|
+
You MUST start the response with this exact line:
|
|
102
|
+
__RECOMMENDED_BUMP__: <major|minor|patch>
|
|
103
|
+
|
|
104
|
+
After that, output valid Markdown with these sections in this order:
|
|
105
|
+
# Release Assistant Brief
|
|
106
|
+
## Release Summary
|
|
107
|
+
## Highlights
|
|
108
|
+
## Risks & Checks
|
|
109
|
+
## Suggested Release Notes
|
|
110
|
+
|
|
111
|
+
Rules:
|
|
112
|
+
- The release summary must be 2 to 4 sentences.
|
|
113
|
+
- Highlights must contain 3 to 6 bullets when possible.
|
|
114
|
+
- Risks & Checks must focus on validation, regressions, migrations, or rollout concerns.
|
|
115
|
+
- Suggested Release Notes must be user-facing and grouped under:
|
|
116
|
+
- ### Features
|
|
117
|
+
- ### Bug Fixes
|
|
118
|
+
- ### Breaking Changes
|
|
119
|
+
- ### Improvements
|
|
120
|
+
- Skip empty sections inside Suggested Release Notes.
|
|
121
|
+
- Use present tense.
|
|
122
|
+
- Avoid internal jargon unless needed for risk explanation.
|
|
123
|
+
- Mention breaking changes only when justified by commits.
|
|
124
|
+
- Choose major for breaking changes, minor for new visible functionality, patch for fixes/internal improvements only.
|
|
125
|
+
|
|
126
|
+
Current version context: {current_version}
|
|
127
|
+
|
|
128
|
+
Commits:
|
|
129
|
+
{lines}
|
|
130
|
+
|
|
131
|
+
Output only the marker line and Markdown."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_ai_response(ai_raw: str) -> tuple[str, str]:
|
|
135
|
+
bump_type = "patch"
|
|
136
|
+
body_lines: list[str] = []
|
|
137
|
+
|
|
138
|
+
for line in ai_raw.splitlines():
|
|
139
|
+
if line.startswith("__RECOMMENDED_BUMP__"):
|
|
140
|
+
match = re.search(r"__RECOMMENDED_BUMP__:\s*(major|minor|patch)", line, re.IGNORECASE)
|
|
141
|
+
if match:
|
|
142
|
+
bump_type = match.group(1).lower()
|
|
143
|
+
continue
|
|
144
|
+
body_lines.append(line)
|
|
145
|
+
|
|
146
|
+
return bump_type, "\n".join(body_lines).strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def extract_release_notes(release_brief: str) -> str:
|
|
150
|
+
marker = "## Suggested Release Notes"
|
|
151
|
+
if marker not in release_brief:
|
|
152
|
+
return release_brief.strip()
|
|
153
|
+
return release_brief.split(marker, 1)[1].strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_output_document(result: ReleaseResult) -> str:
|
|
157
|
+
header = [
|
|
158
|
+
f"# Release Plan {result.version}",
|
|
159
|
+
"",
|
|
160
|
+
f"- Recommended bump: `{result.bump_type}`",
|
|
161
|
+
f"- Commits analyzed: `{result.commit_count}`",
|
|
162
|
+
"",
|
|
163
|
+
]
|
|
164
|
+
return "\n".join(header) + result.release_brief.strip() + "\n"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def publish_github_release(
|
|
168
|
+
repo_slug: str,
|
|
169
|
+
tag_name: str,
|
|
170
|
+
name: str,
|
|
171
|
+
body: str,
|
|
172
|
+
token: str,
|
|
173
|
+
draft: bool,
|
|
174
|
+
prerelease: bool,
|
|
175
|
+
dry_run: bool,
|
|
176
|
+
) -> str:
|
|
177
|
+
if dry_run:
|
|
178
|
+
return f"https://github.com/{repo_slug}/releases/tag/{tag_name}"
|
|
179
|
+
|
|
180
|
+
url = f"https://api.github.com/repos/{repo_slug}/releases"
|
|
181
|
+
headers = {
|
|
182
|
+
"Authorization": f"Bearer {token}",
|
|
183
|
+
"Accept": "application/vnd.github+json",
|
|
184
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
185
|
+
}
|
|
186
|
+
payload = {
|
|
187
|
+
"tag_name": tag_name,
|
|
188
|
+
"name": name,
|
|
189
|
+
"body": body,
|
|
190
|
+
"draft": draft,
|
|
191
|
+
"prerelease": prerelease,
|
|
192
|
+
}
|
|
193
|
+
response = requests.post(url, json=payload, headers=headers, timeout=30)
|
|
194
|
+
if response.status_code not in (200, 201):
|
|
195
|
+
raise click.ClickException(f"GitHub API error: {response.status_code} - {response.text}")
|
|
196
|
+
return response.json().get("html_url", "")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def write_github_output(result: ReleaseResult) -> None:
|
|
200
|
+
output_path = os.environ.get("GITHUB_OUTPUT")
|
|
201
|
+
if not output_path:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
lines = [
|
|
205
|
+
f"version={result.version}",
|
|
206
|
+
f"bump_type={result.bump_type}",
|
|
207
|
+
f"commit_count={result.commit_count}",
|
|
208
|
+
]
|
|
209
|
+
if result.published_url:
|
|
210
|
+
lines.append(f"release_url={result.published_url}")
|
|
211
|
+
|
|
212
|
+
with open(output_path, "a", encoding="utf-8") as handle:
|
|
213
|
+
handle.write("\n".join(lines) + "\n")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_result(result: ReleaseResult, output_format: str, notes_only: bool) -> str:
|
|
217
|
+
if output_format == "json":
|
|
218
|
+
return json.dumps(asdict(result), indent=2)
|
|
219
|
+
if notes_only:
|
|
220
|
+
return result.release_notes + "\n"
|
|
221
|
+
return build_output_document(result)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def call_provider(provider: str, prompt: str, api_key: str | None, model: str | None, mock_response: str | None) -> str:
|
|
225
|
+
if mock_response:
|
|
226
|
+
return Path(mock_response).read_text(encoding="utf-8")
|
|
227
|
+
return PROVIDERS[provider](prompt, api_key=api_key, model=model)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@click.command()
|
|
231
|
+
@click.option("--from", "from_ref", default=None, help="Start tag/ref. Default: latest git tag")
|
|
232
|
+
@click.option("--to", "to_ref", default="HEAD", help="End ref. Default: HEAD")
|
|
233
|
+
@click.option("--version", default=None, help="Explicit release version")
|
|
234
|
+
@click.option(
|
|
235
|
+
"--provider",
|
|
236
|
+
default=None,
|
|
237
|
+
type=click.Choice(["groq", "openai", "anthropic", "gemini", "xai"]),
|
|
238
|
+
help="LLM provider. Default: auto-detect from env keys",
|
|
239
|
+
)
|
|
240
|
+
@click.option("--api-key", default=None, help="Custom API key for the selected provider")
|
|
241
|
+
@click.option("--model", default=None, help="Custom model name for the selected provider")
|
|
242
|
+
@click.option("--publish", is_flag=True, help="Create GitHub release")
|
|
243
|
+
@click.option("--dry-run", is_flag=True, help="Generate output without creating tags or GitHub releases")
|
|
244
|
+
@click.option("--draft", is_flag=True, help="Publish GitHub release as draft")
|
|
245
|
+
@click.option("--prerelease", is_flag=True, help="Publish GitHub release as prerelease")
|
|
246
|
+
@click.option("--skip-tag", is_flag=True, help="Do not create or push a git tag before publishing")
|
|
247
|
+
@click.option("--github-repo", default=None, help="GitHub repo slug, e.g. owner/repo")
|
|
248
|
+
@click.option("--github-token", default=None, help="GitHub token. Default: GITHUB_TOKEN env var")
|
|
249
|
+
@click.option("--output", type=click.Path(), default=None, help="Write output to file")
|
|
250
|
+
@click.option("--format", "output_format", type=click.Choice(["markdown", "json"]), default="markdown")
|
|
251
|
+
@click.option("--notes-only", is_flag=True, help="Print only Suggested Release Notes")
|
|
252
|
+
@click.option("--ci-output", is_flag=True, help="Write version, bump_type, commit_count, release_url to GITHUB_OUTPUT")
|
|
253
|
+
@click.option("--mock-response", default=None, hidden=True, help="Read AI response from file for tests")
|
|
254
|
+
def main(
|
|
255
|
+
from_ref,
|
|
256
|
+
to_ref,
|
|
257
|
+
version,
|
|
258
|
+
provider,
|
|
259
|
+
api_key,
|
|
260
|
+
model,
|
|
261
|
+
publish,
|
|
262
|
+
dry_run,
|
|
263
|
+
draft,
|
|
264
|
+
prerelease,
|
|
265
|
+
skip_tag,
|
|
266
|
+
github_repo,
|
|
267
|
+
github_token,
|
|
268
|
+
output,
|
|
269
|
+
output_format,
|
|
270
|
+
notes_only,
|
|
271
|
+
ci_output,
|
|
272
|
+
mock_response,
|
|
273
|
+
):
|
|
274
|
+
"""Prepare, inspect, and optionally publish an AI-assisted release."""
|
|
275
|
+
latest_tag = get_latest_tag()
|
|
276
|
+
if not from_ref:
|
|
277
|
+
from_ref = latest_tag
|
|
278
|
+
if from_ref:
|
|
279
|
+
click.echo(f"Using latest tag: {from_ref}", err=True)
|
|
280
|
+
|
|
281
|
+
if not provider:
|
|
282
|
+
if mock_response:
|
|
283
|
+
provider = "mock"
|
|
284
|
+
elif api_key:
|
|
285
|
+
raise click.ClickException("Use --provider when passing --api-key.")
|
|
286
|
+
else:
|
|
287
|
+
try:
|
|
288
|
+
provider = auto_detect_provider()
|
|
289
|
+
click.echo(f"Using provider: {provider}", err=True)
|
|
290
|
+
except EnvironmentError as exc:
|
|
291
|
+
raise click.ClickException(str(exc))
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
commits = get_commits(from_ref, to_ref)
|
|
295
|
+
except subprocess.CalledProcessError as exc:
|
|
296
|
+
detail = exc.stderr.strip() if exc.stderr else "Could not execute git log."
|
|
297
|
+
raise click.ClickException(f"Git error: {detail}")
|
|
298
|
+
|
|
299
|
+
if not commits:
|
|
300
|
+
raise click.ClickException("No commits found in that range.")
|
|
301
|
+
|
|
302
|
+
click.echo(f"Found {len(commits)} commits. Building release brief...", err=True)
|
|
303
|
+
|
|
304
|
+
current_version = version or latest_tag or "v0.0.0"
|
|
305
|
+
prompt = build_prompt(commits, current_version)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
ai_raw = call_provider(provider, prompt, api_key, model, mock_response)
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
raise click.ClickException(f"Error calling {provider} provider: {exc}")
|
|
311
|
+
|
|
312
|
+
bump_type, release_brief = parse_ai_response(ai_raw)
|
|
313
|
+
if not version:
|
|
314
|
+
version = bump_version(latest_tag, bump_type) if latest_tag else "v1.0.0"
|
|
315
|
+
click.echo(f"Resolved release version: {version}", err=True)
|
|
316
|
+
|
|
317
|
+
result = ReleaseResult(
|
|
318
|
+
version=version,
|
|
319
|
+
bump_type=bump_type,
|
|
320
|
+
release_brief=release_brief,
|
|
321
|
+
release_notes=extract_release_notes(release_brief),
|
|
322
|
+
commit_count=len(commits),
|
|
323
|
+
from_ref=from_ref,
|
|
324
|
+
to_ref=to_ref,
|
|
325
|
+
provider=provider,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if publish:
|
|
329
|
+
token = github_token or os.environ.get("GITHUB_TOKEN")
|
|
330
|
+
if not token and not dry_run:
|
|
331
|
+
raise click.ClickException("GitHub token not found. Set GITHUB_TOKEN or pass --github-token.")
|
|
332
|
+
|
|
333
|
+
repo_slug = github_repo or get_github_repo_slug()
|
|
334
|
+
if not repo_slug:
|
|
335
|
+
raise click.ClickException("Could not detect GitHub repo. Pass --github-repo owner/repo.")
|
|
336
|
+
|
|
337
|
+
if not skip_tag:
|
|
338
|
+
try:
|
|
339
|
+
create_and_push_tag(version, dry_run)
|
|
340
|
+
except subprocess.CalledProcessError as exc:
|
|
341
|
+
detail = exc.stderr.strip() if exc.stderr else "Tag create/push failed."
|
|
342
|
+
raise click.ClickException(f"Git tag error: {detail}")
|
|
343
|
+
|
|
344
|
+
result.published_url = publish_github_release(
|
|
345
|
+
repo_slug=repo_slug,
|
|
346
|
+
tag_name=version,
|
|
347
|
+
name=f"Release {version}",
|
|
348
|
+
body=result.release_notes if notes_only else build_output_document(result),
|
|
349
|
+
token=token or "",
|
|
350
|
+
draft=draft,
|
|
351
|
+
prerelease=prerelease,
|
|
352
|
+
dry_run=dry_run,
|
|
353
|
+
)
|
|
354
|
+
click.echo(f"Release URL: {result.published_url}", err=True)
|
|
355
|
+
|
|
356
|
+
rendered = render_result(result, output_format, notes_only)
|
|
357
|
+
if output:
|
|
358
|
+
Path(output).write_text(rendered, encoding="utf-8")
|
|
359
|
+
click.echo(f"Saved output to {output}", err=True)
|
|
360
|
+
else:
|
|
361
|
+
click.echo(rendered)
|
|
362
|
+
|
|
363
|
+
if ci_output:
|
|
364
|
+
write_github_output(result)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
main()
|
releaseguru/providers.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def call_groq(prompt: str, api_key: str | None = None, model: str | None = None) -> str:
|
|
5
|
+
from groq import Groq
|
|
6
|
+
|
|
7
|
+
key = api_key or os.environ.get("GROQ_API_KEY")
|
|
8
|
+
if not key:
|
|
9
|
+
raise ValueError("GROQ_API_KEY not found in environment or passed as argument.")
|
|
10
|
+
|
|
11
|
+
client = Groq(api_key=key)
|
|
12
|
+
response = client.chat.completions.create(
|
|
13
|
+
model=model or "llama-3.3-70b-versatile",
|
|
14
|
+
messages=[{"role": "user", "content": prompt}],
|
|
15
|
+
max_tokens=1024,
|
|
16
|
+
)
|
|
17
|
+
return response.choices[0].message.content
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def call_openai(prompt: str, api_key: str | None = None, model: str | None = None) -> str:
|
|
21
|
+
from openai import OpenAI
|
|
22
|
+
|
|
23
|
+
key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
24
|
+
if not key:
|
|
25
|
+
raise ValueError("OPENAI_API_KEY not found in environment or passed as argument.")
|
|
26
|
+
|
|
27
|
+
client = OpenAI(api_key=key)
|
|
28
|
+
response = client.chat.completions.create(
|
|
29
|
+
model=model or "gpt-4o-mini",
|
|
30
|
+
messages=[{"role": "user", "content": prompt}],
|
|
31
|
+
max_tokens=1024,
|
|
32
|
+
)
|
|
33
|
+
return response.choices[0].message.content
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def call_anthropic(prompt: str, api_key: str | None = None, model: str | None = None) -> str:
|
|
37
|
+
import anthropic
|
|
38
|
+
|
|
39
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
40
|
+
if not key:
|
|
41
|
+
raise ValueError("ANTHROPIC_API_KEY not found in environment or passed as argument.")
|
|
42
|
+
|
|
43
|
+
client = anthropic.Anthropic(api_key=key)
|
|
44
|
+
response = client.messages.create(
|
|
45
|
+
model=model or "claude-3-5-haiku-20241022",
|
|
46
|
+
max_tokens=1024,
|
|
47
|
+
messages=[{"role": "user", "content": prompt}],
|
|
48
|
+
)
|
|
49
|
+
return response.content[0].text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def call_gemini(prompt: str, api_key: str | None = None, model: str | None = None) -> str:
|
|
53
|
+
import google.generativeai as genai
|
|
54
|
+
|
|
55
|
+
key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
56
|
+
if not key:
|
|
57
|
+
raise ValueError("GEMINI_API_KEY not found in environment or passed as argument.")
|
|
58
|
+
|
|
59
|
+
genai.configure(api_key=key)
|
|
60
|
+
model_instance = genai.GenerativeModel(model or "gemini-1.5-flash")
|
|
61
|
+
response = model_instance.generate_content(prompt)
|
|
62
|
+
return response.text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def call_xai(prompt: str, api_key: str | None = None, model: str | None = None) -> str:
|
|
66
|
+
from openai import OpenAI
|
|
67
|
+
|
|
68
|
+
key = api_key or os.environ.get("XAI_API_KEY")
|
|
69
|
+
if not key:
|
|
70
|
+
raise ValueError("XAI_API_KEY not found in environment or passed as argument.")
|
|
71
|
+
|
|
72
|
+
client = OpenAI(api_key=key, base_url="https://api.x.ai/v1")
|
|
73
|
+
response = client.chat.completions.create(
|
|
74
|
+
model=model or "grok-3-mini",
|
|
75
|
+
messages=[{"role": "user", "content": prompt}],
|
|
76
|
+
max_tokens=1024,
|
|
77
|
+
)
|
|
78
|
+
return response.choices[0].message.content
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
PROVIDERS = {
|
|
82
|
+
"groq": call_groq,
|
|
83
|
+
"openai": call_openai,
|
|
84
|
+
"anthropic": call_anthropic,
|
|
85
|
+
"gemini": call_gemini,
|
|
86
|
+
"xai": call_xai,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def auto_detect_provider() -> str:
|
|
91
|
+
for name, key in [
|
|
92
|
+
("groq", "GROQ_API_KEY"),
|
|
93
|
+
("gemini", "GEMINI_API_KEY"),
|
|
94
|
+
("openai", "OPENAI_API_KEY"),
|
|
95
|
+
("anthropic", "ANTHROPIC_API_KEY"),
|
|
96
|
+
("xai", "XAI_API_KEY"),
|
|
97
|
+
]:
|
|
98
|
+
if os.environ.get(key):
|
|
99
|
+
return name
|
|
100
|
+
|
|
101
|
+
raise EnvironmentError(
|
|
102
|
+
"No API key found. Set one of: GROQ_API_KEY, GEMINI_API_KEY, "
|
|
103
|
+
"OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY in your .env, "
|
|
104
|
+
"or pass a custom key via --api-key."
|
|
105
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: releaseguru
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI release assistant CLI for git repositories.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ArhamAzeem/ReleaseGuru
|
|
6
|
+
Project-URL: Repository, https://github.com/ArhamAzeem/ReleaseGuru
|
|
7
|
+
Project-URL: Issues, https://github.com/ArhamAzeem/ReleaseGuru/issues
|
|
8
|
+
Author: ReleaseGuru Contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,changelog,cli,github,release,semver
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
22
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: anthropic>=0.30
|
|
25
|
+
Requires-Dist: click>=8.0
|
|
26
|
+
Requires-Dist: google-generativeai>=0.7
|
|
27
|
+
Requires-Dist: groq>=0.9
|
|
28
|
+
Requires-Dist: openai>=1.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0
|
|
30
|
+
Requires-Dist: requests>=2.25
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ReleaseGuru
|
|
34
|
+
|
|
35
|
+
ReleaseGuru is an AI release assistant CLI for git repositories.
|
|
36
|
+
|
|
37
|
+
Main feature: turn commit history into a release plan that tells the team what changed, what matters, what to test, and what can be published.
|
|
38
|
+
|
|
39
|
+
Secondary helpers: version bumping, release notes, GitHub Releases, JSON output, and GitHub Actions outputs.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- AI release brief from git commits
|
|
44
|
+
- Release summary, highlights, risks, checks, and user-facing notes
|
|
45
|
+
- SemVer recommendation: `major`, `minor`, or `patch`
|
|
46
|
+
- Auto-version from latest git tag
|
|
47
|
+
- Manual version override
|
|
48
|
+
- Markdown or JSON output
|
|
49
|
+
- Notes-only mode for GitHub release bodies
|
|
50
|
+
- Dry-run mode for CI and review
|
|
51
|
+
- GitHub Release publish
|
|
52
|
+
- Draft and prerelease publish modes
|
|
53
|
+
- GitHub repo override with `--github-repo`
|
|
54
|
+
- Optional tag creation and push
|
|
55
|
+
- GitHub Actions output support with `--ci-output`
|
|
56
|
+
- Multi-provider support: Groq, Gemini, OpenAI, Anthropic, xAI
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
After PyPI publish:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pipx install releaseguru
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install releaseguru
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
During local development:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cd ReleaseGuru
|
|
76
|
+
python -m venv venv
|
|
77
|
+
pip install -e .
|
|
78
|
+
cp .env.example .env
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Add one AI provider key to `.env`.
|
|
82
|
+
|
|
83
|
+
```env
|
|
84
|
+
GROQ_API_KEY=
|
|
85
|
+
OPENAI_API_KEY=
|
|
86
|
+
ANTHROPIC_API_KEY=
|
|
87
|
+
GEMINI_API_KEY=
|
|
88
|
+
XAI_API_KEY=
|
|
89
|
+
GITHUB_TOKEN=
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`GITHUB_TOKEN` is only needed when publishing.
|
|
93
|
+
|
|
94
|
+
## Basic Use
|
|
95
|
+
|
|
96
|
+
Generate release plan:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
releaseguru
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use custom range:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
releaseguru --from v1.2.0 --to HEAD
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Save to file:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
releaseguru --output RELEASE.md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Generate JSON for automation:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
releaseguru --format json --output release.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Only print release notes:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
releaseguru --notes-only
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Dry run publish flow:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
releaseguru --publish --dry-run --github-repo owner/repo
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Publish full release:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
releaseguru --publish
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Publish only notes body:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
releaseguru --publish --notes-only
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Publish draft:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
releaseguru --publish --draft
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Publish prerelease:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
releaseguru --publish --prerelease
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Publish without creating/pushing a local tag:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
releaseguru --publish --skip-tag
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## GitHub Actions
|
|
163
|
+
|
|
164
|
+
Use this when you want manual release from Actions.
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
name: ReleaseGuru
|
|
168
|
+
|
|
169
|
+
on:
|
|
170
|
+
workflow_dispatch:
|
|
171
|
+
|
|
172
|
+
permissions:
|
|
173
|
+
contents: write
|
|
174
|
+
|
|
175
|
+
jobs:
|
|
176
|
+
release:
|
|
177
|
+
runs-on: ubuntu-latest
|
|
178
|
+
|
|
179
|
+
steps:
|
|
180
|
+
- uses: actions/checkout@v4
|
|
181
|
+
with:
|
|
182
|
+
fetch-depth: 0
|
|
183
|
+
|
|
184
|
+
- uses: actions/setup-python@v5
|
|
185
|
+
with:
|
|
186
|
+
python-version: "3.11"
|
|
187
|
+
|
|
188
|
+
- name: Install ReleaseGuru
|
|
189
|
+
run: pip install releaseguru
|
|
190
|
+
- name: Create release
|
|
191
|
+
id: releaseguru
|
|
192
|
+
env:
|
|
193
|
+
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
|
194
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
195
|
+
run: releaseguru --publish --notes-only --ci-output
|
|
196
|
+
|
|
197
|
+
- name: Show result
|
|
198
|
+
run: |
|
|
199
|
+
echo "Version: ${{ steps.releaseguru.outputs.version }}"
|
|
200
|
+
echo "Bump: ${{ steps.releaseguru.outputs.bump_type }}"
|
|
201
|
+
echo "URL: ${{ steps.releaseguru.outputs.release_url }}"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
For review before publishing:
|
|
205
|
+
|
|
206
|
+
```yaml
|
|
207
|
+
- name: Preview release
|
|
208
|
+
env:
|
|
209
|
+
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
|
210
|
+
run: releaseguru --dry-run --format json --output release.json
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Testing
|
|
214
|
+
|
|
215
|
+
Syntax check:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
python -m py_compile releaseguru/cli.py releaseguru/providers.py
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Unit tests:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
python -m unittest discover -s tests
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
CLI dry run with mocked AI response:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
releaseguru --mock-response tests/fixtures/ai_response.txt --dry-run
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Real AI test:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
releaseguru --provider groq --dry-run
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Real GitHub publish test:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
releaseguru --provider groq --publish --draft
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Use `--draft` first so you can inspect the release before making it public.
|
|
246
|
+
|
|
247
|
+
## Release Ways
|
|
248
|
+
|
|
249
|
+
- Local preview: `releaseguru --dry-run`
|
|
250
|
+
- Local file: `releaseguru --output RELEASE.md`
|
|
251
|
+
- CI preview: `releaseguru --format json --ci-output`
|
|
252
|
+
- GitHub draft: `releaseguru --publish --draft`
|
|
253
|
+
- GitHub prerelease: `releaseguru --publish --prerelease`
|
|
254
|
+
- GitHub stable release: `releaseguru --publish`
|
|
255
|
+
- Notes-only release body: `releaseguru --publish --notes-only`
|
|
256
|
+
- Existing tag release: `releaseguru --publish --skip-tag --version v1.2.3`
|
|
257
|
+
- Manual version release: `releaseguru --version v2.0.0 --publish`
|
|
258
|
+
|
|
259
|
+
## Publish To PyPI
|
|
260
|
+
|
|
261
|
+
Build package:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
python -m pip install --upgrade build twine
|
|
265
|
+
python -m build
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Check package:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
python -m twine check dist/*
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Upload to TestPyPI first:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
python -m twine upload --repository testpypi dist/*
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Install from TestPyPI:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pipx install --index-url https://test.pypi.org/simple/ --pip-args="--extra-index-url https://pypi.org/simple/" releaseguru
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Upload to real PyPI:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
python -m twine upload dist/*
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
GitHub Actions publishing is also included in `.github/workflows/publish-pypi.yml`.
|
|
293
|
+
For tokenless publishing, configure PyPI Trusted Publishing for this repository and publish a GitHub Release.
|
|
294
|
+
|
|
295
|
+
## Provider Defaults
|
|
296
|
+
|
|
297
|
+
| Provider | Option | Default model |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| Groq | `--provider groq` | `llama-3.3-70b-versatile` |
|
|
300
|
+
| Gemini | `--provider gemini` | `gemini-1.5-flash` |
|
|
301
|
+
| OpenAI | `--provider openai` | `gpt-4o-mini` |
|
|
302
|
+
| Anthropic | `--provider anthropic` | `claude-3-5-haiku-20241022` |
|
|
303
|
+
| xAI | `--provider xai` | `grok-3-mini` |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
releaseguru/__init__.py,sha256=OWzbI7MeIDzzKb1GWwLDc-pyzF0EcwBG-OGMDxR6pNc,50
|
|
2
|
+
releaseguru/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
3
|
+
releaseguru/cli.py,sha256=medj_hSGY9PVG7lI4xVn2ZxyqtK7O5P5Go8qO-kNOx0,12274
|
|
4
|
+
releaseguru/providers.py,sha256=BvN8zw_UDz1RRE59lCpHj03wG4rwpHFqSc6yOej0tAA,3359
|
|
5
|
+
releaseguru-0.1.0.dist-info/METADATA,sha256=erzTcWo20wfKViScBNWbOyVW3qLfjJSXLGvfnM3hGkM,6574
|
|
6
|
+
releaseguru-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
releaseguru-0.1.0.dist-info/entry_points.txt,sha256=bLEuC472qvtq-D8Mx24IMgBZiWEuOh9sGJ6e_i0uHZ4,53
|
|
8
|
+
releaseguru-0.1.0.dist-info/licenses/LICENSE,sha256=E2qtczSgPoHzOU_YqSayrfszXHR0v5B8HrNYJ_-pBBc,1081
|
|
9
|
+
releaseguru-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ReleaseGuru Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|