From 8891160bd80d1ec0d6cea1668db3344d41f0e66f Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 5 Apr 2026 10:01:43 +0000 Subject: [PATCH] fix(cli): add Name and Updated columns to agents plan list table output - Implemented Updated column in the plan list rich output by extending lifecycle_list_plans() in src/cleveragents/cli/commands/plan.py. The column is inserted after the existing Project column and before Elapsed to maintain logical grouping. - Added updated_str using plan.timestamps.updated_at.strftime('%Y-%m-%d %H:%M') for a human-friendly timestamp in local time. - Added three Behave scenarios in features/plan_cli_spec_alignment.feature to assert presence of Name, Updated, and Invariants columns. - Added 'When I run plan list with no filters' step definition. - Added list_columns() function to robot/helper_plan_cli_spec.py verifying all three columns with partial header checks due to terminal truncation. - Added 'Plan List Rich Output Includes Required Columns' test case to robot/plan_cli_spec.robot. ISSUES CLOSED: #2611 --- features/plan_cli_spec_alignment.feature | 20 +++++++++++ .../steps/plan_cli_spec_alignment_steps.py | 34 ++++++++++++++++++ robot/helper_plan_cli_spec.py | 35 +++++++++++++++++++ robot/plan_cli_spec.robot | 8 +++++ src/cleveragents/cli/commands/plan.py | 9 +++++ 5 files changed, 106 insertions(+) diff --git a/features/plan_cli_spec_alignment.feature b/features/plan_cli_spec_alignment.feature index 70260338a..ec587aa8f 100644 --- a/features/plan_cli_spec_alignment.feature +++ b/features/plan_cli_spec_alignment.feature @@ -141,3 +141,23 @@ Feature: Plan CLI spec alignment When I run plan cancel with reason "Requirements changed" Then the plan spec cancel should succeed And the plan spec cancel output should contain "Requirements changed" + + # ---- plan list: required column rendering ---- + Scenario: Plan list rich output includes Name column + Given plan spec alignment plans exist + When I run plan list with no filters + Then the plan spec list should succeed + And the plan spec list output should contain "Name" + + Scenario: Plan list rich output includes Updated column + Given plan spec alignment plans exist + When I run plan list with no filters + Then the plan spec list should succeed + And the plan spec list output should contain "Updat" + And the plan spec list output should contain the current year-month timestamp + + Scenario: Plan list rich output includes Invariants column + Given plan spec alignment plans exist + When I run plan list with no filters + Then the plan spec list should succeed + And the plan spec list output should contain "Invar" diff --git a/features/steps/plan_cli_spec_alignment_steps.py b/features/steps/plan_cli_spec_alignment_steps.py index 5ada04116..0b19b4e15 100644 --- a/features/steps/plan_cli_spec_alignment_steps.py +++ b/features/steps/plan_cli_spec_alignment_steps.py @@ -350,6 +350,26 @@ def step_plan_list_combined(context: Context, phase: str, project: str) -> None: ) +@when("I run plan list with no filters") +def step_plan_list_no_filters(context: Context) -> None: + """Run list with no filters to get all plans in rich output. + + Forces the module-level Rich console to use a 200-column width so that + all table columns (including ``Updated``) are rendered without truncation + or being dropped. The console's ``_width`` attribute is restored after + the invocation. + """ + import cleveragents.cli.commands.plan as _plan_mod + + wide_runner = CliRunner(mix_stderr=False) + original_width = _plan_mod.console._width + _plan_mod.console._width = 200 + try: + context.result = wide_runner.invoke(plan_app, ["list"]) + finally: + _plan_mod.console._width = original_width + + # --------------------------------------------------------------------------- # When steps -- plan status # --------------------------------------------------------------------------- @@ -496,3 +516,17 @@ def step_plan_cancel_contains(context: Context, text: str) -> None: assert text in context.result.output, ( f"Expected '{text}' in cancel output but got:\n{context.result.output}" ) + + +@then("the plan spec list output should contain the current year-month timestamp") +def step_plan_list_output_contains_timestamp(context: Context) -> None: + """Verify the list output contains a formatted timestamp value for the Updated column. + + The test fixture creates plans with ``datetime.now()``, so the current + year-month prefix (e.g. ``"2026-04"``) must appear in the output. + """ + year_month = datetime.now().strftime("%Y-%m") + assert year_month in context.result.output, ( + f"Expected current year-month '{year_month}' in list output " + f"(Updated column value) but got:\n{context.result.output}" + ) diff --git a/robot/helper_plan_cli_spec.py b/robot/helper_plan_cli_spec.py index 376f04f71..8961aaa90 100644 --- a/robot/helper_plan_cli_spec.py +++ b/robot/helper_plan_cli_spec.py @@ -235,6 +235,40 @@ def list_filters() -> None: sys.exit(1) +def list_columns() -> None: + """Verify plan list rich output includes Name, Updated, and Invariants columns.""" + mock_service = MagicMock() + mock_service.list_plans.return_value = [ + _mock_plan(project_links=[ProjectLink(project_name="proj-a")]), + ] + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=mock_service, + ): + # Use a wide terminal so column headers are not truncated + wide_runner = CliRunner(mix_stderr=False) + result = wide_runner.invoke(plan_app, ["list"], env={"COLUMNS": "200"}) + if result.exit_code != 0: + print(f"FAIL: list returned {result.exit_code}", file=sys.stderr) + print(result.output, file=sys.stderr) + sys.exit(1) + output = result.output + # Check for column headers (rich may truncate in narrow terminals, + # so we check for the first 5 chars which are always rendered) + required_columns = ["Name", "Updat", "Invar"] + missing = [col for col in required_columns if col not in output] + if missing: + print(f"FAIL: missing columns: {missing}", file=sys.stderr) + print(output, file=sys.stderr) + sys.exit(1) + # Verify the plan name appears in the output + if "local/smoke-plan" not in output and "local/smok" not in output: + print("FAIL: plan name not in output", file=sys.stderr) + print(output, file=sys.stderr) + sys.exit(1) + print("plan-cli-list-columns-ok") + + def status_fields() -> None: """Verify plan status renders required fields.""" mock_service = MagicMock() @@ -294,6 +328,7 @@ _COMMANDS = { "use-invariant": use_invariant, "use-actor-overrides": use_actor_overrides, "list-filters": list_filters, + "list-columns": list_columns, "status-fields": status_fields, "cancel-reason": cancel_reason, } diff --git a/robot/plan_cli_spec.robot b/robot/plan_cli_spec.robot index 773a5d792..c624b97ed 100644 --- a/robot/plan_cli_spec.robot +++ b/robot/plan_cli_spec.robot @@ -51,3 +51,11 @@ Plan Cancel Accepts Reason Flag ${result}= Run Process ${PYTHON} ${HELPER} cancel-reason cwd=${WORKSPACE} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} plan-cli-cancel-reason-ok + +Plan List Rich Output Includes Required Columns + [Documentation] Verify that ``plan list`` rich output includes Name, Updated, and Invariants columns + ${result}= Run Process ${PYTHON} ${HELPER} list-columns cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} plan-cli-list-columns-ok diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 649b2c838..b1752e85e 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -2461,6 +2461,7 @@ def lifecycle_list_plans( table.add_column("Action", style="blue") table.add_column("Invariants", style="dim") table.add_column("Project", style="green") + table.add_column("Updated", style="dim") table.add_column("Elapsed", style="dim") for plan in plans: @@ -2487,6 +2488,13 @@ def lifecycle_list_plans( # Invariant count invariant_count = str(len(plan.invariants)) if plan.invariants else "0" + # Last-updated timestamp. + # PlanTimestamps.updated_at is typed as datetime (never None), + # so we call strftime directly. The value is stored as-is + # (naive local time by default); no timezone conversion is + # applied here, consistent with how created_at is stored. + updated_str = plan.timestamps.updated_at.strftime("%Y-%m-%d %H:%M") + table.add_row( plan.identity.plan_id[:8], str(plan.namespaced_name), @@ -2495,6 +2503,7 @@ def lifecycle_list_plans( plan.action_name, invariant_count, project_display, + updated_str, elapsed_str, ) -- 2.52.0