diff --git a/CLAUDE.md b/CLAUDE.md index 3b9024d9..bcc70fb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1289,7 +1289,7 @@ kbagent update [--beta] # completes discovery first, then does the terminal exact-version reinstall and immediately # re-executes; failures print a copy-paste recovery command. kbagent changelog [--limit N] [--full] -# Default shows a one-line summary (first sentence) per version; --full / -v expands every note. +# Default shows first sentences: every BREAKING note of a version, plus its first other notes until at least two show; --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] [--no-banner] # `--config-dir` on serve (since 0.91.0, #679): `serve` is the only subcommand with a --config-dir of # its own, and most specific wins -- `serve --config-dir X` beats a root `kbagent --config-dir Y`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea416d62..944fe821 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -635,7 +635,7 @@ silent-drift risks summarized in the Those merged PRs are **exactly** the scope of the release: the changelog entry and the release notes must cover each of them, and nothing else. 2. **Edit `pyproject.toml`** -- bump `version = "X.Y.Z"`. Single source of truth; everything else derives from it. This is the release PR's defining change -- if you are doing this in a feature PR, stop and read the section intro above. -3. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- ONE entry for the new version, covering **every PR merged since the last release** (step 1), 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:`, ...), carrying its `(#PR)` reference, 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. The first sentence is also **capped at 160 characters**, enforced by `tests/test_changelog_render.py::TestLiveChangelogHeadlines::test_newest_release_notes_are_not_truncated` (so `make check` in step 12 catches it) -- past the cap the default view and the release page show it cut mid-clause. Write a short self-contained first sentence and put the detail in the sentences after it; 2 of 0.90.0's 13 bullets needed exactly this rewrite. +3. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- ONE entry for the new version, covering **every PR merged since the last release** (step 1), 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:`, ...), carrying its `(#PR)` reference, and leading with a self-contained first sentence. `kbagent changelog` shows only first sentences by default: of every `BREAKING` bullet of a version, plus of the first other bullets until at least two show (the rest is revealed by `--full`). The `What's new` notice after an update shows the first sentence of every bullet. So a buried headline or a single wall-of-text bullet reads as an unscannable blob. The first sentence is also **capped at 160 characters**, enforced by `tests/test_changelog_render.py::TestLiveChangelogHeadlines::test_newest_release_notes_are_not_truncated` (so `make check` in step 12 catches it) -- past the cap the default view and the release page show it cut mid-clause. Write a short self-contained first sentence and put the detail in the sentences after it; 2 of 0.90.0's 13 bullets needed exactly this rewrite. 4. **Replace every `vNEXT` placeholder** left behind by the feature PRs with the version being released. Do it mechanically -- never by hand, and never with a repo-wide `sed`: ```bash make vnext-resolve VERSION=X.Y.Z diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 7aab5b68..a7976673 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -9,7 +9,7 @@ All commands support `--json` for structured output. Multi-project flags (`--pro - `doctor` -- health check for CLI config (no `--fix` since v0.85.0 -- it only installed the MCP server) - `version [--beta]` -- show kbagent version info and update status (kbagent only since v0.85.0; no `dependencies` key). On a standalone binary the payload carries additive `kbagent.install_channel` + `kbagent.upgrade_hint` keys and `upgrade_command` holds the channel's command (empty for a hand-unpacked archive). `--beta` 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` 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. **Standalone (PyInstaller) binaries refuse the self-update** and report their own channel's command instead -- a uv/pip reinstall would install a second, unrelated kbagent rather than upgrade the packaged one -- `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` +- `changelog [--limit N] [--full]` -- show recent changelog (default: last 5 versions; per version the first sentence of every BREAKING note, plus of the first other notes until at least two show; `--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 ## Programmatic Auth (Browser Login) (since v0.80.0) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 4c4d30d1..64053af2 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -2,8 +2,9 @@ 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): +Authoring contract (keep entries scannable -- ``kbagent changelog`` shows +only first sentences by default: of every ``BREAKING`` note of a version, plus +of its first other notes until at least two show): * One *logical* change per bullet -- split a release into several entries instead of cramming everything into one paragraph. @@ -12,8 +13,9 @@ ``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``. +* Lead with a self-contained first sentence -- that sentence is the note's + headline in the default view and in the "What's new" notice after an update; + everything after it is detail shown only under ``--full``. """ from __future__ import annotations diff --git a/src/keboola_agent_cli/commands/changelog.py b/src/keboola_agent_cli/commands/changelog.py index 5a35f8f3..f0a4f69f 100644 --- a/src/keboola_agent_cli/commands/changelog.py +++ b/src/keboola_agent_cli/commands/changelog.py @@ -13,6 +13,7 @@ from rich.text import Text from ..changelog import DEFAULT_CHANGELOG_LIMIT, get_changelog, headline +from ..constants import CHANGELOG_SUMMARY_NOTES from ._helpers import get_formatter # Map each known prefix word to a Rich style. Order does not matter; the @@ -99,12 +100,29 @@ def _print_bullet(console: Console, styled: Text, body_width: int) -> None: console.print(row) +def _summary_notes(notes: list[str]) -> list[str]: + """Return the notes that the default view shows for one version. + + Every BREAKING note, so a user who only skims still sees each migration + step, plus the first other notes until at least ``CHANGELOG_SUMMARY_NOTES`` + show. The notes keep their changelog order. + """ + breaking = { + i + for i, note in enumerate(notes) + if (m := _PREFIX_RE.match(note)) is not None and m.group(1).lower() == "breaking" + } + others = [i for i in range(len(notes)) if i not in breaking] + fill = others[: max(0, CHANGELOG_SUMMARY_NOTES - len(breaking))] + return [notes[i] for i in sorted(breaking.union(fill))] + + 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. + Default (``full=False``): per version, the first sentence of each note + that ``_summary_notes`` picks, plus a dim ``(+N more)`` when the version + carries other 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 @@ -118,14 +136,18 @@ def _format_changelog_human(console: Console, data: dict, *, full: bool) -> None 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 + shown = _summary_notes(notes) + extra = len(notes) - len(shown) 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) + for j, note in enumerate(shown): + head = headline(note) + styled = _styled_note(head) + if extra > 0 and j == len(shown) - 1: + styled.append(f" (+{extra} more)", style="dim") + if head != note.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: @@ -147,13 +169,14 @@ def changelog_command( False, "--full", "-v", - help="Show complete notes for each version (default: one-line summary).", + help="Show complete notes for each version (default: headlines of the BREAKING and first notes).", ), ) -> 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. + By default each version shows the first sentence of each BREAKING note, + plus of its first other notes until at least two show. 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 diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index ab42b443..1648f11b 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -2202,7 +2202,8 @@ 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. + versions, the first sentence of every BREAKING note, plus of the first other + notes until at least two show; --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 a2e6b0b5..055ab5c0 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -613,11 +613,13 @@ def _resolve_app_name() -> str: PAYG_FEATURE: str = "pay-as-you-go" # --- 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 +# `kbagent changelog` shows headlines by default (--full expands): every +# BREAKING note of a version, plus its first other notes until at least +# CHANGELOG_SUMMARY_NOTES show. A headline 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 +CHANGELOG_SUMMARY_NOTES: int = 2 # --- 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 index fe9f6836..f4a6a79a 100644 --- a/tests/test_changelog_render.py +++ b/tests/test_changelog_render.py @@ -4,8 +4,9 @@ * ``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. +* ``commands/changelog`` -- default headlines vs ``--full`` (every BREAKING + note, plus the first other notes until at least two show), 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. @@ -37,8 +38,9 @@ def _render(entries: dict[str, list[str]], *, full: bool) -> str: 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). +# A multi-version fixture: 1.2.0 has three notes and no BREAKING one (so a +# summary shows two and hides one), 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.", @@ -88,14 +90,44 @@ def test_dangling_backtick_dropped_on_truncation(self) -> None: class TestRendererSummary: - def test_shows_headline_and_more_count(self) -> None: + def test_without_breaking_shows_first_two_headlines_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 "Fix: beta thing. (+1 more)" in out # count on the last shown note assert "Detail about alpha" not in out # detail hidden - assert "Fix: beta thing." not in out # sibling notes hidden + assert "Internal: gamma thing." not in out # third note hidden assert "--full" in out # footer hint present + def test_shows_every_breaking_headline_and_only_those(self) -> None: + notes = [ + "New: alpha thing.", + "BREAKING (#1): beta removed. Migration detail stays hidden.", + "Fix: gamma thing.", + "Breaking: delta renamed.", + ] + out = _render({"2.0.0": notes}, full=False) + assert "BREAKING (#1): beta removed." in out + assert "Breaking: delta renamed. (+2 more)" in out + assert "Migration detail" not in out + assert "New: alpha thing." not in out # non-breaking notes hidden + assert "Fix: gamma thing." not in out + assert out.index("beta removed") < out.index("delta renamed") # changelog order + + def test_single_breaking_note_is_filled_up_with_the_first_other_note(self) -> None: + notes = ["New: alpha thing.", "Fix: beta thing.", "BREAKING: gamma removed."] + out = _render({"2.1.0": notes}, full=False) + assert "New: alpha thing." in out # first other note fills the second line + assert "BREAKING: gamma removed. (+1 more)" in out + assert "Fix: beta thing." not in out + assert out.index("alpha thing") < out.index("gamma removed") # changelog order + + def test_two_short_notes_hide_nothing(self) -> None: + out = _render({"1.3.0": ["New: one.", "Fix: two."]}, full=False) + assert "New: one." in out + assert "Fix: two." in out + assert "(+" not in out + assert "--full" not in out # nothing hidden -> no hint + 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