stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
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:
1 parent
9a2d7d34f5
commit
1b0158fc8d
12 files changed
+690
-106
No files matched your search
+141
-28
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user