rylees 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.
- app/.env.example +12 -0
- app/__init__.py +0 -0
- app/api_client.py +40 -0
- app/cli.py +509 -0
- app/code_analyzer.py +46 -0
- app/config.py +46 -0
- app/git_connector.py +40 -0
- app/models.py +30 -0
- app/release_notes_generator.py +67 -0
- app/rn_publisher.py +27 -0
- app/validator.py +12 -0
- rylees-0.1.0.dist-info/METADATA +172 -0
- rylees-0.1.0.dist-info/RECORD +15 -0
- rylees-0.1.0.dist-info/WHEEL +4 -0
- rylees-0.1.0.dist-info/entry_points.txt +2 -0
app/.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Required
|
|
2
|
+
#RYLEES_API_URL=http://api.rylees.test/v1
|
|
3
|
+
RYLEES_API_URL=https://api.rylees.ai/v1
|
|
4
|
+
RYLEES_API_TOKEN=
|
|
5
|
+
RYLEES_PROJECT_TOKEN=
|
|
6
|
+
OPENAI_API_KEY=
|
|
7
|
+
|
|
8
|
+
# Optional — overrides the temperature fetched from the API
|
|
9
|
+
# RYLEES_LLM_TEMPERATURE=0.5
|
|
10
|
+
|
|
11
|
+
# Optional — overrides the default model
|
|
12
|
+
RYLEES_LLM_MODEL=GPT-5.4
|
app/__init__.py
ADDED
|
File without changes
|
app/api_client.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from app.models import ProjectConfig, PublishPayload, PublishResponse
|
|
3
|
+
|
|
4
|
+
BASE_URL = "https://api.rylees.ai/v1"
|
|
5
|
+
|
|
6
|
+
class ApiClient:
|
|
7
|
+
def __init__(self, api_token: str, base_url: str = BASE_URL):
|
|
8
|
+
self._client = httpx.Client(
|
|
9
|
+
base_url=base_url,
|
|
10
|
+
headers={"Authorization": f"Bearer {api_token}"},
|
|
11
|
+
timeout=30.0,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def get_project(self, project_token: str) -> ProjectConfig:
|
|
15
|
+
response = self._client.get(f"/projects/{project_token}")
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
data = response.json()
|
|
18
|
+
return ProjectConfig(
|
|
19
|
+
id=data["id"],
|
|
20
|
+
name=data["name"],
|
|
21
|
+
key=data["key"],
|
|
22
|
+
description=data.get("description", ""),
|
|
23
|
+
customer_name=data["customer"]["name"],
|
|
24
|
+
customer_industry=data["customer"]["industry"],
|
|
25
|
+
llm_temperature=data["llm"]["temperature"],
|
|
26
|
+
llm_tonality=data["llm"]["tonality"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def publish_release_note(
|
|
30
|
+
self, project_token: str, payload: PublishPayload
|
|
31
|
+
) -> PublishResponse:
|
|
32
|
+
response = self._client.post(
|
|
33
|
+
f"/projects/{project_token}/release-history",
|
|
34
|
+
json=payload,
|
|
35
|
+
)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
return response.json()
|
|
38
|
+
|
|
39
|
+
def close(self):
|
|
40
|
+
self._client.close()
|
app/cli.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import shutil
|
|
5
|
+
import textwrap
|
|
6
|
+
import itertools
|
|
7
|
+
import threading
|
|
8
|
+
import tempfile
|
|
9
|
+
import subprocess
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="rylees")
|
|
15
|
+
|
|
16
|
+
SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def _spinner(message: str):
|
|
21
|
+
"""Animate a braille spinner on stderr while a slow task runs.
|
|
22
|
+
|
|
23
|
+
Writes to stderr so the generated draft on stdout stays clean and
|
|
24
|
+
pipeable. Renders nothing when stderr isn't a TTY (e.g. CI logs).
|
|
25
|
+
"""
|
|
26
|
+
if not sys.stderr.isatty():
|
|
27
|
+
yield
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
done = threading.Event()
|
|
31
|
+
|
|
32
|
+
def spin():
|
|
33
|
+
for frame in itertools.cycle(SPINNER_FRAMES):
|
|
34
|
+
if done.is_set():
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
sys.stderr.write(f"\r{frame} {message}")
|
|
38
|
+
sys.stderr.flush()
|
|
39
|
+
time.sleep(0.08)
|
|
40
|
+
|
|
41
|
+
thread = threading.Thread(target=spin, daemon=True)
|
|
42
|
+
thread.start()
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
done.set()
|
|
47
|
+
thread.join()
|
|
48
|
+
# Clear the spinner line so it leaves no residue behind.
|
|
49
|
+
sys.stderr.write("\r\033[K")
|
|
50
|
+
sys.stderr.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Checklist:
|
|
54
|
+
"""A live, multi-line task checklist rendered on stderr.
|
|
55
|
+
|
|
56
|
+
Each step shows a grey tick while pending, a round spinner while active,
|
|
57
|
+
and a green tick once done (red cross on failure). On a non-TTY (CI logs,
|
|
58
|
+
piped output) it degrades to one plain line per completed/failed step.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
PENDING, ACTIVE, DONE, FAIL = "pending", "active", "done", "fail"
|
|
62
|
+
|
|
63
|
+
GREY, GREEN, RED, CYAN, RESET = (
|
|
64
|
+
"\033[90m", "\033[32m", "\033[31m", "\033[36m", "\033[0m",
|
|
65
|
+
)
|
|
66
|
+
ROUND_FRAMES = "◐◓◑◒"
|
|
67
|
+
|
|
68
|
+
def __init__(self, labels: list[str]):
|
|
69
|
+
self._labels = list(labels)
|
|
70
|
+
self._states = [self.PENDING] * len(labels)
|
|
71
|
+
self._enabled = sys.stderr.isatty()
|
|
72
|
+
self._frame = 0
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
self._stop = threading.Event()
|
|
75
|
+
self._thread: threading.Thread | None = None
|
|
76
|
+
self._drawn = False
|
|
77
|
+
|
|
78
|
+
def _glyph(self, i: int) -> str:
|
|
79
|
+
state = self._states[i]
|
|
80
|
+
if state == self.DONE:
|
|
81
|
+
return f"{self.GREEN}✓{self.RESET}"
|
|
82
|
+
if state == self.FAIL:
|
|
83
|
+
return f"{self.RED}✗{self.RESET}"
|
|
84
|
+
if state == self.ACTIVE:
|
|
85
|
+
frame = self.ROUND_FRAMES[self._frame % len(self.ROUND_FRAMES)]
|
|
86
|
+
return f"{self.CYAN}{frame}{self.RESET}"
|
|
87
|
+
return f"{self.GREY}✓{self.RESET}" # pending — grey tick
|
|
88
|
+
|
|
89
|
+
def _render(self) -> None:
|
|
90
|
+
out = []
|
|
91
|
+
if self._drawn:
|
|
92
|
+
out.append(f"\033[{len(self._labels)}A") # cursor up to block top
|
|
93
|
+
for i, label in enumerate(self._labels):
|
|
94
|
+
out.append(f"\r\033[K {self._glyph(i)} {label}\n")
|
|
95
|
+
sys.stderr.write("".join(out))
|
|
96
|
+
sys.stderr.flush()
|
|
97
|
+
self._drawn = True
|
|
98
|
+
|
|
99
|
+
def _animate(self) -> None:
|
|
100
|
+
while not self._stop.is_set():
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._frame += 1
|
|
103
|
+
self._render()
|
|
104
|
+
time.sleep(0.12)
|
|
105
|
+
|
|
106
|
+
def _ensure_thread(self) -> None:
|
|
107
|
+
if self._thread is None:
|
|
108
|
+
self._stop.clear()
|
|
109
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
110
|
+
self._thread.start()
|
|
111
|
+
|
|
112
|
+
def _halt_thread(self) -> None:
|
|
113
|
+
self._stop.set()
|
|
114
|
+
if self._thread is not None:
|
|
115
|
+
self._thread.join()
|
|
116
|
+
self._thread = None
|
|
117
|
+
|
|
118
|
+
def begin(self) -> None:
|
|
119
|
+
"""Draw the full checklist (all steps pending) and start animating."""
|
|
120
|
+
if not self._enabled:
|
|
121
|
+
return
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._render()
|
|
124
|
+
self._ensure_thread()
|
|
125
|
+
|
|
126
|
+
def active(self, i: int) -> None:
|
|
127
|
+
if not self._enabled:
|
|
128
|
+
return
|
|
129
|
+
with self._lock:
|
|
130
|
+
self._states[i] = self.ACTIVE
|
|
131
|
+
if not self._drawn:
|
|
132
|
+
self._render()
|
|
133
|
+
self._ensure_thread()
|
|
134
|
+
|
|
135
|
+
def done(self, i: int) -> None:
|
|
136
|
+
if not self._enabled:
|
|
137
|
+
sys.stderr.write(f" ✓ {self._labels[i]}\n")
|
|
138
|
+
sys.stderr.flush()
|
|
139
|
+
return
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._states[i] = self.DONE
|
|
142
|
+
self._render()
|
|
143
|
+
|
|
144
|
+
def fail(self, i: int) -> None:
|
|
145
|
+
if not self._enabled:
|
|
146
|
+
sys.stderr.write(f" ✗ {self._labels[i]}\n")
|
|
147
|
+
sys.stderr.flush()
|
|
148
|
+
return
|
|
149
|
+
self._halt_thread()
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._states[i] = self.FAIL
|
|
152
|
+
self._render()
|
|
153
|
+
sys.stderr.write("\n")
|
|
154
|
+
sys.stderr.flush()
|
|
155
|
+
|
|
156
|
+
def pause(self) -> None:
|
|
157
|
+
"""Freeze the current block so other output can print below it."""
|
|
158
|
+
if not self._enabled:
|
|
159
|
+
return
|
|
160
|
+
self._halt_thread()
|
|
161
|
+
with self._lock:
|
|
162
|
+
self._render()
|
|
163
|
+
sys.stderr.write("\n")
|
|
164
|
+
sys.stderr.flush()
|
|
165
|
+
|
|
166
|
+
def resume(self) -> None:
|
|
167
|
+
"""Re-draw the checklist fresh at the cursor (after paused output)."""
|
|
168
|
+
if not self._enabled:
|
|
169
|
+
return
|
|
170
|
+
self._drawn = False
|
|
171
|
+
with self._lock:
|
|
172
|
+
self._render()
|
|
173
|
+
self._ensure_thread()
|
|
174
|
+
|
|
175
|
+
def stop(self) -> None:
|
|
176
|
+
self._halt_thread()
|
|
177
|
+
if self._enabled:
|
|
178
|
+
with self._lock:
|
|
179
|
+
self._render()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _scrub(message: str, *secrets: str) -> str:
|
|
183
|
+
"""Redact secrets (tokens/keys) that error messages may echo back.
|
|
184
|
+
|
|
185
|
+
httpx embeds the request URL in its error strings, and the project token
|
|
186
|
+
travels in that URL — so a failed API call would otherwise leak it to
|
|
187
|
+
stderr/CI logs.
|
|
188
|
+
"""
|
|
189
|
+
for secret in secrets:
|
|
190
|
+
if secret:
|
|
191
|
+
message = message.replace(secret, "***")
|
|
192
|
+
return message
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _render_box(text: str, actions: str | None = None) -> None:
|
|
196
|
+
"""Print text inside a heavy-bordered (bold) box.
|
|
197
|
+
|
|
198
|
+
Has spacing outside the box (blank line above/below) and inside the box
|
|
199
|
+
(padding around the text). Long lines wrap to the terminal width. When
|
|
200
|
+
``actions`` is given it is embedded into the bottom border line.
|
|
201
|
+
"""
|
|
202
|
+
PAD_X = 3
|
|
203
|
+
term_width = shutil.get_terminal_size((80, 24)).columns
|
|
204
|
+
inner_width = max(24, min(76, term_width - 2 - PAD_X * 2))
|
|
205
|
+
|
|
206
|
+
wrapped: list[str] = []
|
|
207
|
+
for paragraph in text.splitlines() or [""]:
|
|
208
|
+
if paragraph.strip() == "":
|
|
209
|
+
wrapped.append("")
|
|
210
|
+
else:
|
|
211
|
+
wrapped.extend(textwrap.wrap(paragraph, width=inner_width) or [""])
|
|
212
|
+
|
|
213
|
+
width = max((len(line) for line in wrapped), default=0)
|
|
214
|
+
span = width + PAD_X * 2
|
|
215
|
+
if actions is not None:
|
|
216
|
+
span = max(span, len(actions) + 6) # room for "━━ <actions> ━…"
|
|
217
|
+
width = span - PAD_X * 2 # keep content lines aligned
|
|
218
|
+
|
|
219
|
+
blank = "┃" + " " * span + "┃"
|
|
220
|
+
if actions is not None:
|
|
221
|
+
fill = span - len(actions) - 4 # 2 leading ━, a space each side
|
|
222
|
+
bottom = "┗━━ " + actions + " " + "━" * max(fill, 0) + "┛"
|
|
223
|
+
else:
|
|
224
|
+
bottom = "┗" + "━" * span + "┛"
|
|
225
|
+
|
|
226
|
+
print() # spacing outside the box (top)
|
|
227
|
+
print("┏" + "━" * span + "┓")
|
|
228
|
+
print(blank) # spacing inside the box (top)
|
|
229
|
+
for line in wrapped:
|
|
230
|
+
print("┃" + " " * PAD_X + line.ljust(width) + " " * PAD_X + "┃")
|
|
231
|
+
print(blank) # spacing inside the box (bottom)
|
|
232
|
+
print(bottom)
|
|
233
|
+
print() # spacing outside the box (bottom)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _read_key() -> str:
|
|
237
|
+
"""Read a single keypress (lowercased), no Enter required.
|
|
238
|
+
|
|
239
|
+
Uses cbreak mode so the key is captured immediately while Ctrl-C still
|
|
240
|
+
raises. Falls back to line-based reading when stdin isn't a TTY (tests,
|
|
241
|
+
pipes).
|
|
242
|
+
"""
|
|
243
|
+
if not sys.stdin.isatty():
|
|
244
|
+
return sys.stdin.readline().strip().lower()[:1]
|
|
245
|
+
|
|
246
|
+
import termios
|
|
247
|
+
import tty
|
|
248
|
+
|
|
249
|
+
fd = sys.stdin.fileno()
|
|
250
|
+
old = termios.tcgetattr(fd)
|
|
251
|
+
try:
|
|
252
|
+
tty.setcbreak(fd)
|
|
253
|
+
ch = sys.stdin.read(1)
|
|
254
|
+
finally:
|
|
255
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
256
|
+
return ch.lower()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _display_draft(draft: str) -> None:
|
|
260
|
+
_render_box(draft, actions="[A] Accept [R] Regenerate [E] Edit [C] Cancel")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _open_in_editor(text: str) -> str:
|
|
264
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
265
|
+
with tempfile.NamedTemporaryFile(
|
|
266
|
+
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
|
267
|
+
) as f:
|
|
268
|
+
f.write(text)
|
|
269
|
+
tmp_path = f.name
|
|
270
|
+
subprocess.call([editor, tmp_path])
|
|
271
|
+
with open(tmp_path, encoding="utf-8") as f:
|
|
272
|
+
edited = f.read()
|
|
273
|
+
os.unlink(tmp_path)
|
|
274
|
+
return edited
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _run_generate(
|
|
278
|
+
start: str,
|
|
279
|
+
end: str,
|
|
280
|
+
type_: str,
|
|
281
|
+
major: bool,
|
|
282
|
+
minor: bool,
|
|
283
|
+
patch: bool,
|
|
284
|
+
publish: bool,
|
|
285
|
+
) -> None:
|
|
286
|
+
from app.config import Config, ConfigError
|
|
287
|
+
from app.api_client import ApiClient
|
|
288
|
+
from app.git_connector import GitConnector, GitConnectorError
|
|
289
|
+
from app.code_analyzer import CodeAnalyzer
|
|
290
|
+
from app.release_notes_generator import ReleaseNotesGenerator, GenerationError
|
|
291
|
+
from app.rn_publisher import RNPublisher
|
|
292
|
+
|
|
293
|
+
# Version bump mutual exclusivity
|
|
294
|
+
bump_count = sum([major, minor, patch])
|
|
295
|
+
if bump_count > 1:
|
|
296
|
+
typer.echo("Error: only one of --major, --minor, --patch may be set at a time.", err=True)
|
|
297
|
+
raise typer.Exit(code=1)
|
|
298
|
+
if bump_count == 0:
|
|
299
|
+
minor = True
|
|
300
|
+
|
|
301
|
+
if major:
|
|
302
|
+
version_bump = "major"
|
|
303
|
+
elif patch:
|
|
304
|
+
version_bump = "patch"
|
|
305
|
+
else:
|
|
306
|
+
version_bump = "minor"
|
|
307
|
+
|
|
308
|
+
# --type validation
|
|
309
|
+
if type_ not in ("tag", "commit"):
|
|
310
|
+
typer.echo("Error: --type must be 'tag' or 'commit'.", err=True)
|
|
311
|
+
raise typer.Exit(code=1)
|
|
312
|
+
ref_type_api = "tag" if type_ == "tag" else "commits"
|
|
313
|
+
|
|
314
|
+
# Step 1 — Load config
|
|
315
|
+
try:
|
|
316
|
+
config = Config.load()
|
|
317
|
+
except ConfigError as e:
|
|
318
|
+
typer.echo(str(e), err=True)
|
|
319
|
+
raise typer.Exit(code=1)
|
|
320
|
+
|
|
321
|
+
# Step 2 — Fetch project config from API
|
|
322
|
+
api_client = ApiClient(api_token=config.api_token, base_url=config.api_url)
|
|
323
|
+
try:
|
|
324
|
+
project = api_client.get_project(config.project_token)
|
|
325
|
+
except Exception as e:
|
|
326
|
+
msg = _scrub(str(e), config.project_token, config.api_token)
|
|
327
|
+
typer.echo(f"Failed to fetch project config: {msg}", err=True)
|
|
328
|
+
raise typer.Exit(code=1)
|
|
329
|
+
|
|
330
|
+
# Step 3 — Apply temperature override if set
|
|
331
|
+
temperature = (
|
|
332
|
+
config.llm_temperature_override
|
|
333
|
+
if config.llm_temperature_override is not None
|
|
334
|
+
else project["llm_temperature"]
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Step 4 — Open Git repository (need the branch name for the checklist label)
|
|
338
|
+
try:
|
|
339
|
+
connector = GitConnector()
|
|
340
|
+
branch = connector.current_branch()
|
|
341
|
+
except GitConnectorError as e:
|
|
342
|
+
typer.echo(str(e), err=True)
|
|
343
|
+
raise typer.Exit(code=1)
|
|
344
|
+
|
|
345
|
+
checklist = Checklist([
|
|
346
|
+
f"Diffing from {type_} {start} from {branch} branch",
|
|
347
|
+
"LLM generates release note",
|
|
348
|
+
"Publishing to project",
|
|
349
|
+
])
|
|
350
|
+
checklist.begin()
|
|
351
|
+
|
|
352
|
+
# Step 4 (cont.) — Compute diff + Step 5 — Analyze (strip noise, truncate)
|
|
353
|
+
checklist.active(0)
|
|
354
|
+
try:
|
|
355
|
+
commits, diff = connector.get_diff(start, end, type_)
|
|
356
|
+
analysis = CodeAnalyzer().analyze(commits, diff)
|
|
357
|
+
except GitConnectorError as e:
|
|
358
|
+
checklist.fail(0)
|
|
359
|
+
typer.echo(str(e), err=True)
|
|
360
|
+
raise typer.Exit(code=1)
|
|
361
|
+
checklist.done(0)
|
|
362
|
+
|
|
363
|
+
# Step 6 — Generate release note draft
|
|
364
|
+
checklist.active(1)
|
|
365
|
+
try:
|
|
366
|
+
generator = ReleaseNotesGenerator(model=config.llm_model, temperature=temperature)
|
|
367
|
+
draft = generator.generate(analysis, project)
|
|
368
|
+
except GenerationError as e:
|
|
369
|
+
checklist.fail(1)
|
|
370
|
+
typer.echo(str(e), err=True)
|
|
371
|
+
raise typer.Exit(code=1)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
checklist.fail(1)
|
|
374
|
+
msg = _scrub(str(e), config.openai_api_key, config.project_token, config.api_token)
|
|
375
|
+
typer.echo(f"Release note generation failed: {msg}", err=True)
|
|
376
|
+
raise typer.Exit(code=1)
|
|
377
|
+
checklist.done(1)
|
|
378
|
+
|
|
379
|
+
publisher = RNPublisher(api_client, config.project_token)
|
|
380
|
+
|
|
381
|
+
def _publish(body: str):
|
|
382
|
+
checklist.active(2)
|
|
383
|
+
try:
|
|
384
|
+
result = publisher.publish(
|
|
385
|
+
body=body,
|
|
386
|
+
version_bump=version_bump,
|
|
387
|
+
start_ref=start,
|
|
388
|
+
end_ref=end,
|
|
389
|
+
ref_type=ref_type_api,
|
|
390
|
+
)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
checklist.fail(2)
|
|
393
|
+
msg = _scrub(str(e), config.project_token, config.api_token)
|
|
394
|
+
typer.echo(f"Publish failed: {msg}", err=True)
|
|
395
|
+
raise typer.Exit(code=1)
|
|
396
|
+
checklist.done(2)
|
|
397
|
+
checklist.stop()
|
|
398
|
+
typer.echo(f"Published: {result['status']} — version {result['version']}")
|
|
399
|
+
|
|
400
|
+
# Freeze the checklist so the generated note prints in its box below it.
|
|
401
|
+
checklist.pause()
|
|
402
|
+
|
|
403
|
+
# Step 7 — --publish bypass (no HITL): all three steps run back-to-back
|
|
404
|
+
if publish:
|
|
405
|
+
_render_box(draft)
|
|
406
|
+
typer.echo(
|
|
407
|
+
"⚠ Publishing release note without human review (--publish flag active).",
|
|
408
|
+
err=True,
|
|
409
|
+
)
|
|
410
|
+
checklist.resume()
|
|
411
|
+
_publish(draft)
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
# Step 8–13 — Interactive HITL loop. _display_draft renders the note box;
|
|
415
|
+
# the publishing step resumes the checklist on accept.
|
|
416
|
+
current_draft = draft
|
|
417
|
+
while True:
|
|
418
|
+
_display_draft(current_draft)
|
|
419
|
+
|
|
420
|
+
# Wait for a single, valid keypress (no Enter needed).
|
|
421
|
+
while True:
|
|
422
|
+
choice = _read_key()
|
|
423
|
+
if choice in ("a", "r", "e", "c"):
|
|
424
|
+
break
|
|
425
|
+
if choice in ("", "q", "\x03", "\x04"): # EOF / quit / Ctrl-C / Ctrl-D
|
|
426
|
+
typer.echo("Nothing published.", err=True)
|
|
427
|
+
raise typer.Exit(code=1)
|
|
428
|
+
|
|
429
|
+
if choice == "c":
|
|
430
|
+
# Cancel — quit immediately without publishing
|
|
431
|
+
typer.echo("Nothing published.")
|
|
432
|
+
raise typer.Exit(code=0)
|
|
433
|
+
|
|
434
|
+
if choice == "a":
|
|
435
|
+
# Accept and publish
|
|
436
|
+
checklist.resume()
|
|
437
|
+
_publish(current_draft)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
elif choice == "r":
|
|
441
|
+
# Regenerate from same analysis
|
|
442
|
+
try:
|
|
443
|
+
with _spinner("Regenerating release note…"):
|
|
444
|
+
current_draft = generator.generate(analysis, project)
|
|
445
|
+
except GenerationError as e:
|
|
446
|
+
typer.echo(str(e), err=True)
|
|
447
|
+
raise typer.Exit(code=1)
|
|
448
|
+
except Exception as e:
|
|
449
|
+
msg = _scrub(str(e), config.openai_api_key, config.project_token, config.api_token)
|
|
450
|
+
typer.echo(f"Release note generation failed: {msg}", err=True)
|
|
451
|
+
raise typer.Exit(code=1)
|
|
452
|
+
|
|
453
|
+
elif choice == "e":
|
|
454
|
+
# Open in editor
|
|
455
|
+
current_draft = _open_in_editor(current_draft)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@app.command(name="generate")
|
|
459
|
+
def generate(
|
|
460
|
+
start: Annotated[str, typer.Option("--start", "-s", help="Start tag or commit hash")] = ...,
|
|
461
|
+
end: Annotated[str, typer.Option("--end", "-e", help="End tag or commit hash")] = "HEAD",
|
|
462
|
+
type_: Annotated[str, typer.Option("--type", "-t", help="'tag' or 'commit'")] = "tag",
|
|
463
|
+
major: Annotated[bool, typer.Option("--major", help="Bump major version")] = False,
|
|
464
|
+
minor: Annotated[bool, typer.Option("--minor", help="Bump minor version")] = False,
|
|
465
|
+
patch: Annotated[bool, typer.Option("--patch", help="Bump patch version")] = False,
|
|
466
|
+
publish: Annotated[bool, typer.Option("--publish", "-p", help="Skip HITL and publish immediately")] = False,
|
|
467
|
+
):
|
|
468
|
+
_run_generate(start, end, type_, major, minor, patch, publish)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@app.command(name="gen", hidden=True)
|
|
472
|
+
def gen(
|
|
473
|
+
start: Annotated[str, typer.Option("--start", "-s", help="Start tag or commit hash")] = ...,
|
|
474
|
+
end: Annotated[str, typer.Option("--end", "-e", help="End tag or commit hash")] = "HEAD",
|
|
475
|
+
type_: Annotated[str, typer.Option("--type", "-t", help="'tag' or 'commit'")] = "tag",
|
|
476
|
+
major: Annotated[bool, typer.Option("--major", help="Bump major version")] = False,
|
|
477
|
+
minor: Annotated[bool, typer.Option("--minor", help="Bump minor version")] = False,
|
|
478
|
+
patch: Annotated[bool, typer.Option("--patch", help="Bump patch version")] = False,
|
|
479
|
+
publish: Annotated[bool, typer.Option("--publish", "-p", help="Skip HITL and publish immediately")] = False,
|
|
480
|
+
):
|
|
481
|
+
_run_generate(start, end, type_, major, minor, patch, publish)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def version_callback(value: bool):
|
|
485
|
+
if value:
|
|
486
|
+
typer.echo("rylees 0.1.0")
|
|
487
|
+
raise typer.Exit()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@app.callback(invoke_without_command=True)
|
|
491
|
+
def main(
|
|
492
|
+
ctx: typer.Context,
|
|
493
|
+
version: Annotated[
|
|
494
|
+
bool,
|
|
495
|
+
typer.Option("--version", "-V", callback=version_callback, is_eager=True),
|
|
496
|
+
] = False,
|
|
497
|
+
):
|
|
498
|
+
# Called without a subcommand: show the `generate` help (the primary command).
|
|
499
|
+
if ctx.invoked_subcommand is None:
|
|
500
|
+
generate_cmd = ctx.command.get_command(ctx, "generate")
|
|
501
|
+
with typer.Context(
|
|
502
|
+
generate_cmd, info_name="generate", parent=ctx
|
|
503
|
+
) as sub_ctx:
|
|
504
|
+
typer.echo(generate_cmd.get_help(sub_ctx))
|
|
505
|
+
raise typer.Exit()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == "__main__":
|
|
509
|
+
app()
|
app/code_analyzer.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from app.models import AnalysisResult
|
|
3
|
+
|
|
4
|
+
EXCLUDED_FILE_PATTERNS = re.compile(
|
|
5
|
+
r"^diff --git a/(.*\.lock|package-lock\.json|yarn\.lock|.*\.min\.js|.*\.min\.css)\b",
|
|
6
|
+
re.MULTILINE,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
class CodeAnalyzer:
|
|
10
|
+
MAX_WORDS = int(8000 / 1.3) # ≈ 6153 words ≈ 8000 tokens
|
|
11
|
+
|
|
12
|
+
def analyze(self, commits: list, diff: str) -> AnalysisResult:
|
|
13
|
+
cleaned = self._strip_excluded(diff)
|
|
14
|
+
cleaned = self._strip_binary(cleaned)
|
|
15
|
+
cleaned = self._truncate(cleaned)
|
|
16
|
+
messages = [c.message.strip() for c in commits]
|
|
17
|
+
return AnalysisResult(diff=cleaned, commit_messages=messages)
|
|
18
|
+
|
|
19
|
+
def _strip_excluded(self, diff: str) -> str:
|
|
20
|
+
# Split on "diff --git" boundaries and discard excluded files
|
|
21
|
+
hunks = re.split(r"(?=^diff --git )", diff, flags=re.MULTILINE)
|
|
22
|
+
kept = []
|
|
23
|
+
for hunk in hunks:
|
|
24
|
+
if not hunk:
|
|
25
|
+
continue
|
|
26
|
+
if EXCLUDED_FILE_PATTERNS.match(hunk):
|
|
27
|
+
continue
|
|
28
|
+
kept.append(hunk)
|
|
29
|
+
return "".join(kept)
|
|
30
|
+
|
|
31
|
+
def _strip_binary(self, diff: str) -> str:
|
|
32
|
+
hunks = re.split(r"(?=^diff --git )", diff, flags=re.MULTILINE)
|
|
33
|
+
kept = []
|
|
34
|
+
for hunk in hunks:
|
|
35
|
+
if not hunk:
|
|
36
|
+
continue
|
|
37
|
+
if "Binary files" in hunk:
|
|
38
|
+
continue
|
|
39
|
+
kept.append(hunk)
|
|
40
|
+
return "".join(kept)
|
|
41
|
+
|
|
42
|
+
def _truncate(self, diff: str) -> str:
|
|
43
|
+
words = diff.split()
|
|
44
|
+
if len(words) <= self.MAX_WORDS:
|
|
45
|
+
return diff
|
|
46
|
+
return " ".join(words[: self.MAX_WORDS]) + "\n...[truncated]"
|
app/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from dotenv import find_dotenv, load_dotenv
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from app.api_client import BASE_URL as DEFAULT_API_URL
|
|
6
|
+
|
|
7
|
+
class ConfigError(Exception):
|
|
8
|
+
def __init__(self, var_name: str):
|
|
9
|
+
self.var_name = var_name
|
|
10
|
+
super().__init__(f"Missing required configuration variable: {var_name}")
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Config:
|
|
14
|
+
api_token: str
|
|
15
|
+
project_token: str
|
|
16
|
+
openai_api_key: str
|
|
17
|
+
api_url: str
|
|
18
|
+
llm_model: str
|
|
19
|
+
llm_temperature_override: float | None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def load(cls) -> "Config":
|
|
23
|
+
# Load .env from the current working directory (the target project), not
|
|
24
|
+
# from the rylees source tree. usecwd=True makes find_dotenv search up
|
|
25
|
+
# from the cwd instead of from this file's location.
|
|
26
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
27
|
+
required = {
|
|
28
|
+
"RYLEES_API_TOKEN": None,
|
|
29
|
+
"RYLEES_PROJECT_TOKEN": None,
|
|
30
|
+
"OPENAI_API_KEY": None,
|
|
31
|
+
}
|
|
32
|
+
for var in required:
|
|
33
|
+
val = os.getenv(var)
|
|
34
|
+
if not val:
|
|
35
|
+
raise ConfigError(var)
|
|
36
|
+
required[var] = val
|
|
37
|
+
|
|
38
|
+
temp_override = os.getenv("RYLEES_LLM_TEMPERATURE")
|
|
39
|
+
return cls(
|
|
40
|
+
api_token=required["RYLEES_API_TOKEN"],
|
|
41
|
+
project_token=required["RYLEES_PROJECT_TOKEN"],
|
|
42
|
+
openai_api_key=required["OPENAI_API_KEY"],
|
|
43
|
+
api_url=os.getenv("RYLEES_API_URL", DEFAULT_API_URL),
|
|
44
|
+
llm_model=os.getenv("RYLEES_LLM_MODEL", "GPT-5.4"),
|
|
45
|
+
llm_temperature_override=float(temp_override) if temp_override else None,
|
|
46
|
+
)
|
app/git_connector.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
import git
|
|
3
|
+
from app.models import AnalysisResult
|
|
4
|
+
|
|
5
|
+
class GitConnectorError(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
class GitConnector:
|
|
9
|
+
def __init__(self, repo_path: str = "."):
|
|
10
|
+
try:
|
|
11
|
+
self._repo = git.Repo(repo_path)
|
|
12
|
+
except git.InvalidGitRepositoryError:
|
|
13
|
+
raise GitConnectorError(f"Not a valid git repository: {repo_path}")
|
|
14
|
+
|
|
15
|
+
def current_branch(self) -> str:
|
|
16
|
+
"""Return the active branch name, or the short HEAD sha when detached."""
|
|
17
|
+
try:
|
|
18
|
+
return self._repo.active_branch.name
|
|
19
|
+
except TypeError:
|
|
20
|
+
return self._repo.head.commit.hexsha[:7]
|
|
21
|
+
|
|
22
|
+
def get_diff(
|
|
23
|
+
self,
|
|
24
|
+
start_ref: str,
|
|
25
|
+
end_ref: str,
|
|
26
|
+
ref_type: Literal["tag", "commit"],
|
|
27
|
+
) -> tuple[list[git.Commit], str]:
|
|
28
|
+
try:
|
|
29
|
+
if ref_type == "tag":
|
|
30
|
+
start = self._repo.tags[start_ref].commit
|
|
31
|
+
end = self._repo.tags[end_ref].commit if end_ref != "HEAD" else self._repo.head.commit
|
|
32
|
+
else:
|
|
33
|
+
start = self._repo.commit(start_ref)
|
|
34
|
+
end = self._repo.commit(end_ref) if end_ref != "HEAD" else self._repo.head.commit
|
|
35
|
+
except (IndexError, git.BadName, KeyError, ValueError):
|
|
36
|
+
raise GitConnectorError(f"Reference not found: check --start and --end values")
|
|
37
|
+
|
|
38
|
+
commits = list(self._repo.iter_commits(f"{start.hexsha}..{end.hexsha}"))
|
|
39
|
+
diff_str = self._repo.git.diff(start.hexsha, end.hexsha)
|
|
40
|
+
return commits, diff_str
|
app/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TypedDict, Literal
|
|
3
|
+
|
|
4
|
+
class ProjectConfig(TypedDict):
|
|
5
|
+
id: str
|
|
6
|
+
name: str
|
|
7
|
+
key: str
|
|
8
|
+
description: str
|
|
9
|
+
customer_name: str
|
|
10
|
+
customer_industry: str
|
|
11
|
+
llm_temperature: float
|
|
12
|
+
llm_tonality: str
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AnalysisResult:
|
|
16
|
+
diff: str
|
|
17
|
+
commit_messages: list[str]
|
|
18
|
+
|
|
19
|
+
class PublishPayload(TypedDict):
|
|
20
|
+
startRef: str
|
|
21
|
+
endRef: str
|
|
22
|
+
type: Literal["commits", "tag"]
|
|
23
|
+
branchName: str
|
|
24
|
+
body: str
|
|
25
|
+
versionBump: Literal["major", "minor", "patch"]
|
|
26
|
+
|
|
27
|
+
class PublishResponse(TypedDict):
|
|
28
|
+
id: str
|
|
29
|
+
status: str
|
|
30
|
+
version: str
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from langchain_openai import ChatOpenAI
|
|
2
|
+
from langchain_core.messages import SystemMessage, HumanMessage
|
|
3
|
+
from app.models import AnalysisResult, ProjectConfig
|
|
4
|
+
from app.validator import Validator, ValidationError
|
|
5
|
+
|
|
6
|
+
class GenerationError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
SYSTEM_PROMPT_TEMPLATE = """\
|
|
10
|
+
You are a technical writer creating release notes for {customer_name},
|
|
11
|
+
a company in the {customer_industry} industry.
|
|
12
|
+
|
|
13
|
+
Your task is to summarise the following code changes in one short,
|
|
14
|
+
{tonality} paragraph written for a non-technical audience.
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
- Do NOT mention file names, function names, or code.
|
|
18
|
+
- Do NOT use technical jargon.
|
|
19
|
+
- Write in German.
|
|
20
|
+
- Maximum 500 words.
|
|
21
|
+
- Describe what changed from the user's perspective, not how it was implemented.\
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
USER_PROMPT_TEMPLATE = """\
|
|
25
|
+
Project: {project_description}
|
|
26
|
+
|
|
27
|
+
Commit messages:
|
|
28
|
+
{commit_messages}
|
|
29
|
+
|
|
30
|
+
Code diff summary:
|
|
31
|
+
{diff}\
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
class ReleaseNotesGenerator:
|
|
35
|
+
MAX_RETRIES = 3
|
|
36
|
+
|
|
37
|
+
def __init__(self, model: str, temperature: float):
|
|
38
|
+
self._llm = ChatOpenAI(model=model, temperature=temperature)
|
|
39
|
+
self._validator = Validator()
|
|
40
|
+
|
|
41
|
+
def generate(self, analysis: AnalysisResult, project: ProjectConfig) -> str:
|
|
42
|
+
system_content = SYSTEM_PROMPT_TEMPLATE.format(
|
|
43
|
+
customer_name=project["customer_name"],
|
|
44
|
+
customer_industry=project["customer_industry"],
|
|
45
|
+
tonality=project["llm_tonality"],
|
|
46
|
+
)
|
|
47
|
+
user_content = USER_PROMPT_TEMPLATE.format(
|
|
48
|
+
project_description=project["description"],
|
|
49
|
+
commit_messages="\n".join(analysis.commit_messages),
|
|
50
|
+
diff=analysis.diff,
|
|
51
|
+
)
|
|
52
|
+
for attempt in range(self.MAX_RETRIES):
|
|
53
|
+
messages = [
|
|
54
|
+
SystemMessage(content=system_content),
|
|
55
|
+
HumanMessage(content=user_content),
|
|
56
|
+
]
|
|
57
|
+
response = self._llm.invoke(messages)
|
|
58
|
+
draft = response.content
|
|
59
|
+
try:
|
|
60
|
+
self._validator.validate(draft)
|
|
61
|
+
return draft
|
|
62
|
+
except ValidationError:
|
|
63
|
+
if attempt == self.MAX_RETRIES - 1:
|
|
64
|
+
raise GenerationError(
|
|
65
|
+
f"LLM output failed validation after {self.MAX_RETRIES} attempts"
|
|
66
|
+
)
|
|
67
|
+
raise GenerationError("Unreachable")
|
app/rn_publisher.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from app.api_client import ApiClient
|
|
3
|
+
from app.models import PublishPayload, PublishResponse
|
|
4
|
+
|
|
5
|
+
class RNPublisher:
|
|
6
|
+
def __init__(self, api_client: ApiClient, project_token: str):
|
|
7
|
+
self._client = api_client
|
|
8
|
+
self._project_token = project_token
|
|
9
|
+
|
|
10
|
+
def publish(
|
|
11
|
+
self,
|
|
12
|
+
body: str,
|
|
13
|
+
version_bump: Literal["major", "minor", "patch"],
|
|
14
|
+
start_ref: str,
|
|
15
|
+
end_ref: str,
|
|
16
|
+
ref_type: Literal["commits", "tag"],
|
|
17
|
+
branch_name: str | None = None,
|
|
18
|
+
) -> PublishResponse:
|
|
19
|
+
payload: PublishPayload = {
|
|
20
|
+
"startRef": start_ref,
|
|
21
|
+
"endRef": end_ref,
|
|
22
|
+
"type": ref_type,
|
|
23
|
+
"branchName": branch_name or "",
|
|
24
|
+
"body": body,
|
|
25
|
+
"versionBump": version_bump,
|
|
26
|
+
}
|
|
27
|
+
return self._client.publish_release_note(self._project_token, payload)
|
app/validator.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class ValidationError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
class Validator:
|
|
5
|
+
def validate(self, text: str) -> None:
|
|
6
|
+
stripped = text.strip()
|
|
7
|
+
if not stripped:
|
|
8
|
+
raise ValidationError("Response is empty or whitespace")
|
|
9
|
+
if len(stripped) < 10:
|
|
10
|
+
raise ValidationError("Response too short (min 10 chars)")
|
|
11
|
+
if len(stripped) > 2000:
|
|
12
|
+
raise ValidationError("Response too long (max 2000 chars)")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rylees
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LLM-assisted release note generator and publisher
|
|
5
|
+
Project-URL: Repository, https://github.com/zihmm/rylees
|
|
6
|
+
Author-email: Marc Zimmerli <marc@uniqode.ch>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: cli,git,llm,release-notes
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: gitpython>=3.1
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: langchain-openai>=0.2
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0
|
|
17
|
+
Requires-Dist: typer>=0.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Rylees CLI
|
|
25
|
+
|
|
26
|
+
An LLM-assisted release-note generator and publisher. `rylees` reads the diff
|
|
27
|
+
between two points in your Git history, asks an LLM to draft a release note in
|
|
28
|
+
your project's configured tone, lets you review or edit it, and publishes it to
|
|
29
|
+
the Rylees backend.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Python **3.12** or newer
|
|
34
|
+
- A Git repository to generate notes from
|
|
35
|
+
- A Rylees API token and project token (from the developer console)
|
|
36
|
+
- An OpenAI API key
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
The CLI is a standard PEP 517 package. Install it from the `src/cli` directory.
|
|
41
|
+
|
|
42
|
+
### Using a virtual environment (recommended)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd src/cli
|
|
46
|
+
python3.12 -m venv .venv
|
|
47
|
+
source .venv/bin/activate
|
|
48
|
+
pip install .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For development, install in editable mode with the dev dependencies:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install -e ".[dev]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Using uv
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cd src/cli
|
|
61
|
+
uv venv
|
|
62
|
+
uv pip install -e ".[dev]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Once installed, the `rylees` command is available on your `PATH` (inside the
|
|
66
|
+
activated environment):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
rylees --version
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
`rylees` reads configuration from a `.env` file in your **current working
|
|
75
|
+
directory** (it searches upward from the cwd, not from the CLI's install
|
|
76
|
+
location). Copy the bundled example and fill in your values:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cp .env.example .env
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Variable | Required | Description |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| `RYLEES_API_TOKEN` | yes | Authenticates you against the Rylees API. |
|
|
85
|
+
| `RYLEES_PROJECT_TOKEN` | yes | Identifies the project to publish notes to. |
|
|
86
|
+
| `OPENAI_API_KEY` | yes | Used by the LLM to draft the release note. |
|
|
87
|
+
| `RYLEES_API_URL` | no | Override the base API URL. Default: `https://api.rylees.ai/v1`. |
|
|
88
|
+
| `RYLEES_LLM_TEMPERATURE` | no | Override the temperature configured on the project. |
|
|
89
|
+
| `RYLEES_LLM_MODEL` | no | Override the model. Default: `GPT-5.4`. |
|
|
90
|
+
|
|
91
|
+
If a required variable is missing, the CLI exits with an error naming the
|
|
92
|
+
variable.
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
Run `rylees` from the root of the Git repository you want to generate notes for.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
rylees generate --start <ref> [options]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Options
|
|
103
|
+
|
|
104
|
+
| Option | Alias | Default | Description |
|
|
105
|
+
| --- | --- | --- | --- |
|
|
106
|
+
| `--start` | `-s` | *(required)* | Start tag or commit hash. |
|
|
107
|
+
| `--end` | `-e` | `HEAD` | End tag or commit hash. |
|
|
108
|
+
| `--type` | `-t` | `tag` | Reference type: `tag` or `commit`. |
|
|
109
|
+
| `--major` | | | Bump the major version. |
|
|
110
|
+
| `--minor` | | (default) | Bump the minor version. |
|
|
111
|
+
| `--patch` | | | Bump the patch version. |
|
|
112
|
+
| `--publish` | `-p` | off | Skip the review step and publish immediately. |
|
|
113
|
+
|
|
114
|
+
Only one of `--major`, `--minor`, `--patch` may be set. If none is given,
|
|
115
|
+
`--minor` is assumed.
|
|
116
|
+
|
|
117
|
+
### Examples
|
|
118
|
+
|
|
119
|
+
Generate a note for the changes between two tags and review it interactively:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
rylees generate --start v1.2.0 --end v1.3.0 --minor
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Generate from a range of commits instead of tags:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
rylees generate --type commit --start a1b2c3d --end HEAD
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Generate and publish a major release without manual review (use with care —
|
|
132
|
+
this skips human review and prints a warning to stderr):
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
rylees generate --start v1.0.0 --major --publish
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Interactive review (HITL)
|
|
139
|
+
|
|
140
|
+
Without `--publish`, the CLI prints the generated draft and prompts for an
|
|
141
|
+
action:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
[A] Accept and publish [R] Regenerate [E] Edit
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- **A** — publish the current draft to the Rylees backend.
|
|
148
|
+
- **R** — regenerate a fresh draft from the same diff.
|
|
149
|
+
- **E** — open the draft in your editor (`$EDITOR`, default `nano`), then return
|
|
150
|
+
to the prompt with your edits.
|
|
151
|
+
|
|
152
|
+
On a successful publish, the CLI prints the resulting status and version.
|
|
153
|
+
|
|
154
|
+
## How it works
|
|
155
|
+
|
|
156
|
+
1. Loads and validates configuration from `.env`.
|
|
157
|
+
2. Fetches the project's configuration (tone, temperature) from the Rylees API.
|
|
158
|
+
3. Opens the local Git repository and computes the diff between `--start` and
|
|
159
|
+
`--end`.
|
|
160
|
+
4. Strips noise (binary and lock-file diffs) and truncates the diff to fit the
|
|
161
|
+
LLM context window.
|
|
162
|
+
5. Generates a release-note draft in the project's configured tonality.
|
|
163
|
+
6. Lets you review, regenerate, or edit the draft — then publishes it.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
Run the test suite from `src/cli`:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pip install -e ".[dev]"
|
|
171
|
+
pytest
|
|
172
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
app/api_client.py,sha256=i0l9y-K3zSHLCsKkqIgPee6pSBO1YjKzcFC5jwOd1tI,1333
|
|
3
|
+
app/cli.py,sha256=OMPUL0Zun5BJFaUpFotHW0Lia4MB-haxc1u-XruLf_I,17028
|
|
4
|
+
app/code_analyzer.py,sha256=N5r1gNb9xFVY0wUDAimXtnw0V3Yd7VKl5pBKSsmpHNE,1578
|
|
5
|
+
app/config.py,sha256=si_21lHtNZiGJ9JQiryHzcZo4bsjo4GX0-r90pquIwI,1593
|
|
6
|
+
app/git_connector.py,sha256=Oax3BnFBsVSMjdBJo5bM6-wV_-grGQ6qAXr47DuAOy0,1509
|
|
7
|
+
app/models.py,sha256=Aqc0kdMQXaOp4tC7tejHR_kYdOJ3_xxnsRiR3Clx_2U,613
|
|
8
|
+
app/release_notes_generator.py,sha256=vwzYyX7DGoVVECVTSj4DnDZ1CBuHuCeIng7R1Am7fWw,2275
|
|
9
|
+
app/rn_publisher.py,sha256=2nDxjMFyWIEKldu1_0YfUN-J1Qf_PFWiyDEne4hving,883
|
|
10
|
+
app/validator.py,sha256=ruUlwcMS_6iRvx1qXVLNsI1eI4F0n2PdFmIEyLUaOPc,437
|
|
11
|
+
app/.env.example,sha256=019FsNFq6TMGRoFK1LgrqWdYtGWYKoEEirDTqRgYLH0,309
|
|
12
|
+
rylees-0.1.0.dist-info/METADATA,sha256=iHAwjLWSJt268pJU0oaAfz6OL73TCOcP4l_L_ayJ110,4919
|
|
13
|
+
rylees-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
rylees-0.1.0.dist-info/entry_points.txt,sha256=LETpo6YzvTSvquDulgx4fi7U2hKHSHK1K24Bt3CkR8g,39
|
|
15
|
+
rylees-0.1.0.dist-info/RECORD,,
|