Files
roboco/roboco/services/pitch.py
T
5612375cba 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>
2026-06-26 01:43:08 +02:00

275 lines
11 KiB
Python

"""PitchService — Board proposals and the CEO approve -> auto-provision flow.
On approval the service provisions one GitHub repo per target cell, registers
each as a Project (and a Product when the pitch spans multiple cells), and seeds
a single Main-PM delivery task. It reuses the existing Product / coordination-
task machinery wholesale, so the produced work flows through the normal delivery
lifecycle unchanged.
Partial-failure note: GitHub repo creation is an external side effect that
cannot be rolled back with the DB transaction. If provisioning fails partway,
the DB writes roll back (the route does not commit) but any repos already
created on GitHub remain; re-approval will collide on the repo name. The CEO
resolves such a rare case manually.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import PitchTable
from roboco.foundation.identity import Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
from roboco.models.project import ProjectCreate
from roboco.models.task import TaskCreateRequest
from roboco.services.agent import get_agent_service
from roboco.services.base import (
BaseService,
ConflictError,
NotFoundError,
ValidationError,
)
from roboco.services.github_provisioning import (
ProvisioningDisabledError,
get_github_provisioning_service,
)
from roboco.services.product import get_product_service
from roboco.services.project import get_project_service
from roboco.services.task import get_task_service
from roboco.utils.converters import require_uuid
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.services.github_provisioning import GitHubProvisioningService
_DESCRIPTION_CAP = 500
class PitchService(BaseService):
"""CRUD + approve/reject for Board pitches."""
service_name = "pitch"
async def get(self, pitch_id: UUID) -> PitchTable | None:
result = await self.session.execute(
select(PitchTable).where(PitchTable.id == pitch_id)
)
return result.scalar_one_or_none()
async def get_by_slug(self, slug: str) -> PitchTable | None:
result = await self.session.execute(
select(PitchTable).where(PitchTable.slug == slug)
)
return result.scalar_one_or_none()
async def list_pitches(self, status: PitchStatus | None = None) -> list[PitchTable]:
stmt = select(PitchTable).order_by(PitchTable.created_at.desc())
if status is not None:
stmt = stmt.where(PitchTable.status == status.value)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def create(self, data: PitchCreate, created_by: UUID) -> PitchTable:
if await self.get_by_slug(data.slug):
raise ConflictError(
f"Pitch with slug '{data.slug}' already exists",
resource_type="pitch",
)
pitch = PitchTable(
title=data.title,
slug=data.slug,
problem=data.problem,
proposed_solution=data.proposed_solution,
target_cells=[
c.value if isinstance(c, Team) else str(c) for c in data.target_cells
],
status=PitchStatus.PROPOSED.value,
created_by=created_by,
)
self.session.add(pitch)
await self.session.flush()
return pitch
async def reject(self, pitch_id: UUID, notes: str, decided_by: UUID) -> PitchTable:
pitch = await self._proposed_or_raise(pitch_id)
pitch.status = PitchStatus.REJECTED.value
pitch.decided_by = decided_by
pitch.decision_notes = notes
await self.session.flush()
return pitch
async def approve(
self,
pitch_id: UUID,
notes: str,
decided_by: UUID,
*,
provisioning: GitHubProvisioningService | None = None,
) -> PitchTable:
"""Provision repos + Projects (+ Product) and seed a Main-PM task."""
pitch = await self._proposed_or_raise(pitch_id)
prov = provisioning or get_github_provisioning_service()
if not prov.enabled:
raise ProvisioningDisabledError(
"GitHub provisioning is not configured; cannot approve this "
"pitch. Set ROBOCO_PROVISIONING_TOKEN and ROBOCO_PROVISIONING_ORG."
)
try:
project_ids, cell_mappings = await self._provision_repos(
pitch, decided_by, prov
)
finally:
await prov.close()
seed_project_id, seed_product_id = await self._register_topology(
pitch, decided_by, project_ids, cell_mappings
)
seed_task_id = await self._seed_main_pm_task(
pitch, decided_by, seed_project_id, seed_product_id
)
pitch.status = PitchStatus.PROVISIONED.value
pitch.decided_by = decided_by
pitch.decision_notes = notes
pitch.provisioned_product_id = seed_product_id
pitch.provisioned_project_ids = [str(pid) for pid in project_ids]
pitch.seed_task_id = seed_task_id
await self.session.flush()
return pitch
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
async def _proposed_or_raise(self, pitch_id: UUID) -> PitchTable:
pitch = await self.get(pitch_id)
if pitch is None:
raise NotFoundError("pitch", str(pitch_id))
if pitch.status != PitchStatus.PROPOSED.value:
raise ConflictError(
f"Pitch is '{pitch.status}', not 'proposed'; cannot decide it again",
resource_type="pitch",
)
return pitch
async def _provision_repos(
self,
pitch: PitchTable,
decided_by: UUID,
prov: GitHubProvisioningService,
) -> tuple[list[UUID], list[ProductCellMapping]]:
cells = [Team(c) for c in pitch.target_cells]
multi = len(cells) > 1
project_svc = get_project_service(self.session)
project_ids: list[UUID] = []
cell_mappings: list[ProductCellMapping] = []
for cell in cells:
repo_name = f"{pitch.slug}-{cell.value}" if multi else pitch.slug
# Idempotent: a prior (partially-rolled-back) approval may have left
# the repo + Project committed. Reuse an existing Project by slug
# instead of re-creating the repo and colliding on the unique slug.
existing = await project_svc.get_by_slug(repo_name)
if existing is not None:
project_id = require_uuid(existing.id)
else:
repo = await prov.create_repo(
repo_name, pitch.problem, private=settings.provisioning_repo_private
)
project = await project_svc.create(
ProjectCreate(
name=repo_name,
slug=repo_name,
git_url=repo.clone_url,
assigned_cell=cell,
git_token=settings.provisioning_token or None,
),
created_by=decided_by,
)
project_id = require_uuid(project.id)
project_ids.append(project_id)
cell_mappings.append(ProductCellMapping(team=cell, project_id=project_id))
return project_ids, cell_mappings
async def _register_topology(
self,
pitch: PitchTable,
decided_by: UUID,
project_ids: list[UUID],
cell_mappings: list[ProductCellMapping],
) -> tuple[UUID | None, UUID | None]:
"""Return (seed_project_id, seed_product_id). Multi-cell -> a Product."""
if len(cell_mappings) > 1:
product_svc = get_product_service(self.session)
# Idempotent: reuse an existing Product (same slug) from a prior
# partial provision and refresh its cell map (ProductService.update
# → _replace_cells flushes the DELETEs before the INSERTs, so the
# mappings never collide on uq_product_projects_product_team) rather
# than creating a duplicate and crashing the re-approval.
existing = await product_svc.get_by_slug(pitch.slug)
if existing is not None:
updated = await product_svc.update(
require_uuid(existing.id), ProductUpdate(cells=cell_mappings)
)
return None, require_uuid((updated or existing).id)
product = await product_svc.create(
ProductCreate(
name=pitch.title,
slug=pitch.slug,
description=pitch.proposed_solution[:_DESCRIPTION_CAP],
cells=cell_mappings,
),
created_by=decided_by,
)
return None, require_uuid(product.id)
return project_ids[0], None
async def _seed_main_pm_task(
self,
pitch: PitchTable,
decided_by: UUID,
seed_project_id: UUID | None,
seed_product_id: UUID | None,
) -> UUID:
main_pm = await get_agent_service(self.session).get_by_slug("main-pm")
if main_pm is None:
raise ValidationError("main-pm agent not found; cannot seed delivery task")
description = (
f"{pitch.problem}\n\nProposed solution:\n{pitch.proposed_solution}\n\n"
"(Originated from an approved Board pitch.)"
)
seed = await get_task_service(self.session).create(
TaskCreateRequest(
title=f"Build: {pitch.title}",
description=description,
acceptance_criteria=[
"Main PM scopes and decomposes the initiative across cells",
"Working software merged for each target cell",
],
team=Team.MAIN_PM,
created_by=decided_by,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.HIGH,
assigned_to=require_uuid(main_pm.id),
project_id=seed_project_id,
product_id=seed_product_id,
status=TaskStatus.PENDING,
source="pitch",
confirmed_by_human=True,
)
)
return require_uuid(seed.id)
def get_pitch_service(session: AsyncSession) -> PitchService:
"""Construct a PitchService bound to ``session``."""
return PitchService(session)