Files
cleveragents-core/robot/helper_wf07_cicd.py
freemo 1d36449a98
CI / build (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / security (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / status-check (push) Has been cancelled
fix(cli): remove extra --mode flag from validation attach
Remove the --mode/-m CLI flag from the validation attach command to align
with the specification, which defines validation mode as an inherent
property of the validation definition set at registration time via
validation add, not as a per-attachment override.

The service layer (ToolRegistryService.attach_validation) no longer
accepts mode as a parameter; instead it reads the mode from the
validation's registered definition. The ToolRegistryRepository
_to_legacy_domain now exposes the mode field so the service can access it.

All tests that passed --mode to the CLI or service have been updated or
removed. The invalid-mode validation scenarios were removed since the
mode is no longer caller-supplied. Coverage remains at 98.7%.

ISSUES CLOSED: #913

Co-authored-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-committed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
2026-04-02 17:07:26 +00:00

503 lines
20 KiB
Python

"""Helper script for wf07_cicd_integration.robot tests.
Each subcommand is a self-contained integration check that prints a sentinel
on success and exercises specification workflow example 7.
"""
from __future__ import annotations
import json
import sys
import tempfile
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.exc import IntegrityError # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from cleveragents.application.services.automation_profile_service import ( # noqa: E402
AutomationProfileService,
)
from cleveragents.application.services.config_service import ( # noqa: E402
ConfigService,
)
from cleveragents.application.services.plan_lifecycle_service import ( # noqa: E402
PlanLifecycleService,
)
from cleveragents.application.services.resource_registry_service import ( # noqa: E402
ResourceRegistryService,
)
from cleveragents.application.services.tool_registry_service import ( # noqa: E402
ToolRegistryService,
)
from cleveragents.application.services.validation_pipeline import ( # noqa: E402
ValidationCommand,
ValidationPipeline,
)
from cleveragents.config.settings import Settings # noqa: E402
from cleveragents.core.exceptions import DatabaseError # noqa: E402
from cleveragents.domain.models.core.action import ( # noqa: E402
ActionArgument,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import ( # noqa: E402
InvariantSource,
PlanPhase,
ProcessingState,
ProjectLink,
)
from cleveragents.domain.models.core.project import ( # noqa: E402
NamespacedProject,
parse_namespaced_name,
)
from cleveragents.domain.models.core.tool import ValidationMode # noqa: E402
from cleveragents.infrastructure.database.models import Base # noqa: E402
from cleveragents.infrastructure.database.repositories import ( # noqa: E402
NamespacedProjectRepository,
ToolRegistryRepository,
ValidationAttachmentRepository,
)
# ---------------------------------------------------------------------------
# Database helpers (in-memory SQLite)
# ---------------------------------------------------------------------------
class _NoCloseSession:
"""Wrapper that prevents the session from being closed by service code."""
def __init__(self, session: object) -> None:
object.__setattr__(self, "_s", session)
def close(self) -> None:
pass
def __setattr__(self, name: str, value: object) -> None:
setattr(object.__getattribute__(self, "_s"), name, value)
def __getattr__(self, name: str) -> object:
return getattr(object.__getattribute__(self, "_s"), name)
def _setup_db() -> tuple[Any, Any]:
"""Create an in-memory SQLite DB and return (session_factory, session)."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
wrapper = _NoCloseSession(session)
def factory() -> object:
return wrapper
return factory, session
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def config_ci_profile() -> None:
"""Set and get ci profile, json format, and log level via ConfigService."""
with tempfile.TemporaryDirectory() as tmpdir:
svc = ConfigService(config_dir=Path(tmpdir))
svc.set_value("core.automation-profile", "ci")
resolved = svc.resolve("core.automation-profile")
assert resolved.value == "ci", f"Expected 'ci', got '{resolved.value}'"
svc.set_value("core.format", "json")
resolved = svc.resolve("core.format")
assert resolved.value == "json", f"Expected 'json', got '{resolved.value}'"
svc.set_value("core.log.level", "WARN")
resolved = svc.resolve("core.log.level")
assert resolved.value == "WARN", f"Expected 'WARN', got '{resolved.value}'"
print("wf07-config-ci-profile-ok")
def resource_idempotent() -> None:
"""Register a git-checkout resource twice; verify only one exists."""
factory, _session = _setup_db()
svc = ResourceRegistryService(session_factory=factory)
svc.bootstrap_builtin_types()
# First registration — includes branch per spec Step 3
with tempfile.TemporaryDirectory() as repo_dir:
svc.register_resource(
type_name="git-checkout",
name="local/cicd-repo",
location=repo_dir,
description="CI/CD repository",
properties={"branch": "fix/handle-null-users"},
)
# Second registration mirrors spec idempotency; CI handles duplicates with || true.
duplicate_raised = False
with tempfile.TemporaryDirectory() as dup_dir:
try:
svc.register_resource(
type_name="git-checkout",
name="local/cicd-repo",
location=dup_dir,
description="CI/CD repository (updated)",
)
except (IntegrityError, DatabaseError):
duplicate_raised = True
# The DB unique constraint should prevent a second resource
assert duplicate_raised, (
"Expected IntegrityError or DatabaseError on duplicate resource registration"
)
resources = svc.list_resources(type_name="git-checkout")
assert len(resources) == 1, f"Expected 1 resource, got {len(resources)}"
assert resources[0].description == "CI/CD repository"
assert resources[0].properties.get("branch") == "fix/handle-null-users", (
f"Expected branch 'fix/handle-null-users', got {resources[0].properties}"
)
print("wf07-resource-idempotent-ok")
def project_idempotent() -> None:
"""Create a project twice; verify it exists exactly once."""
factory, _session = _setup_db()
proj_repo = NamespacedProjectRepository(session_factory=factory)
parsed = parse_namespaced_name("cicd-project")
proj = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
description="CI/CD integration project",
)
proj_repo.create(proj)
# Second creation with same name: expect DatabaseError (integrity violation).
# NOTE: NamespacedProjectRepository.create has @database_retry (3 attempts,
# 0.5s wait) so the expected IntegrityError→DatabaseError adds ~1s overhead.
duplicate_raised = False
try:
proj_dup = NamespacedProject(
name=parsed.name,
namespace=parsed.namespace,
description="CI/CD integration project (dup)",
)
proj_repo.create(proj_dup)
except (DatabaseError, IntegrityError):
duplicate_raised = True
assert duplicate_raised, (
"Expected DatabaseError or IntegrityError on duplicate project creation"
)
projects = proj_repo.list_projects()
assert len(projects) == 1, f"Expected exactly 1 project, got {len(projects)}"
fetched = proj_repo.get("local/cicd-project")
assert fetched.name == "cicd-project", (
f"Expected name 'cicd-project', got '{fetched.name}'"
)
print("wf07-project-idempotent-ok")
def validation_attach() -> None:
"""Register validation tools, attach to a resource, and run pipeline."""
factory, _session = _setup_db()
tool_repo = ToolRegistryRepository(session_factory=factory)
attachment_repo = ValidationAttachmentRepository(session_factory=factory)
svc = ToolRegistryService(
tool_repo=tool_repo,
attachment_repo=attachment_repo,
)
# Spec Step 3 registers 3 validations: ci-lint, ci-typecheck, ci-tests
now = datetime.now(tz=timezone.utc).isoformat() # noqa: UP017
validation_names = ["local/ci-lint", "local/ci-typecheck", "local/ci-tests"]
for vname in validation_names:
svc.register_tool(
{
"name": vname,
"description": f"CI validation: {vname.rsplit('/', 1)[-1]}",
"tool_type": "validation",
"source": "builtin",
"timeout": 300,
"created_at": now,
"updated_at": now,
"resource_bindings": [],
"mode": "required",
}
)
# Verify all 3 tools are registered
for vname in validation_names:
registered = svc.get_tool(vname)
assert registered is not None, f"Validation tool {vname} must be registered"
# --- Step 2: Attach to resource (spec: ``validation attach``) ---
# Register a resource so the attachment references a real entity.
res_svc = ResourceRegistryService(session_factory=factory)
res_svc.bootstrap_builtin_types()
with tempfile.TemporaryDirectory(prefix="val-test-repo-") as val_tmpdir:
registered_resource = res_svc.register_resource(
type_name="git-checkout",
name="local/cicd-repo",
location=val_tmpdir,
description="Validation test repo",
)
resource_id = registered_resource.resource_id
# Attach all 3 validations per spec Step 3
for vname in validation_names:
attachment = svc.attach_validation(
validation_name=vname,
resource_id=resource_id,
project_name="local/ci-workspace",
)
assert attachment is not None, f"Attachment for {vname} must be created"
# Verify all 3 attachments exist
attachments = svc.list_validations_for_resource(resource_id)
assert len(attachments) == 3, f"Expected 3 attachments, got {len(attachments)}"
# --- Step 3: Verify pipeline execution with the validations ---
commands = [
ValidationCommand(
validation_name=vn,
resource_id=resource_id,
resource_name="local/cicd-repo",
mode=ValidationMode.REQUIRED,
arguments={"language": "python"},
timeout_seconds=30.0,
)
for vn in validation_names
]
def mock_executor(
validation_name: str,
arguments: dict[str, Any],
) -> dict[str, Any]:
return {"passed": True, "message": f"{validation_name} passed"}
pipeline = ValidationPipeline(
commands=commands,
executor=mock_executor,
max_workers=1,
)
summary = pipeline.run()
assert summary.total == 3, f"Expected 3 validations, got {summary.total}"
assert summary.all_required_passed, "Expected all required validations to pass"
print("wf07-validation-attach-ok")
def ci_plan_lifecycle() -> None:
"""Create action, verify phase transitions, and assert terminal states."""
settings = Settings()
service = PlanLifecycleService(settings=settings)
# Create action for PR review (spec Step 2). Actor names use local/
# prefixes (no real API connectivity in integration tests).
action = service.create_action(
name="local/review-pr",
description="Automatically review a PR and fix issues",
definition_of_done=(
"- All lint issues are resolved\n"
"- Type checking passes\n"
"- Test coverage does not decrease\n"
"- Security scan passes\n"
"- All fixes are committed to the PR branch"
),
strategy_actor="local/ci-planner",
execution_actor="local/ci-executor",
automation_profile="ci",
reusable=True,
arguments=[
ActionArgument(
name="pr_branch",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.REQUIRED,
description="Branch name of the PR",
),
ActionArgument(
name="base_branch",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.OPTIONAL,
description="Base branch to compare against",
default_value="main",
),
],
invariants=[
"Only modify files that are already changed in the PR",
"Do not change the intent of any code"
" — only fix style, types, and test issues",
"All fixes must include a comment explaining what was changed and why",
],
)
assert str(action.namespaced_name) == "local/review-pr" # L6: correctness
assert action.automation_profile == "ci", (
f"Expected profile 'ci', got '{action.automation_profile}'"
)
assert len(action.arguments) == 2
assert len(action.invariants) == 3
assert action.reusable is True, "Expected reusable=True"
# Use the action to create a plan with spec-required arguments
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="cicd-project")],
created_by="ci-pipeline",
arguments={"pr_branch": "fix/handle-null-users", "base_branch": "main"},
)
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.state == ProcessingState.QUEUED
plan_id = plan.identity.plan_id
assert plan_id, "Plan must have a plan_id"
# Verify arguments flowed to the plan
assert plan.arguments == {
"pr_branch": "fix/handle-null-users",
"base_branch": "main",
}, f"Plan arguments mismatch: {plan.arguments}"
# Verify invariants flowed from action to plan — count and content (M5)
action_invariants = [
inv for inv in plan.invariants if inv.source == InvariantSource.ACTION
]
assert len(action_invariants) == 3, (
f"Expected 3 action invariants on plan, got {len(action_invariants)}"
)
expected_inv_texts = sorted(
[
"Only modify files that are already changed in the PR",
"Do not change the intent of any code"
" — only fix style, types, and test issues",
"All fixes must include a comment explaining what was changed and why",
]
)
actual_inv_texts = sorted(inv.text for inv in action_invariants)
assert actual_inv_texts == expected_inv_texts, (
f"Invariant text mismatch: {actual_inv_texts}"
)
fetched = service.get_plan(plan_id)
assert fetched.identity.plan_id == plan_id
assert fetched.action_name == str(action.namespaced_name)
# Verify the automation profile resolved to ci
profile_svc = AutomationProfileService(repo=None)
ci_profile = profile_svc.resolve_profile(plan_profile="ci")
assert ci_profile.name == "ci"
# use_action now resolves automation profile precedence and can auto-progress
# beyond phase boundaries for non-manual profiles (e.g., ci).
# --- Phase-by-phase plan completion (spec Step 3) ---
service.start_strategize(plan_id)
p = service.get_plan(plan_id)
assert p.state == ProcessingState.PROCESSING, (
f"Expected PROCESSING after start_strategize, got {p.state}"
)
service.complete_strategize(plan_id)
p = service.get_plan(plan_id)
assert (p.phase, p.state) in {
(PlanPhase.STRATEGIZE, ProcessingState.COMPLETE),
(PlanPhase.EXECUTE, ProcessingState.QUEUED),
}, (
"After complete_strategize expected strategize/complete or execute/queued, "
f"got {p.phase.value}/{p.state.value}"
)
if p.phase == PlanPhase.STRATEGIZE:
service.execute_plan(plan_id)
# Execute phase
service.start_execute(plan_id)
service.complete_execute(plan_id)
p = service.get_plan(plan_id)
assert (p.phase, p.state) in {
(PlanPhase.EXECUTE, ProcessingState.COMPLETE),
(PlanPhase.APPLY, ProcessingState.QUEUED),
(PlanPhase.APPLY, ProcessingState.APPLIED),
}, (
"After complete_execute expected execute/complete, apply/queued, "
"or apply/applied, "
f"got {p.phase.value}/{p.state.value}"
)
if p.phase == PlanPhase.EXECUTE:
service.apply_plan(plan_id)
# Apply phase
if p.phase == PlanPhase.APPLY and p.state == ProcessingState.QUEUED:
service.start_apply(plan_id)
service.complete_apply(plan_id)
# Verify terminal state — the polling loop exits on 'applied'
final = service.get_plan(plan_id)
assert final.state == ProcessingState.APPLIED, (
f"Expected APPLIED, got {final.state}"
)
cancelled_plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="cicd-project")],
created_by="ci-pipeline",
arguments={"pr_branch": "fix/handle-null-users", "base_branch": "main"},
)
cancelled = service.cancel_plan(cancelled_plan.identity.plan_id, reason="ci cancel")
assert cancelled.state == ProcessingState.CANCELLED
assert cancelled.is_terminal
print("wf07-ci-plan-lifecycle-ok")
def json_output() -> None:
"""Verify JSON dict from Action and Plan models via as_cli_dict()."""
settings = Settings()
service = PlanLifecycleService(settings=settings)
# Create an action with arguments to verify full JSON round-trip
action = service.create_action(
name="local/json-test",
description="JSON output test action",
definition_of_done="Verify JSON output",
strategy_actor="local/s",
execution_actor="local/e",
arguments=[
ActionArgument(
name="pr_branch",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.REQUIRED,
description="Branch name of the PR",
),
ActionArgument(
name="base_branch",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.OPTIONAL,
description="Base branch to compare against",
default_value="main",
),
],
)
# Get CLI dict representation (simulates --format json output)
parsed = json.loads(json.dumps(action.as_cli_dict()))
assert isinstance(parsed, dict), "Parsed JSON must be a dict"
assert parsed.get("name") == "local/json-test"
assert parsed.get("state") == "available"
assert "description" in parsed, "JSON must contain 'description' field"
# Create a plan with arguments to verify full JSON output per spec Step 3
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="json-project")],
arguments={"pr_branch": "fix/test", "base_branch": "main"},
)
plan_parsed = json.loads(json.dumps(plan.as_cli_dict()))
# NOTE: as_cli_dict() uses "arguments"/"projects" (plural/dict) while
# the spec sample JSON (line 39199) uses "args"/"project" (singular).
# as_cli_dict() also omits "attempt" and "resources" (CLI-level fields)
# and "automation_profile" (not yet propagated — TODO #1060).
assert "plan_id" in plan_parsed
assert plan_parsed.get("phase") == "strategize"
assert plan_parsed.get("state") == "queued"
assert plan_parsed.get("action") == "local/json-test"
assert plan_parsed["projects"][0]["name"] == "json-project"
assert plan_parsed["arguments"]["pr_branch"] == "fix/test"
print("wf07-json-output-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Callable[[], None]] = {
"config-ci-profile": config_ci_profile,
"resource-idempotent": resource_idempotent,
"project-idempotent": project_idempotent,
"validation-attach": validation_attach,
"ci-plan-lifecycle": ci_plan_lifecycle,
"json-output": json_output,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(
f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>",
file=sys.stderr,
)
sys.exit(1)
_COMMANDS[sys.argv[1]]()