mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user