Skip to content

Commit 2c452d1

Browse files
minhdqdevCopilot
andcommitted
feat: add tp task <id> top-level command for better UX
Migrate 'tp get task <task_id>' to direct 'tp task <task_id>' shortcut. The get task sub-command is kept for backwards compatibility. - Add task_command.py with top-level 'task' command - Register in main.py as a top-level command - Add unit tests (7 new tests, 100% passing) - Bump version to 1.0.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c31eb47 commit 2c452d1

4 files changed

Lines changed: 154 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "todopro-cli"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
description = "A professional CLI for TodoPro task management system"
55
readme = "README.md"
66
requires-python = ">=3.12"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Top-level `tp task <task_id>` command — quick shortcut to get task details."""
2+
3+
from typing import Annotated
4+
5+
import typer
6+
7+
from todopro_cli.services.context_manager import get_strategy_context
8+
from todopro_cli.services.task_service import TaskService
9+
from todopro_cli.utils.task_helpers import resolve_task_id
10+
from todopro_cli.utils.ui.formatters import format_output
11+
12+
from .decorators import command_wrapper
13+
14+
app = typer.Typer()
15+
16+
17+
@app.command("task")
18+
@command_wrapper
19+
async def task_command(
20+
task_id: Annotated[str, typer.Argument(help="Task ID or suffix")],
21+
output: Annotated[
22+
str, typer.Option("--output", "-o", help="Output format")
23+
] = "table",
24+
) -> None:
25+
"""Get task details by ID or suffix."""
26+
strategy = get_strategy_context()
27+
task_repo = strategy.task_repository
28+
task_service = TaskService(task_repo)
29+
30+
resolved_id = await resolve_task_id(task_service, task_id)
31+
task = await task_service.get_task(resolved_id)
32+
format_output(task.model_dump(), output)

src/todopro_cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .commands.encryption import app as encryption_app # E2EE commands (setup, status, recover, rotate, show-recovery)
1515
# from .commands.export_command import app as export_app # DISABLED: Duplicate of data.py export, references undefined factory
1616
from .commands.get_command import app as get_app
17+
from .commands.task_command import app as task_command_app
1718
# from .commands.import_command import app as import_app # DISABLED: Duplicate of data.py import, references undefined factory
1819
from .commands.list_command import app as list_app
1920
from .commands.login_command import app as login_command_app
@@ -71,6 +72,7 @@
7172
app.add_typer(
7273
list_app, name="list", help="List resources (tasks, projects, labels, etc.)"
7374
)
75+
app.add_typer(task_command_app, name="", help="Get task details by ID or suffix")
7476
app.add_typer(get_app, name="get", help="Get resource details")
7577
app.add_typer(create_app, name="create", help="Create new resources")
7678
app.add_typer(update_resource_app, name="update", help="Update existing resources")
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Unit tests for the top-level `tp task <task_id>` command."""
2+
# pylint: disable=redefined-outer-name
3+
4+
from datetime import datetime
5+
from unittest.mock import AsyncMock, MagicMock, patch
6+
7+
import pytest
8+
from typer.testing import CliRunner
9+
10+
from todopro_cli.commands.task_command import app
11+
from todopro_cli.models import Task
12+
13+
runner = CliRunner()
14+
15+
16+
@pytest.fixture
17+
def mock_task():
18+
"""Create a mock task for testing."""
19+
return Task(
20+
id="task-abc123",
21+
content="Write unit tests",
22+
description="Cover the new task command",
23+
project_id=None,
24+
due_date=None,
25+
priority=1,
26+
is_completed=False,
27+
labels=[],
28+
contexts=[],
29+
created_at=datetime(2024, 1, 1, 12, 0, 0),
30+
updated_at=datetime(2024, 1, 1, 12, 0, 0),
31+
)
32+
33+
34+
@pytest.fixture
35+
def mock_task_service(mock_task):
36+
"""Patch TaskService and strategy context for all task_command tests."""
37+
service_mock = MagicMock()
38+
service_mock.get_task = AsyncMock(return_value=mock_task)
39+
40+
strategy_mock = MagicMock()
41+
strategy_mock.task_repository = MagicMock()
42+
43+
with (
44+
patch(
45+
"todopro_cli.commands.task_command.get_strategy_context",
46+
return_value=strategy_mock,
47+
),
48+
patch(
49+
"todopro_cli.commands.task_command.TaskService",
50+
return_value=service_mock,
51+
),
52+
patch(
53+
"todopro_cli.commands.decorators.get_config_service",
54+
return_value=MagicMock(
55+
config=MagicMock(
56+
get_current_context=MagicMock(
57+
return_value=MagicMock(type="local")
58+
)
59+
)
60+
),
61+
),
62+
):
63+
yield service_mock
64+
65+
66+
class TestTaskCommandHelp:
67+
"""Structural tests — no network/auth needed."""
68+
69+
def test_help_exits_zero(self):
70+
"""--help should succeed."""
71+
result = runner.invoke(app, ["--help"])
72+
assert result.exit_code == 0
73+
74+
def test_help_contains_description(self):
75+
"""Help text should describe the command."""
76+
result = runner.invoke(app, ["--help"])
77+
assert "Get task details" in result.stdout
78+
79+
def test_help_mentions_id_argument(self):
80+
"""Help text should mention the task_id argument."""
81+
result = runner.invoke(app, ["--help"])
82+
assert "task_id" in result.stdout.lower() or "TASK_ID" in result.stdout
83+
84+
def test_missing_task_id_exits_nonzero(self):
85+
"""Invoking without a task_id should exit with an error."""
86+
result = runner.invoke(app, [])
87+
assert result.exit_code != 0
88+
89+
90+
class TestTaskCommandSuccess:
91+
"""Functional tests for the happy path."""
92+
93+
@patch("todopro_cli.commands.task_command.resolve_task_id")
94+
def test_get_task_by_suffix(self, mock_resolve, mock_task_service, mock_task):
95+
"""tp task <suffix> should resolve and display the task."""
96+
mock_resolve.return_value = AsyncMock(return_value="task-abc123")()
97+
98+
result = runner.invoke(app, ["abc123"])
99+
100+
assert result.exit_code == 0
101+
mock_task_service.get_task.assert_called_once()
102+
103+
@patch("todopro_cli.commands.task_command.resolve_task_id")
104+
def test_get_task_by_full_id(self, mock_resolve, mock_task_service, mock_task):
105+
"""tp task <full-id> should work with a full UUID-style ID."""
106+
mock_resolve.return_value = AsyncMock(return_value="task-abc123")()
107+
108+
result = runner.invoke(app, ["task-abc123"])
109+
110+
assert result.exit_code == 0
111+
112+
@patch("todopro_cli.commands.task_command.resolve_task_id")
113+
def test_output_json_flag(self, mock_resolve, mock_task_service, mock_task):
114+
"""tp task <id> --output json should pass json format."""
115+
mock_resolve.return_value = AsyncMock(return_value="task-abc123")()
116+
117+
result = runner.invoke(app, ["abc123", "--output", "json"])
118+
119+
assert result.exit_code == 0

0 commit comments

Comments
 (0)