stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s

Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
This commit is contained in:
torben committed 2026-09-12 18:23:58 +02:00
1 parent 9a2d7d34f5
commit 1b0158fc8d
12 files changed
+690 -106

No files matched your search

+141 -28
View File
@@ -459,20 +459,125 @@ def changes_section(text: str, version: Version) -> Optional[str]:
return None
def _bumps_block(titles: list[str]) -> str:
lines = "\n".join(f"- {title}" for title in titles)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
# Grading it at bump time, and letting a session regrade it before release,
# is the fix; see instructions/dev/version-parts.md § The candidate model.
IMPACT_LEVELS = ("high", "medium", "low")
DEFAULT_IMPACT = "medium"
_IMPACT_GROUP_RE = re.compile(r"^\*\*(High|Medium|Low) impact\*\*$", re.MULTILINE)
def _bump_titles(section: str) -> list[str]:
def _bumps_block(entries: list[tuple[str, str]]) -> str:
"""Render the bumps region from `(impact, title)` pairs.
Grouped under a `**High/Medium/Low impact**` heading, in that order, each
present only if it holds at least one title. **Except** when every entry
is `medium` (the default, and the only grade that existed before this):
rendered flat, with no heading at all, exactly as `version bump` always
wrote it. That keeps a single-bump patch entry, and every entry a build
that predates `--impact` ever wrote, byte-identical to what it was.
"""
if all(impact == DEFAULT_IMPACT for impact, _ in entries):
lines = "\n".join(f"- {title}" for _, title in entries)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
groups: dict[str, list[str]] = {level: [] for level in IMPACT_LEVELS}
for impact, title in entries:
groups[impact].append(title)
rendered = [
f"**{level.capitalize()} impact**\n" + "\n".join(f"- {title}" for title in groups[level])
for level in IMPACT_LEVELS
if groups[level]
]
return f"{_BUMPS_OPEN}\n" + "\n\n".join(rendered) + f"\n{_BUMPS_CLOSE}"
def bump_entries(section: str) -> list[tuple[str, str]]:
"""The bumps region parsed back into `(impact, title)` pairs, in rendered
order - the addressing `version regrade` and `version_cmd.release_command`
use.
A `**<Grade> impact**` heading sets the running grade for the `- ` lines
beneath it; a `- ` line with none above it - the shape every region had
before `--impact` existed, and the flat shape `_bumps_block` still writes
when every grade is `medium` - reads as `medium`. That is what makes an
old region parse the same as a new one that happens to grade everything
the same way.
"""
match = _BUMPS_RE.search(section)
if not match:
return []
return [
line[2:].strip()
for line in match.group(1).strip("\n").splitlines()
if line.strip().startswith("- ")
entries: list[tuple[str, str]] = []
current = DEFAULT_IMPACT
for line in match.group(1).strip("\n").splitlines():
stripped = line.strip()
heading_match = _IMPACT_GROUP_RE.match(stripped)
if heading_match:
current = heading_match.group(1).lower()
continue
if stripped.startswith("- "):
entries.append((current, stripped[2:].strip()))
return entries
# The free-form paragraph `version release` requires above the changesets once
# a candidate collected more than one bump - see `summary_prose` and
# `version_cmd.release_command`. A number, not a quality judgement: it catches
# the empty and the one-line "TODO" case, nothing subtler.
SUMMARY_MIN_CHARS = 200
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
def summary_prose(section: str) -> str:
"""The text between the bumps region (or, for an entry with none, the
heading) and the first `### <bump title>` changeset heading - the
candidate's own summary of what it did, as opposed to the per-bump detail
below it.
"""
close = section.find(_BUMPS_CLOSE)
if close != -1:
start = close + len(_BUMPS_CLOSE)
else:
heading_match = _CHANGES_ENTRY_RE.match(section)
start = heading_match.end() if heading_match else 0
heading = _CHANGESET_HEADING_RE.search(section, start)
end = heading.start() if heading else len(section)
return section[start:end]
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
"""Change the impact grade of one or more of the topmost entry's bump
titles, addressed by their 1-based position in `bump_entries`'s rendered
order.
All of `updates` are read against a **single** parse of the region, so
`{3: "high", 7: "high"}` in one call means "regrade these two against
today's list" - not "regrade #3, re-render, then regrade #7 against
whatever that produced". `version_cmd.regrade_command` is the only
caller; `version` must already equal the entry it addresses (the same
VERSION/newest-entry agreement every other write here requires).
"""
start, end = _entry_span(text)
section = text[start:end]
heading_match = _CHANGES_ENTRY_RE.match(section)
if not heading_match or Version.parse(heading_match.group(1)) != version:
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry does not name {version}")
entries = bump_entries(section)
if not entries:
raise VersionError(f"{version}'s {CHANGES_FILENAME} entry has no bump list to regrade")
out_of_range = sorted(i for i in updates if i < 1 or i > len(entries))
if out_of_range:
raise VersionError(
f"index/indices out of range (1-{len(entries)}): {', '.join(map(str, out_of_range))}"
)
new_entries = [
(updates.get(position, impact), title)
for position, (impact, title) in enumerate(entries, start=1)
]
new_section = _BUMPS_RE.sub(lambda _m: _bumps_block(new_entries), section, count=1)
return text[:start] + new_section + text[end:]
def _set_marker_line(section: str, marker: str, line: str) -> str:
@@ -481,17 +586,17 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
Anchored just above the bumps region (not below it, as before Gitea #95):
with a graded, potentially 30-line list, the line an operator most needs
to act on stayed the deepest thing in the entry otherwise.
"""
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
if pattern.search(section):
return pattern.sub(line, section, count=1)
anchor = section.find(_BUMPS_CLOSE)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
insert_at = section.find("\n", anchor)
insert_at = insert_at + 1 if insert_at != -1 else len(section)
else:
insert_at = len(section)
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
return section[:anchor] + f"{line}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{line}\n"
def _clear_marker_line(section: str, marker: str) -> str:
@@ -525,10 +630,12 @@ def _update_open_candidate(
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`title` to its machine-managed bump list, and set the breaking/no-migration
lines only where this call supplies them - see `insert_changes_entry`.
`(impact, title)` to its machine-managed bump list, and set the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
@@ -541,7 +648,9 @@ def _update_open_candidate(
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
section = _BUMPS_RE.sub(
lambda _m: _bumps_block(bump_entries(section) + [(impact, title)]), section, count=1
)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
@@ -562,6 +671,7 @@ def insert_changes_entry(
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Open a new entry above the newest existing one, or - when the topmost
entry is still an open candidate (a pre-release heading) - update that
@@ -573,14 +683,17 @@ def insert_changes_entry(
the topmost heading still a pre-release" the right test for "is a
candidate still open" here.
A fresh entry gets the skeleton only: heading, date, author, the
machine-managed bump-title list (started with this one title, for a
candidate), and - when a compatibility boundary is crossed - the line
saying what breaks, plus the line saying no content has to change where
that applies. The break comes first: it is what an operator reading the
release notes has to act on, and the migration line only qualifies it. The
entry's actual prose is written afterwards by whoever made the change,
which is also why `bump` refuses to invent a title.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks, plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than
below it (Gitea #95): it is what an operator reading the release notes has
to act on, the migration line only qualifies it, and neither should sit
beneath a list that can run to dozens of graded entries. The entry's
actual prose - the release summary, and each bump's own changeset - is
written afterwards by whoever made the change, which is also why `bump`
refuses to invent a title.
`migration_required` only has anything to retract on an already-open
candidate, so a fresh entry ignores it - there is no earlier
@@ -591,16 +704,16 @@ def insert_changes_entry(
return _update_open_candidate(
text, version, date, title,
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
migration_required=migration_required,
migration_required=migration_required, impact=impact,
)
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if version.is_prerelease:
lines += [_bumps_block([title]), ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason:
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
if version.is_prerelease:
lines += [_bumps_block([(impact, title)]), ""]
entry = "\n".join(lines) + "\n---\n\n"
anchor = re.search(r"^## ", text, re.MULTILINE)
if anchor: