feat(release): drafter prefers curated [Unreleased] notes; executor moves them instead of duplicating

The readiness drafter transcribed raw commit subjects even when
[Unreleased] carried curated prose, and the executor inserted its entry
below a still-populated [Unreleased] — shipping the same content twice
in two qualities. The drafter now uses the curated body as the release
entry when present (transcription stays the fallback; completeness gaps
still police curation), and the executor empties [Unreleased] as it
stamps the entry. [Unreleased] itself catches up with the feedback
round, the dense tooltip passes, and the slave-CI fix.
This commit is contained in:
Renn F
2026-07-15 21:04:46 +02:00
parent 657018eb14
commit bdb0dd6cdd
5 changed files with 109 additions and 5 deletions
+9
View File
@@ -20,6 +20,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Agent detail page gains a token sparkline and an activity timeline.** A 7-day per-agent token sparkline plus a merged work-session/journal timeline now sit on the agent detail page (`GET /usage/time-series` gained an optional `agent_slug` filter).
- **Product and project tables gain real progress signals.** The product table now shows each product's cell mappings (team/project) and a done/active/blocked progress readout; the project table gains the same task-count readout plus a CI-watch badge when a project has opted into CI-watch — both computed in one grouped query per table, no N+1.
- **Code-snippet viewer on revision findings.** Each `file:line` finding on a task now shows the surrounding source, fetched from a new `GET /git/file` endpoint that slices a file at a branch tip to a line window (explicit range, a centered context window, or the whole file capped at 2000 lines); a missing file renders a muted hint instead of breaking the card.
- **Dense hover-help across the entire panel (~700 new tips).** Three annotate-by-default passes took every page tree — including the previously unswept sidebar/header chrome and the mobile tab bar — so every lifecycle action (a 25-action tip map on the task header), dialog field, sortable column, stat derivation, badge, feature flag, and disabled control explains itself on hover; roughly 25 controls gained their first accessible name. En route, two earlier tips that sat directly on disabled buttons (and so could never fire) moved to the span-wrap pattern, and dynamic tip labels are pinned non-empty since a falsy-to-truthy flip remounts the wrapped element.
### Changed
- **Slave carries CI, and the release pipeline prefers curated notes.** The dev branch joined the CI push triggers — the release gate is fail-closed on the head commit's verdict, and a branch with no runs read as "unknown", silently blocking every release proposal. The readiness drafter now uses the curated `[Unreleased]` body as the release entry when one exists (falling back to per-commit transcription), and the executor empties `[Unreleased]` when stamping the entry so curated content never ships twice. CLAUDE.md gains the docs-sync engine paragraph it was missing.
### Fixed
@@ -28,6 +33,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Release-proposal reject no longer deadlocks the readiness loop, and a failed approve surfaces why.** Rejecting a proposal left it PENDING rather than CANCELLED, so the one-open-proposal dedup blocked every future assessment cycle; reject now moves it to CANCELLED. A failed background execute (red gate, red CI, or an unhandled exception) used to leave the proposal silently pending with no signal; the approve route now stamps an execute-outcome marker on every terminal path so the panel can show a running/failed state and a retry instead of a silent wait.
- **Tooltip and aria-label sweep.** A shared `HelpTip` helper (a one-line wrapper over the verbose Radix Tooltip pattern) backs a broad accessibility pass: per-state tooltips on the task-status and agent-state badges (15 and 11 states respectively, previously undocumented anywhere), plus 35 more `HelpTip`/`aria-label` additions across 22 panel components — icon-only buttons, status dots, truncated ids/branches.
- **Post-merge hardening basket.** `GET /git/file`'s line-window cap now applies after resolving an explicit `start`/`end` range too, not only the whole-file default; collision-context evidence-building in both `claim_gate_review` and the collision-map route degrades to no siblings on a fetch/build failure instead of raising; Telegram sends are deferred until after commit so a slow Bot API call can no longer hold the caller's open transaction; the periodic re-index loop now watches `docs/map` in addition to `docs/rag`; the possibilities-matrix fast-path prompt no longer offers `WORK_ALREADY_DONE` while a task is `verifying` (only `claimed`/`in_progress`); and a shadowed loop variable in the lifecycle smoke-replay test is renamed for mypy 2.3 compatibility.
- **Task-detail active tab highlight (root-caused).** The tooltip retrofit wrapped each tab trigger in `TooltipTrigger asChild`, whose own `data-state="closed"` survives the Radix Slot merge and clobbers Tabs' active/inactive attribute — so no tab ever matched the active styles. The trigger now re-asserts its own `data-state`, recorded as the house pattern for any tooltip around a stateful Radix trigger.
- **Agent detail survives a stopped agent.** The orchestrator's status endpoint 404s for any non-running container, and the page's error branch unmounted the DB-backed header, sparkline, and timeline it had already rendered. The fatal card now gates on the roster lookup alone; a live-status error degrades in place to a not-running banner with a spawn button, and the deterministic 404 no longer retry-loops.
- **Panel counted phantom agent states.** The Total/Active counters read `by_state.running`/`ready` — states that don't exist in the orchestrator's enum — so they were structurally zero against a real backend. Total now counts the roster and Active reads `by_state.active`, fixed on the agents page, the metrics page, and the dashboard hook.
- **Overview and layout polish round.** Quick actions and the key-metric cards now lead the overview with Team Health second and the decision queues last (PR Reviews and Social share a row); the agents page renders an intrinsic auto-fill grid of roomier cards with a merged, truthful stat row and a Board + Main-PM leadership band; metrics' five oversized status cards became a compact tile band beside the donut; journal ids render in full with copy buttons and a task deep-link; the AI-providers page groups per-agent overrides by org structure in two properly spaced columns with the key cards beside the self-hosted section; the environment-ladder editor speaks promotion flow ("PRs land" → "promotes to" → "release") with a worked-example tooltip; and an urgent flag's Report-CEO button renders only when a real handler is wired.
## [0.24.0] - 2026-07-14
+15 -3
View File
@@ -499,14 +499,26 @@ def _bump_uv_lock(text: str, old: str, new: str) -> str:
def _insert_changelog_entry(existing: str, entry: str) -> str:
"""Insert ``entry`` above the first released version heading (Keep a Changelog)."""
"""Insert ``entry`` above the first released version heading (Keep a
Changelog), emptying ``[Unreleased]``'s body — the readiness drafter uses
that body as the entry when it's curated, so leaving it in place would
ship the same content twice."""
block = entry.rstrip() + "\n"
if not existing.strip():
return block
lines = existing.splitlines(keepends=True)
unreleased_idx = None
for idx, line in enumerate(lines):
if line.startswith("## [") and "Unreleased" not in line:
return "".join(lines[:idx]) + block + "\n" + "".join(lines[idx:])
if line.startswith("## [") and "Unreleased" in line:
unreleased_idx = idx
elif line.startswith("## ["):
if unreleased_idx is None:
return "".join(lines[:idx]) + block + "\n" + "".join(lines[idx:])
prefix = "".join(lines[: unreleased_idx + 1]).rstrip("\n") + "\n\n"
return prefix + block + "\n" + "".join(lines[idx:])
if unreleased_idx is not None:
prefix = "".join(lines[: unreleased_idx + 1]).rstrip("\n") + "\n\n"
return prefix + block
return existing.rstrip() + "\n\n" + block
+35 -2
View File
@@ -232,7 +232,38 @@ def _is_documented(change: ClassifiedChange, changelog_text: str) -> bool:
return bool(change.summary) and change.summary in changelog_text
def _draft_changelog(version: str, changes: list[ClassifiedChange], today: str) -> str:
def _unreleased_body(changelog_text: str) -> str:
"""The curated content under ``## [Unreleased]``, empty when the section
is absent or blank."""
lines = changelog_text.splitlines()
start = None
for idx, line in enumerate(lines):
if line.startswith("## [") and "Unreleased" in line:
start = idx + 1
break
if start is None:
return ""
body: list[str] = []
for line in lines[start:]:
if line.startswith("## ["):
break
body.append(line)
return "\n".join(body).strip()
def _draft_changelog(
version: str,
changes: list[ClassifiedChange],
today: str,
changelog_text: str = "",
) -> str:
# Curated notes win: when [Unreleased] carries content, the release entry
# IS that content — the per-commit transcription below is the fallback for
# a repo whose changelog wasn't maintained. Completeness is still policed
# separately by _changelog_gaps, so curation gaps stay visible to the CEO.
curated = _unreleased_body(changelog_text)
if curated:
return f"## [{version}] - {today}\n\n{curated}\n"
sections: dict[str, list[str]] = {}
for change in changes:
section = _KIND_SECTION.get(change.kind, "Changed")
@@ -373,7 +404,9 @@ def assess(snapshot: ReleaseRepoSnapshot, *, today: str) -> ReleaseReadinessRepo
proposed_version=proposed,
bump_kind=bump,
change_summary=[f"{c.kind}: {c.summary}" for c in changes],
drafted_changelog=_draft_changelog(proposed, changes, today),
drafted_changelog=_draft_changelog(
proposed, changes, today, snapshot.changelog_text
),
version_bump_plan=list(snapshot.canonical_bump_files),
gaps=gaps,
migration_notes=migration_notes,
@@ -562,3 +562,26 @@ async def test_release_push_argv_uses_extraheader_not_url_token(
== f"http.extraheader=Authorization: Basic {expected_basic}"
)
assert "push" in push_argv[c_idx + 2 :]
def test_insert_changelog_entry_empties_unreleased_body() -> None:
existing = (
"# Changelog\n\nintro\n\n## [Unreleased]\n\n### Added\n\n"
"- **Curated bullet.**\n\n## [0.24.0] - 2026-07-14\n\n- old\n"
)
entry = "## [0.25.0] - 2026-07-15\n\n### Added\n\n- **Curated bullet.**\n"
result = re._insert_changelog_entry(existing, entry)
unreleased = result.split("## [Unreleased]")[1].split("## [0.25.0]")[0]
assert "Curated bullet" not in unreleased
assert result.count("Curated bullet") == 1
assert result.index("## [Unreleased]") < result.index("## [0.25.0]")
assert result.index("## [0.25.0]") < result.index("## [0.24.0]")
assert "- old" in result
def test_insert_changelog_entry_without_unreleased_is_unchanged_behavior() -> None:
existing = "# Changelog\n\n## [0.24.0] - 2026-07-14\n\n- old\n"
entry = "## [0.25.0] - 2026-07-15\n\n- new\n"
result = re._insert_changelog_entry(existing, entry)
assert result.index("## [0.25.0]") < result.index("## [0.24.0]")
assert "- old" in result and "- new" in result
@@ -13,6 +13,7 @@ import pytest
from roboco.services.release_readiness import (
CommitInfo,
_commits_since,
_draft_changelog,
_run_git,
classify_changes,
derive_bump,
@@ -137,3 +138,29 @@ def test_commits_since_preserves_field_sep_in_body(
assert commit.subject == subject
assert commit.body == body # the embedded \x1f is preserved
assert commit.pr_number == 1
def test_draft_changelog_uses_curated_unreleased_body() -> None:
changes = classify_changes(
[CommitInfo(sha="a" * 8, subject="feat: shiny thing", body="")]
)
changelog = (
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n"
"- **Shiny thing.** Curated prose about it.\n\n"
"## [0.24.0] - 2026-07-14\n\n- old\n"
)
draft = _draft_changelog("0.25.0", changes, "2026-07-15", changelog)
assert draft.startswith("## [0.25.0] - 2026-07-15")
assert "Curated prose about it." in draft
assert "feat: shiny thing" not in draft
assert "[Unreleased]" not in draft
def test_draft_changelog_falls_back_to_transcription_without_curation() -> None:
changes = classify_changes(
[CommitInfo(sha="a" * 8, subject="feat: shiny thing", body="")]
)
empty = "# Changelog\n\n## [Unreleased]\n\n## [0.24.0] - 2026-07-14\n"
draft = _draft_changelog("0.25.0", changes, "2026-07-15", empty)
assert "### Added" in draft
assert "- shiny thing" in draft