From b0dcb033568132aa8e9bc12dfc8cdf375fe4e904 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sat, 18 Jul 2026 16:48:45 +0200 Subject: [PATCH] fix(marketing): release and spotlight drafts carry their source project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit draft_release_post / draft_release_video accept a project_id and the release-proposal approve hooks pass the proposal task's own project; the spotlight companion video forwards the spotlight draft's project into open_video_task — previously it always authored against the deployment-anchor project's motion/ tree regardless of which project the spotlight was about. Omitted project_id keeps the anchor-project fallback, so single-project deployments are unchanged. --- roboco/services/release_proposal.py | 23 +++++++++++++------ roboco/services/video_engine.py | 3 ++- roboco/services/x_engine.py | 16 +++++++++---- roboco/services/x_post_service.py | 6 ++++- .../test_release_proposal_video_hook.py | 2 +- .../services/test_release_proposal_x_hook.py | 4 +++- tests/unit/services/test_x_post_service.py | 3 +++ 7 files changed, 42 insertions(+), 15 deletions(-) diff --git a/roboco/services/release_proposal.py b/roboco/services/release_proposal.py index 9d7478bb..cca86af0 100644 --- a/roboco/services/release_proposal.py +++ b/roboco/services/release_proposal.py @@ -13,7 +13,7 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from uuid import uuid4 import redis.asyncio as redis @@ -263,8 +263,9 @@ class ReleaseProposalService(BaseService): if result.status in ("published", "already_published"): task.status = TaskStatus.COMPLETED await self.session.flush() - await self._draft_x_post(report) - await self._draft_video(report) + release_project_id = cast("UUID | None", task.project_id) + await self._draft_x_post(report, release_project_id) + await self._draft_video(report, release_project_id) await self._draft_docs_update(report) return result finally: @@ -273,32 +274,40 @@ class ReleaseProposalService(BaseService): ) await self._close_redis() - async def _draft_x_post(self, report: ReleaseReadinessReport) -> None: + async def _draft_x_post( + self, report: ReleaseReadinessReport, project_id: UUID | None + ) -> None: """Hand the just-published release to the X engine for a held announcement draft (best-effort — never raises into approve(); a drafting failure must not affect the release's already-succeeded - publish). Off/no-creds is itself a no-op inside the engine.""" + publish). Off/no-creds is itself a no-op inside the engine. The + proposal task's project scopes the draft to the released project.""" try: from roboco.services.x_engine import get_x_engine await get_x_engine(self.session).draft_release_post( version=report.proposed_version, highlights=list(report.change_summary), + project_id=project_id, ) except Exception as exc: logger.warning("x-post draft failed (best-effort): %s", exc) - async def _draft_video(self, report: ReleaseReadinessReport) -> None: + async def _draft_video( + self, report: ReleaseReadinessReport, project_id: UUID | None + ) -> None: """Hand the just-published release to the video engine for a held UX/UI authoring task (best-effort — never raises into approve(); a drafting failure must not affect the release's already-succeeded - publish). Off/no-sub-switch is itself a no-op inside the engine.""" + publish). Off/no-sub-switch is itself a no-op inside the engine. The + proposal task's project scopes the draft to the released project.""" try: from roboco.services.video_engine import get_video_engine await get_video_engine(self.session).draft_release_video( version=report.proposed_version, changelog=report.drafted_changelog, + project_id=project_id, ) except Exception as exc: logger.warning("video draft failed (best-effort): %s", exc) diff --git a/roboco/services/video_engine.py b/roboco/services/video_engine.py index c1b2bde1..4cb83ff1 100644 --- a/roboco/services/video_engine.py +++ b/roboco/services/video_engine.py @@ -437,7 +437,7 @@ class VideoEngine(BaseService): # ---- release trigger (event-driven hook) ------------------------------- async def draft_release_video( - self, *, version: str, changelog: str + self, *, version: str, changelog: str, project_id: UUID | None = None ) -> TaskTable | None: """Originate ONE UX/UI video-authoring task for a release announcement, or None (no-op). @@ -464,6 +464,7 @@ class VideoEngine(BaseService): platforms=["x", "tiktok"], brief=brief, suggested_input_props={"version": version, "highlights": highlights}, + project_id=project_id, ) async def _draft_release_script(self, version: str, changelog: str) -> str: diff --git a/roboco/services/x_engine.py b/roboco/services/x_engine.py index a3741e2e..6fa07ebc 100644 --- a/roboco/services/x_engine.py +++ b/roboco/services/x_engine.py @@ -243,6 +243,12 @@ class XEngine(BaseService): slug = (settings.self_heal_project_slug or "roboco-api").strip() return await get_project_service(self.session).get_by_slug(slug) + async def _project_or_default(self, project_id: UUID | None) -> ProjectTable | None: + """The explicitly-targeted project, or the deployment-anchor fallback.""" + if project_id is not None: + return await get_project_service(self.session).get(project_id) + return await self._roboco_project() + async def _voice_guide(self) -> str: """Baseline house style plus the CEO's brand-voice sample, when set. @@ -263,14 +269,16 @@ class XEngine(BaseService): # ---- release posts (event-driven hook) -------------------------------- async def draft_release_post( - self, *, version: str, highlights: list[str] + self, *, version: str, highlights: list[str], project_id: UUID | None = None ) -> TaskTable | None: """Originate ONE held release-announcement draft, or None (no-op). No-ops when the flag is off, no credentials are configured, a draft for this version already exists (retry-safe), or the open-post cap is reached. Called from ``ReleaseProposalService.approve()``'s publish - success branch — never invoked by the loop itself. + success branch — never invoked by the loop itself. ``project_id`` + scopes the draft to the released project; omitted falls back to the + deployment-anchor project. """ if not settings.x_engine_enabled: return None @@ -291,10 +299,10 @@ class XEngine(BaseService): version=version, ) return None - project = await self._roboco_project() + project = await self._project_or_default(project_id) if project is None or project.id is None: self.log.warning( - "x-engine: RoboCo project not resolvable; skipping release post", + "x-engine: target project not resolvable; skipping release post", version=version, ) return None diff --git a/roboco/services/x_post_service.py b/roboco/services/x_post_service.py index 54b9be5a..5762fc07 100644 --- a/roboco/services/x_post_service.py +++ b/roboco/services/x_post_service.py @@ -21,7 +21,7 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from uuid import uuid4 import redis.asyncio as redis @@ -244,6 +244,10 @@ class XPostService(BaseService): script=video_script.strip() or feature_brief, platforms=["x", "tiktok"], brief=feature_brief, + # The spotlight draft's own project — without it the video + # authors against the deployment-anchor project's motion/ + # tree regardless of which project the spotlight is about. + project_id=cast("UUID | None", task.project_id), ) except Exception as exc: logger.warning("spotlight video draft failed (best-effort): %s", exc) diff --git a/tests/unit/services/test_release_proposal_video_hook.py b/tests/unit/services/test_release_proposal_video_hook.py index 5fcb3a47..42f84b28 100644 --- a/tests/unit/services/test_release_proposal_video_hook.py +++ b/tests/unit/services/test_release_proposal_video_hook.py @@ -147,7 +147,7 @@ async def test_publish_success_calls_video_engine_draft_seam( assert result is not None assert result.status == "published" fake_video_engine.draft_release_video.assert_awaited_once_with( - version=_VERSION, changelog=_CHANGELOG + version=_VERSION, changelog=_CHANGELOG, project_id=task.project_id ) diff --git a/tests/unit/services/test_release_proposal_x_hook.py b/tests/unit/services/test_release_proposal_x_hook.py index e2c71362..b31dd833 100644 --- a/tests/unit/services/test_release_proposal_x_hook.py +++ b/tests/unit/services/test_release_proposal_x_hook.py @@ -139,7 +139,9 @@ async def test_publish_success_calls_x_engine_draft_seam( assert result is not None assert result.status == "published" fake_engine.draft_release_post.assert_awaited_once_with( - version=_VERSION, highlights=["feat: a thing", "fix: another thing"] + version=_VERSION, + highlights=["feat: a thing", "fix: another thing"], + project_id=task.project_id, ) diff --git a/tests/unit/services/test_x_post_service.py b/tests/unit/services/test_x_post_service.py index 2084cddc..cfc59012 100644 --- a/tests/unit/services/test_x_post_service.py +++ b/tests/unit/services/test_x_post_service.py @@ -584,6 +584,9 @@ async def test_approve_feature_spotlight_with_video_opens_video_task( assert kwargs["platforms"] == ["x", "tiktok"] assert kwargs["script"] == "Custom voiceover script" assert kwargs["brief"] == "Organizational Memory Loop: Draft body" + # The spotlight's own project scopes the video authoring — without it the + # video authored against the deployment-anchor project regardless. + assert kwargs["project_id"] == task.project_id @pytest.mark.asyncio