From 52730b08468f6e50a7c69706a79f37f8a14a0546 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Fri, 3 Apr 2026 19:45:15 +0000 Subject: [PATCH] fix(cli): add --namespace/-n option to agents plan list command Add the missing --namespace/-n option to lifecycle_list_plans() in plan.py, mirroring the existing implementation in list_actions() in action.py. The service layer already supported namespace filtering; only the CLI layer was missing the option. Changes: - Add namespace parameter to lifecycle_list_plans() with --namespace/-n option flags and 'Filter plans by namespace' help text - Pass namespace through to service.list_plans(namespace=namespace, ...) - Update TUI Filters panel to display 'Namespace: ' when provided - Add usage examples to command docstring - Add 4 Behave unit test scenarios covering --namespace/-n option - Add 2 Robot Framework integration tests verifying namespace filtering ISSUES CLOSED: #2165 --- features/plan_cli_spec_alignment.feature | 25 ++++++ .../steps/plan_cli_spec_alignment_steps.py | 48 +++++++++++ .../steps/plan_namespaced_name_tdd_steps.py | 12 ++- robot/cli_lifecycle_e2e.robot | 16 ++++ robot/helper_cli_lifecycle_e2e.py | 80 +++++++++++++++++++ src/cleveragents/cli/commands/plan.py | 25 +++++- 6 files changed, 201 insertions(+), 5 deletions(-) diff --git a/features/plan_cli_spec_alignment.feature b/features/plan_cli_spec_alignment.feature index 87bcde47d..70260338a 100644 --- a/features/plan_cli_spec_alignment.feature +++ b/features/plan_cli_spec_alignment.feature @@ -61,6 +61,31 @@ Feature: Plan CLI spec alignment And the plan spec use should pass argument "target_coverage" with value 80 # ---- plan list: filter combinations ---- + Scenario: Plan list with --namespace filter (long form) + Given plan spec alignment plans exist + When I run plan list with namespace "myteam" + Then the plan spec list should succeed + And the plan spec list should pass namespace "myteam" to service + + Scenario: Plan list with -n namespace filter (short form) + Given plan spec alignment plans exist + When I run plan list with short namespace "local" + Then the plan spec list should succeed + And the plan spec list should pass namespace "local" to service + + Scenario: Plan list with --namespace shows Namespace in Filters panel + Given plan spec alignment plans exist + When I run plan list with namespace "myteam" + Then the plan spec list should succeed + And the plan spec list output should contain "Namespace" + And the plan spec list output should contain "myteam" + + Scenario: Plan list with --namespace combined with --state + Given plan spec alignment plans exist + When I run plan list filtering by namespace "myteam" and processing state "queued" + Then the plan spec list should succeed + And the plan spec list should pass namespace "myteam" to service + Scenario: Plan list with --phase filter Given plan spec alignment plans exist When I run plan list with phase "strategize" diff --git a/features/steps/plan_cli_spec_alignment_steps.py b/features/steps/plan_cli_spec_alignment_steps.py index 45842fcaa..5ada04116 100644 --- a/features/steps/plan_cli_spec_alignment_steps.py +++ b/features/steps/plan_cli_spec_alignment_steps.py @@ -280,6 +280,30 @@ def step_plan_use_arg(context: Context, arg_str: str) -> None: # --------------------------------------------------------------------------- +@when('I run plan list with namespace "{namespace}"') +def step_plan_list_namespace(context: Context, namespace: str) -> None: + """Run list with --namespace filter (long form).""" + context.result = context.runner.invoke(plan_app, ["list", "--namespace", namespace]) + + +@when('I run plan list with short namespace "{namespace}"') +def step_plan_list_short_namespace(context: Context, namespace: str) -> None: + """Run list with -n namespace filter (short form).""" + context.result = context.runner.invoke(plan_app, ["list", "-n", namespace]) + + +@when( + 'I run plan list filtering by namespace "{namespace}" and processing state "{state}"' +) +def step_plan_list_namespace_and_state( + context: Context, namespace: str, state: str +) -> None: + """Run list with --namespace and --state combined.""" + context.result = context.runner.invoke( + plan_app, ["list", "--namespace", namespace, "--state", state] + ) + + @when('I run plan list with phase "{phase}"') def step_plan_list_phase(context: Context, phase: str) -> None: """Run list with --phase filter.""" @@ -416,6 +440,30 @@ def step_plan_list_ok(context: Context) -> None: ) +@then('the plan spec list should pass namespace "{namespace}" to service') +def step_plan_list_namespace_passed(context: Context, namespace: str) -> None: + """Verify the namespace was passed to list_plans service call.""" + call_kwargs = context.mock_service.list_plans.call_args + assert call_kwargs is not None, "list_plans was not called" + # Support both positional and keyword argument styles + kwargs = call_kwargs[1] if call_kwargs[1] else {} + args = call_kwargs[0] if call_kwargs[0] else () + passed_namespace = kwargs.get("namespace", args[0] if args else None) + assert passed_namespace == namespace, ( + f"Expected namespace='{namespace}', got namespace='{passed_namespace}'" + ) + + +@then('the plan spec list output should contain "{text}"') +def step_plan_list_output_contains(context: Context, text: str) -> None: + """Verify the list output contains expected text (case-insensitive).""" + output_lower = context.result.output.lower() + text_lower = text.lower() + assert text_lower in output_lower, ( + f"Expected '{text}' in list output but got:\n{context.result.output}" + ) + + @then("the plan spec status should succeed") def step_plan_status_ok(context: Context) -> None: """Verify plan status succeeded.""" diff --git a/features/steps/plan_namespaced_name_tdd_steps.py b/features/steps/plan_namespaced_name_tdd_steps.py index 6728627fa..cf9bd8295 100644 --- a/features/steps/plan_namespaced_name_tdd_steps.py +++ b/features/steps/plan_namespaced_name_tdd_steps.py @@ -8,7 +8,9 @@ from cleveragents.domain.models.core.plan import NamespacedName @when('I parse the namespaced name "{full_name}" expecting an error') -def step_when_parse_namespaced_name_expecting_error(context: Context, full_name: str) -> None: +def step_when_parse_namespaced_name_expecting_error( + context: Context, full_name: str +) -> None: """Parse a namespaced name string and capture any error.""" try: context.namespaced_name = NamespacedName.parse(full_name) @@ -18,8 +20,12 @@ def step_when_parse_namespaced_name_expecting_error(context: Context, full_name: context.namespaced_name = None -@when('I construct a NamespacedName with namespace "{namespace}" and name "{name}" expecting an error') -def step_when_construct_namespaced_name_expecting_error(context: Context, namespace: str, name: str) -> None: +@when( + 'I construct a NamespacedName with namespace "{namespace}" and name "{name}" expecting an error' +) +def step_when_construct_namespaced_name_expecting_error( + context: Context, namespace: str, name: str +) -> None: """Create a NamespacedName and capture any error.""" try: context.namespaced_name = NamespacedName(namespace=namespace, name=name) diff --git a/robot/cli_lifecycle_e2e.robot b/robot/cli_lifecycle_e2e.robot index 0c78537cb..52d9da275 100644 --- a/robot/cli_lifecycle_e2e.robot +++ b/robot/cli_lifecycle_e2e.robot @@ -71,3 +71,19 @@ Plan Lifecycle List Shows Plans Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} cli-lifecycle-plan-list-ok + +Plan List Namespace Option Filters By Namespace + [Documentation] Verify agents plan list --namespace myteam returns only plans in that namespace + ${result}= Run Process ${PYTHON} ${HELPER} plan-list-namespace cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-lifecycle-plan-list-namespace-ok + +Plan List Short Namespace Option Works + [Documentation] Verify agents plan list -n myteam short-form alias works correctly + ${result}= Run Process ${PYTHON} ${HELPER} plan-list-namespace-short cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-lifecycle-plan-list-namespace-short-ok diff --git a/robot/helper_cli_lifecycle_e2e.py b/robot/helper_cli_lifecycle_e2e.py index d3671cf10..00a63e7f8 100644 --- a/robot/helper_cli_lifecycle_e2e.py +++ b/robot/helper_cli_lifecycle_e2e.py @@ -322,6 +322,84 @@ def full_lifecycle() -> None: os.unlink(yaml_path) +def plan_list_namespace() -> None: + """Verify plan list --namespace myteam filters plans by namespace.""" + mock_service = MagicMock() + # Only plans in "myteam" namespace should be returned by the service + mock_service.list_plans.return_value = [ + _mock_plan(name="myteam/plan-a", plan_id="01KHDE6WWS2171PWW3GJEBXZ8C"), + ] + + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=mock_service, + ): + result = runner.invoke(plan_app, ["list", "--namespace", "myteam"]) + if result.exit_code != 0: + print( + f"FAIL: plan list --namespace returned {result.exit_code}", + file=sys.stderr, + ) + print(result.output, file=sys.stderr) + sys.exit(1) + + # Verify the namespace was passed to the service + call_kwargs = mock_service.list_plans.call_args + passed_namespace = call_kwargs[1].get("namespace") if call_kwargs[1] else None + if passed_namespace != "myteam": + print( + "FAIL: expected namespace='myteam', " + f"got namespace='{passed_namespace}'", + file=sys.stderr, + ) + sys.exit(1) + + # Verify Filters panel shows Namespace + if "Namespace" not in result.output or "myteam" not in result.output: + print( + "FAIL: Filters panel missing 'Namespace: myteam'", + file=sys.stderr, + ) + print(result.output, file=sys.stderr) + sys.exit(1) + + print("cli-lifecycle-plan-list-namespace-ok") + + +def plan_list_namespace_short() -> None: + """Verify plan list -n myteam short-form alias works correctly.""" + mock_service = MagicMock() + mock_service.list_plans.return_value = [ + _mock_plan(name="myteam/plan-b", plan_id="01KHDE6WWS2171PWW3GJEBXZ8D"), + ] + + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=mock_service, + ): + result = runner.invoke(plan_app, ["list", "-n", "myteam"]) + if result.exit_code != 0: + print( + f"FAIL: plan list -n returned {result.exit_code}", + file=sys.stderr, + ) + print(result.output, file=sys.stderr) + sys.exit(1) + + # Verify the namespace was passed to the service via short form + call_kwargs = mock_service.list_plans.call_args + passed_namespace = call_kwargs[1].get("namespace") if call_kwargs[1] else None + if passed_namespace != "myteam": + print( + "FAIL: expected namespace='myteam', " + f"got namespace='{passed_namespace}'", + file=sys.stderr, + ) + sys.exit(1) + + print("cli-lifecycle-plan-list-namespace-short-ok") + + # --------------------------------------------------------------------------- # Main dispatcher # --------------------------------------------------------------------------- @@ -334,6 +412,8 @@ _COMMANDS = { "plan-status": plan_status, "plan-cancel": plan_cancel, "plan-list": plan_list, + "plan-list-namespace": plan_list_namespace, + "plan-list-namespace-short": plan_list_namespace_short, "full-lifecycle": full_lifecycle, } diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index d26dca65d..6f5932f5a 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -2311,6 +2311,14 @@ def lifecycle_list_plans( help="Optional regex pattern to filter plan names", ), ] = None, + namespace: Annotated[ + str | None, + typer.Option( + "--namespace", + "-n", + help="Filter plans by namespace", + ), + ] = None, phase: Annotated[ str | None, typer.Option( @@ -2363,6 +2371,12 @@ def lifecycle_list_plans( This command lists plans created through the new plan lifecycle (Strategize -> Execute -> Apply -> Applied). + + Examples: + agents plan list + agents plan list --namespace myteam + agents plan list -n myteam --state processing + agents plan list --phase strategize """ try: import re @@ -2399,7 +2413,9 @@ def lifecycle_list_plans( ) raise typer.Abort() from exc - plans = service.list_plans(phase=phase_filter, project_name=project_id) + plans = service.list_plans( + namespace=namespace, phase=phase_filter, project_name=project_id + ) # Apply processing state filter if state_filter: @@ -2472,6 +2488,11 @@ def lifecycle_list_plans( # Display Filters panel (only if any filter is active) active_filters = [] + if namespace: + active_filters.append(f"[yellow]Namespace:[/yellow] {namespace}") + else: + active_filters.append("[yellow]Namespace:[/yellow] (any)") + if phase_filter: active_filters.append(f"[yellow]Phase:[/yellow] {phase_filter}") else: @@ -2493,7 +2514,7 @@ def lifecycle_list_plans( active_filters.append("[yellow]Action:[/yellow] (any)") # Only show Filters panel if at least one filter is active - if phase_filter or state_filter or project_id or action_filter: + if namespace or phase_filter or state_filter or project_id or action_filter: filters_text = "\n".join(active_filters) filters_panel = Panel(filters_text, title="Filters", border_style="dim") console.print(filters_panel) -- 2.52.0