Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions src/keboola_agent_cli/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
47 changes: 35 additions & 12 deletions src/keboola_agent_cli/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/keboola_agent_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 39 additions & 7 deletions tests/test_changelog_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Expand Down
Loading