From b2a6b71a522b8c2904dc1dcda98b1ec2f911d5c9 Mon Sep 17 00:00:00 2001 From: Petr Date: Fri, 5 Jun 2026 01:10:05 +0200 Subject: [PATCH 1/2] feat(changelog): one-line summaries by default, --full to expand `kbagent changelog` dumped one giant paragraph per version (0.57.0 was a single ~3 KB bullet), which made the command unscannable. Rendering: default mode now shows a one-line headline per version (the first note's first sentence) plus a dim `(+N more)` marker and a `--full` (-v) footer hint when detail is hidden. `--full` expands every note as before. The post-update "What's new" banner is summarised the same way. `BREAKING:` is now a recognised prefix (bold red); previously it got no prefix styling at all. Summarisation: new `changelog.headline()` extracts the first sentence, skipping periods inside version numbers (`0.57.0`) and abbreviations (`e.g.`), capped at `CHANGELOG_HEADLINE_MAX_CHARS` on a word boundary with a dangling-backtick guard. Data + authoring: the 0.57.0 entry is re-split from one mega-paragraph into five prefixed bullets as the reference example. The authoring contract (one logical change per bullet, recognised prefix, self-contained first sentence) lives in the changelog.py module docstring and CONTRIBUTING.md. No version bump: this ships in a later release PR. Docs synced: CLAUDE.md command list, AGENT_CONTEXT (context.py), commands-reference.md. Tests: tests/test_changelog_render.py (16 cases). --- CLAUDE.md | 3 +- CONTRIBUTING.md | 2 +- .../kbagent/references/commands-reference.md | 2 +- src/keboola_agent_cli/changelog.py | 168 ++++++++++++------ src/keboola_agent_cli/commands/changelog.py | 84 ++++++--- src/keboola_agent_cli/commands/context.py | 5 +- src/keboola_agent_cli/constants.py | 7 + tests/test_changelog_render.py | 164 +++++++++++++++++ 8 files changed, 353 insertions(+), 82 deletions(-) create mode 100644 tests/test_changelog_render.py diff --git a/CLAUDE.md b/CLAUDE.md index 6ff32b1d..dc416893 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -565,6 +565,7 @@ kbagent update [--beta] # `--beta` (or env `KBAGENT_INCLUDE_PRERELEASE=1`) opts into pre-release versions # (PEP 440 betas/rc, e.g. 0.43.0b1). Default (no flag) is stable-only -- auto-update # startup hook never silently lands on a beta. -kbagent changelog [--limit N] +kbagent changelog [--limit N] [--full] +# Default shows a one-line summary (first sentence) per version; --full / -v expands every note. kbagent serve [--host HOST] [--port PORT] [--ui] [--ui-dist PATH] [--reload] [--log-level LVL] [--cors-origin ORIGIN] [--config-dir DIR] ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42cd599e..2c6c8762 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -532,7 +532,7 @@ manual safety net for the silent-drift risks summarized in the > below for the workflow. 1. **Edit `pyproject.toml`** -- bump `version = "X.Y.Z"`. Single source of truth; everything else derives from it. -2. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- one entry per release, no exceptions. CI fails (`make changelog-check`) if this is missing. +2. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- one entry per release, no exceptions. CI fails (`make changelog-check`) if this is missing. Author it as the file's docstring describes: **one logical change per bullet** (split the release into several list items rather than one mega-paragraph), each starting with a recognised prefix (`BREAKING:`, `New:`, `Fix:`, `Change:`, `Note:`, `Security:`, ...) and leading with a self-contained first sentence. `kbagent changelog` shows only that first sentence per version by default (the rest is revealed by `--full`), so a buried headline or a single wall-of-text bullet reads as an unscannable blob. 3. **Run `make version-sync`** -- propagates the new version to `plugins/kbagent/.claude-plugin/plugin.json`. The pre-commit hook does this automatically on `git commit`, but running it explicitly lets you eyeball the diff. 4. **Run `make skill-gen`** -- regenerates the decision table in `SKILL.md`. Idempotent if no commands changed since the previous release. 5. **Manually review `plugins/kbagent/agents/keboola-expert.md`**: diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index e37e6efe..486d3a70 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -7,7 +7,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro - `doctor [--fix]` -- health check for CLI config and MCP server - `version [--beta]` -- show version info and dependency update status. `--beta` (since v0.42.0) reports the latest pre-release (beta / rc) instead of the latest stable. Env override: `KBAGENT_INCLUDE_PRERELEASE=1` - `update [--beta]` -- self-update to latest version. `--beta` (since v0.42.0) opts into pre-release versions (PEP 440 betas / rc, e.g. `0.43.0b1`). Default behaviour: GitHub's `/releases/latest` endpoint filters prereleases server-side, so the startup auto-update hook never silently lands on a beta. Resolver-level opt-in (`--prerelease=allow` for uv, `--pre` for pip) is added automatically when `--beta` is set -- `changelog [--limit N]` -- show recent changelog (default: last 5 versions). After auto-update, "What's new" is printed automatically. Manual trigger: `KBAGENT_UPDATED_FROM=0.17.0 kbagent version` +- `changelog [--limit N] [--full]` -- show recent changelog (default: last 5 versions, one-line summary per version; `--full` / `-v` expands every note). After auto-update, "What's new" is printed automatically (summarised). Manual trigger: `KBAGENT_UPDATED_FROM=0.17.0 kbagent version` - `context` -- print full CLI reference for AI agents ## Project Management diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 140a5f42..e6732aa5 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -1,67 +1,84 @@ """Changelog data for kbagent releases. -Maintained manually: one-line summaries per version. Run ``make changelog`` to scaffold new entries from GitHub releases. + +Authoring contract (keep entries scannable -- ``kbagent changelog`` shows a +one-line summary per version by default): + +* One *logical* change per bullet -- split a release into several entries + instead of cramming everything into one paragraph. +* Start each bullet with a recognised prefix so the renderer can colour it and + ``headline()`` can summarise it: ``BREAKING:``, ``New:``, ``Fix:``, + ``Change:``, ``Note:``, ``Security:``, ``UX:`` ... (see + ``commands/changelog.py:_PREFIX_STYLES``). The prefix may carry a ``(#274)`` + decoration. +* Lead with a self-contained first sentence -- that sentence becomes the + default summary; everything after it is detail shown only under ``--full``. """ from __future__ import annotations +import re + +from .constants import CHANGELOG_HEADLINE_MAX_CHARS + # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { "0.57.0": [ "BREAKING (flow / conditional flows): the `flow` command group now targets " "conditional flows (`keboola.flow`) ONLY; `keboola.orchestrator` support is " "dropped. `--component-id` is removed from every `flow` subcommand and from " - "the `/flows` REST surface (FlowCreate/FlowUpdate/FlowSchedule models + query " - "params). `flow new`/`flow update` validate the body against the live " - "conditional-flow JSON Schema (Draft7), fetched at runtime from the stack's " - "component registry (AI Service `configurationSchema` for `keboola.flow` -- " - "never bundled/vendored), plus semantic checks -- phases use " - "`next[].goto` transitions (a phase id or `null` to end) with optional " - "`condition` objects (operator/function/phase/task/variable/const/array); " - "tasks are typed (`job`/`notification`/`variable`); **IDs are strings, not " - "integers**; a phase with conditional transitions must end with a default " - "(condition-less) transition; every phase needs >=1 enabled task; " - "operator/function operand-arity is enforced. Invalid bodies are rejected " - "with `INVALID_FLOW_DEFINITION` (replaces `INVALID_FLOW_DAG`, which is " - "removed from `ErrorCode`). When the schema fetch fails (network, " - "KeboolaApiError, or empty/missing schema) the write is NOT blocked: " - "structural validation is skipped, the semantic checks still run (the " - "Storage API does not validate flow configs server-side), and a " - "`structural schema validation skipped: ` warning is surfaced. " - "`flow validate --file @flow.yaml|- [--project ALIAS]` validates a " - "definition: with `--project` it fetches the live schema for full " - "structural + semantic validation (fetch failure degrades to semantic-only " - "+ a note); without `--project` it runs semantic-only and notes that " - "structural schema validation was skipped (no schema source). Exit 0 valid " - "/ exit 2 errors; `--json` lists `{valid, errors, warnings, notes}`. " - "`flow schema --full --project ALIAS` fetches and dumps the live JSON " - "Schema from the stack (`--full` without `--project` errors -- the schema " - "is no longer bundled); plain `flow schema` still prints the offline YAML " - "template, conditional-flow shaped. `flow detail` human rendering is " - "rewritten for conditional flows (per-phase transitions, task-type badges, " - "retry); JSON output is the raw body, unchanged. `flow list` no longer lists " - "legacy orchestrator configs -- it counts them and reports " - "`legacy_orchestrator_count` (+ a warning) so a 'disappeared' flow is " - "explained; the `Component` column is dropped (every row is keboola.flow). " - "Unreachable phases are reported as warnings (forward BFS from the first " - "phase), never blocking a write; `goto` loops are legal (no cycle detection). " - "New module `services/flow_validation.py` (pure: structural validation " - "takes an explicit optional `schema` parameter, semantic checks always run; " - "no network, no bundled schema). `config new` flow scaffold now emits a " - "conditional-flow skeleton (string ids, `phases`/`tasks`, a `job` task) and " - "defaults to `keboola.flow`; dead `ORCHESTRATOR_COMPONENTS` removed from " - "`sync/config_format.py`. New permission `flow.validate` (read). Docs/agent " - "surfaces synced: CLAUDE.md, AGENT_CONTEXT, keboola-expert.md, SKILL.md, " - "commands-reference.md, flow-workflow.md (full rewrite), gotchas.md " - "(string-ids, dropped orchestrator, removed --component-id, " - "INVALID_FLOW_DEFINITION rename; old default-component gotcha marked " - "resolved). Execute a conditional flow with " - "`kbagent job run --component-id keboola.flow --config-id ID`. Tests: " + "the `/flows` REST surface (FlowCreate/FlowUpdate/FlowSchedule models + " + "query params). Conditional flows use **string IDs (not integers)**, " + "`phases` with `next[].goto` transitions (a phase id or `null` to end) and " + "optional `condition` objects " + "(operator/function/phase/task/variable/const/array), and typed tasks " + "(`job`/`notification`/`variable`). Execute one with " + "`kbagent job run --component-id keboola.flow --config-id ID`.", + "Change (flow validation): `flow new`/`flow update` validate the body " + "against the live conditional-flow JSON Schema (Draft7), fetched at runtime " + "from the stack's component registry (AI Service `configurationSchema` for " + "`keboola.flow` -- never bundled/vendored), plus semantic checks: a phase " + "with conditional transitions must end with a default (condition-less) " + "transition; every phase needs >=1 enabled task; operator/function " + "operand-arity is enforced; unreachable phases are reported as warnings " + "(forward BFS from the first phase), and `goto` loops are legal (no cycle " + "detection). Invalid bodies are rejected with `INVALID_FLOW_DEFINITION` " + "(replaces `INVALID_FLOW_DAG`, removed from `ErrorCode`). When the schema " + "fetch fails (network, KeboolaApiError, or empty/missing schema) the write " + "is NOT blocked: structural validation is skipped, the semantic checks " + "still run (the Storage API does not validate flow configs server-side), " + "and a `structural schema validation skipped: ` warning is " + "surfaced.", + "New: `flow validate --file @flow.yaml|- [--project ALIAS]` validates a " + "definition without writing it. With `--project` it fetches the live schema " + "for full structural + semantic validation (fetch failure degrades to " + "semantic-only + a note); without `--project` it runs semantic-only and " + "notes that structural schema validation was skipped (no schema source). " + "Exit 0 valid / exit 2 on errors; `--json` lists " + "`{valid, errors, warnings, notes}`. New permission `flow.validate` (read).", + "Change (flow output): `flow schema --full --project ALIAS` fetches and " + "dumps the live JSON Schema from the stack (`--full` without `--project` " + "errors -- the schema is no longer bundled); plain `flow schema` still " + "prints the offline, conditional-flow-shaped YAML template. `flow detail` " + "human rendering is rewritten for conditional flows (per-phase transitions, " + "task-type badges, retry); JSON output is the raw body, unchanged. " + "`flow list` no longer lists legacy orchestrator configs -- it counts them " + "and reports `legacy_orchestrator_count` (+ a warning) so a 'disappeared' " + "flow is explained, and the `Component` column is dropped (every row is " + "`keboola.flow`).", + "Internal: new pure module `services/flow_validation.py` (structural " + "validation takes an explicit optional `schema` parameter, semantic checks " + "always run; no network, no bundled schema); `config new` flow scaffold now " + "emits a conditional-flow skeleton (string ids, `phases`/`tasks`, a `job` " + "task) and defaults to `keboola.flow`; dead `ORCHESTRATOR_COMPONENTS` " + "removed from `sync/config_format.py`. Docs/agent surfaces synced: " + "CLAUDE.md, AGENT_CONTEXT, keboola-expert.md, SKILL.md, " + "commands-reference.md, flow-workflow.md (full rewrite), gotchas.md. Tests: " "`tests/test_flow_validation.py` (new), `tests/test_flow_service.py` + " "`tests/test_flow_cli.py` rewritten, `tests/test_e2e.py` flow round-trip " "uses a CF payload + `flow validate` and skips cleanly on " - "conditional_flows=false.", + "`conditional_flows=false`.", ], "0.56.0": [ "Maintenance re-release -- no code changes since 0.55.0. The `0.55.0` version number lived in " @@ -801,15 +818,66 @@ def get_version_notes(version: str) -> list[str] | None: return CHANGELOG.get(version) +# Abbreviations whose trailing period must NOT be read as a sentence end when +# extracting a headline (otherwise "e.g. a Chart" splits after "e.g"). +_HEADLINE_ABBREVIATIONS = frozenset({"e.g", "i.e", "vs", "etc", "cf", "no", "al", "inc"}) + +# A sentence boundary is a period (or other terminator) followed by whitespace. +_SENTENCE_BOUNDARY = re.compile(r"[.!?]\s") + +# The final alphabetic token (incl. internal dots) immediately before a period, +# used to test it against the abbreviation list -- "(e.g" -> "e.g". +_TRAILING_TOKEN = re.compile(r"[A-Za-z][A-Za-z.]*$") + + +def _truncate_headline(text: str, max_chars: int) -> str: + """Cut *text* to at most *max_chars* on a word boundary, adding an ellipsis. + + A dangling unbalanced backtick (from cutting mid-code-span) is dropped so + the renderer does not mistake the rest of the line for inline code. + """ + text = text.strip() + if len(text) <= max_chars: + return text + cut = text[:max_chars].rsplit(" ", 1)[0].rstrip(" ,;:-") + if cut.count("`") % 2: + cut = cut.rsplit("`", 1)[0].rstrip() + return f"{cut} …" + + +def headline(note: str, max_chars: int = CHANGELOG_HEADLINE_MAX_CHARS) -> str: + """Return a one-line summary of a changelog *note*. + + The headline is the note's first sentence, capped at *max_chars*. Sentence + detection skips periods inside version numbers (``0.57.0``) and common + abbreviations (``e.g.``) so the summary is a complete thought, not a + fragment. + """ + first = note + for match in _SENTENCE_BOUNDARY.finditer(note): + dot = match.start() + before = note[dot - 1] if dot > 0 else "" + if before.isdigit(): + continue # part of a version number like 0.57.0 + token_match = _TRAILING_TOKEN.search(note[:dot]) + token = token_match.group(0).rstrip(".").lower() if token_match else "" + if token in _HEADLINE_ABBREVIATIONS: + continue + first = note[: dot + 1] + break + return _truncate_headline(first, max_chars) + + def format_whats_new(old_version: str, new_version: str) -> str: """Format a brief 'What's new' message for display after auto-update. - Shows entries for the new version only (not intermediate versions). + Shows a one-line headline per entry for the new version only (not + intermediate versions); run ``kbagent changelog --full`` for the detail. """ notes = get_version_notes(new_version) if not notes: return "" lines = [f" What's new in v{new_version}:"] for note in notes: - lines.append(f" - {note}") + lines.append(f" - {headline(note)}") return "\n".join(lines) + "\n" diff --git a/src/keboola_agent_cli/commands/changelog.py b/src/keboola_agent_cli/commands/changelog.py index b1d11b2d..5a35f8f3 100644 --- a/src/keboola_agent_cli/commands/changelog.py +++ b/src/keboola_agent_cli/commands/changelog.py @@ -6,18 +6,20 @@ from __future__ import annotations import re +from functools import partial import typer from rich.console import Console from rich.text import Text -from ..changelog import DEFAULT_CHANGELOG_LIMIT, get_changelog +from ..changelog import DEFAULT_CHANGELOG_LIMIT, get_changelog, headline from ._helpers import get_formatter # Map each known prefix word to a Rich style. Order does not matter; the # regex below recognises the prefix regardless of trailing decorations like # ``(#274)`` or `` (sec-20 follow-up)``. _PREFIX_STYLES: dict[str, str] = { + "breaking": "bold red", "new": "bold green", "fix": "bold yellow", "change": "bold blue", @@ -38,7 +40,7 @@ # alternation is anchored to the longest match first so "Plugin docs" wins # over "Plugin". Case-insensitive; we look up the style by lowercase key. _PREFIX_RE = re.compile( - r"^(Plugin docs|Review fixes|Observability|Security|Closed|Tests|" + r"^(Plugin docs|Review fixes|Observability|Breaking|Security|Closed|Tests|" r"Internal|Change|Note|Fix|New|UX|E2E|Why)" r"(\s*\([^)]*\))?" # optional "(#274)" or "(sec-20 follow-up)" decoration r":\s+", @@ -79,39 +81,56 @@ def _styled_note(note: str) -> Text: return text -def _format_changelog_human(console: Console, data: dict) -> None: - """Render the changelog as a styled, word-wrapped bullet list.""" +def _print_bullet(console: Console, styled: Text, body_width: int) -> None: + """Word-wrap a styled bullet and print it with a manual gutter. + + Bullet glyph on the first line, two-space indent on continuations. This + preserves per-span styling without the right-side padding Table cells emit. + """ + for j, line in enumerate(styled.wrap(console, body_width)): + # rstrip in place -- Rich keeps the word-break space at the end of each + # wrapped row, which shows up as trailing whitespace on copy/paste. + line.rstrip() + # The gutter is a styleless Text so the body keeps its own spans; dim + # applies only to the bullet glyph, not to everything that follows. + row = Text() + row.append(" • " if j == 0 else " ", style="dim" if j == 0 else None) + row.append_text(line) + console.print(row) + + +def _format_changelog_human(console: Console, data: dict, *, full: bool) -> None: + """Render the changelog. + + Default (``full=False``): one headline bullet per version -- the first + note's first sentence, plus a dim ``(+N more)`` when a version carries + extra notes. ``full=True``: every note, word-wrapped in full. + """ # Body width = terminal width minus the 4-char bullet gutter. Floor at # 40 to stay readable on pathologically narrow terminals and to handle # Console.width == 0 when stdout is piped to /dev/null. body_width = max(40, console.width - 4) entries = list(data["entries"].items()) + has_hidden_detail = False for i, (version, notes) in enumerate(entries): console.print(f"v{version}", style="bold cyan") - for note in notes: - styled = _styled_note(note) - # Word-wrap the styled Text into a list of Text lines, then - # render each with a manual gutter: bullet on line 0, two-space - # indent on continuations. This preserves spans without the - # right-side padding that Table cells emit. - lines = styled.wrap(console, body_width) - for j, line in enumerate(lines): - # rstrip the wrapped line in place -- Rich preserves the - # word-break space at the end of each wrapped row, which - # shows up as trailing whitespace on copy/paste. - line.rstrip() - # Build the gutter as a styleless Text so the body line - # keeps its own per-span styling. Dim is applied only to - # the bullet glyph itself, not to everything that follows. - row = Text() - if j == 0: - row.append(" • ", style="dim") - else: - row.append(" ") - row.append_text(line) - console.print(row) + if full: + for note in notes: + _print_bullet(console, _styled_note(note), body_width) + else: + head = headline(notes[0]) + styled = _styled_note(head) + extra = len(notes) - 1 + if extra > 0: + styled.append(f" (+{extra} more)", style="dim") + if extra > 0 or head != notes[0].strip(): + has_hidden_detail = True + _print_bullet(console, styled, body_width) if i < len(entries) - 1: console.print("") + if not full and has_hidden_detail: + console.print("") + console.print("Run with --full (-v) to see complete notes.", style="dim") def changelog_command( @@ -124,9 +143,18 @@ def changelog_command( min=1, max=100, ), + full: bool = typer.Option( + False, + "--full", + "-v", + help="Show complete notes for each version (default: one-line summary).", + ), ) -> None: """Show recent changelog (what changed in each version). + By default each version is summarised as a single headline; pass --full + (-v) for the complete notes. + After auto-update, kbagent automatically prints "What's new" for the new version. To see changes for a specific version manually, set KBAGENT_UPDATED_FROM to any older version: @@ -135,4 +163,6 @@ def changelog_command( """ formatter = get_formatter(ctx) entries = get_changelog(limit) - formatter.output({"entries": entries}, _format_changelog_human) + # Bind ``full`` via partial so the JSON payload stays ``{"entries": ...}`` + # (the flag is a presentation concern, not data). + formatter.output({"entries": entries}, partial(_format_changelog_human, full=full)) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 1c79983e..4a301511 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1265,8 +1265,9 @@ KBAGENT_INCLUDE_PRERELEASE=1 in env to make every update in the session treat betas as installable without re-typing --beta. - kbagent changelog [--limit N] - Show recent changelog (what changed in each version). Default: last 5 versions. + kbagent changelog [--limit N] [--full] + Show recent changelog (what changed in each version). Default: last 5 + versions, one-line summary each; --full (-v) expands every note. kbagent permissions list [--category read|write|destructive|admin] List all operations with risk categories and current allowed/denied status. diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index f6c8e725..6c3680b0 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -330,6 +330,13 @@ KAI_STREAM_TIMEOUT: float = 600.0 # 10 min for SSE streaming responses SECRET_PLACEHOLDER: str = "" +# --- Changelog rendering --- +# `kbagent changelog` shows a one-line summary per version by default (--full +# expands). A summary is the note's first sentence, capped at this many chars +# (cut on a word boundary) so a verbose release note collapses to a scannable +# headline instead of a wall of text. +CHANGELOG_HEADLINE_MAX_CHARS: int = 160 + # --- Job Run --- DEFAULT_JOB_RUN_TIMEOUT: float = 300.0 # 5 min default for --wait polling diff --git a/tests/test_changelog_render.py b/tests/test_changelog_render.py new file mode 100644 index 00000000..faef280e --- /dev/null +++ b/tests/test_changelog_render.py @@ -0,0 +1,164 @@ +"""Tests for changelog summarisation and rendering. + +Covers two new surfaces: + +* ``changelog.headline`` -- first-sentence extraction with version-number and + abbreviation guards, max-char truncation, and dangling-backtick cleanup. +* ``commands/changelog`` -- default one-line summary vs ``--full``, the + ``(+N more)`` indicator, the footer hint, and BREAKING prefix styling. + +Renderer tests drive ``_format_changelog_human`` with a synthetic entries dict +(not the live ``CHANGELOG``) so they stay green as real release notes change. +""" + +from __future__ import annotations + +import io +import json + +from rich.console import Console +from typer.testing import CliRunner + +from keboola_agent_cli.changelog import format_whats_new, headline +from keboola_agent_cli.cli import app +from keboola_agent_cli.commands.changelog import ( + _PREFIX_RE, + _format_changelog_human, + _styled_note, +) +from keboola_agent_cli.constants import CHANGELOG_HEADLINE_MAX_CHARS, ENV_SKIP_UPDATE + + +def _render(entries: dict[str, list[str]], *, full: bool) -> str: + """Render entries to plain text through a captured Rich console.""" + buf = io.StringIO() + console = Console(file=buf, width=80, no_color=True) + _format_changelog_human(console, {"entries": entries}, full=full) + return buf.getvalue() + + +# A multi-version fixture: 1.2.0 has three notes (so a summary hides two), +# 1.1.0 has a single short one (so a summary hides nothing). +_ENTRIES: dict[str, list[str]] = { + "1.2.0": [ + "New: alpha thing. Detail about alpha that stays hidden.", + "Fix: beta thing.", + "Internal: gamma thing.", + ], + "1.1.0": ["Change: single short note."], +} + + +class TestHeadline: + def test_first_sentence_only(self) -> None: + note = "New: a thing happened. And then a second thing. And a third." + assert headline(note) == "New: a thing happened." + + def test_short_note_returned_verbatim(self) -> None: + assert headline("Fix: small thing.") == "Fix: small thing." + + def test_version_number_period_is_not_a_boundary(self) -> None: + note = "Bumped to 0.57.0 for everyone. Details follow." + assert headline(note) == "Bumped to 0.57.0 for everyone." + + def test_abbreviation_period_is_not_a_boundary(self) -> None: + note = "Holds a dimension (e.g. a Chart of Accounts) as one record. More." + assert headline(note) == "Holds a dimension (e.g. a Chart of Accounts) as one record." + + def test_long_first_sentence_truncated_on_word_boundary(self) -> None: + note = "Word " * 100 # no terminator, far over the cap + out = headline(note) + assert out.endswith("…") + # cap + " …" (space + ellipsis) is the worst case + assert len(out) <= CHANGELOG_HEADLINE_MAX_CHARS + 2 + assert not out.endswith("Wor …") # cut on a whole word, not mid-token + + def test_dangling_backtick_dropped_on_truncation(self) -> None: + # Opening backtick whose closer lands in the dropped tail. + note = "uses `unterminated then a bunch of normal words follow afterwards ok" + out = headline(note, max_chars=40) + assert "`" not in out + assert out.endswith("…") + + +class TestRendererSummary: + def test_shows_headline_and_more_count(self) -> None: + out = _render(_ENTRIES, full=False) + assert "New: alpha thing." in out + assert "(+2 more)" in out # 3 notes -> 2 hidden + assert "Detail about alpha" not in out # detail hidden + assert "Fix: beta thing." not in out # sibling notes hidden + assert "--full" in out # footer hint present + + def test_single_short_note_has_no_more_and_no_footer(self) -> None: + out = _render({"1.1.0": ["Change: single short note."]}, full=False) + assert "(+" not in out + assert "--full" not in out # nothing hidden -> no hint + + def test_truncated_single_note_triggers_footer(self) -> None: + long_note = "Change: " + "word " * 100 # one note, but truncated + out = _render({"1.0.0": [long_note]}, full=False) + assert "(+" not in out # still only one note + assert "--full" in out # but detail was hidden -> hint + + +class TestRendererFull: + def test_shows_every_note(self) -> None: + out = _render(_ENTRIES, full=True) + assert "Detail about alpha that stays hidden." in out + assert "Fix: beta thing." in out + assert "Internal: gamma thing." in out + assert "(+2 more)" not in out + assert "--full" not in out # no footer hint in full mode + + +class TestBreakingPrefix: + def test_prefix_regex_matches_breaking_with_decoration(self) -> None: + m = _PREFIX_RE.match("BREAKING (flow): orchestrator dropped.") + assert m is not None + assert m.group(1).lower() == "breaking" + + def test_breaking_prefix_styled_red(self) -> None: + text = _styled_note("BREAKING: a thing changed.") + assert any("red" in str(span.style) for span in text.spans) + + +class TestFormatWhatsNew: + def test_uses_headline_not_full_blob(self, monkeypatch) -> None: + from keboola_agent_cli import changelog as cl + + long_tail = "x" * 500 + monkeypatch.setattr( + cl, "CHANGELOG", {"9.9.9": [f"New: short headline. Long detail {long_tail}"]} + ) + out = format_whats_new("9.9.8", "9.9.9") + assert "What's new in v9.9.9" in out + assert "New: short headline." in out + assert long_tail not in out # detail is summarised away + + +class TestChangelogCliIntegration: + """End-to-end against the live CHANGELOG -- assertions stay data-agnostic.""" + + def test_summary_is_shorter_than_full(self, monkeypatch) -> None: + monkeypatch.setenv(ENV_SKIP_UPDATE, "1") + runner = CliRunner() + summary = runner.invoke(app, ["changelog", "-n", "3"]) + full = runner.invoke(app, ["changelog", "-n", "3", "--full"]) + assert summary.exit_code == 0, summary.output + assert full.exit_code == 0, full.output + assert len(summary.output) < len(full.output) + + def test_v_is_an_alias_for_full(self, monkeypatch) -> None: + monkeypatch.setenv(ENV_SKIP_UPDATE, "1") + runner = CliRunner() + short_flag = runner.invoke(app, ["changelog", "-n", "2", "-v"]) + long_flag = runner.invoke(app, ["changelog", "-n", "2", "--full"]) + assert short_flag.output == long_flag.output + + def test_json_payload_has_no_full_key(self, monkeypatch) -> None: + monkeypatch.setenv(ENV_SKIP_UPDATE, "1") + result = CliRunner().invoke(app, ["--json", "changelog", "-n", "1"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output)["data"] + assert list(data.keys()) == ["entries"] From e1c12495810ef4a5507dda521ad263b2005dffc5 Mon Sep 17 00:00:00 2001 From: Petr Date: Fri, 5 Jun 2026 01:20:45 +0200 Subject: [PATCH 2/2] fix(changelog): scope digit guard to periods, tighten 0.57.0 bullet Addresses two findings from the Devin review of #401: - headline(): the `before.isdigit()` guard meant to skip version-number periods (0.57.0) was applied to all `[.!?]` terminators, so a digit before "!" or "?" (e.g. "exit code 5!") was wrongly suppressed as a sentence boundary. Scope the guard to periods only. - The 0.57.0 "Change (flow validation)" reference bullet led with a ~600-char first sentence, contradicting the very authoring contract this PR introduces. Split it into a tight 149-char first sentence so the example actually models the contract. Test: headline() digit-guard-is-period-only case added. --- src/keboola_agent_cli/changelog.py | 28 ++++++++++++++++------------ tests/test_changelog_render.py | 6 ++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index e6732aa5..2e9015af 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -35,16 +35,17 @@ "(operator/function/phase/task/variable/const/array), and typed tasks " "(`job`/`notification`/`variable`). Execute one with " "`kbagent job run --component-id keboola.flow --config-id ID`.", - "Change (flow validation): `flow new`/`flow update` validate the body " - "against the live conditional-flow JSON Schema (Draft7), fetched at runtime " - "from the stack's component registry (AI Service `configurationSchema` for " - "`keboola.flow` -- never bundled/vendored), plus semantic checks: a phase " - "with conditional transitions must end with a default (condition-less) " - "transition; every phase needs >=1 enabled task; operator/function " - "operand-arity is enforced; unreachable phases are reported as warnings " - "(forward BFS from the first phase), and `goto` loops are legal (no cycle " - "detection). Invalid bodies are rejected with `INVALID_FLOW_DEFINITION` " - "(replaces `INVALID_FLOW_DAG`, removed from `ErrorCode`). When the schema " + "Change (flow validation): `flow new`/`flow update` now validate the body " + "against the live conditional-flow JSON Schema (Draft7) plus semantic " + "checks. The schema is fetched at runtime from the stack's component " + "registry (AI Service `configurationSchema` for `keboola.flow` -- never " + "bundled/vendored). Semantic checks: a phase with conditional transitions " + "must end with a default (condition-less) transition; every phase needs " + ">=1 enabled task; operator/function operand-arity is enforced; unreachable " + "phases are reported as warnings (forward BFS from the first phase), and " + "`goto` loops are legal (no cycle detection). Invalid bodies are rejected " + "with `INVALID_FLOW_DEFINITION` (replaces `INVALID_FLOW_DAG`, removed from " + "`ErrorCode`). When the schema " "fetch fails (network, KeboolaApiError, or empty/missing schema) the write " "is NOT blocked: structural validation is skipped, the semantic checks " "still run (the Storage API does not validate flow configs server-side), " @@ -857,8 +858,11 @@ def headline(note: str, max_chars: int = CHANGELOG_HEADLINE_MAX_CHARS) -> str: for match in _SENTENCE_BOUNDARY.finditer(note): dot = match.start() before = note[dot - 1] if dot > 0 else "" - if before.isdigit(): - continue # part of a version number like 0.57.0 + # Only a *period* after a digit is suspect (a version number like + # 0.57.0); a digit before "!" or "?" -- e.g. "exit code 5!" -- is a + # genuine sentence end and must not be skipped. + if note[dot] == "." and before.isdigit(): + continue token_match = _TRAILING_TOKEN.search(note[:dot]) token = token_match.group(0).rstrip(".").lower() if token_match else "" if token in _HEADLINE_ABBREVIATIONS: diff --git a/tests/test_changelog_render.py b/tests/test_changelog_render.py index faef280e..cf1f0320 100644 --- a/tests/test_changelog_render.py +++ b/tests/test_changelog_render.py @@ -65,6 +65,12 @@ def test_abbreviation_period_is_not_a_boundary(self) -> None: note = "Holds a dimension (e.g. a Chart of Accounts) as one record. More." assert headline(note) == "Holds a dimension (e.g. a Chart of Accounts) as one record." + def test_digit_guard_is_period_only(self) -> None: + # The digit guard targets version-number periods only -- a digit before + # "!" or "?" is a genuine sentence end and must still split. + assert headline("Exit code 5! Details follow.") == "Exit code 5!" + assert headline("Ready in v2? Yes, fully ready.") == "Ready in v2?" + def test_long_first_sentence_truncated_on_word_boundary(self) -> None: note = "Word " * 100 # no terminator, far over the cap out = headline(note)