diff --git a/changelog/unreleased/release-scoped-changelog-entry-ids.md b/changelog/unreleased/release-scoped-changelog-entry-ids.md new file mode 100644 index 0000000..a5d8c9e --- /dev/null +++ b/changelog/unreleased/release-scoped-changelog-entry-ids.md @@ -0,0 +1,16 @@ +--- +title: Release-scoped changelog entry IDs +type: bugfix +authors: + - mavam + - codex +prs: + - 39 +created: 2026-07-21T11:07:32.400232Z +--- + +Release creation now treats changelog entry IDs as unique within each release +instead of across the entire project history. Reusing a slug from an older +release remains eligible for automatic version bumps, validation checks +release-local references, and show output preserves every occurrence with its +release context. diff --git a/schemas/release-manifest.schema.json b/schemas/release-manifest.schema.json index 260c3d3..cb1c91b 100644 --- a/schemas/release-manifest.schema.json +++ b/schemas/release-manifest.schema.json @@ -25,6 +25,7 @@ }, "entries": { "type": "array", + "uniqueItems": true, "items": { "type": "string", "pattern": "\\S", diff --git a/src/tenzir_ship/cli/_export.py b/src/tenzir_ship/cli/_export.py index c1f5063..985fff8 100644 --- a/src/tenzir_ship/cli/_export.py +++ b/src/tenzir_ship/cli/_export.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date +from pathlib import Path from typing import Optional from ..config import Config @@ -42,6 +43,7 @@ def _entry_to_dict( data = { "id": entry.entry_id, + "release": entry.release, "title": title, "type": entry_type, "created": entry.created_at.isoformat() if entry.created_at else None, @@ -104,7 +106,7 @@ def _render_markdown_release_block( manifest: ReleaseManifest | None, entries: list[Entry], config: Config, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], *, include_emoji: bool = True, explicit_links: bool = False, @@ -188,7 +190,7 @@ def _export_markdown_release( manifest: Optional[ReleaseManifest], entries: list[Entry], config: Config, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], *, include_emoji: bool = True, explicit_links: bool = False, @@ -237,7 +239,7 @@ def _export_markdown_compact( manifest: Optional[ReleaseManifest], entries: list[Entry], config: Config, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], *, include_emoji: bool = True, explicit_links: bool = False, diff --git a/src/tenzir_ship/cli/_release.py b/src/tenzir_ship/cli/_release.py index 6f08c3e..69ff48f 100644 --- a/src/tenzir_ship/cli/_release.py +++ b/src/tenzir_ship/cli/_release.py @@ -638,6 +638,18 @@ def _combine_release_entries( existing_entries = _load_manifest_entries(project_root, existing_manifest) existing_entry_ids = {entry.entry_id for entry in existing_entries} + colliding_entry_ids = sorted( + {entry.entry_id for entry in selected_entries} & existing_entry_ids + ) + if colliding_entry_ids: + target = render_release_tag(existing_manifest.version) if existing_manifest else "release" + quoted_ids = ", ".join(f"'{entry_id}'" for entry_id in colliding_entry_ids) + raise click.ClickException( + f"Cannot add entries to {target} because that release already contains " + f"the same entry ID(s): {quoted_ids}. Entry IDs must be unique within " + "a release. Rename the unreleased entry or choose a different target release." + ) + new_entries = [entry for entry in selected_entries if entry.entry_id not in existing_entry_ids] combined_entries: dict[str, Entry] = {entry.entry_id: entry for entry in existing_entries} for entry in new_entries: diff --git a/src/tenzir_ship/cli/_rendering.py b/src/tenzir_ship/cli/_rendering.py index d458817..b6704fe 100644 --- a/src/tenzir_ship/cli/_rendering.py +++ b/src/tenzir_ship/cli/_rendering.py @@ -330,7 +330,7 @@ def _build_release_sort_order(project_root: Path) -> dict[str, int]: def _sort_entries_for_display( entries: Iterable[Entry], - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], release_order: dict[str, int], ) -> list[Entry]: """Sort entries so the newest entry ends up last in the table view.""" @@ -338,7 +338,7 @@ def _sort_entries_for_display( unreleased_rank = len(release_order) + 1 def sort_key(entry: Entry) -> tuple[int, datetime, str]: - versions = release_index.get(entry.entry_id) or [] + versions = release_index.get(entry.path) or [] if versions: ranks = [release_order.get(version, unreleased_rank) for version in versions] release_rank = min(ranks) @@ -353,11 +353,11 @@ def sort_key(entry: Entry) -> tuple[int, datetime, str]: def _entry_release_group( entry: Entry, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], release_order: dict[str, int], ) -> int: """Return a group key for sectioning entries by release.""" - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) if not versions: return -1 # unreleased return max(release_order.get(v, 0) for v in versions) @@ -365,13 +365,13 @@ def _entry_release_group( def _render_entries( entries: Iterable[Entry], - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], config: Config, show_banner: bool = False, release_order: dict[str, int] | None = None, *, include_emoji: bool = True, - release_versions: dict[str, str] | None = None, + release_versions: dict[Path, str] | None = None, ) -> None: if show_banner: _render_project_header(config) @@ -488,7 +488,7 @@ def _render_entries( metadata = entry.metadata created_display = entry.created_date.isoformat() if entry.created_date else "—" type_value = metadata.get("type", "change") - versions = release_index.get(entry.entry_id) + versions = release_index.get(entry.path) version_display = ", ".join(versions) if versions else "—" if include_emoji: glyph = ENTRY_TYPE_EMOJIS.get(type_value, "•") @@ -507,7 +507,7 @@ def _render_entries( if "version" in visible_columns: row.append(version_display) if "release" in visible_columns: - release_display = release_versions.get(entry.entry_id, "—") if release_versions else "—" + release_display = release_versions.get(entry.path, "—") if release_versions else "—" row.append(release_display) if "prs" in visible_columns: pr_numbers = _parse_pr_numbers(metadata) @@ -532,9 +532,9 @@ def _render_entries( end_section = False if release_versions is not None and index < len(sorted_entries) - 1: # Section dividers based on release_versions mapping - current_release = release_versions.get(entry.entry_id) + current_release = release_versions.get(entry.path) next_entry = sorted_entries[index + 1] - next_release = release_versions.get(next_entry.entry_id) + next_release = release_versions.get(next_entry.path) if current_release != next_release: end_section = True elif release_order is not None and index < len(sorted_entries) - 1: @@ -728,7 +728,7 @@ def _render_entries_multi_project( project_order = {config.id: index for index, (_, config) in enumerate(projects)} # Build release index for each project - release_indices: dict[str, dict[str, list[str]]] = {} + release_indices: dict[str, dict[Path, list[str]]] = {} for project_root, config in projects: release_indices[config.id] = build_entry_release_index(project_root, project=config.id) @@ -782,7 +782,7 @@ def sort_key(multi: MultiProjectEntry) -> tuple[int, float, str]: type_value = metadata.get("type", "change") # Get version from project-specific release index proj_release_index = release_indices.get(project_id, {}) - versions = proj_release_index.get(entry.entry_id) + versions = proj_release_index.get(entry.path) version_display = ", ".join(versions) if versions else "—" if include_emoji: glyph = ENTRY_TYPE_EMOJIS.get(type_value, "•") diff --git a/src/tenzir_ship/cli/_show.py b/src/tenzir_ship/cli/_show.py index 69c0345..7621704 100644 --- a/src/tenzir_ship/cli/_show.py +++ b/src/tenzir_ship/cli/_show.py @@ -35,12 +35,12 @@ build_entry_release_index, collect_release_entries, is_release_candidate, + is_stable_release, iter_release_manifests, load_release_entry, parse_release_version, render_release_tag, - unused_entries, - used_entry_ids, + stable_release_version, ) from ..utils import ( emit_output, @@ -170,11 +170,34 @@ def _collect_unused_entries_for_release( *, include_prereleases: bool = False, ) -> list[Entry]: - """Collect unreleased entries that haven't been included in matching releases.""" - all_entries = list(iter_entries(project_root)) - used = used_entry_ids(project_root, include_prereleases=include_prereleases) - unused = unused_entries(all_entries, used) - filtered = [entry for entry in unused if entry.project is None or entry.project == config.id] + """Collect entries from the current unreleased namespace. + + Entry IDs may be reused in later releases, so historical manifests do not + affect this selection. When editing an existing stable release, callers can + request that entries already owned by a release-candidate lineage remain + outside the older target release. + """ + entries = list(iter_entries(project_root)) + if include_prereleases: + # Editing an older stable release must not pull in entries owned by an + # active release-candidate cycle. This is the one intentional + # cross-manifest relationship: candidates and their eventual stable + # release form a single release lineage. + manifests = list(iter_release_manifests(project_root)) + stable_versions = { + stable_release_version(manifest.version) + for manifest in manifests + if is_stable_release(manifest.version) + } + prerelease_entry_ids = { + entry_id + for manifest in manifests + if is_release_candidate(manifest.version) + and stable_release_version(manifest.version) not in stable_versions + for entry_id in manifest.entries + } + entries = [entry for entry in entries if entry.entry_id not in prerelease_entry_ids] + filtered = [entry for entry in entries if entry.project is None or entry.project == config.id] return filtered @@ -337,7 +360,7 @@ def _append_release_module_sections( def _load_release_entries_for_display( project_root: Path, release_version: str, - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], ) -> tuple[ReleaseManifest, list[Entry]]: """Load entries for a specific release version.""" normalized_version = render_release_tag(release_version).lower() @@ -356,7 +379,9 @@ def _load_release_entries_for_display( if entry is None: missing_entries.append(entry_id) continue - entry_map[entry_id] = entry + occurrences = entry_map.setdefault(entry_id, []) + if all(existing.path != entry.path for existing in occurrences): + occurrences.append(entry) release_entries.append(entry) if missing_entries: missing_list = ", ".join(sorted(missing_entries)) @@ -372,7 +397,7 @@ def _resolve_identifier( project_root: Path, config: Config, sorted_entries: list[Entry], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], known_versions: dict[str, str], allowed_kinds: Optional[Iterable[IdentifierKind]] = None, ) -> IdentifierResolution: @@ -428,25 +453,24 @@ def _resolve_identifier( manifest=manifest, ) - exact_match = entry_map.get(token) - if exact_match: - return IdentifierResolution(kind="entry", entries=[exact_match], identifier=token) + exact_matches = entry_map.get(token) + if exact_matches: + return IdentifierResolution(kind="entry", entries=exact_matches, identifier=token) - matches = [(entry_id, entry) for entry_id, entry in entry_map.items() if token in entry_id] - if not matches: + matching_ids = [entry_id for entry_id in entry_map if token in entry_id] + if not matching_ids: raise click.ClickException( f"No entry found matching '{token}'. Use 'tenzir-ship show' to see all entries." ) - if len(matches) > 1: - match_ids = [entry_id for entry_id, _ in matches] + if len(matching_ids) > 1: raise click.ClickException( f"Multiple entries match '{token}':\n " - + "\n ".join(match_ids) + + "\n ".join(matching_ids) + "\n\nPlease be more specific or use a row number." ) - entry_id, entry = matches[0] - return IdentifierResolution(kind="entry", entries=[entry], identifier=entry_id) + entry_id = matching_ids[0] + return IdentifierResolution(kind="entry", entries=entry_map[entry_id], identifier=entry_id) def _resolve_identifiers_sequence( @@ -455,12 +479,35 @@ def _resolve_identifiers_sequence( project_root: Path, config: Config, sorted_entries: list[Entry], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], known_versions: dict[str, str], allowed_kinds: Optional[Iterable[IdentifierKind]] = None, + scope: Scope = "all", ) -> list[IdentifierResolution]: """Resolve a list of identifiers into their matching entries.""" - return [ + if scope != "all": + latest_version = None + if scope == "latest": + latest = _get_latest_release_manifest(project_root) + if latest is None: + raise click.ClickException("No stable releases found.") + latest_version = render_release_tag(latest.version) + + def in_scope(entry: Entry) -> bool: + if scope == "unreleased": + return entry.release is None + if scope == "released": + return entry.release is not None + return entry.release == latest_version + + entry_map = { + entry_id: matches + for entry_id, occurrences in entry_map.items() + if (matches := [entry for entry in occurrences if in_scope(entry)]) + } + sorted_entries = [entry for entry in sorted_entries if in_scope(entry)] + + resolutions = [ _resolve_identifier( identifier, project_root=project_root, @@ -472,18 +519,33 @@ def _resolve_identifiers_sequence( ) for identifier in identifiers ] + if scope != "all": + for resolution in resolutions: + if ( + resolution.kind == "release" + and scope == "latest" + and resolution.manifest is not None + and render_release_tag(resolution.manifest.version) != latest_version + ): + raise click.ClickException( + f"Release '{resolution.identifier}' is outside the 'latest' scope." + ) + resolution.entries = [entry for entry in resolution.entries if in_scope(entry)] + return resolutions def _gather_entry_context( project_root: Path, modules: list[Module] | None = None, -) -> tuple[dict[str, Entry], dict[str, list[str]], dict[str, int], list[Entry]]: +) -> tuple[dict[str, list[Entry]], dict[Path, list[str]], dict[str, int], list[Entry]]: """Gather all entries and build release index for display.""" entries = list(iter_entries(project_root)) - entry_map = {entry.entry_id: entry for entry in entries} + entry_map: dict[str, list[Entry]] = {} + for entry in entries: + entry_map.setdefault(entry.entry_id, []).append(entry) released_entries = collect_release_entries(project_root) - for entry_id, entry in released_entries.items(): - entry_map.setdefault(entry_id, entry) + for entry in released_entries: + entry_map.setdefault(entry.entry_id, []).append(entry) release_index_all = build_entry_release_index(project_root, project=None) release_order = _build_release_sort_order(project_root) @@ -491,10 +553,10 @@ def _gather_entry_context( for module in modules: module_entries = list(iter_entries(module.root)) for entry in module_entries: - entry_map.setdefault(entry.entry_id, entry) + entry_map.setdefault(entry.entry_id, []).append(entry) module_released = collect_release_entries(module.root) - for entry_id, entry in module_released.items(): - entry_map.setdefault(entry_id, entry) + for entry in module_released: + entry_map.setdefault(entry.entry_id, []).append(entry) module_release_index = build_entry_release_index(module.root, project=None) for entry_id, versions in module_release_index.items(): if entry_id in release_index_all: @@ -505,15 +567,34 @@ def _gather_entry_context( for version, order in module_release_order.items(): release_order.setdefault(version, order) - sorted_entries = _sort_entries_for_display(entry_map.values(), release_index_all, release_order) + all_entries = [entry for occurrences in entry_map.values() for entry in occurrences] + sorted_entries = _sort_entries_for_display(all_entries, release_index_all, release_order) return entry_map, release_index_all, release_order, sorted_entries +def _group_entries_by_release_context( + project_root: Path, + entries: Iterable[Entry], +) -> list[tuple[ReleaseManifest | None, list[Entry]]]: + """Group entry occurrences by their release namespace.""" + manifests_by_tag = { + render_release_tag(manifest.version): manifest + for manifest in iter_release_manifests(project_root) + } + grouped: dict[str | None, list[Entry]] = {} + for entry in entries: + grouped.setdefault(entry.release, []).append(entry) + return [ + (manifests_by_tag.get(release) if release else None, grouped_entries) + for release, grouped_entries in grouped.items() + ] + + def _render_release_card( manifest: ReleaseManifest | None, entries: list[Entry], config: Config, - release_index: dict[str, list[str]], + release_index: dict[Path, list[str]], *, include_emoji: bool, compact: bool = True, @@ -618,7 +699,7 @@ def _show_entries_table_all( all_entries: list[Entry] if release_mode: # Build unified table with Release column (oldest release first) - release_versions: dict[str, str] = {} + release_versions: dict[Path, str] = {} all_entries = [] # Add released entries grouped by release (oldest first) @@ -631,12 +712,12 @@ def _show_entries_table_all( release_entries.append(entry) filtered = _filter_entries_by_component(release_entries, components) for entry in filtered: - release_versions[entry.entry_id] = render_release_tag(manifest.version) + release_versions[entry.path] = render_release_tag(manifest.version) all_entries.append(entry) # Add unreleased entries last (they are the "newest" release) for entry in unreleased_entries: - release_versions[entry.entry_id] = UNRELEASED_LABEL + release_versions[entry.path] = UNRELEASED_LABEL all_entries.append(entry) # Sort entries by release first, then by date (oldest first) @@ -681,9 +762,10 @@ def _show_entries_table_release_mode( *, components: set[str], include_emoji: bool, - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], + scope: Scope, ) -> None: """Handle --release flag with identifiers: display entries in a unified table with Release column.""" config = ctx.ensure_config() @@ -697,6 +779,7 @@ def _show_entries_table_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) # Build flat list of entries with their release versions @@ -708,7 +791,7 @@ def _show_entries_table_release_mode( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for release_manifest in iter_release_manifests(project_root): @@ -739,12 +822,12 @@ def _show_entries_table_release_mode( raise click.ClickException("No entries found for the given identifiers.") # Build release_versions mapping and flat entry list (preserving release group order) - release_versions: dict[str, str] = {} + release_versions: dict[Path, str] = {} all_entries: list[Entry] = [] for manifest, entries in release_groups: version_label = render_release_tag(manifest.version) if manifest else UNRELEASED_LABEL for entry in entries: - release_versions[entry.entry_id] = version_label + release_versions[entry.path] = version_label all_entries.append(entry) if not all_entries: @@ -817,16 +900,18 @@ def filtered_with_modules( components = _normalize_component_filters(component_filter, config) entries = list(iter_entries(project_root)) - entry_map = {entry.entry_id: entry for entry in entries} + entry_map: dict[str, list[Entry]] = {} + for entry in entries: + entry_map.setdefault(entry.entry_id, []).append(entry) released_entries = collect_release_entries(project_root) - for entry_id, entry in released_entries.items(): - if entry_id not in entry_map: - entry_map[entry_id] = entry + for entry in released_entries: + entry_map.setdefault(entry.entry_id, []).append(entry) release_index = build_entry_release_index(project_root, project=config.id) release_order = _build_release_sort_order(project_root) - sorted_entries = _sort_entries_for_display(entry_map.values(), release_index, release_order) + all_entries = [entry for occurrences in entry_map.values() for entry in occurrences] + sorted_entries = _sort_entries_for_display(all_entries, release_index, release_order) # Handle scope-based filtering (no specific identifiers provided) if not identifiers: @@ -849,6 +934,7 @@ def filtered_with_modules( entry_map=entry_map, sorted_entries=sorted_entries, known_versions=known_versions, + scope=scope, ) return @@ -860,6 +946,7 @@ def filtered_with_modules( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release" and not components: release_resolution = resolutions[0] @@ -971,7 +1058,7 @@ def _show_entries_card( raise click.ClickException("No entries found.") for entry in all_entries: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) _render_single_entry(entry, versions, include_emoji=include_emoji) return @@ -988,6 +1075,7 @@ def _show_entries_card( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_groups: list[tuple[ReleaseManifest | None, list[Entry]]] = [] @@ -998,7 +1086,7 @@ def _show_entries_card( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index_all.get(entry.entry_id, []) + versions = release_index_all.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for release_manifest in iter_release_manifests(project_root): @@ -1070,8 +1158,6 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: sorted_multi = sorted(multi_entries, key=sort_key) sorted_entries = [item.entry for item in sorted_multi] - for item in sorted_multi: - entry_map.setdefault(item.entry.entry_id, item.entry) resolutions = _resolve_identifiers_sequence( identifiers, @@ -1080,6 +1166,7 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_index = release_index_all @@ -1089,7 +1176,7 @@ def sort_key(item: MultiProjectEntry) -> tuple[float, int, str]: if not filtered_entries: continue for entry in filtered_entries: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) if resolution.kind == "release" and resolution.manifest: version = render_release_tag(resolution.manifest.version) if version and version not in versions: @@ -1291,9 +1378,10 @@ def _show_entries_export_release_mode( include_emoji: bool, explicit_links: bool, components: set[str], - entry_map: dict[str, Entry], + entry_map: dict[str, list[Entry]], sorted_entries: list[Entry], known_versions: dict[str, str], + scope: Scope, ) -> None: """Handle --release flag with explicit identifiers: group entries by release.""" config = ctx.ensure_config() @@ -1308,6 +1396,7 @@ def _show_entries_export_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release": if view == "json": @@ -1418,6 +1507,7 @@ def _show_entries_export_release_mode( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) release_groups: list[tuple[ReleaseManifest | None, list[Entry]]] = [] @@ -1428,7 +1518,7 @@ def _show_entries_export_release_mode( release_groups.append((resolution.manifest, filtered)) else: for entry in filtered: - versions = release_index.get(entry.entry_id, []) + versions = release_index.get(entry.path, []) target_version = _preferred_release_version(versions) if target_version: for manifest in iter_release_manifests(project_root): @@ -1528,6 +1618,7 @@ def _show_entries_export( entry_map=entry_map, sorted_entries=sorted_entries, known_versions=known_versions, + scope=scope, ) return @@ -1541,16 +1632,16 @@ def _show_entries_export( sorted_entries=sorted_entries, entry_map=entry_map, known_versions=known_versions, + scope=scope, ) if len(resolutions) == 1 and resolutions[0].kind == "release": manifest_for_export = resolutions[0].manifest - ordered_entries: dict[str, Entry] = {} + ordered_entries: dict[Path, Entry] = {} for resolution in resolutions: for entry in resolution.entries: - if entry.entry_id not in ordered_entries: - ordered_entries[entry.entry_id] = entry + ordered_entries.setdefault(entry.path, entry) filtered_entries = _filter_entries_by_component(ordered_entries.values(), components) export_entries = sort_entries_desc(filtered_entries) @@ -1582,6 +1673,27 @@ def _show_entries_export( ) if view == "markdown": + if ( + len(resolutions) == 1 + and resolutions[0].kind == "entry" + and len({entry.release for entry in export_entries}) > 1 + ): + blocks = [ + _render_markdown_release_block( + manifest, + entries, + config, + release_index_export, + include_emoji=include_emoji, + explicit_links=explicit_links, + compact=compact_flag, + ) + for manifest, entries in _group_entries_by_release_context( + project_root, export_entries + ) + ] + emit_output("\n---\n\n".join(blocks), newline=False) + return if compact_flag: content = _export_markdown_compact( manifest_for_export, diff --git a/src/tenzir_ship/cli/_stats.py b/src/tenzir_ship/cli/_stats.py index 069b1c6..2d15bea 100644 --- a/src/tenzir_ship/cli/_stats.py +++ b/src/tenzir_ship/cli/_stats.py @@ -126,7 +126,7 @@ def _collect_project_stats(project_root: Path) -> dict: # including them here would double-count entry totals. released_entries = collect_release_entries(project_root, include_prereleases=False) released_types: Counter[str] = Counter() - for entry in released_entries.values(): + for entry in released_entries: released_types[entry.type] += 1 shipped_count = len(released_entries) diff --git a/src/tenzir_ship/entries.py b/src/tenzir_ship/entries.py index cc668fb..85f9784 100644 --- a/src/tenzir_ship/entries.py +++ b/src/tenzir_ship/entries.py @@ -32,6 +32,7 @@ class Entry: metadata: dict[str, Any] body: str path: Path + release: Optional[str] = None @property def title(self) -> str: @@ -351,14 +352,10 @@ def iter_multi_project_entries(projects: list[tuple[Path, Any]]) -> Iterable[Mul for project_root, config in projects: project_id = getattr(config, "id", slugify(project_root.name)) project_name = getattr(config, "name", str(project_root.name)) - # Collect all entries (unreleased and released), avoiding duplicates - entry_map: dict[str, Entry] = {} - for entry in iter_entries(project_root): - entry_map[entry.entry_id] = entry - for entry_id, entry in collect_release_entries(project_root).items(): - if entry_id not in entry_map: - entry_map[entry_id] = entry - for entry in entry_map.values(): + # Entry IDs are unique within one release namespace, not across the + # project's complete history. Preserve every occurrence here. + entries = [*iter_entries(project_root), *collect_release_entries(project_root)] + for entry in entries: yield MultiProjectEntry( entry=entry, project_root=project_root, diff --git a/src/tenzir_ship/releases.py b/src/tenzir_ship/releases.py index ab1a323..6af0bd5 100644 --- a/src/tenzir_ship/releases.py +++ b/src/tenzir_ship/releases.py @@ -319,40 +319,41 @@ def load_release_entry( entry_path = resolve_release_entry_path(project_root, manifest, entry_id) if entry_path is None: return None - return read_entry(entry_path) + entry = read_entry(entry_path) + entry.release = render_release_tag(manifest.version) + return entry -def collect_release_entries( - project_root: Path, *, include_prereleases: bool = True -) -> dict[str, Entry]: - """Return a mapping of entry ids to entries across release manifests. +def collect_release_entries(project_root: Path, *, include_prereleases: bool = True) -> list[Entry]: + """Return every entry occurrence across release manifests. By default, prerelease manifests are included. Pass ``include_prereleases=False`` when only stable releases should contribute to shipped entry counts. """ - collected: dict[str, Entry] = {} + collected: list[Entry] = [] for manifest in iter_release_manifests(project_root): if not include_prereleases and not is_stable_release(manifest.version): continue for entry_id in manifest.entries: - if entry_id in collected: - continue entry = load_release_entry(project_root, manifest, entry_id) if entry is not None: - collected[entry_id] = entry + collected.append(entry) return collected def build_entry_release_index( project_root: Path, *, project: Optional[str] = None -) -> dict[str, list[str]]: - """Return a mapping from entry id to associated release versions.""" - index: dict[str, list[str]] = {} +) -> dict[Path, list[str]]: + """Return a mapping from entry occurrence paths to release versions.""" + index: dict[Path, list[str]] = {} for manifest in iter_release_manifests(project_root): version = render_release_tag(manifest.version) for entry_id in manifest.entries: - versions = index.setdefault(entry_id, []) + entry_path = resolve_release_entry_path(project_root, manifest, entry_id) + if entry_path is None: + continue + versions = index.setdefault(entry_path, []) if version not in versions: versions.append(version) diff --git a/src/tenzir_ship/validate.py b/src/tenzir_ship/validate.py index 4b1cca9..51fa919 100644 --- a/src/tenzir_ship/validate.py +++ b/src/tenzir_ship/validate.py @@ -419,17 +419,13 @@ def validate_entry(entry: Entry, config: Config) -> Iterable[ValidationIssue]: def validate_release_ids( - entries: Iterable[Entry], releases: Iterable[ReleaseManifest], project_root: Path, issues: list[ValidationIssue], ) -> None: """Ensure release manifests reference existing entry IDs.""" - entry_ids = {entry.entry_id for entry in entries} for manifest in releases: for entry_id in manifest.entries: - if entry_id in entry_ids: - continue entry_path = resolve_release_entry_path(project_root, manifest, entry_id) if entry_path is None: issues.append( @@ -467,7 +463,7 @@ def run_validation(project_root: Path, config: Config) -> list[ValidationIssue]: for entry in all_entries: issues.extend(validate_entry(entry, config)) - validate_release_ids(all_entries, releases, project_root, issues) + validate_release_ids(releases, project_root, issues) return issues diff --git a/tests/test_cli.py b/tests/test_cli.py index 65f3dfa..2e589cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7139,7 +7139,7 @@ def test_release_create_ignores_closed_release_candidate_history(tmp_path: Path) assert (project_dir / "releases" / "v1.3.0-rc.2").exists() -def test_collect_unused_entries_for_release_ignores_release_candidates_by_default( +def test_collect_unused_entries_for_release_filters_only_outstanding_candidates( tmp_path: Path, ) -> None: runner = CliRunner() @@ -7175,6 +7175,26 @@ def test_collect_unused_entries_for_release_ignores_release_candidates_by_defaul ) assert [entry.entry_id for entry in unused_entries] == ["rc-feature"] + unused_entries = _collect_unused_entries_for_release( + project_dir, + load_config(project_dir / "config.yaml"), + include_prereleases=True, + ) + assert unused_entries == [] + + stable_dir = project_dir / "releases" / "v1.2.3" + stable_dir.mkdir() + (stable_dir / "manifest.yaml").write_text( + "version: v1.2.3\ncreated: 2025-01-02\nentries:\n - rc-feature\n", + encoding="utf-8", + ) + unused_entries = _collect_unused_entries_for_release( + project_dir, + load_config(project_dir / "config.yaml"), + include_prereleases=True, + ) + assert [entry.entry_id for entry in unused_entries] == ["rc-feature"] + def test_collect_unused_entries_for_release_ignores_non_stable_legacy_prereleases( tmp_path: Path, @@ -8929,3 +8949,199 @@ def test_show_version_case_insensitive(tmp_path: Path) -> None: ) assert markdown_result.exit_code == 0, markdown_result.output assert "Case Test Feature" in markdown_result.output + + +def test_entry_ids_are_scoped_to_each_release(tmp_path: Path) -> None: + """Historical ID reuse stays releasable, visible, valid, and countable.""" + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + entry_id = "dependency-compatibility-updates" + + def add_compatibility_update(description: str) -> None: + result = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "add", + "--title", + "Dependency Compatibility Updates", + "--type", + "bugfix", + "--description", + description, + "--author", + "tester", + ], + ) + assert result.exit_code == 0, result.output + + add_compatibility_update("Original compatibility update.") + first_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.0", "--yes"], + ) + assert first_release.exit_code == 0, first_release.output + + # Reusing the generated slug in the current unreleased namespace is valid. + add_compatibility_update("A later compatibility update.") + validation = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + assert validation.exit_code == 0, validation.output + + # The same ID cannot be appended to the release that already owns it. + target_collision = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.0", "--yes"], + ) + assert target_collision.exit_code != 0 + assert "already contains the same entry ID" in target_collision.output + + # Auto-bumping must see the reused unreleased entry instead of treating it + # as consumed by v3.0.0. + second_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "--yes"], + ) + assert second_release.exit_code == 0, second_release.output + assert (project_dir / "releases" / "v3.0.1" / "entries" / f"{entry_id}.md").exists() + + out_of_scope_release = runner.invoke( + cli, + ["--root", str(project_dir), "show", "latest", "v3.0.0"], + ) + assert out_of_scope_release.exit_code != 0 + assert "Release 'v3.0.0' is outside the 'latest' scope" in out_of_scope_release.output + + add_compatibility_update("An unreleased compatibility update.") + + show_json = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + entry_id, + "--json", + ], + ) + assert show_json.exit_code == 0, show_json.output + payload = json.loads(show_json.output) + assert [entry["release"] for entry in payload["entries"]] == [ + None, + "v3.0.1", + "v3.0.0", + ] + + unreleased_json = runner.invoke( + cli, + ["--root", str(project_dir), "show", "unreleased", entry_id, "-j"], + ) + assert unreleased_json.exit_code == 0, unreleased_json.output + payload = json.loads(unreleased_json.output) + assert [entry["release"] for entry in payload["entries"]] == [None] + + show_markdown = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + entry_id, + "--markdown", + ], + ) + assert show_markdown.exit_code == 0, show_markdown.output + assert "# Unreleased Changes" in show_markdown.output + assert "# v3.0.0" in show_markdown.output + assert "# v3.0.1" in show_markdown.output + + show_card = runner.invoke( + cli, + [ + "--root", + str(project_dir), + "show", + entry_id, + "--card", + ], + ) + assert show_card.exit_code == 0, show_card.output + card_output = click.utils.strip_ansi(show_card.output) + assert card_output.count("Entry ID:") == 3 + assert "Released in v3.0.0" in card_output + assert "Released in v3.0.1" in card_output + assert "Unreleased" in card_output + + release_candidate = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v3.0.2", "--rc", "--yes"], + ) + assert release_candidate.exit_code == 0, release_candidate.output + + stats = runner.invoke(cli, ["--root", str(project_dir), "stats", "--json"]) + assert stats.exit_code == 0, stats.output + stats_payload = json.loads(stats.output) + assert stats_payload["parent"]["entries"]["shipped"] == 2 + assert stats_payload["parent"]["entries"]["unreleased"] == 1 + assert stats_payload["parent"]["entries"]["total"] == 3 + + +def test_validate_resolves_manifest_entries_within_their_release(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _bootstrap_changelog_project(project_dir) + + write_entry( + project_dir, + {"title": "Shared ID", "type": "bugfix", "created": date(2024, 1, 1)}, + "Present only in the first release.", + entry_id="shared-id", + default_project="project", + ) + first_release = runner.invoke( + cli, + ["--root", str(project_dir), "release", "create", "v1.0.0", "--yes"], + ) + assert first_release.exit_code == 0, first_release.output + + missing_release_dir = project_dir / "releases" / "v2.0.0" + (missing_release_dir / "entries").mkdir(parents=True) + (missing_release_dir / "manifest.yaml").write_text( + yaml.safe_dump( + {"created": "2024-02-01", "entries": ["shared-id"]}, + sort_keys=False, + ), + encoding="utf-8", + ) + + result = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + + assert result.exit_code != 0 + assert "Release references missing entry id 'shared-id'" in result.output + + +def test_validate_rejects_duplicate_ids_within_one_manifest(tmp_path: Path) -> None: + runner = CliRunner() + project_dir = tmp_path / "project" + _bootstrap_changelog_project(project_dir) + + release_dir = project_dir / "releases" / "v1.0.0" + entries_dir = release_dir / "entries" + entries_dir.mkdir(parents=True) + (release_dir / "manifest.yaml").write_text( + yaml.safe_dump( + {"created": "2024-01-01", "entries": ["shared-id", "shared-id"]}, + sort_keys=False, + ), + encoding="utf-8", + ) + (entries_dir / "shared-id.md").write_text( + "---\ntitle: Shared ID\ntype: bugfix\ncreated: 2024-01-01T00:00:00Z\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = runner.invoke(cli, ["--root", str(project_dir), "validate"]) + + assert result.exit_code != 0 + assert "non-unique elements" in result.output diff --git a/tests/test_modules.py b/tests/test_modules.py index fa0c2db..a1eb97a 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -322,6 +322,30 @@ def test_cli_show_includes_modules_by_default(tmp_path: Path) -> None: assert "Parent Feature" in result.output +def test_cli_show_preserves_reused_entry_ids_in_modules(tmp_path: Path) -> None: + """Module history keeps one occurrence per release namespace.""" + packages = tmp_path / "packages" + mod_root = create_module(packages, "mymod", "My Module") + create_released_entry(mod_root, "Compatibility Update", "v1.0.0") + create_released_entry(mod_root, "Compatibility Update", "v1.1.0") + + project_dir = tmp_path / "changelog" + project_dir.mkdir() + write_yaml( + project_dir / "config.yaml", + {"id": "parent", "name": "Parent", "modules": "../packages/*/changelog"}, + ) + (project_dir / "unreleased").mkdir() + + runner = CliRunner() + result = runner.invoke(cli, ["--root", str(project_dir), "show"]) + + assert result.exit_code == 0, result.output + assert result.output.count("Compatibility Update") == 2 + assert "v1.0.0" in result.output + assert "v1.1.0" in result.output + + def test_cli_validate_with_modules(tmp_path: Path) -> None: """validate command checks parent and modules.""" packages = tmp_path / "packages"