fix(cli): make --plan and --action repeatable in agents invariant add
CI / push-validation (pull_request) Successful in 20s
CI / quality (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 24s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 1m14s
CI / typecheck (pull_request) Successful in 1m26s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Successful in 5m12s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 6m4s
CI / integration_tests (pull_request) Successful in 7m46s
CI / status-check (pull_request) Failing after 1s
CI / push-validation (pull_request) Successful in 20s
CI / quality (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 24s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 1m14s
CI / typecheck (pull_request) Successful in 1m26s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Successful in 5m12s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 6m4s
CI / integration_tests (pull_request) Successful in 7m46s
CI / status-check (pull_request) Failing after 1s
Change --plan and --action flags in `agents invariant add` from single-value str | None options to repeatable list[str] options, allowing users to attach the same invariant to multiple plans or actions in a single command. - Add `_resolve_scope_targets()` helper that returns a list of (InvariantScope, source_name) pairs for repeatable flag support - Update `add` command to loop over all targets and create one invariant per target, printing each created invariant - Update non-rich output to emit a list when multiple targets are given - Add BDD scenarios in features/invariant_cli_new_coverage.feature for repeatable --plan and --action flags - Add step definitions in features/steps/invariant_cli_new_coverage_steps.py ISSUES CLOSED: #9070
This commit is contained in:
@@ -159,3 +159,96 @@ Feature: Invariant CLI commands coverage
|
||||
When I invoke invariant remove with "--yes" for id "MISSING_ID"
|
||||
Then the invariant CLI exit code should be non-zero
|
||||
And the invariant CLI output should contain "not found"
|
||||
|
||||
# === _resolve_scope_targets helper ===
|
||||
|
||||
Scenario: Resolve scope targets with --global flag returns single GLOBAL target
|
||||
When I resolve invariant scope targets with global flag set
|
||||
Then the resolved invariant targets should have 1 entry
|
||||
And the first resolved target scope should be "global"
|
||||
And the first resolved target source should be "system"
|
||||
|
||||
Scenario: Resolve scope targets with --project flag returns single PROJECT target
|
||||
When I resolve invariant scope targets with project "myapp"
|
||||
Then the resolved invariant targets should have 1 entry
|
||||
And the first resolved target scope should be "project"
|
||||
And the first resolved target source should be "myapp"
|
||||
|
||||
Scenario: Resolve scope targets with single --plan returns one PLAN target
|
||||
When I resolve invariant scope targets with plans "PLAN-001"
|
||||
Then the resolved invariant targets should have 1 entry
|
||||
And the first resolved target scope should be "plan"
|
||||
And the first resolved target source should be "PLAN-001"
|
||||
|
||||
Scenario: Resolve scope targets with two --plan values returns two PLAN targets
|
||||
When I resolve invariant scope targets with plans "PLAN-001,PLAN-002"
|
||||
Then the resolved invariant targets should have 2 entries
|
||||
And the first resolved target scope should be "plan"
|
||||
And the first resolved target source should be "PLAN-001"
|
||||
And the second resolved target scope should be "plan"
|
||||
And the second resolved target source should be "PLAN-002"
|
||||
|
||||
Scenario: Resolve scope targets with single --action returns one ACTION target
|
||||
When I resolve invariant scope targets with actions "deploy"
|
||||
Then the resolved invariant targets should have 1 entry
|
||||
And the first resolved target scope should be "action"
|
||||
And the first resolved target source should be "deploy"
|
||||
|
||||
Scenario: Resolve scope targets with two --action values returns two ACTION targets
|
||||
When I resolve invariant scope targets with actions "deploy,rollback"
|
||||
Then the resolved invariant targets should have 2 entries
|
||||
And the first resolved target scope should be "action"
|
||||
And the first resolved target source should be "deploy"
|
||||
And the second resolved target scope should be "action"
|
||||
And the second resolved target source should be "rollback"
|
||||
|
||||
Scenario: Resolve scope targets with no flags defaults to GLOBAL
|
||||
When I resolve invariant scope targets with no flags
|
||||
Then the resolved invariant targets should have 1 entry
|
||||
And the first resolved target scope should be "global"
|
||||
And the first resolved target source should be "system"
|
||||
|
||||
Scenario: Resolve scope targets with conflicting flags raises BadParameter
|
||||
When I resolve invariant scope targets with global and plans "PLAN-001"
|
||||
Then a BadParameter error should be raised for invariant scope targets
|
||||
|
||||
# === invariant add with repeatable --plan flags ===
|
||||
|
||||
Scenario: Add invariant with two --plan flags creates two invariants
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002" and text "No prod deletes"
|
||||
Then the invariant CLI exit code should be 0
|
||||
And the invariant CLI output should contain "Invariant added"
|
||||
And the invariant add service was called 2 times
|
||||
|
||||
Scenario: Add invariant with two --action flags creates two invariants
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--action deploy --action rollback" and text "Require approval"
|
||||
Then the invariant CLI exit code should be 0
|
||||
And the invariant CLI output should contain "Invariant added"
|
||||
And the invariant add service was called 2 times
|
||||
|
||||
Scenario: Add invariant with single --plan flag creates one invariant
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--plan PLAN-001" and text "Single plan invariant"
|
||||
Then the invariant CLI exit code should be 0
|
||||
And the invariant CLI output should contain "Invariant added"
|
||||
And the invariant add service was called 1 time
|
||||
|
||||
Scenario: Add invariant with single --action flag creates one invariant
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--action deploy" and text "Single action invariant"
|
||||
Then the invariant CLI exit code should be 0
|
||||
And the invariant CLI output should contain "Invariant added"
|
||||
And the invariant add service was called 1 time
|
||||
|
||||
Scenario: Add invariant with two --plan flags in json format outputs list
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002 --format json" and text "No prod deletes"
|
||||
Then the invariant CLI exit code should be 0
|
||||
And the invariant CLI output should contain "No prod deletes"
|
||||
|
||||
Scenario: Add invariant with conflicting --plan and --global flags raises error
|
||||
Given a mocked InvariantService for invariant CLI add
|
||||
When I invoke invariant add with "--global --plan PLAN-001" and text "conflict"
|
||||
Then the invariant CLI exit code should be non-zero
|
||||
|
||||
@@ -10,13 +10,14 @@ from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import typer
|
||||
from behave import given, then, when
|
||||
from behave import given, then, use_step_matcher, when
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cleveragents.cli.commands.invariant import (
|
||||
_get_service,
|
||||
_invariant_dict,
|
||||
_resolve_scope,
|
||||
_resolve_scope_targets,
|
||||
app,
|
||||
)
|
||||
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
|
||||
@@ -359,3 +360,126 @@ def step_mock_service_not_found_remove(context):
|
||||
resource_type="invariant", resource_id="MISSING_ID"
|
||||
)
|
||||
_patch_svc(context, svc)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# _resolve_scope_targets helper steps
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when("I resolve invariant scope targets with global flag set")
|
||||
def step_resolve_targets_global(context):
|
||||
context.resolved_targets = _resolve_scope_targets(
|
||||
is_global=True, project=None, plans=[], actions=[]
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve invariant scope targets with project "{project}"')
|
||||
def step_resolve_targets_project(context, project):
|
||||
context.resolved_targets = _resolve_scope_targets(
|
||||
is_global=False, project=project, plans=[], actions=[]
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve invariant scope targets with plans "{plans_csv}"')
|
||||
def step_resolve_targets_plans(context, plans_csv):
|
||||
plans = plans_csv.split(",")
|
||||
context.resolved_targets = _resolve_scope_targets(
|
||||
is_global=False, project=None, plans=plans, actions=[]
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve invariant scope targets with actions "{actions_csv}"')
|
||||
def step_resolve_targets_actions(context, actions_csv):
|
||||
actions = actions_csv.split(",")
|
||||
context.resolved_targets = _resolve_scope_targets(
|
||||
is_global=False, project=None, plans=[], actions=actions
|
||||
)
|
||||
|
||||
|
||||
@when("I resolve invariant scope targets with no flags")
|
||||
def step_resolve_targets_default(context):
|
||||
context.resolved_targets = _resolve_scope_targets(
|
||||
is_global=False, project=None, plans=[], actions=[]
|
||||
)
|
||||
|
||||
|
||||
@when('I resolve invariant scope targets with global and plans "{plans_csv}"')
|
||||
def step_resolve_targets_conflicting(context, plans_csv):
|
||||
plans = plans_csv.split(",")
|
||||
context.inv_bad_parameter_raised = False
|
||||
try:
|
||||
_resolve_scope_targets(
|
||||
is_global=True, project=None, plans=plans, actions=[]
|
||||
)
|
||||
except typer.BadParameter:
|
||||
context.inv_bad_parameter_raised = True
|
||||
|
||||
|
||||
@then("the resolved invariant targets should have {count:d} entries")
|
||||
def step_check_targets_count_plural(context, count):
|
||||
assert len(context.resolved_targets) == count, (
|
||||
f"Expected {count} targets, got {len(context.resolved_targets)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resolved invariant targets should have {count:d} entry")
|
||||
def step_check_targets_count_singular(context, count):
|
||||
assert len(context.resolved_targets) == count, (
|
||||
f"Expected {count} targets, got {len(context.resolved_targets)}"
|
||||
)
|
||||
|
||||
|
||||
@then('the first resolved target scope should be "{scope}"')
|
||||
def step_check_first_target_scope(context, scope):
|
||||
actual_scope, _ = context.resolved_targets[0]
|
||||
assert actual_scope.value == scope, (
|
||||
f"Expected first target scope '{scope}', got '{actual_scope.value}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the first resolved target source should be "{source}"')
|
||||
def step_check_first_target_source(context, source):
|
||||
_, actual_source = context.resolved_targets[0]
|
||||
assert actual_source == source, (
|
||||
f"Expected first target source '{source}', got '{actual_source}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the second resolved target scope should be "{scope}"')
|
||||
def step_check_second_target_scope(context, scope):
|
||||
actual_scope, _ = context.resolved_targets[1]
|
||||
assert actual_scope.value == scope, (
|
||||
f"Expected second target scope '{scope}', got '{actual_scope.value}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the second resolved target source should be "{source}"')
|
||||
def step_check_second_target_source(context, source):
|
||||
_, actual_source = context.resolved_targets[1]
|
||||
assert actual_source == source, (
|
||||
f"Expected second target source '{source}', got '{actual_source}'"
|
||||
)
|
||||
|
||||
|
||||
@then("a BadParameter error should be raised for invariant scope targets")
|
||||
def step_check_bad_parameter_targets(context):
|
||||
assert context.inv_bad_parameter_raised, (
|
||||
"Expected typer.BadParameter to be raised for scope targets"
|
||||
)
|
||||
|
||||
|
||||
# Use regex step matcher for the call count check to handle "1 time" and "2 times"
|
||||
use_step_matcher("re")
|
||||
|
||||
|
||||
@then(r"the invariant add service was called (?P<count>\d+) times?")
|
||||
def step_check_add_called_n_times(context, count):
|
||||
expected = int(count)
|
||||
actual = context.inv_mock_svc.add_invariant.call_count
|
||||
assert actual == expected, (
|
||||
f"Expected add_invariant to be called {expected} time(s), got {actual}"
|
||||
)
|
||||
|
||||
|
||||
use_step_matcher("parse")
|
||||
|
||||
@@ -17,16 +17,22 @@ Each invariant belongs to exactly one scope. Pass the matching flag:
|
||||
|
||||
- ``--global``: System-wide invariant
|
||||
- ``--project PROJECT``: Project-scoped invariant
|
||||
- ``--action ACTION``: Action-template invariant
|
||||
- ``--plan PLAN_ID``: Plan-specific invariant
|
||||
- ``--action ACTION``: Action-template invariant (repeatable)
|
||||
- ``--plan PLAN_ID``: Plan-specific invariant (repeatable)
|
||||
|
||||
If no scope flag is given, ``--global`` is assumed.
|
||||
|
||||
The ``--plan`` and ``--action`` flags are repeatable: you may pass them
|
||||
multiple times to attach the same invariant to several plans or actions
|
||||
in a single command.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
agents invariant add --global "Never delete production data"
|
||||
agents invariant add --project myapp "All API changes need tests"
|
||||
agents invariant add --plan PLAN-001 --plan PLAN-002 "Never delete production data"
|
||||
agents invariant add --action deploy --action rollback "Require approval"
|
||||
agents invariant list --effective --project myapp
|
||||
agents invariant remove INV_ULID
|
||||
```
|
||||
@@ -75,6 +81,10 @@ def _resolve_scope(
|
||||
) -> tuple[InvariantScope, str]:
|
||||
"""Resolve scope flag combination to (scope, source_name).
|
||||
|
||||
This helper is used for single-value resolution (e.g. in ``list``).
|
||||
For the ``add`` command, use ``_resolve_scope_targets`` which handles
|
||||
repeatable ``--plan`` and ``--action`` flags.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: On conflicting or missing flags.
|
||||
"""
|
||||
@@ -97,6 +107,49 @@ def _resolve_scope(
|
||||
return InvariantScope.GLOBAL, "system"
|
||||
|
||||
|
||||
def _resolve_scope_targets(
|
||||
is_global: bool,
|
||||
project: str | None,
|
||||
plans: list[str],
|
||||
actions: list[str],
|
||||
) -> list[tuple[InvariantScope, str]]:
|
||||
"""Resolve scope flags to a list of (scope, source_name) pairs.
|
||||
|
||||
Supports repeatable ``--plan`` and ``--action`` flags so that a single
|
||||
``agents invariant add`` call can create the same invariant for multiple
|
||||
plans or actions.
|
||||
|
||||
Args:
|
||||
is_global: Whether ``--global`` was passed.
|
||||
project: Value of ``--project`` (single-value flag).
|
||||
plans: All values passed via ``--plan`` (may be empty).
|
||||
actions: All values passed via ``--action`` (may be empty).
|
||||
|
||||
Returns:
|
||||
A list of ``(InvariantScope, source_name)`` pairs — one per target.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: When conflicting scope flags are provided.
|
||||
"""
|
||||
flags_set = sum(
|
||||
[is_global, project is not None, bool(plans), bool(actions)]
|
||||
)
|
||||
if flags_set > 1:
|
||||
raise typer.BadParameter(
|
||||
"Specify at most one scope flag: --global, --project, --plan, or --action"
|
||||
)
|
||||
|
||||
if project is not None:
|
||||
return [(InvariantScope.PROJECT, project)]
|
||||
if plans:
|
||||
return [(InvariantScope.PLAN, p) for p in plans]
|
||||
if actions:
|
||||
return [(InvariantScope.ACTION, a) for a in actions]
|
||||
|
||||
# Default to global
|
||||
return [(InvariantScope.GLOBAL, "system")]
|
||||
|
||||
|
||||
def _invariant_dict(inv: Invariant) -> dict[str, object]:
|
||||
"""Serialise an Invariant for non-rich output formats."""
|
||||
return {
|
||||
@@ -118,29 +171,46 @@ def add(
|
||||
project: Annotated[
|
||||
str | None, typer.Option("--project", help="Project name")
|
||||
] = None,
|
||||
plan: Annotated[str | None, typer.Option("--plan", help="Plan ID (ULID)")] = None,
|
||||
action: Annotated[str | None, typer.Option("--action", help="Action name")] = None,
|
||||
plan: Annotated[
|
||||
list[str], typer.Option("--plan", help="Plan ID (ULID); repeatable")
|
||||
] = [], # noqa: B006
|
||||
action: Annotated[
|
||||
list[str], typer.Option("--action", help="Action name; repeatable")
|
||||
] = [], # noqa: B006
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""Add a new invariant constraint.
|
||||
|
||||
The ``--plan`` and ``--action`` flags are repeatable: pass them multiple
|
||||
times to attach the same invariant to several plans or actions at once.
|
||||
|
||||
Examples:
|
||||
agents invariant add --global "Never delete production data"
|
||||
agents invariant add --project myapp "All API changes need tests"
|
||||
agents invariant add --plan PLAN-001 --plan PLAN-002 "No prod deletes"
|
||||
agents invariant add --action deploy --action rollback "Require approval"
|
||||
"""
|
||||
try:
|
||||
scope, source_name = _resolve_scope(is_global, project, plan, action)
|
||||
targets = _resolve_scope_targets(is_global, project, plan, action)
|
||||
service = _get_service()
|
||||
inv = service.add_invariant(text=text, scope=scope, source_name=source_name)
|
||||
created: list[Invariant] = []
|
||||
for scope, source_name in targets:
|
||||
inv = service.add_invariant(
|
||||
text=text, scope=scope, source_name=source_name
|
||||
)
|
||||
created.append(inv)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(_invariant_dict(inv), fmt))
|
||||
data = [_invariant_dict(inv) for inv in created]
|
||||
output: object = data[0] if len(data) == 1 else data
|
||||
console.print(format_output(output, fmt))
|
||||
return
|
||||
|
||||
console.print(f"[green]Invariant added:[/green] {inv.id}")
|
||||
console.print(f" Text: {inv.text}")
|
||||
console.print(f" Scope: {inv.scope.value}")
|
||||
console.print(f" Source: {inv.source_name}")
|
||||
for inv in created:
|
||||
console.print(f"[green]Invariant added:[/green] {inv.id}")
|
||||
console.print(f" Text: {inv.text}")
|
||||
console.print(f" Scope: {inv.scope.value}")
|
||||
console.print(f" Source: {inv.source_name}")
|
||||
|
||||
except CleverAgentsError as e:
|
||||
console.print(f"[red]Error:[/red] {e.message}")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Test Framework: generic
|
||||
Total Tests: 3
|
||||
Passed: 2
|
||||
Failed: 1
|
||||
|
||||
--- Test Results ---
|
||||
✓ Output Block 1
|
||||
✓ nox > Running session lint
|
||||
✗ Error Output
|
||||
|
||||
--- Failed Tests ---
|
||||
✗ Error Output
|
||||
nox > Running session lint
|
||||
nox > Reusing existing virtual environment at .nox/lint.
|
||||
nox > uv pip install 'ruff>=0.15,<0.16'
|
||||
nox > ruff check src/ scripts/ examples/ features/ robot/
|
||||
nox > Session lint was successful.
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"framework": "generic",
|
||||
"tests": [
|
||||
{
|
||||
"name": "Output Block 1",
|
||||
"passed": true,
|
||||
"output": [
|
||||
"All checks passed!"
|
||||
],
|
||||
"rawOutput": "All checks passed!"
|
||||
},
|
||||
{
|
||||
"name": "nox > Running session lint",
|
||||
"passed": true,
|
||||
"output": [
|
||||
"nox > Running session lint",
|
||||
"nox > Reusing existing virtual environment at .nox/lint.",
|
||||
"nox > uv pip install 'ruff>=0.15,<0.16'",
|
||||
"nox > ruff check src/ scripts/ examples/ features/ robot/",
|
||||
"nox > Session lint was successful."
|
||||
],
|
||||
"rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful."
|
||||
},
|
||||
{
|
||||
"name": "Error Output",
|
||||
"passed": false,
|
||||
"output": [
|
||||
"nox > Running session lint",
|
||||
"nox > Reusing existing virtual environment at .nox/lint.",
|
||||
"nox > uv pip install 'ruff>=0.15,<0.16'",
|
||||
"nox > ruff check src/ scripts/ examples/ features/ robot/",
|
||||
"nox > Session lint was successful.",
|
||||
""
|
||||
],
|
||||
"rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful.\n"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"passed": 2,
|
||||
"failed": 1
|
||||
},
|
||||
"rawOutput": "All checks passed!\n\nnox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful."
|
||||
}
|
||||
Reference in New Issue
Block a user