Files
cleveragents-core/features/steps/repo_indexing_cli_steps.py
T
brent.edwards 4f950a1600
CI / lint (push) Successful in 24s
CI / build (push) Successful in 16s
CI / typecheck (push) Successful in 38s
CI / quality (push) Successful in 43s
CI / security (push) Successful in 53s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m39s
CI / unit_tests (push) Successful in 3m58s
CI / docker (push) Successful in 1m8s
CI / e2e_tests (push) Successful in 5m21s
CI / coverage (push) Successful in 6m31s
CI / benchmark-publish (push) Successful in 19m39s
feat(cli): repo indexing CLI functional (#982)
## Summary

Add `agents repo index` and `agents repo status` CLI commands, wiring the existing `RepoIndexingService` backend to the CLI layer.

### Commands

- **`agents repo index <resource_name>`** — Triggers full or incremental indexing of a repository resource. Options: `--full` (force full re-index), `--format text|json`
- **`agents repo status <resource_name>`** — Displays indexing metadata: status, file count, token estimate, primary language, last indexed timestamp. Options: `--format text|json`

### Implementation

- New CLI module `src/cleveragents/cli/commands/repo.py` (238 lines)
- Registered as `repo` subcommand group in `cli/main.py`
- Resolves resource by name via `ResourceRegistryService`
- Calls `RepoIndexingService.index_resource()` / `refresh_index()` / `get_index_status()`
- Both text (Rich panel) and JSON output formats

### Quality Gates

| Session | Result |
|---|---|
| `nox -s lint` | PASS |
| `nox -s typecheck` | PASS (0 errors) |
| `nox -s unit_tests` | PASS (10,819 scenarios) |
| `nox -s coverage_report` | 98% (>= 97%) |
| Integration tests | 5/5 PASS |

Closes #856

Reviewed-on: #982
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
2026-03-20 22:28:32 +00:00

274 lines
9.7 KiB
Python

"""Step definitions for Repository Indexing CLI feature tests."""
from __future__ import annotations
import contextlib
import json
import tempfile
from io import StringIO
from pathlib import Path
from typing import Any
from unittest.mock import patch
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from rich.console import Console
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.repo_indexing_service import (
RepoIndexingService,
)
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.infrastructure.database.models import Base
def _setup_services(
context: Context,
) -> tuple[ResourceRegistryService, RepoIndexingService]:
"""Return the cached services created by the Background step."""
return context._repo_cli_resource_svc, context._repo_cli_indexing_svc
def _capture_repo_command(
context: Context,
func: Any,
*args: Any,
**kwargs: Any,
) -> None:
"""Run a CLI function capturing output and success status.
Captures both Rich console output (via *buf*) **and** direct
``sys.stdout`` writes (used by ``format_output`` for JSON/YAML
to avoid Rich line-wrapping).
"""
buf = StringIO()
stdout_buf = StringIO()
console = Console(
file=buf, width=200, no_color=True, highlight=False, force_terminal=False
)
import cleveragents.cli.commands.repo as repo_mod
orig_console = repo_mod.console
repo_mod.console = console
failed = False
try:
with contextlib.redirect_stdout(stdout_buf):
func(*args, **kwargs)
except SystemExit:
failed = True
except Exception:
failed = True
finally:
repo_mod.console = orig_console
# Combine Rich console output with any direct stdout writes
context.repo_cli_output = buf.getvalue() + stdout_buf.getvalue()
context.repo_cli_failed = failed
def _run_with_mock(
context: Context,
func: Any,
*args: Any,
**kwargs: Any,
) -> None:
"""Run a CLI function with mocked container providing test services."""
resource_svc, indexing_svc = _setup_services(context)
with patch("cleveragents.cli.commands.repo.get_container") as mock_container:
container = mock_container.return_value
container.resource_registry_service.return_value = resource_svc
container.repo_indexing_service.return_value = indexing_svc
_capture_repo_command(context, func, *args, **kwargs)
@given("a fresh in-memory repo indexing environment")
def step_fresh_repo_env(context: Context) -> None:
"""Set up fresh in-memory services for repo indexing tests."""
# Create fresh in-memory database for each scenario. We cannot
# ``delattr`` on behave Context (it only works at the current level),
# so we unconditionally set new services each time.
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
context._repo_cli_engine = engine
context._repo_cli_factory = factory
context._repo_cli_resource_svc = ResourceRegistryService(session_factory=factory)
context._repo_cli_resource_svc.bootstrap_builtin_types()
context._repo_cli_indexing_svc = RepoIndexingService(session_factory=factory)
context.repo_cli_output = ""
context.repo_cli_failed = False
context._repo_cli_temp_dirs = {} # dict[str, str]
@given('a registered git-checkout resource "{name}" with a temp directory')
def step_register_resource_with_tempdir(context: Context, name: str) -> None:
"""Register a git-checkout resource backed by a real temporary directory."""
resource_svc, _indexing_svc = _setup_services(context)
tmp_dir = tempfile.mkdtemp()
context._repo_cli_temp_dirs[name] = tmp_dir
sample = Path(tmp_dir) / "README.md"
sample.write_text("# Test Repository\n\nSample content for indexing.\n")
resource_svc.register_resource(
type_name="git-checkout",
name=name,
location=tmp_dir,
properties={"path": tmp_dir},
)
@given('I have already indexed "{name}"')
def step_already_indexed(context: Context, name: str) -> None:
"""Perform an initial index on the resource."""
resource_svc, indexing_svc = _setup_services(context)
resource = resource_svc.show_resource(name)
root_path = resource.location or ""
if not root_path and resource.properties:
path_val = resource.properties.get("path")
root_path = str(path_val) if path_val is not None else ""
indexing_svc.index_resource(resource.resource_id, root_path)
@when('I run repo index on resource "{name}"')
def step_run_repo_index(context: Context, name: str) -> None:
"""Run the repo index command (incremental)."""
from cleveragents.cli.commands.repo import index_command
_run_with_mock(context, index_command, name, full=False, fmt="text")
@when('I run repo full-index on resource "{name}"')
def step_run_repo_full_index(context: Context, name: str) -> None:
"""Run the repo index command with --full flag."""
from cleveragents.cli.commands.repo import index_command
_run_with_mock(context, index_command, name, full=True, fmt="text")
@when('I run repo index-json on resource "{name}"')
def step_run_repo_index_json(context: Context, name: str) -> None:
"""Run the repo index command with JSON format."""
from cleveragents.cli.commands.repo import index_command
_run_with_mock(context, index_command, name, full=False, fmt="json")
@when('I run repo status on resource "{name}"')
def step_run_repo_status(context: Context, name: str) -> None:
"""Run the repo status command."""
from cleveragents.cli.commands.repo import status_command
_run_with_mock(context, status_command, name, fmt="text")
@when('I run repo status-json on resource "{name}"')
def step_run_repo_status_json(context: Context, name: str) -> None:
"""Run the repo status command with JSON format."""
from cleveragents.cli.commands.repo import status_command
_run_with_mock(context, status_command, name, fmt="json")
@then("the repo command should succeed")
def step_repo_should_succeed(context: Context) -> None:
"""Assert the repo command did not fail."""
assert not context.repo_cli_failed, (
f"Expected success but command failed. Output:\n{context.repo_cli_output}"
)
@then("the repo command should fail")
def step_repo_should_fail(context: Context) -> None:
"""Assert the repo command failed."""
assert context.repo_cli_failed, (
f"Expected failure but command succeeded. Output:\n{context.repo_cli_output}"
)
@then('the repo output should contain "{text}"')
def step_repo_output_contains(context: Context, text: str) -> None:
"""Assert the repo output contains the given text."""
assert text in context.repo_cli_output, (
f"Expected output to contain '{text}'. Got:\n{context.repo_cli_output}"
)
@then("the repo output should be valid JSON")
def step_repo_output_valid_json(context: Context) -> None:
"""Assert the repo output is valid JSON."""
output = context.repo_cli_output.strip()
try:
context.repo_cli_json = json.loads(output)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Output is not valid JSON: {exc}\nOutput:\n{output}"
) from exc
@then('the repo JSON output should contain key "{key}"')
def step_repo_json_has_key(context: Context, key: str) -> None:
"""Assert the parsed JSON output contains a key."""
assert hasattr(context, "repo_cli_json"), "JSON not parsed; call 'valid JSON' first"
assert key in context.repo_cli_json, (
f"Key '{key}' not found in JSON: {context.repo_cli_json}"
)
@then('the repo JSON output field "{key}" should equal "{value}"')
def step_repo_json_field_equals(context: Context, key: str, value: str) -> None:
"""Assert a JSON field equals the expected value."""
assert hasattr(context, "repo_cli_json"), "JSON not parsed; call 'valid JSON' first"
actual = str(context.repo_cli_json.get(key, ""))
assert actual == value, f"Expected {key}='{value}', got '{actual}'"
@given('a registered resource "{name}" with no location')
def step_register_resource_no_location(context: Context, name: str) -> None:
"""Register a resource with no location or path property."""
resource_svc, _indexing_svc = _setup_services(context)
resource_svc.register_resource(
type_name="git-checkout",
name=name,
location=None,
properties={},
)
@given('a registered resource "{name}" with path in properties only')
def step_register_resource_properties_path(context: Context, name: str) -> None:
"""Register a resource with path only in properties (no location)."""
resource_svc, _indexing_svc = _setup_services(context)
tmp_dir = tempfile.mkdtemp()
context._repo_cli_temp_dirs[name] = tmp_dir
sample = Path(tmp_dir) / "README.md"
sample.write_text("# Properties path test\n")
resource_svc.register_resource(
type_name="git-checkout",
name=name,
location=None,
properties={"path": tmp_dir},
)
@given('a registered resource "{name}" with nonexistent path')
def step_register_resource_bad_path(context: Context, name: str) -> None:
"""Register a resource pointing to a nonexistent directory."""
resource_svc, _indexing_svc = _setup_services(context)
resource_svc.register_resource(
type_name="git-checkout",
name=name,
location="/tmp/nonexistent-path-for-indexing-test-12345",
properties={"path": "/tmp/nonexistent-path-for-indexing-test-12345"},
)