Feat/v0.13.0 (#270)

* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-26 01:43:08 +02:00
committed by GitHub
co-authored by Renn F
parent 153723406e
commit 5612375cba
87 changed files with 5032 additions and 91 deletions
@@ -0,0 +1,40 @@
"""PlaybooksIndexPlugin — index_type + pure metadata/URI methods.
Mirrors the other index-plugin unit tests (instantiate via __new__, exercise the
pure methods). The embed + pgvector ingest/search path is inherited from
BaseIndexPlugin (shared, proven by the other 8 plugins) and runs live.
"""
from __future__ import annotations
from roboco.models.optimal import IndexType
from roboco.services.optimal_brain.indexes.playbooks import PlaybooksIndexPlugin
def _plugin() -> PlaybooksIndexPlugin:
return PlaybooksIndexPlugin.__new__(PlaybooksIndexPlugin)
def test_index_type_is_playbooks() -> None:
assert _plugin().index_type == IndexType.PLAYBOOKS
def test_prepare_metadata_marks_approved_with_routing_fields() -> None:
md = _plugin().prepare_metadata(
"content", playbook_id="pb-1", team="backend", scope="org", tags=["retry"]
)
assert md["type"] == "playbook"
assert md["playbook_id"] == "pb-1"
assert md["team"] == "backend"
assert md["scope"] == "org"
assert md["tags"] == ["retry"]
# Only approved playbooks are ever indexed — the metadata says so.
assert md["status"] == "approved"
def test_build_source_uri_with_id() -> None:
assert _plugin().build_source_uri(doc_id="pb-1") == "roboco://playbooks/pb-1"
def test_build_source_uri_none_when_missing() -> None:
assert _plugin().build_source_uri(doc_id=None) is None
@@ -0,0 +1,59 @@
"""MemoryDistiller — a local-LLM distilled completion lesson (best-effort)."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from roboco.services.memory_distiller import LessonInput, MemoryDistiller
def _input() -> LessonInput:
return LessonInput(
title="Add a retry to the flaky pg fixture",
acceptance_criteria=["The pg test passes 100 times in a row"],
dev_notes="Wrapped the connect in a 3x retry with backoff.",
qa_notes="Confirmed stable across 200 runs.",
commit_messages=["fix: retry pg connect", "test: stress the fixture"],
)
@pytest.mark.asyncio
async def test_distill_returns_lesson(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat",
AsyncMock(
return_value="Problem: flaky pg. Approach: retry+backoff. Gotcha: reset."
),
)
out = await MemoryDistiller().distill(_input())
assert out is not None
assert "Gotcha" in out
@pytest.mark.asyncio
async def test_distill_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat", AsyncMock(side_effect=RuntimeError)
)
assert await MemoryDistiller().distill(_input()) is None
@pytest.mark.asyncio
async def test_distill_none_on_empty_response(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"roboco.services.memory_distiller._chat", AsyncMock(return_value=" ")
)
assert await MemoryDistiller().distill(_input()) is None
@pytest.mark.asyncio
async def test_distill_caps_at_120_words(monkeypatch: pytest.MonkeyPatch) -> None:
long_lesson = " ".join(f"word{i}" for i in range(300))
monkeypatch.setattr(
"roboco.services.memory_distiller._chat",
AsyncMock(return_value=long_lesson),
)
out = await MemoryDistiller().distill(_input())
assert out is not None
assert len(out.split()) <= 120 # noqa: PLR2004 - the documented word budget
@@ -32,7 +32,7 @@ def _ci(conclusion: str) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_fanout_red_green_and_none() -> None:
projects = [_project("red"), _project("green"), _project("nosig")]
projects: list[object] = [_project("red"), _project("green"), _project("nosig")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
return {"red": _ci("failure"), "green": _ci("success"), "nosig": None}[slug]
@@ -50,7 +50,7 @@ async def test_fanout_red_green_and_none() -> None:
@pytest.mark.asyncio
async def test_per_project_error_isolated() -> None:
projects = [_project("boom"), _project("ok")]
projects: list[object] = [_project("boom"), _project("ok")]
async def conclusion(slug: str, **_kwargs: Any) -> Any:
if slug == "boom":
@@ -72,7 +72,10 @@ async def test_per_project_workflow_passthrough(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "ci_watch_default_workflow", "ci.yml")
projects = [_project("custom", workflow="release.yml"), _project("default")]
projects: list[object] = [
_project("custom", workflow="release.yml"),
_project("default"),
]
git = MagicMock()
git.get_latest_ci_conclusion = AsyncMock(return_value=_ci("success"))
with patch("roboco.services.telemetry.source.GitService", return_value=git):
+51
View File
@@ -76,12 +76,17 @@ def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
proj.id = uuid4()
project_svc = MagicMock()
project_svc.create = AsyncMock(return_value=proj)
# Default: nothing pre-exists, so provisioning takes the create path. The
# idempotency tests override these to return an existing row.
project_svc.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr(pitch_module, "get_project_service", lambda _s: project_svc)
prod = MagicMock()
prod.id = uuid4()
product_svc = MagicMock()
product_svc.create = AsyncMock(return_value=prod)
product_svc.get_by_slug = AsyncMock(return_value=None)
product_svc.update = AsyncMock(return_value=prod)
monkeypatch.setattr(pitch_module, "get_product_service", lambda _s: product_svc)
task = MagicMock()
@@ -215,3 +220,49 @@ async def test_approve_blocked_when_provisioning_disabled(
await svc.approve(
uuid4(), "x", uuid4(), provisioning=_FakeProvisioning(enabled=False)
)
@pytest.mark.asyncio
async def test_approve_reuses_existing_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Re-approval after a partial provision reuses a committed Project by slug —
no repo re-create, no slug collision (idempotent provisioning)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["project"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend"]))
)
prov = _FakeProvisioning()
await svc.approve(
uuid4(), "ship it, aligned with the charter", uuid4(), provisioning=prov
)
assert prov.created == [] # repo NOT re-created
svcs["project"].create.assert_not_awaited() # Project reused, not re-created
@pytest.mark.asyncio
async def test_approve_reuses_existing_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multi-cell re-approval reuses the committed Product and refreshes its cell
map (no uq_product_projects_product_team collision)."""
svcs = _patch_topology(monkeypatch)
existing = MagicMock()
existing.id = uuid4()
svcs["product"].get_by_slug = AsyncMock(return_value=existing)
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(target_cells=["backend", "frontend"]))
)
await svc.approve(
uuid4(),
"ship it, aligned with the charter",
uuid4(),
provisioning=_FakeProvisioning(),
)
svcs["product"].update.assert_awaited_once() # reused + cell map refreshed
svcs["product"].create.assert_not_awaited() # NOT re-created
@@ -0,0 +1,143 @@
"""ReleaseExecutor: fail-closed bump → gate → commit → CI → publish (post-approval).
The executor's correctness is its ORDERING + fail-closed aborts: a red gate
aborts before any commit, a red release-commit CI aborts before publish, and a
green path publishes exactly once. Tested against a fake ops that records the
call sequence; the production git/gh ops is exercised live (CEO-gated).
"""
from __future__ import annotations
import pytest
from roboco.services.release_executor import ReleaseExecutor, ReleaseResult
from roboco.services.release_readiness import ReleaseReadinessReport
_PLAN = ["pyproject.toml", "roboco/__init__.py", "CHANGELOG.md"]
_VERSION = "0.13.0"
_ONE = 1
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=(
f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- a thing (#1)\n"
),
version_bump_plan=list(_PLAN),
gaps=[],
migration_notes=[],
gate_state="green",
)
class _FakeOps:
"""Records the call sequence; flags drive gate/CI/already-published outcomes."""
def __init__(self, *, already: bool = False, gate: bool = True, ci: bool = True):
self._already = already
self._gate = gate
self._ci = ci
self.calls: list[str] = []
self.bumped_plan: list[str] | None = None
self.bumped_version: str | None = None
async def is_already_published(self, _version: str) -> bool:
self.calls.append("check")
return self._already
async def apply_version_bumps(self, plan: list[str], new_version: str) -> list[str]:
self.calls.append("bump")
self.bumped_plan = list(plan)
self.bumped_version = new_version
return list(plan)
async def write_changelog_entry(self, _entry: str) -> None:
self.calls.append("changelog")
async def run_gate(self) -> bool:
self.calls.append("gate")
return self._gate
async def commit_and_push(self, _version: str) -> str:
self.calls.append("commit")
return "deadbeef"
async def wait_for_ci(self, _commit_sha: str) -> bool:
self.calls.append("ci")
return self._ci
async def publish_release(self, version: str, _notes: str) -> str:
self.calls.append("publish")
return f"https://github.com/x/roboco/releases/tag/v{version}"
@pytest.mark.asyncio
async def test_green_path_publishes_once() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "published"
assert result.release_url is not None
assert result.commit_sha == "deadbeef"
assert ops.calls.count("publish") == _ONE
assert ops.calls == [
"check",
"bump",
"changelog",
"gate",
"commit",
"ci",
"publish",
]
@pytest.mark.asyncio
async def test_bump_targets_the_canonical_set() -> None:
ops = _FakeOps()
result = await ReleaseExecutor(ops).execute(_report())
assert ops.bumped_plan == _PLAN
assert ops.bumped_version == _VERSION
assert result.files_changed == _PLAN
@pytest.mark.asyncio
async def test_red_gate_aborts_before_commit() -> None:
ops = _FakeOps(gate=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "gate_failed"
assert "commit" not in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_red_ci_aborts_before_publish() -> None:
ops = _FakeOps(ci=False)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "ci_failed"
assert "commit" in ops.calls
assert "publish" not in ops.calls
@pytest.mark.asyncio
async def test_already_published_is_a_noop() -> None:
ops = _FakeOps(already=True)
result = await ReleaseExecutor(ops).execute(_report())
assert result.status == "already_published"
assert "bump" not in ops.calls
assert "commit" not in ops.calls
assert "publish" not in ops.calls
def test_release_result_carries_outcome_fields() -> None:
result = ReleaseResult(
status="published",
version=_VERSION,
files_changed=list(_PLAN),
commit_sha="abc",
release_url="https://example/releases/v0.13.0",
detail="ok",
)
assert result.version == _VERSION
assert result.files_changed == _PLAN
assert result.release_url is not None
@@ -0,0 +1,227 @@
"""Release-manager engine: propose a CEO-gated release, held + deduped, never publish.
Mirrors the self-heal engine tests. The engine proposes only past the threshold +
green gate, holds the proposal for the CEO (confirmed_by_human=False, owned by the
Secretary, never dispatched), dedupes to one open proposal, and NEVER publishes /
approves asserted here against a real Postgres DB.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.base import TaskStatus as TS
from roboco.services.notification import NotificationService
from roboco.services.release_manager_engine import ReleaseAssessor, ReleaseManagerEngine
from roboco.services.release_readiness import (
BumpKind,
Gap,
ReleaseReadinessReport,
report_from_dict,
report_to_dict,
)
from roboco.services.task import RELEASE_MANAGER_SOURCE, TaskService, get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
SLUG = "roboco"
ONE = 1
MIN_COMMITS = 8
_VERSION = "0.13.0"
def _report(
*,
bump: BumpKind = "minor",
gate: str = "green",
kind: str = "feat",
n_commits: int = 10,
gaps: list[Gap] | None = None,
) -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind=bump,
change_summary=[f"{kind}: change {i}" for i in range(n_commits)],
drafted_changelog=f"## [{_VERSION}] - 2026-06-25\n\n### Added\n- stuff (#1)\n",
version_bump_plan=["pyproject.toml"],
gaps=gaps or [],
migration_notes=[],
gate_state=gate,
)
def _assessor(report: ReleaseReadinessReport | None) -> ReleaseAssessor:
async def _a() -> ReleaseReadinessReport | None:
return report
return _a
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
monkeypatch.setattr(cfg, "release_manager_enabled", True)
monkeypatch.setattr(cfg, "release_min_commits", MIN_COMMITS)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
monkeypatch.setattr(NotificationService, "send_ack_notification", AsyncMock())
def test_report_dict_round_trip() -> None:
report = _report(gaps=[Gap("gate", "x"), Gap("changelog", "y")])
assert report_from_dict(report_to_dict(report)) == report
@pytest.mark.asyncio
async def test_disabled_creates_no_proposal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "release_manager_enabled", False)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report()))
assert await engine.run_cycle() is None
assert await get_task_service(db_session).list_open_release_proposals() == []
@pytest.mark.asyncio
async def test_below_threshold_no_proposal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
# Patch bump + few fix commits + no security → below the threshold.
report = _report(bump="patch", kind="fix", n_commits=2)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(report))
assert await engine.run_cycle() is None
assert await get_task_service(db_session).list_open_release_proposals() == []
@pytest.mark.asyncio
async def test_red_gate_no_proposal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report(gate="red")))
assert await engine.run_cycle() is None
assert await get_task_service(db_session).list_open_release_proposals() == []
@pytest.mark.asyncio
async def test_proposes_held_proposal_past_threshold(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(_report()))
task = await engine.run_cycle()
assert task is not None
open_proposals = await get_task_service(db_session).list_open_release_proposals()
assert len(open_proposals) == ONE
proposal = open_proposals[0]
assert proposal.status == TS.PENDING
assert proposal.confirmed_by_human is False # HELD for the CEO, not dispatched
assert proposal.assigned_to == SECRETARY_UUID
assert proposal.source == RELEASE_MANAGER_SOURCE
assert "0.13.0" in proposal.title
stored = markers.get_release_report(proposal)
assert stored is not None
assert report_from_dict(stored).proposed_version == "0.13.0"
@pytest.mark.asyncio
async def test_security_only_patch_still_proposes(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
# One security fix (patch bump, below the commit floor) still warrants a release.
report = _report(bump="patch", kind="security", n_commits=1)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(report))
assert await engine.run_cycle() is not None
@pytest.mark.asyncio
async def test_dedupe_one_open_proposal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle()
await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle()
assert len(await get_task_service(db_session).list_open_release_proposals()) == ONE
@pytest.mark.asyncio
async def test_loop_never_publishes_or_approves(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
approve = AsyncMock()
ceo_approve = AsyncMock()
monkeypatch.setattr(TaskService, "approve_and_start", approve)
monkeypatch.setattr(TaskService, "ceo_approve", ceo_approve)
await ReleaseManagerEngine(db_session, assessor=_assessor(_report())).run_cycle()
approve.assert_not_awaited()
ceo_approve.assert_not_awaited()
proposals = await get_task_service(db_session).list_open_release_proposals()
assert proposals[0].status == TS.PENDING # never advanced by the loop
@pytest.mark.asyncio
async def test_none_assessment_no_proposal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = ReleaseManagerEngine(db_session, assessor=_assessor(None))
assert await engine.run_cycle() is None
assert await get_task_service(db_session).list_open_release_proposals() == []
@@ -0,0 +1,90 @@
"""Pure release-readiness primitives: classify changes + derive semver bump.
These are git-free so they're unit-testable from synthetic commits. The
git-backed assess() is covered in test_release_readiness_audit.py (Task 3).
"""
from __future__ import annotations
from roboco.services.release_readiness import (
CommitInfo,
classify_changes,
derive_bump,
next_version,
)
def _commit(subject: str, body: str = "", labels: tuple[str, ...] = ()) -> CommitInfo:
return CommitInfo(sha="abc1234", subject=subject, body=body, labels=labels)
def test_feat_drives_minor_bump() -> None:
changes = classify_changes([_commit("feat: add X"), _commit("fix: a bug")])
assert derive_bump(changes) == "minor"
def test_only_fix_and_chore_is_patch() -> None:
changes = classify_changes([_commit("fix: a bug"), _commit("chore: bump deps")])
assert derive_bump(changes) == "patch"
def test_bang_marker_drives_major() -> None:
changes = classify_changes([_commit("feat!: drop the old API")])
assert derive_bump(changes) == "major"
def test_breaking_change_body_drives_major() -> None:
changes = classify_changes(
[_commit("feat: new thing", body="BREAKING CHANGE: removes Y")]
)
assert derive_bump(changes) == "major"
def test_security_change_is_patch_when_not_breaking() -> None:
changes = classify_changes([_commit("security: patch a CVE")])
assert derive_bump(changes) == "patch"
def test_empty_change_set_is_patch() -> None:
assert derive_bump([]) == "patch"
def test_next_version_minor() -> None:
assert next_version("0.8.0", "minor") == "0.9.0"
def test_next_version_patch() -> None:
assert next_version("0.8.0", "patch") == "0.8.1"
def test_next_version_major() -> None:
assert next_version("0.8.0", "major") == "1.0.0"
def test_next_version_tolerates_v_prefix() -> None:
assert next_version("v0.12.0", "minor") == "0.13.0"
def test_classify_extracts_kind_and_summary() -> None:
[change] = classify_changes([_commit("feat(api): add endpoint (#12)")])
assert change.kind == "feat"
assert change.breaking is False
assert change.summary == "add endpoint (#12)"
assert change.needs_manual_classification is False
def test_unknown_subject_flags_manual_classification() -> None:
[change] = classify_changes([_commit("Random merge subject")])
assert change.kind == "other"
assert change.needs_manual_classification is True
def test_pr_label_fallback_classifies_unconventional_subject() -> None:
[change] = classify_changes([_commit("Random subject", labels=("bug",))])
assert change.kind == "fix"
assert change.needs_manual_classification is False
def test_breaking_label_drives_major_even_on_unconventional_subject() -> None:
changes = classify_changes([_commit("Big rework", labels=("breaking",))])
assert derive_bump(changes) == "major"
@@ -0,0 +1,176 @@
"""The readiness audit: assess() turns a repo snapshot into a gap-flagged report.
assess() is pure over a ``ReleaseRepoSnapshot`` so every "no stone unturned"
check (changelog/version-ref/docs-drift/migration/gate completeness) is tested
from synthetic data. The git-backed gather_snapshot() is smoke-tested against
the real repo at the bottom.
"""
from __future__ import annotations
import re
from dataclasses import replace
from pathlib import Path
from typing import Any
from roboco.services.release_readiness import (
CommitInfo,
ReleaseReadinessReport,
ReleaseRepoSnapshot,
assess,
gather_snapshot,
)
_TODAY = "2026-06-25"
_DECLARED = 25
_DRIFTED = 26
def _snap(**overrides: Any) -> ReleaseRepoSnapshot:
base = ReleaseRepoSnapshot(
current_version="0.12.0",
last_tag="v0.12.0",
commits=[CommitInfo(sha="a1", subject="feat: add a thing", pr_number=1)],
tracked_files_with_version=["pyproject.toml"],
canonical_bump_files=["pyproject.toml"],
changelog_text="## [Unreleased]\n### Added\n- add a thing (#1)\n",
new_migrations=[],
migration_head_count=1,
master_ci_conclusion="success",
declared_agent_count=_DECLARED,
actual_agent_count=_DECLARED,
verb_tables_stale=False,
)
return replace(base, **overrides)
def _categories(report: ReleaseReadinessReport) -> set[str]:
return {gap.category for gap in report.gaps}
def test_assess_proposes_next_version_and_bump() -> None:
report = assess(_snap(), today=_TODAY)
assert report.bump_kind == "minor"
assert report.proposed_version == "0.13.0"
assert report.gate_state == "green"
def test_clean_snapshot_has_no_gaps() -> None:
assert assess(_snap(), today=_TODAY).gaps == []
def test_undocumented_commit_is_a_changelog_gap() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="feat: undocumented", pr_number=99)],
changelog_text="## [Unreleased]\n",
)
assert "changelog" in _categories(assess(snap, today=_TODAY))
def test_chore_commit_does_not_need_a_changelog_line() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="chore: tidy imports", pr_number=7)],
changelog_text="## [Unreleased]\n",
)
assert "changelog" not in _categories(assess(snap, today=_TODAY))
def test_missed_version_ref_is_a_gap() -> None:
snap = _snap(
tracked_files_with_version=["pyproject.toml", "panel/pnpm-lock.yaml"],
canonical_bump_files=["pyproject.toml"],
)
report = assess(snap, today=_TODAY)
version_gaps = [g for g in report.gaps if g.category == "version_ref"]
assert any("pnpm-lock.yaml" in g.detail for g in version_gaps)
def test_bump_plan_is_the_canonical_set() -> None:
snap = _snap(canonical_bump_files=["pyproject.toml", "roboco/__init__.py"])
report = assess(snap, today=_TODAY)
assert report.version_bump_plan == ["pyproject.toml", "roboco/__init__.py"]
def test_stale_agent_count_is_docs_drift_gap() -> None:
snap = _snap(declared_agent_count=_DECLARED, actual_agent_count=_DRIFTED)
assert "docs_drift" in _categories(assess(snap, today=_TODAY))
def test_stale_verb_tables_is_docs_drift_gap() -> None:
assert "docs_drift" in _categories(
assess(_snap(verb_tables_stale=True), today=_TODAY)
)
def test_new_migration_listed_in_notes() -> None:
snap = _snap(new_migrations=["alembic/versions/050_playbooks.py"])
report = assess(snap, today=_TODAY)
assert any("050_playbooks" in note for note in report.migration_notes)
def test_multiple_alembic_heads_is_a_migration_gap() -> None:
head_count = 2
assert "migration" in _categories(
assess(_snap(migration_head_count=head_count), today=_TODAY)
)
def test_red_ci_is_a_gate_gap() -> None:
report = assess(_snap(master_ci_conclusion="failure"), today=_TODAY)
assert report.gate_state == "red"
assert "gate" in _categories(report)
def test_unknown_ci_is_a_gate_gap() -> None:
report = assess(_snap(master_ci_conclusion=None), today=_TODAY)
assert report.gate_state == "unknown"
assert "gate" in _categories(report)
def test_unclassifiable_commit_is_a_classification_gap() -> None:
snap = _snap(
commits=[CommitInfo(sha="a1", subject="random merge subject", pr_number=5)],
changelog_text="- random merge subject (#5)\n",
)
assert "classification" in _categories(assess(snap, today=_TODAY))
def test_drafted_changelog_is_keepachangelog_and_single_line() -> None:
snap = _snap(
commits=[
CommitInfo(sha="a1", subject="feat: add A", pr_number=1),
CommitInfo(sha="b2", subject="fix: fix B", pr_number=2),
],
changelog_text="- add A (#1)\n- fix B (#2)\n",
)
report = assess(snap, today=_TODAY)
assert "## [0.13.0] - 2026-06-25" in report.drafted_changelog
assert "### Added" in report.drafted_changelog
assert "### Fixed" in report.drafted_changelog
bullets = [
ln for ln in report.drafted_changelog.splitlines() if ln.startswith("- ")
]
assert len(bullets) == 2 # noqa: PLR2004 - exactly the two commits above
# --- gather_snapshot: real-repo smoke (this repo is a git checkout at 0.12.0) ---
def test_gather_snapshot_reads_the_real_repo() -> None:
root = Path(__file__).resolve().parents[3]
snap = gather_snapshot(root, master_ci_conclusion=None)
# The repo version moves with each release — assert it's a semver, not a literal.
assert re.fullmatch(r"\d+\.\d+\.\d+", snap.current_version)
assert snap.last_tag is not None
assert isinstance(snap.commits, list)
assert "pyproject.toml" in snap.canonical_bump_files
assert snap.changelog_text # CHANGELOG.md is non-empty
assert snap.migration_head_count >= 1
def test_gather_snapshot_then_assess_produces_a_report() -> None:
root = Path(__file__).resolve().parents[3]
report = assess(gather_snapshot(root, master_ci_conclusion="success"), today=_TODAY)
assert report.proposed_version
assert report.bump_kind in {"major", "minor", "patch"}
assert report.gate_state == "green"