fix(marketing): release and spotlight drafts carry their source project

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.
This commit is contained in:
Renn F
2026-07-18 16:48:45 +02:00
parent fecb021eef
commit b0dcb03356
7 changed files with 42 additions and 15 deletions
+16 -7
View File
@@ -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)
+2 -1
View File
@@ -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:
+12 -4
View File
@@ -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
+5 -1
View File
@@ -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)
@@ -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
)
@@ -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,
)
@@ -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