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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rylees = app.cli:app