diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 74caa72..54a25a2 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -14,7 +14,7 @@ body: attributes: label: Package Version description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. - placeholder: 0.3.0 + placeholder: 0.4.0 validations: required: false - type: input diff --git a/README.md b/README.md index 60208f7..c0b4dc5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ auto-detect your current sprint. It features interactive mode for easy navigatio - **Grouping**: Group tasks by status, assignee, or priority - **Task Details**: View detailed information about a specific task - **Task Updates**: Update task status with confirmation display +- **Task Comments**: Post comments on tasks from the CLI or via piped input - **Sprint Detection**: Auto-detect current sprint/iteration lists - **Interactive Mode**: Navigate through Team → Space → Project → List hierarchy with prompts - **Caching**: Disk-based caching for improved performance (24h for teams/lists, 5min for tasks) @@ -32,7 +33,15 @@ pip install quickup ## Quick Start -Set your ClickUp API token: +Authenticate with ClickUp (recommended): + +```bash +quickup login +``` + +This opens your browser for OAuth authentication and saves your credentials to `~/.quickup/auth.json`. + +Alternatively, set your ClickUp API token directly (useful for CI/automation): ```bash export CLICKUP_TOKEN=your_token_here @@ -52,6 +61,26 @@ quickup --team --list ## Commands +### `quickup login` - Authenticate + +Authenticate with ClickUp via OAuth. Opens your default browser and waits for the callback (up to 120 seconds). + +```bash +quickup login +``` + +Credentials are saved to `~/.quickup/auth.json` (permissions: `0o600`). + +### `quickup logout` - Remove Stored Credentials + +Remove the stored OAuth token. + +```bash +quickup logout +``` + +This only removes the OAuth token — it does not affect tokens set via `CLICKUP_TOKEN` or `.env`. + ### `quickup` (default) - List Tasks List all tasks from a ClickUp list, grouped by status. @@ -120,11 +149,15 @@ quickup task # With team specification quickup task --team + +# Include task comments +quickup task --comments ``` **Options:** - `task_id`: ClickUp task ID - `--team`: Team ID (required if multiple teams exist) +- `--comments`: Fetch and display task comments - `-i, --interactive`: Enable interactive mode ### `quickup update ` - Update Task Status @@ -145,6 +178,26 @@ quickup update --status "Done" --team - `--team`: Team ID (required if multiple teams exist) - `-i, --interactive`: Enable interactive mode +### `quickup comment ` - Post a Comment + +Post a comment on a specific task. + +```bash +# Post a comment +quickup comment --text "This is my comment" + +# Notify all task watchers +quickup comment --text "Attention everyone" --notify-all + +# Pipe comment from stdin +echo "Piped comment" | quickup comment +``` + +**Options:** +- `task_id`: ClickUp task ID +- `--text`: Comment text to post (reads from stdin if omitted) +- `--notify-all`: Notify all task watchers + ## Interactive Mode When multiple teams, spaces, projects, or lists exist, use `-i` flag to enable interactive selection: diff --git a/__about__.py b/__about__.py index 493f741..6a9beea 100644 --- a/__about__.py +++ b/__about__.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/docs/source/Installation.rst b/docs/source/Installation.rst index e6d452e..17dee91 100644 --- a/docs/source/Installation.rst +++ b/docs/source/Installation.rst @@ -32,12 +32,31 @@ Installation Steps Configuration ------------- -After installation, you need to configure your ClickUp API token. +After installation, authenticate with ClickUp using one of the methods below. -Option 1: Environment Variable +Option 1: OAuth Login (Recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Run the login command to authenticate via your browser: + +.. code-block:: bash + + quickup login + +This opens ClickUp in your default browser. After approving access, your credentials are +saved automatically to ``~/.quickup/auth.json`` (permissions: ``0o600``). No manual token +management needed. + +To sign out: + +.. code-block:: bash + + quickup logout + +Option 2: Environment Variable ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Export the token in your shell: +For automation or CI environments, export the token in your shell: .. code-block:: bash @@ -49,7 +68,7 @@ To make this permanent, add it to your shell configuration file (e.g., ``~/.bash echo 'export CLICKUP_TOKEN=your_token_here' >> ~/.zshrc -Option 2: .env File +Option 3: .env File ~~~~~~~~~~~~~~~~~~~ Create a ``.env`` file in your project directory: @@ -60,8 +79,13 @@ Create a ``.env`` file in your project directory: QuickUp! will automatically load the token from this file. -Getting Your ClickUp API Token ------------------------------- +.. note:: + + When both an environment/``.env`` token and an OAuth token exist, the environment token + takes precedence. + +Getting Your ClickUp API Token (for manual setup) +-------------------------------------------------- 1. Log in to your ClickUp account 2. Go to Settings → Apps → ClickUp API diff --git a/docs/source/api.rst b/docs/source/api.rst index 8840537..6c60943 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -38,6 +38,13 @@ cli.cache :members: :undoc-members: +cli.auth +-------- + +.. automodule:: quickup.cli.auth + :members: + :undoc-members: + cli.exceptions -------------- diff --git a/docs/source/commands.rst b/docs/source/commands.rst index 87a81a5..f076b1f 100644 --- a/docs/source/commands.rst +++ b/docs/source/commands.rst @@ -3,6 +3,63 @@ Commands Reference This page documents all available QuickUp! CLI commands and their options. +``quickup login`` - Authenticate +--------------------------------- + +Authenticate with ClickUp via OAuth. Opens your default browser and waits for the callback. + +Synopsis +~~~~~~~~ + +.. code-block:: bash + + quickup login + +Description +~~~~~~~~~~~ + +Opens the ClickUp authorization page in your browser and starts a local HTTP server on +``localhost:4242`` to receive the OAuth callback. After you approve access in the browser, +the token is exchanged and saved securely to ``~/.quickup/auth.json`` (permissions: +``0o600``). The callback times out after 120 seconds if not completed. + +To use a custom OAuth application, set ``QUICKUP_CLIENT_ID`` and ``QUICKUP_CLIENT_SECRET`` +environment variables before running ``quickup login``. + +Examples +~~~~~~~~ + +Log in with the default QuickUp! OAuth application: + +.. code-block:: bash + + quickup login + +``quickup logout`` - Remove Stored Credentials +----------------------------------------------- + +Remove the stored OAuth token from disk. + +Synopsis +~~~~~~~~ + +.. code-block:: bash + + quickup logout + +Description +~~~~~~~~~~~ + +Deletes ``~/.quickup/auth.json``. This only removes the OAuth token — tokens set via the +``CLICKUP_TOKEN`` environment variable or a ``.env`` file are not affected. + +Examples +~~~~~~~~ + +.. code-block:: bash + + quickup logout + ``quickup`` (default) - List Tasks ---------------------------------- @@ -155,6 +212,10 @@ Options Team ID (required if multiple teams exist) +.. option:: --comments + + Fetch and display task comments + .. option:: -i, --interactive Enable interactive mode @@ -174,6 +235,12 @@ With team specification: quickup task 123456 --team 12345 +Include comments: + +.. code-block:: bash + + quickup task 123456 --comments + ``quickup update`` - Update Task Status --------------------------------------- @@ -222,3 +289,54 @@ With team specification: .. code-block:: bash quickup update 123456 --status "Done" --team 12345 + +``quickup comment`` - Post a Comment +------------------------------------- + +Post a comment on a specific task. Provide text via ``--text`` or pipe from stdin. + +Synopsis +~~~~~~~~ + +.. code-block:: bash + + quickup comment [OPTIONS] + +Arguments +~~~~~~~~~ + +.. option:: task_id + + ClickUp task ID + +Options +~~~~~~~ + +.. option:: --text + + Comment text to post. If omitted, reads from stdin. + +.. option:: --notify-all + + Notify all task watchers (default: false) + +Examples +~~~~~~~~ + +Post a comment: + +.. code-block:: bash + + quickup comment 123456 --text "This looks good, merging now" + +Notify all watchers: + +.. code-block:: bash + + quickup comment 123456 --text "Attention everyone" --notify-all + +Pipe comment from stdin: + +.. code-block:: bash + + echo "Comment from a script" | quickup comment 123456 diff --git a/docs/source/features.rst b/docs/source/features.rst index bb6a6fa..6d22a47 100644 --- a/docs/source/features.rst +++ b/docs/source/features.rst @@ -3,6 +3,32 @@ Features This page describes the key features of QuickUp!. +Authentication +-------------- + +QuickUp! supports two authentication modes: + +**OAuth (recommended)** — authenticate once via your browser: + +.. code-block:: bash + + quickup login # opens browser, saves token to ~/.quickup/auth.json + quickup logout # removes the stored token + +**API Token** — set a personal token via environment variable or ``.env`` file: + +.. code-block:: bash + + export CLICKUP_TOKEN=your_token_here + +Token resolution order: + +1. ``CLICKUP_TOKEN`` environment variable or ``.env`` file (takes precedence) +2. OAuth token stored in ``~/.quickup/auth.json`` + +The OAuth token is stored with restrictive file permissions (``0o600``) so only the +current user can read it. + Task Listing ------------ @@ -64,6 +90,32 @@ Group tasks by different criteria: # Group by priority quickup --team 123 --list 456 --group-by priority +Task Comments +------------- + +Post comments on tasks directly from the CLI or by piping text from stdin: + +.. code-block:: bash + + # Post a comment with --text + quickup comment 123456 --text "Looks good!" + + # Pipe from another command + echo "Automated comment" | quickup comment 123456 + + # Notify all task watchers + quickup comment 123456 --text "Please review" --notify-all + +Long comments are truncated in the confirmation output for readability. + +View all comments on a task with the ``--comments`` flag on the ``task`` command: + +.. code-block:: bash + + quickup task 123456 --comments + +Each comment shows the author's username, timestamp, and full comment text. + Sprint Detection ---------------- diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 38234df..38d38f3 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -12,20 +12,23 @@ Install QuickUp! using pip: pip install quickup -Configuration -------------- +Authentication +-------------- -Set your ClickUp API token as an environment variable: +Log in with ClickUp via your browser: .. code-block:: bash - export CLICKUP_TOKEN=your_token_here + quickup login + +This opens ClickUp in your browser. After approving access, your credentials are saved +automatically — no manual token setup needed. -Alternatively, create a ``.env`` file in your project directory: +For CI or automation environments, you can instead set an API token directly: .. code-block:: bash - CLICKUP_TOKEN=your_token_here + export CLICKUP_TOKEN=your_token_here Basic Usage ----------- diff --git a/docs/source/troubleshooting.rst b/docs/source/troubleshooting.rst index 7d0ab4e..aa06d76 100644 --- a/docs/source/troubleshooting.rst +++ b/docs/source/troubleshooting.rst @@ -11,7 +11,13 @@ Problem: Token not found Error message: ``Token error`` -Solution: Ensure your ClickUp API token is set correctly: +Solution: Authenticate using the login command (recommended): + +.. code-block:: bash + + quickup login + +Alternatively, set your ClickUp API token manually: .. code-block:: bash @@ -23,7 +29,7 @@ Or create a ``.env`` file: CLICKUP_TOKEN=your_token_here -Verify the token is loaded: +Verify the environment token is loaded: .. code-block:: bash diff --git a/pyproject.toml b/pyproject.toml index 726a8bd..243505b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,13 @@ app = "python quickup.py" [tool.ruff] line-length = 120 +[tool.ruff.lint] +fixable = [ + "ALL", +] +unfixable = [] +select = ["E", "F", "UP", "B", "SIM", "PLC0415"] + [tool.black] line-length = 120 diff --git a/quickup/cli/api_client.py b/quickup/cli/api_client.py index f4b1c31..6d97135 100644 --- a/quickup/cli/api_client.py +++ b/quickup/cli/api_client.py @@ -1,5 +1,6 @@ """API client wrapper for ClickUp API interactions.""" +from datetime import datetime, timezone import sys from colorist import Color @@ -41,7 +42,7 @@ def get_team(clickup, argv, interactive=False): except ValueError: teams = get_teams_data(clickup) if len(teams) == 0: - raise TeamNotFoundError() + raise TeamNotFoundError() from None if len(teams) > 1: if interactive: @@ -58,14 +59,14 @@ def get_team(clickup, argv, interactive=False): if f"{t.name} [{t.id}]" == answers["team"]: return t - raise TeamAmbiguousError([t.name for t in teams]) + raise TeamAmbiguousError([t.name for t in teams]) from None return teams[0] except (TeamNotFoundError, TeamAmbiguousError): raise - except Exception: - raise TeamNotFoundError(team_id=argv[argv.index("--team") + 1] if "--team" in argv else None) + except Exception as err: + raise TeamNotFoundError(team_id=argv[argv.index("--team") + 1] if "--team" in argv else None) from err def get_space_for(team, argv, interactive=False): @@ -90,31 +91,30 @@ def get_space_for(team, argv, interactive=False): except ValueError: spaces = get_spaces_data(team) if len(spaces) == 0: - raise SpaceNotFoundError() + raise SpaceNotFoundError() from None - if len(spaces) > 1: - if interactive: - questions = [ - inquirer.List( - "space", - message=f"Select a {Color.CYAN}Space{Color.OFF}", - choices=[f"{space.name} [{space.id}]" for space in spaces], - ) - ] + if len(spaces) > 1 and interactive: + questions = [ + inquirer.List( + "space", + message=f"Select a {Color.CYAN}Space{Color.OFF}", + choices=[f"{space.name} [{space.id}]" for space in spaces], + ) + ] - answers = inquirer.prompt(questions) + answers = inquirer.prompt(questions) - if answers: - for space in spaces: - if f"{space.name} [{space.id}]" == answers["space"]: - return space + if answers: + for space in spaces: + if f"{space.name} [{space.id}]" == answers["space"]: + return space return spaces[0] except SpaceNotFoundError: raise - except Exception: - raise SpaceNotFoundError(space_id=argv[argv.index("--space") + 1] if "--space" in argv else None) + except Exception as err: + raise SpaceNotFoundError(space_id=argv[argv.index("--space") + 1] if "--space" in argv else None) from err def get_project_for(space, argv, interactive=False): @@ -139,35 +139,36 @@ def get_project_for(space, argv, interactive=False): except ValueError: projects = get_projects_data(space) if len(projects) == 0: - raise ProjectNotFoundError() + raise ProjectNotFoundError() from None visible = [p for p in projects if not getattr(p, "hidden", False)] if not visible: visible = projects - if len(visible) > 1: - if interactive: - questions = [ - inquirer.List( - "project", - message=f"Select a {Color.GREEN}Project{Color.OFF}", - choices=[f"{project.name} [{project.id}]" for project in visible], - ) - ] + if len(visible) > 1 and interactive: + questions = [ + inquirer.List( + "project", + message=f"Select a {Color.GREEN}Project{Color.OFF}", + choices=[f"{project.name} [{project.id}]" for project in visible], + ) + ] - answers = inquirer.prompt(questions) + answers = inquirer.prompt(questions) - if answers: - for project in visible: - if f"{project.name} [{project.id}]" == answers["project"]: - return project + if answers: + for project in visible: + if f"{project.name} [{project.id}]" == answers["project"]: + return project return visible[0] except ProjectNotFoundError: raise - except Exception: - raise ProjectNotFoundError(project_id=argv[argv.index("--project") + 1] if "--project" in argv else None) + except Exception as err: + raise ProjectNotFoundError( + project_id=argv[argv.index("--project") + 1] if "--project" in argv else None + ) from err def get_list_for(space_obj, argv, interactive=False): @@ -204,31 +205,30 @@ def get_list_for(space_obj, argv, interactive=False): lists = lists + get_lists_data(p) if len(lists) == 0: - raise ListNotFoundError() + raise ListNotFoundError() from None - if len(lists) > 1: - if interactive: - questions = [ - inquirer.List( - "list", - message=f"Select a {Color.YELLOW}List{Color.OFF}", - choices=[f"{li.name} [{li.id}]" for li in lists], - ) - ] + if len(lists) > 1 and interactive: + questions = [ + inquirer.List( + "list", + message=f"Select a {Color.YELLOW}List{Color.OFF}", + choices=[f"{li.name} [{li.id}]" for li in lists], + ) + ] - answers = inquirer.prompt(questions) + answers = inquirer.prompt(questions) - if answers: - for li in lists: - if f"{li.name} [{li.id}]" == answers["list"]: - return li + if answers: + for li in lists: + if f"{li.name} [{li.id}]" == answers["list"]: + return li return lists[0] except ListNotFoundError: raise - except Exception: - raise ListNotFoundError(list_id=argv[argv.index("--list") + 1] if "--list" in argv else None) + except Exception as err: + raise ListNotFoundError(list_id=argv[argv.index("--list") + 1] if "--list" in argv else None) from err def get_current_sprint_list(team, space): @@ -261,10 +261,19 @@ def get_current_sprint_list(team, space): if not sprint_lists: raise ListNotFoundError(hint="No lists found with 'sprint' or 'iteration' in the name") - # Prefer the sprint explicitly marked as started (currently active) - active_sprints = [li for li in sprint_lists if getattr(li, "status", None) == "started"] - if active_sprints: - return active_sprints[0] + # Prefer the sprint whose date range includes today + now = datetime.now(timezone.utc) + for li in sprint_lists: + try: + start = getattr(li, "start_date", None) + due = getattr(li, "due_date", None) + if start and due: + start_dt = datetime.fromtimestamp(int(start) / 1000, tz=timezone.utc) + due_dt = datetime.fromtimestamp(int(due) / 1000, tz=timezone.utc) + if start_dt <= now <= due_dt: + return li + except (TypeError, ValueError): + continue # Fallback: sort by ID descending (most recent first) sprint_lists.sort(key=lambda x: x.id, reverse=True) diff --git a/quickup/cli/auth.py b/quickup/cli/auth.py new file mode 100644 index 0000000..61c9d42 --- /dev/null +++ b/quickup/cli/auth.py @@ -0,0 +1,197 @@ +"""OAuth2 authentication flow for QuickUp! CLI.""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import secrets +from urllib.parse import parse_qs, urlparse +from urllib.request import Request, urlopen +import webbrowser + +# Default OAuth app credentials — override via QUICKUP_CLIENT_ID / QUICKUP_CLIENT_SECRET env vars +_DEFAULT_CLIENT_ID = "G0F2EFTGBIKJD3YY3EOWGMPZZ4ENRYWK" +_DEFAULT_CLIENT_SECRET = "4K8KUVGU9CFQZ83TSGABMJM30KJ3BE5L8H8HAAPI6OZOPBJ54JE05DJL91VR575A" + +AUTH_DIR = Path.home() / ".quickup" +AUTH_FILE = AUTH_DIR / "auth.json" + +CLICKUP_AUTH_URL = "https://app.clickup.com/api" +CLICKUP_TOKEN_URL = "https://api.clickup.com/api/v2/oauth/token" +CLICKUP_USER_URL = "https://api.clickup.com/api/v2/user" + +_CALLBACK_PORT = 4242 +_REDIRECT_URI = f"http://localhost:{_CALLBACK_PORT}" +_CALLBACK_TIMEOUT = 120 # seconds + + +def get_oauth_config() -> tuple[str, str]: + """Return (client_id, client_secret) from env vars or defaults.""" + client_id = os.environ.get("QUICKUP_CLIENT_ID", _DEFAULT_CLIENT_ID) + client_secret = os.environ.get("QUICKUP_CLIENT_SECRET", _DEFAULT_CLIENT_SECRET) + return client_id, client_secret + + +def load_oauth_token() -> str | None: + """Load the OAuth access token from ~/.quickup/auth.json.""" + try: + data = json.loads(AUTH_FILE.read_text()) + return data.get("access_token") + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return None + + +def save_oauth_token(token: str, user_info: dict | None = None) -> None: + """Save the OAuth access token to ~/.quickup/auth.json with restricted permissions.""" + AUTH_DIR.mkdir(parents=True, exist_ok=True) + os.chmod(AUTH_DIR, 0o700) + + data: dict[str, object] = {"access_token": token} + if user_info: + data["user"] = user_info + + AUTH_FILE.write_text(json.dumps(data, indent=2)) + os.chmod(AUTH_FILE, 0o600) + + +def delete_oauth_token() -> bool: + """Delete the stored OAuth token. Returns True if a token was removed.""" + try: + AUTH_FILE.unlink() + return True + except FileNotFoundError: + return False + + +class _OAuthCallbackHandler(BaseHTTPRequestHandler): + """HTTP request handler for the OAuth2 callback.""" + + code: str | None = None + error: str | None = None + expected_state: str = "" + + def do_GET(self) -> None: + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + # Validate state parameter + state = params.get("state", [None])[0] + if state != self.expected_state: + self._respond(400, "State mismatch — possible CSRF attack. Please try again.") + self.__class__.error = "State parameter mismatch" + return + + # Check for error from ClickUp + if "error" in params: + error_msg = params.get("error_description", params["error"])[0] + self._respond(400, f"Authorization failed: {error_msg}") + self.__class__.error = error_msg + return + + # Extract authorization code + code = params.get("code", [None])[0] + if not code: + self._respond(400, "No authorization code received.") + self.__class__.error = "No authorization code in callback" + return + + self.__class__.code = code + self._respond( + 200, + "Authentication successful! You can close this tab and return to the terminal.", + ) + + def _respond(self, status: int, message: str) -> None: + self.send_response(status) + self.send_header("Content-Type", "text/html") + self.end_headers() + html = f""" +QuickUp! Login + +
+

{'✅' if status == 200 else '❌'} {message}

+
l>l>l>l>""" + self.wfile.write(html.encode()) + + def log_message(self, format: str, *args: object) -> None: + """Suppress default request logging.""" + pass + + +def _exchange_code_for_token(code: str, client_id: str, client_secret: str) -> str: + """Exchange authorization code for an access token.""" + data = json.dumps( + { + "client_id": client_id, + "client_secret": client_secret, + "code": code, + } + ).encode() + + req = Request( + CLICKUP_TOKEN_URL, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(req) as resp: + result = json.loads(resp.read()) + + token = result.get("access_token") + if not token: + raise RuntimeError(f"No access_token in response: {result}") + return token + + +def _fetch_user_info(token: str) -> dict: + """Fetch the authenticated user's info from ClickUp.""" + req = Request(CLICKUP_USER_URL, headers={"Authorization": token}) + with urlopen(req) as resp: + result = json.loads(resp.read()) + return result.get("user", {}) + + +def perform_oauth_login() -> tuple[str, dict]: + """Run the full OAuth2 login flow. + + Opens the browser for authorization, starts a local server for the callback, + exchanges the code for an access token, and fetches user info. + + Returns: + Tuple of (access_token, user_info dict). + + Raises: + RuntimeError: If the OAuth flow fails at any step. + """ + client_id, client_secret = get_oauth_config() + state = secrets.token_urlsafe(32) + + auth_url = f"{CLICKUP_AUTH_URL}?client_id={client_id}&redirect_uri={_REDIRECT_URI}&state={state}" + + # Reset handler state + _OAuthCallbackHandler.code = None + _OAuthCallbackHandler.error = None + _OAuthCallbackHandler.expected_state = state + + server = HTTPServer(("127.0.0.1", _CALLBACK_PORT), _OAuthCallbackHandler) + server.timeout = _CALLBACK_TIMEOUT + + webbrowser.open(auth_url) + + # Handle a single request (blocks until callback or timeout) + server.handle_request() + server.server_close() + + if _OAuthCallbackHandler.error: + raise RuntimeError(_OAuthCallbackHandler.error) + + code = _OAuthCallbackHandler.code + if not code: + raise RuntimeError("No authorization code received — did the login time out?") + + access_token = _exchange_code_for_token(code, client_id, client_secret) + user_info = _fetch_user_info(access_token) + + return access_token, user_info diff --git a/quickup/cli/cache.py b/quickup/cli/cache.py index d9c9749..2c16ba8 100644 --- a/quickup/cli/cache.py +++ b/quickup/cli/cache.py @@ -10,6 +10,10 @@ import sqlite3 import time +from colorist import Effect +from pyclickup import ClickUp +from pyclickup.models import Task + # Cache location: ~/.quickup/cache/ CACHE_DIR = Path.home() / ".quickup" / "cache" CACHE_FILE = CACHE_DIR / "quickup_cache.db" @@ -222,7 +226,8 @@ def get_task_data(clickup, team_id: str, task_id: str): """Get a single task from cache or fetch from API. Checks the per-task cache key first, then searches cached list entries, - then falls back to the API. Always caches the result under 'task:{task_id}'. + then falls back to fetching the task directly via the single-task endpoint + (which includes subtasks). Always caches the result under 'task:{task_id}'. Args: clickup: ClickUp client instance. @@ -240,11 +245,12 @@ def get_task_data(clickup, team_id: str, task_id: str): cache.set(cache_key, task, expire=TASKS_TTL) return task - all_tasks = clickup._get_all_tasks(team_id) - task = next((t for t in all_tasks if t.id == task_id), None) - if task is not None: + task_data = clickup.get(f"task/{task_id}?include_subtasks=true") + if isinstance(task_data, dict) and "id" in task_data: + task = Task(task_data, client=clickup) cache.set(cache_key, task, expire=TASKS_TTL) - return task + return task + return None def force_refresh_tasks(team, list_id: str) -> list: @@ -279,9 +285,6 @@ def maybe_warmup(token: str) -> None: # Clear everything — hierarchy will be lazily re-fetched from API on next use cache.clear() - from colorist import Effect - from pyclickup import ClickUp - clickup = ClickUp(token) teams = {t.id: t for t in get_teams_data(clickup)} diff --git a/quickup/cli/config.py b/quickup/cli/config.py index 413ae71..f3fc751 100644 --- a/quickup/cli/config.py +++ b/quickup/cli/config.py @@ -2,12 +2,25 @@ import dotenv +from .auth import load_oauth_token + def init_environ(): - """Load environment variables from .env file. + """Load environment variables and resolve authentication token. + + Token resolution order: + 1. TOKEN from .env file (personal API token, backward compatible) + 2. OAuth token from ~/.quickup/auth.json Returns: dict: Environment variables as a dictionary. """ dotenv.load_dotenv(".env") - return dotenv.dotenv_values() + env = dotenv.dotenv_values() + + if not env.get("TOKEN"): + oauth_token = load_oauth_token() + if oauth_token: + env["TOKEN"] = oauth_token + + return env diff --git a/quickup/cli/exceptions.py b/quickup/cli/exceptions.py index 44bb24e..014ff3d 100644 --- a/quickup/cli/exceptions.py +++ b/quickup/cli/exceptions.py @@ -27,8 +27,8 @@ class TokenError(ClickupyError): def __init__(self): super().__init__( "Invalid or missing ClickUp API token.", - "Set CLICKUP_TOKEN in your .env file or environment variables. " - "Get your token from ClickUp Settings > Apps > Personal Settings.", + "Run 'quickup login' to authenticate via browser, " + "or set TOKEN in your .env file with a personal API token.", ) @@ -118,6 +118,15 @@ def __init__(self, list_id: str | None = None, hint: str | None = None): ) +class OAuthError(ClickupyError): + """Raised when OAuth authentication fails.""" + + exit_code = 6 + + def __init__(self, message: str = "OAuth authentication failed."): + super().__init__(message, "Try running 'quickup login' again.") + + class NetworkError(ClickupyError): """Raised for HTTP/connection failures.""" diff --git a/quickup/cli/main.py b/quickup/cli/main.py index e6b0e10..7817e4a 100644 --- a/quickup/cli/main.py +++ b/quickup/cli/main.py @@ -1,15 +1,18 @@ """Main CLI entry point for QuickUp! using cyclopts.""" +import sys from typing import Annotated, cast from cyclopts import App, Parameter from pyclickup import ClickUp +import requests from .api_client import get_current_sprint_list, get_list_for, get_project_for, get_space_for, get_team -from .cache import get_task_data +from .auth import delete_oauth_token, perform_oauth_login, save_oauth_token +from .cache import get_task_data, maybe_warmup from .config import init_environ -from .exceptions import TokenError, handle_exception -from .renderer import render_list, render_task_detail, render_task_update +from .exceptions import ClickupyError, OAuthError, TokenError, handle_exception +from .renderer import render_comment_posted, render_list, render_task_comments, render_task_detail, render_task_update app = App(name="quickup", help="A simple and beautiful console-based client for ClickUp.") @@ -90,9 +93,6 @@ def list_tasks( def run_app(): """Run the QuickUp! CLI application.""" - from .cache import maybe_warmup - from .exceptions import ClickupyError - environ = init_environ() token = environ.get("TOKEN") if token: @@ -183,6 +183,7 @@ def show_task( task_id: Annotated[str, Parameter(name="task_id", help="Task ID")], team: Annotated[str | None, Parameter(name="--team", help="Team ID")] = None, interactive: Annotated[bool, Parameter(name="-i", help="Enable interactive mode")] = False, + comments: Annotated[bool, Parameter(name="--comments", help="Show task comments")] = False, ) -> None: """Show detailed information about a specific task. @@ -193,6 +194,7 @@ def show_task( task_id: ClickUp task ID. team: Optional team ID (required if multiple teams exist). interactive: Enable interactive team selection. + comments: If True, also fetch and display task comments. """ environ = init_environ() token = environ.get("TOKEN") @@ -208,19 +210,28 @@ def show_task( team_obj = get_team(clickup, argv, interactive=interactive) - if team_obj is None: - team_id = cast(str, clickup.teams[0].id) - else: - team_id = team_obj.id + team_id = cast(str, clickup.teams[0].id) if team_obj is None else team_obj.id task = get_task_data(clickup, team_id, task_id) if task is None: - from .exceptions import ClickupyError - raise ClickupyError(f"Task {task_id} not found") render_task_detail(task) + if comments: + response = requests.get( + f"https://api.clickup.com/api/v2/task/{task_id}/comment", + headers=clickup.headers, + ) + if not response.ok: + try: + err_data = response.json() + err_msg = err_data.get("err", response.text) + except Exception: + err_msg = response.text or f"HTTP {response.status_code}" + raise ClickupyError(f"Failed to fetch comments: {err_msg}") + render_task_comments(response.json().get("comments", [])) + @app.command(name="update") def update_task( @@ -255,17 +266,12 @@ def update_task( team_obj = get_team(clickup, argv, interactive=interactive) # Fall back to first team if get_team returns None - if team_obj is None: - team_id = clickup.teams[0].id - else: - team_id = team_obj.id + team_id = clickup.teams[0].id if team_obj is None else team_obj.id # Get current task to find old status - fetch all tasks and find the matching one all_tasks = clickup._get_all_tasks(cast(str, team_id)) task = next((t for t in all_tasks if t.id == task_id), None) if task is None: - from .exceptions import ClickupyError - raise ClickupyError(f"Task {task_id} not found") old_status = task.status.status # type: ignore[attr-defined] @@ -273,3 +279,72 @@ def update_task( task.update(status=status) render_task_update(task_id, old_status, status) + + +@app.command(name="comment") +def comment_task( + task_id: Annotated[str, Parameter(name="task_id", help="Task ID")], + text: Annotated[str | None, Parameter(name="--text", help="Comment text to post")] = None, + notify_all: Annotated[bool, Parameter(name="--notify-all", help="Notify all task watchers")] = False, +) -> None: + """Post a comment on a task. + + Provide text via --text or pipe from stdin. + + Args: + task_id: ClickUp task ID. + text: Comment text to post. + notify_all: If True, notify all task watchers. + """ + if text is None: + if not sys.stdin.isatty(): + text = sys.stdin.read().strip() + if not text: + raise ClickupyError("No comment text provided. Use --text or pipe from stdin.") + + environ = init_environ() + token = environ.get("TOKEN") + if not token: + raise TokenError() + + clickup = ClickUp(token) + + # The comment endpoint is v2-only; pyclickup uses v1, so we call v2 directly. + response = requests.post( + f"https://api.clickup.com/api/v2/task/{task_id}/comment", + headers=clickup.headers, + json={"comment_text": text, "notify_all": notify_all}, + ) + + if not response.ok: + try: + err_data = response.json() + err_msg = err_data.get("err", response.text) + except Exception: + err_msg = response.text or f"HTTP {response.status_code}" + raise ClickupyError(f"Failed to post comment: {err_msg}") + + render_comment_posted(task_id, text) + + +@app.command +def login() -> None: + """Authenticate with ClickUp via OAuth2 browser login.""" + print("Opening browser for ClickUp authentication...") + try: + access_token, user_info = perform_oauth_login() + save_oauth_token(access_token, user_info) + username = user_info.get("username", "unknown") + email = user_info.get("email", "") + print(f"Successfully logged in as {username} ({email})") + except Exception as e: + raise OAuthError(str(e)) from e + + +@app.command +def logout() -> None: + """Remove stored ClickUp OAuth credentials.""" + if delete_oauth_token(): + print("Logged out successfully. OAuth token removed.") + else: + print("No OAuth token found. Nothing to do.") diff --git a/quickup/cli/renderer.py b/quickup/cli/renderer.py index bd2cc11..f32fd27 100644 --- a/quickup/cli/renderer.py +++ b/quickup/cli/renderer.py @@ -253,6 +253,38 @@ def render_list( print(f"{Effect.DIM}Run again: {suggestion}{Effect.DIM_OFF}") +def _render_subtasks(subtasks): + """Render subtasks (raw dicts from single-task API) grouped by status, same format as list view.""" + by_status = defaultdict(list) + status_color = {} + status_order = {} + for subtask in subtasks: + status = subtask.get("status", {}) + status_name = status.get("status", "unknown") + by_status[status_name].append(subtask) + status_color[status_name] = status.get("color", "#888888") + status_order[status_name] = status.get("orderindex", 0) + + for status_name in sorted(by_status, key=lambda s: status_order[s]): + bg = BgColorHex(status_color[status_name]) + print(f"\n{bg} {status_name.upper()} {bg.OFF} \n") + for subtask in by_status[status_name]: + task_id = subtask["id"] + name = f"{Effect.BOLD}{subtask['name']}{Effect.BOLD_OFF}" + url = f"https://app.clickup.com/t/{task_id}" + url_str = f"{Color.BLUE}{Effect.UNDERLINE}{url}{Effect.UNDERLINE_OFF}{Color.OFF}" + assignees = subtask.get("assignees", []) + assignee_names = ", ".join(a.get("username", "") for a in assignees) + assignee_str = f"{Effect.DIM}({assignee_names}){Effect.DIM_OFF}" if assignee_names else "" + id_str = f"{Effect.DIM}[id={task_id}]{Effect.DIM_OFF}" + priority = subtask.get("priority") + priority_str = "" + if priority and isinstance(priority, dict) and priority.get("priority"): + pc = ColorHex(priority["color"]) + priority_str = f"[{pc}{priority['priority'].capitalize()}{pc.OFF}] " + print(f" ▫ {priority_str}{name}: {url_str} {assignee_str} {id_str}") + + def render_task_detail(task): """Render detailed task information. @@ -271,9 +303,8 @@ def render_task_detail(task): print(f"{Effect.BOLD}Status:{Effect.BOLD_OFF} {status_color}{task.status.status}{status_color.OFF}") # URL - print( - f"{Effect.BOLD}URL:{Effect.BOLD_OFF} {Color.BLUE}{Effect.UNDERLINE}{task.url}{Effect.UNDERLINE_OFF}{Color.OFF}" - ) + url = getattr(task, "url", None) or f"https://app.clickup.com/t/{task.id}" + print(f"{Effect.BOLD}URL:{Effect.BOLD_OFF} {Color.BLUE}{Effect.UNDERLINE}{url}{Effect.UNDERLINE_OFF}{Color.OFF}") # Assignees if task.assignees: @@ -292,21 +323,21 @@ def render_task_detail(task): # Due Date if task.due_date: - due_date = task.due_date.split("T")[0] + due_date = task.due_date.split("T")[0] if isinstance(task.due_date, str) else task.due_date.strftime("%Y-%m-%d") print(f"{Effect.BOLD}Due Date:{Effect.BOLD_OFF} {due_date}") else: print(f"{Effect.BOLD}Due Date:{Effect.BOLD_OFF} None") - # Description - if task.description: + # Description (team endpoint uses 'description'; single-task endpoint uses 'text_content') + description = getattr(task, "description", None) or getattr(task, "text_content", None) + if description: print(f"\n{Effect.BOLD}Description:{Effect.BOLD_OFF}") - print(f"{task.description}") + print(f"{description}") # Subtasks if hasattr(task, "subtasks") and task.subtasks: print(f"\n{Effect.BOLD}Subtasks ({len(task.subtasks)}):{Effect.BOLD_OFF}") - for subtask in task.subtasks: - print(f" - {subtask.name}") + _render_subtasks(task.subtasks) else: print(f"\n{Effect.BOLD}Subtasks:{Effect.BOLD_OFF} None") @@ -323,6 +354,45 @@ def render_task_update(task_id, old_status, new_status): print(f"{'─' * 40}") print(f"\n{Effect.BOLD}Task ID:{Effect.BOLD_OFF} {task_id}") print( - f"{Effect.BOLD}Status:{Effect.BOLD_OFF} {Color.YELLOW}{old_status}{Color.OFF} → {Color.GREEN}{new_status}{Color.OFF}" + f"{Effect.BOLD}Status:{Effect.BOLD_OFF} " + f"{Color.YELLOW}{old_status}{Color.OFF} → {Color.GREEN}{new_status}{Color.OFF}" ) print(f"\n{Color.GREEN}✓{Color.OFF} Status updated successfully") + + +def render_task_comments(comments): + """Render list of comments for a task. + + Args: + comments: List of comment dicts from the ClickUp API. + """ + print(f"\n{Effect.BOLD}{Color.YELLOW}Comments ({len(comments)}){Color.OFF}{Effect.BOLD_OFF}") + print(f"{'─' * 40}") + + if not comments: + print("\nNo comments") + return + + for comment in comments: + user = comment.get("user", {}) + username = user.get("username") or user.get("email") or "Unknown" + date_ms = comment.get("date") + date_str = datetime.fromtimestamp(int(date_ms) / 1000).strftime("%Y-%m-%d %H:%M") if date_ms else "Unknown date" + text = comment.get("comment_text", "") + print(f"\n{Effect.BOLD}{username}{Effect.BOLD_OFF} · {date_str}") + print(f"{text}") + + +def render_comment_posted(task_id, comment_text): + """Render comment posted confirmation. + + Args: + task_id: ClickUp task ID. + comment_text: The comment text that was posted. + """ + print(f"{Effect.BOLD}{Color.GREEN}Comment Posted{Color.OFF}{Effect.BOLD_OFF}") + print(f"{'─' * 40}") + print(f"\n{Effect.BOLD}Task ID:{Effect.BOLD_OFF} {task_id}") + display_text = comment_text[:80] + "..." if len(comment_text) > 80 else comment_text + print(f"{Effect.BOLD}Comment:{Effect.BOLD_OFF} {display_text}") + print(f"\n{Color.GREEN}✓{Color.OFF} Comment posted successfully") diff --git a/screenshots/demo.gif b/screenshots/demo.gif index a4470fa..608066e 100644 Binary files a/screenshots/demo.gif and b/screenshots/demo.gif differ diff --git a/screenshots/demo.tape b/screenshots/demo.tape new file mode 100644 index 0000000..a65200b --- /dev/null +++ b/screenshots/demo.tape @@ -0,0 +1,125 @@ +Output screenshots/demo.gif + +Set Shell zsh +Set FontSize 14 +Set Width 1200 +Set Height 600 +Set Theme "Dracula" +Set WindowBar Colorful +Set Margin 20 +Set MarginFill "#282a36" +Set BorderRadius 8 +Set TypingSpeed 75ms +Set CursorBlink false + +# ── Scene 1: Interactive mode ────────────────────────────────────────────────── +Type "quickup -i" +Enter +Wait+Screen /Select/ +Sleep 500ms +# Navigate to desired project (Project #1 requires Down 3) +Down 3 +Sleep 300ms +Enter +Wait+Screen /Select/ +Sleep 5s + +# ── Scene 2: Sprint command ──────────────────────────────────────────────────── +Hide +Type "clear" +Enter +Show + +Type `echo " # quickup sprint -- auto-detect current sprint"` +Enter +Sleep 1.5s + +Hide +Type "clear" +Enter +Show + +Type "quickup sprint" +Enter +Wait+Screen /Run again/ +Sleep 8s + +# ── Scene 3: Task details ────────────────────────────────────────────────────── +Hide +Type "clear" +Enter +Show + +Type `echo " # quickup task -- show task details"` +Enter +Sleep 1.5s + +Hide +Type "clear" +Enter +Show + +Type "quickup task 86b902nz4" +Enter +Wait+Screen /Status/ +Sleep 8s + +# ── Scene 4: Update task status ──────────────────────────────────────────────── +Hide +Type "clear" +Enter +Show + +Type `echo " # quickup update --status -- update task status"` +Enter +Sleep 1.5s + +Hide +Type "clear" +Enter +Show + +Type `quickup update 86b902nz4 --status "in progress"` +Enter +Wait+Screen /→/ +Sleep 8s + +# ── Scene 5: Add a comment to same task ──────────────────────────────────────────────── +Hide +Type "clear" +Enter +Show + +Type `echo " # quickup comment --text -- add a comment to a task"` +Enter +Sleep 1.5s + +Hide +Type "clear" +Enter +Show + +Type `quickup comment 86b902nz4 --text "I'm working on it"` +Enter +Wait+Screen /Comment Posted/ +Sleep 8s + +# ── Scene 6: Task details with comments ────────────────────────────────────────────────────── +Hide +Type "clear" +Enter +Show + +Type `echo " # quickup task --comments -- show task details with comments"` +Enter +Sleep 1.5s + +Hide +Type "clear" +Enter +Show + +Type "quickup task 86b902nz4 --comments" +Enter +Wait+Screen /Status/ +Sleep 8s diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..0c95a83 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,260 @@ +"""Tests for QuickUp! OAuth authentication module.""" + +from io import BytesIO +import json +import sys +from typing import cast +from unittest.mock import Mock, patch + +import pytest + +from quickup.cli.auth import ( + _exchange_code_for_token, + _fetch_user_info, + _OAuthCallbackHandler, + delete_oauth_token, + get_oauth_config, + load_oauth_token, + perform_oauth_login, + save_oauth_token, +) +from quickup.cli.config import init_environ + + +class TestTokenStorage: + """Tests for OAuth token file read/write/delete.""" + + def test_save_and_load_token(self, tmp_path, monkeypatch): + auth_file = tmp_path / "auth.json" + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", auth_file) + monkeypatch.setattr("quickup.cli.auth.AUTH_DIR", tmp_path) + + save_oauth_token("test-token-123", {"username": "alice", "email": "a@b.com"}) + + assert auth_file.exists() + data = json.loads(auth_file.read_text()) + assert data["access_token"] == "test-token-123" + assert data["user"]["username"] == "alice" + + token = load_oauth_token() + assert token == "test-token-123" + + def test_load_token_missing_file(self, tmp_path, monkeypatch): + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", tmp_path / "nonexistent.json") + assert load_oauth_token() is None + + def test_load_token_invalid_json(self, tmp_path, monkeypatch): + auth_file = tmp_path / "auth.json" + auth_file.write_text("not json") + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", auth_file) + assert load_oauth_token() is None + + def test_save_token_without_user_info(self, tmp_path, monkeypatch): + auth_file = tmp_path / "auth.json" + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", auth_file) + monkeypatch.setattr("quickup.cli.auth.AUTH_DIR", tmp_path) + + save_oauth_token("token-only") + data = json.loads(auth_file.read_text()) + assert data["access_token"] == "token-only" + assert "user" not in data + + def test_delete_token_exists(self, tmp_path, monkeypatch): + auth_file = tmp_path / "auth.json" + auth_file.write_text('{"access_token": "x"}') + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", auth_file) + + assert delete_oauth_token() is True + assert not auth_file.exists() + + def test_delete_token_not_exists(self, tmp_path, monkeypatch): + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", tmp_path / "nonexistent.json") + assert delete_oauth_token() is False + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not support Unix file permissions") + def test_save_token_file_permissions(self, tmp_path, monkeypatch): + auth_file = tmp_path / "auth.json" + monkeypatch.setattr("quickup.cli.auth.AUTH_FILE", auth_file) + monkeypatch.setattr("quickup.cli.auth.AUTH_DIR", tmp_path) + + save_oauth_token("secret-token") + stat = auth_file.stat() + assert stat.st_mode & 0o777 == 0o600 + + +class TestOAuthConfig: + """Tests for OAuth client config resolution.""" + + def test_default_config(self): + config = get_oauth_config() + assert len(config) == 2 + assert isinstance(config[0], str) + assert isinstance(config[1], str) + + def test_env_var_override(self, monkeypatch): + monkeypatch.setenv("QUICKUP_CLIENT_ID", "my-id") + monkeypatch.setenv("QUICKUP_CLIENT_SECRET", "my-secret") + client_id, client_secret = get_oauth_config() + assert client_id == "my-id" + assert client_secret == "my-secret" + + +class TestCallbackHandler: + """Tests for the OAuth callback HTTP handler.""" + + def _make_handler(self, path: str, expected_state: str = "test-state"): + _OAuthCallbackHandler.code = None + _OAuthCallbackHandler.error = None + _OAuthCallbackHandler.expected_state = expected_state + + # Create a real instance without calling __init__ (which needs a socket) + handler = _OAuthCallbackHandler.__new__(_OAuthCallbackHandler) + handler.path = path + handler.wfile = BytesIO() + send_response_mock = Mock() + handler.send_response = send_response_mock # type: ignore[method-assign] + handler.send_header = Mock() # type: ignore[method-assign] + handler.end_headers = Mock() # type: ignore[method-assign] + + return handler + + def test_valid_callback(self): + handler = self._make_handler("/callback?code=abc123&state=test-state") + send_response_mock = cast(Mock, handler.send_response) + handler.do_GET() + assert _OAuthCallbackHandler.code == "abc123" + assert _OAuthCallbackHandler.error is None + send_response_mock.assert_called_with(200) + + def test_state_mismatch(self): + handler = self._make_handler("/callback?code=abc123&state=wrong-state") + send_response_mock = cast(Mock, handler.send_response) + handler.do_GET() + assert _OAuthCallbackHandler.code is None + assert _OAuthCallbackHandler.error is not None + send_response_mock.assert_called_with(400) + + def test_missing_code(self): + handler = self._make_handler("/callback?state=test-state") + send_response_mock = cast(Mock, handler.send_response) + handler.do_GET() + assert _OAuthCallbackHandler.code is None + assert _OAuthCallbackHandler.error is not None + send_response_mock.assert_called_with(400) + + def test_error_from_clickup(self): + handler = self._make_handler("/callback?error=access_denied&error_description=User+denied&state=test-state") + handler.do_GET() + assert _OAuthCallbackHandler.code is None + assert _OAuthCallbackHandler.error is not None + assert "User denied" in _OAuthCallbackHandler.error + + +class TestExchangeCodeForToken: + """Tests for the token exchange HTTP call.""" + + @patch("quickup.cli.auth.urlopen") + def test_successful_exchange(self, mock_urlopen): + mock_resp = Mock() + mock_resp.read.return_value = json.dumps({"access_token": "tok_123"}).encode() + mock_resp.__enter__ = Mock(return_value=mock_resp) + mock_resp.__exit__ = Mock(return_value=False) + mock_urlopen.return_value = mock_resp + + token = _exchange_code_for_token("code123", "cid", "csecret") + assert token == "tok_123" + + @patch("quickup.cli.auth.urlopen") + def test_missing_token_in_response(self, mock_urlopen): + mock_resp = Mock() + mock_resp.read.return_value = json.dumps({"error": "bad"}).encode() + mock_resp.__enter__ = Mock(return_value=mock_resp) + mock_resp.__exit__ = Mock(return_value=False) + mock_urlopen.return_value = mock_resp + + with pytest.raises(RuntimeError, match="No access_token"): + _exchange_code_for_token("code123", "cid", "csecret") + + +class TestFetchUserInfo: + """Tests for fetching user info.""" + + @patch("quickup.cli.auth.urlopen") + def test_successful_fetch(self, mock_urlopen): + mock_resp = Mock() + mock_resp.read.return_value = json.dumps({"user": {"username": "alice", "email": "a@b.com"}}).encode() + mock_resp.__enter__ = Mock(return_value=mock_resp) + mock_resp.__exit__ = Mock(return_value=False) + mock_urlopen.return_value = mock_resp + + info = _fetch_user_info("tok_123") + assert info["username"] == "alice" + + +class TestPerformOAuthLogin: + """Tests for the full OAuth login flow.""" + + @patch("quickup.cli.auth._fetch_user_info") + @patch("quickup.cli.auth._exchange_code_for_token") + @patch("quickup.cli.auth.webbrowser.open") + @patch("quickup.cli.auth.HTTPServer") + def test_successful_login(self, mock_server_cls, mock_browser, mock_exchange, mock_user_info): + # Simulate the callback setting the code + def fake_handle_request(): + _OAuthCallbackHandler.code = "auth-code-xyz" + + mock_server = Mock() + mock_server.handle_request.side_effect = fake_handle_request + mock_server_cls.return_value = mock_server + + mock_exchange.return_value = "access-token-abc" + mock_user_info.return_value = {"username": "bob", "email": "b@c.com"} + + token, user_info = perform_oauth_login() + + assert token == "access-token-abc" + assert user_info["username"] == "bob" + mock_browser.assert_called_once() + mock_exchange.assert_called_once() + mock_server.server_close.assert_called_once() + + @patch("quickup.cli.auth.webbrowser.open") + @patch("quickup.cli.auth.HTTPServer") + def test_login_timeout(self, mock_server_cls, mock_browser): + mock_server = Mock() + # Simulate timeout — code stays None + mock_server.handle_request.return_value = None + mock_server_cls.return_value = mock_server + + _OAuthCallbackHandler.code = None + _OAuthCallbackHandler.error = None + + with pytest.raises(RuntimeError, match="No authorization code"): + perform_oauth_login() + + +class TestInitEnvironOAuthFallback: + """Tests for token resolution order in init_environ.""" + + @patch("dotenv.load_dotenv") + @patch("dotenv.dotenv_values") + def test_env_token_takes_precedence(self, mock_values, mock_load): + mock_values.return_value = {"TOKEN": "env-token"} + result = init_environ() + assert result["TOKEN"] == "env-token" + + @patch("quickup.cli.config.load_oauth_token", return_value="oauth-token") + @patch("dotenv.load_dotenv") + @patch("dotenv.dotenv_values") + def test_oauth_fallback_when_no_env_token(self, mock_values, mock_load, mock_oauth): + mock_values.return_value = {} + result = init_environ() + assert result["TOKEN"] == "oauth-token" + + @patch("quickup.cli.config.load_oauth_token", return_value=None) + @patch("dotenv.load_dotenv") + @patch("dotenv.dotenv_values") + def test_no_token_at_all(self, mock_values, mock_load, mock_oauth): + mock_values.return_value = {} + result = init_environ() + assert result.get("TOKEN") is None diff --git a/tests/test_cache.py b/tests/test_cache.py index 787d84a..2eac840 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,6 +1,7 @@ """Tests for QuickUp! cache module.""" from pathlib import Path +import shutil from unittest.mock import Mock, patch from quickup.cli.cache import ( @@ -29,8 +30,6 @@ class TestGetCache: def test_get_cache_creates_directory(self, tmp_path): """Test that get_cache creates the cache directory.""" - import shutil - cache_dir = Path.home() / ".quickup" / "cache" backup_path = tmp_path / "quickup_cache_backup" @@ -310,22 +309,24 @@ def test_cache_hit(self, mock_get_cache, mock_find): assert result is mock_task mock_cache.set.assert_called_once_with("task:task-abc", mock_task, expire=TASKS_TTL) + @patch("quickup.cli.cache.Task") @patch("quickup.cli.cache.find_task_in_cache") @patch("quickup.cli.cache.get_cache") - def test_cache_miss_fetches_from_api(self, mock_get_cache, mock_find): - """Test falls back to API and caches the result.""" - mock_task = Mock(id="task-abc") + def test_cache_miss_fetches_from_api(self, mock_get_cache, mock_find, mock_task_class): + """Test falls back to single-task API endpoint and caches the result.""" mock_find.return_value = None mock_cache = Mock() mock_get_cache.return_value = mock_cache mock_clickup = Mock() - mock_clickup._get_all_tasks.return_value = [Mock(id="task-xyz"), mock_task] + mock_clickup.get.return_value = {"id": "task-abc", "name": "Test Task"} + mock_task = Mock(id="task-abc") + mock_task_class.return_value = mock_task result = get_task_data(mock_clickup, "team-123", "task-abc") assert result is mock_task - mock_clickup._get_all_tasks.assert_called_once_with("team-123") + mock_clickup.get.assert_called_once_with("task/task-abc?include_subtasks=true") mock_cache.set.assert_called_once_with("task:task-abc", mock_task, expire=TASKS_TTL) @patch("quickup.cli.cache.find_task_in_cache") @@ -337,7 +338,7 @@ def test_not_found_returns_none(self, mock_get_cache, mock_find): mock_get_cache.return_value = mock_cache mock_clickup = Mock() - mock_clickup._get_all_tasks.return_value = [Mock(id="task-xyz")] + mock_clickup.get.return_value = {"err": "Task not found"} result = get_task_data(mock_clickup, "team-123", "task-abc") diff --git a/tests/test_cli.py b/tests/test_cli.py index d2510c5..97b7e59 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,12 +1,13 @@ """Tests for QuickUp! CLI module.""" +import sys from unittest.mock import Mock, patch import pytest from quickup.cli.api_client import get_list_for, get_project_for from quickup.cli.exceptions import ClickupyError, TokenError -from quickup.cli.main import app, list_tasks, run_app, show_task, sprint, update_task +from quickup.cli.main import app, comment_task, list_tasks, run_app, show_task, sprint, update_task class TestApp: @@ -19,8 +20,6 @@ def test_app_creation(self): def test_app_help(self, capsys, monkeypatch): """Test that --help works.""" - import sys - monkeypatch.setattr(sys, "exit", lambda x: None) app(["--help"]) captured = capsys.readouterr() @@ -127,6 +126,14 @@ def test_update_task_raises_token_error(self, mock_environ): with pytest.raises(TokenError): update_task(task_id="task-123", status="Done") + @patch("quickup.cli.main.init_environ") + def test_comment_task_raises_token_error(self, mock_environ): + """Test comment_task raises TokenError when TOKEN not set.""" + mock_environ.return_value = {} # No TOKEN + + with pytest.raises(TokenError): + comment_task(task_id="task-123", text="hello") + class TestListTasks: """Tests for the list_tasks command.""" @@ -433,8 +440,6 @@ def test_update_task_not_found( capsys, ): """Test update_task raises error when task not found.""" - from quickup.cli.exceptions import ClickupyError - mock_environ.return_value = {"TOKEN": "test-token"} mock_clickup = Mock() mock_clickup_class.return_value = mock_clickup @@ -444,8 +449,6 @@ def test_update_task_not_found( mock_get_teams_data.return_value = [mock_team] mock_clickup._get_all_tasks.return_value = [] - import pytest - with pytest.raises(ClickupyError, match="Task .* not found"): update_task(task_id="nonexistent", status="Done") @@ -523,8 +526,6 @@ def test_show_task_not_found( capsys, ): """Test show_task raises error when task not found.""" - from quickup.cli.exceptions import ClickupyError - mock_environ.return_value = {"TOKEN": "test-token"} mock_clickup = Mock() mock_clickup_class.return_value = mock_clickup @@ -534,8 +535,6 @@ def test_show_task_not_found( mock_get_teams_data.return_value = [mock_team] mock_clickup._get_all_tasks.return_value = [] - import pytest - with pytest.raises(ClickupyError, match="Task .* not found"): show_task(task_id="nonexistent") diff --git a/tests/test_comment.py b/tests/test_comment.py new file mode 100644 index 0000000..ef5d5c6 --- /dev/null +++ b/tests/test_comment.py @@ -0,0 +1,168 @@ +"""Tests for QuickUp! comment command.""" + +from io import StringIO +from unittest.mock import Mock, patch + +import pytest + +from quickup.cli.exceptions import ClickupyError, TokenError +from quickup.cli.main import comment_task +from quickup.cli.renderer import render_comment_posted + + +class TestRenderCommentPosted: + """Tests for render_comment_posted function.""" + + @patch("builtins.print") + def test_render_comment_posted_basic(self, mock_print): + """Test render_comment_posted shows confirmation.""" + render_comment_posted("task-123", "This is a comment") + assert mock_print.called + + @patch("builtins.print") + def test_render_comment_posted_shows_task_id(self, mock_print): + """Test render_comment_posted includes the task ID.""" + render_comment_posted("task-456", "Hello") + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("task-456" in arg for arg in printed) + + @patch("builtins.print") + def test_render_comment_posted_shows_comment_text(self, mock_print): + """Test render_comment_posted includes the comment text.""" + render_comment_posted("task-789", "My comment text") + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("My comment text" in arg for arg in printed) + + @patch("builtins.print") + def test_render_comment_posted_shows_success_message(self, mock_print): + """Test render_comment_posted shows success indicator.""" + render_comment_posted("task-123", "text") + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("successfully" in arg.lower() for arg in printed) + + @patch("builtins.print") + def test_render_comment_posted_truncates_long_text(self, mock_print): + """Test render_comment_posted truncates comments longer than 80 chars.""" + long_text = "x" * 100 + render_comment_posted("task-123", long_text) + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + # Should show truncated text with "..." + assert any("..." in arg for arg in printed) + # Should NOT show the full 100-char string + assert not any(long_text in arg for arg in printed) + + +class TestCommentTask: + """Tests for comment_task CLI command.""" + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_comment_posted") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comment_task_posts_and_renders(self, mock_environ, mock_clickup_class, mock_render, mock_requests): + """Test that comment_task calls post and renders confirmation.""" + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup_class.return_value = mock_clickup + mock_requests.post.return_value = Mock(ok=True) + + comment_task(task_id="task-abc", text="Hello world") + + mock_requests.post.assert_called_once_with( + "https://api.clickup.com/api/v2/task/task-abc/comment", + headers=mock_clickup.headers, + json={"comment_text": "Hello world", "notify_all": False}, + ) + mock_render.assert_called_once_with("task-abc", "Hello world") + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_comment_posted") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comment_task_with_notify_all(self, mock_environ, mock_clickup_class, mock_render, mock_requests): + """Test that --notify-all passes notify_all=True to the API.""" + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup_class.return_value = mock_clickup + mock_requests.post.return_value = Mock(ok=True) + + comment_task(task_id="task-abc", text="Ping everyone", notify_all=True) + + mock_requests.post.assert_called_once_with( + "https://api.clickup.com/api/v2/task/task-abc/comment", + headers=mock_clickup.headers, + json={"comment_text": "Ping everyone", "notify_all": True}, + ) + + @patch("quickup.cli.main.init_environ") + def test_comment_task_raises_token_error_when_missing(self, mock_environ): + """Test TokenError is raised when TOKEN is not set.""" + mock_environ.return_value = {} + + with pytest.raises(TokenError): + comment_task(task_id="task-abc", text="test") + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comment_task_raises_on_api_error(self, mock_environ, mock_clickup_class, mock_requests): + """Test ClickupyError is raised when API returns an error response.""" + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup_class.return_value = mock_clickup + mock_response = Mock(ok=False, status_code=404) + mock_response.json.return_value = {"err": "Task not found"} + mock_response.text = '{"err": "Task not found"}' + mock_requests.post.return_value = mock_response + + with pytest.raises(ClickupyError, match="Failed to post comment"): + comment_task(task_id="bad-task", text="test") + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comment_task_raises_on_non_json_error(self, mock_environ, mock_clickup_class, mock_requests): + """Test ClickupyError is raised when API returns a non-JSON error response.""" + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup_class.return_value = mock_clickup + mock_response = Mock(ok=False, status_code=500) + mock_response.json.side_effect = ValueError("No JSON") + mock_response.text = "Internal Server Error" + mock_requests.post.return_value = mock_response + + with pytest.raises(ClickupyError, match="Failed to post comment"): + comment_task(task_id="task-abc", text="test") + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_comment_posted") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comment_task_reads_from_stdin(self, mock_environ, mock_clickup_class, mock_render, mock_requests): + """Test that comment_task reads from stdin when --text is omitted.""" + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup_class.return_value = mock_clickup + mock_requests.post.return_value = Mock(ok=True) + + with patch("quickup.cli.main.sys") as mock_sys: + mock_sys.stdin = StringIO("piped comment text") + mock_sys.stdin.isatty = lambda: False + comment_task(task_id="task-abc") + + mock_requests.post.assert_called_once_with( + "https://api.clickup.com/api/v2/task/task-abc/comment", + headers=mock_clickup.headers, + json={"comment_text": "piped comment text", "notify_all": False}, + ) + + def test_comment_task_raises_when_no_text_and_tty(self): + """Test ClickupyError when no --text and stdin is a TTY.""" + with pytest.raises(ClickupyError, match="No comment text provided"), patch("quickup.cli.main.sys") as mock_sys: + mock_sys.stdin.isatty.return_value = True + comment_task(task_id="task-abc") diff --git a/tests/test_sprint.py b/tests/test_sprint.py index 52c0967..2d20293 100644 --- a/tests/test_sprint.py +++ b/tests/test_sprint.py @@ -1,10 +1,12 @@ """Tests for QuickUp! sprint command.""" +from datetime import datetime, timedelta, timezone from unittest.mock import Mock, patch import pytest from quickup.cli.api_client import get_current_sprint_list +from quickup.cli.exceptions import ListNotFoundError class TestGetCurrentSprintList: @@ -132,7 +134,7 @@ def test_no_sprint_lists_raises_error(self): mock_team = Mock() mock_team.spaces = [mock_space] - with pytest.raises(Exception): # ListNotFoundError + with pytest.raises(ListNotFoundError): get_current_sprint_list(mock_team, mock_space) def test_case_insensitive_search(self): @@ -154,24 +156,27 @@ def test_case_insensitive_search(self): result = get_current_sprint_list(mock_team, mock_space) assert result == sprint_list - def test_returns_started_sprint(self): - """Test returning the sprint marked as started (active), regardless of ID.""" + def test_returns_active_sprint_by_date_range(self): + """Test returning the sprint whose date range includes today, regardless of ID.""" mock_space = Mock(id="space-123") + now = datetime.now(timezone.utc) - sprint_old = Mock() - sprint_old.name = "Sprint 1" - sprint_old.id = "list-005" # Higher ID, but not started - sprint_old.status = None - sprint_old.space_id = "space-123" + sprint_past = Mock() + sprint_past.name = "Sprint 1" + sprint_past.id = "list-005" # Higher ID, but date range is in the past + sprint_past.start_date = str(int((now - timedelta(days=14)).timestamp() * 1000)) + sprint_past.due_date = str(int((now - timedelta(days=7)).timestamp() * 1000)) + sprint_past.space_id = "space-123" sprint_active = Mock() sprint_active.name = "Sprint 2" - sprint_active.id = "list-002" # Lower ID, but this is the active one - sprint_active.status = "started" + sprint_active.id = "list-002" # Lower ID, but currently active + sprint_active.start_date = str(int((now - timedelta(days=3)).timestamp() * 1000)) + sprint_active.due_date = str(int((now + timedelta(days=4)).timestamp() * 1000)) sprint_active.space_id = "space-123" mock_project = Mock() - mock_project.lists = [sprint_old, sprint_active] + mock_project.lists = [sprint_past, sprint_active] mock_space.projects = [mock_project] mock_team = Mock() @@ -180,21 +185,19 @@ def test_returns_started_sprint(self): result = get_current_sprint_list(mock_team, mock_space) assert result == sprint_active - def test_falls_back_to_id_sort_when_no_status(self): - """Test fallback to ID sort when no status field is present.""" + def test_falls_back_to_id_sort_when_no_dates(self): + """Test fallback to ID sort when no start_date/due_date fields are present.""" mock_space = Mock(id="space-123") - sprint_old = Mock() + sprint_old = Mock(spec=["name", "id", "space_id"]) sprint_old.name = "Sprint 1" sprint_old.id = "list-001" sprint_old.space_id = "space-123" - # No status attribute - sprint_new = Mock() + sprint_new = Mock(spec=["name", "id", "space_id"]) sprint_new.name = "Sprint 2" sprint_new.id = "list-002" sprint_new.space_id = "space-123" - # No status attribute mock_project = Mock() mock_project.lists = [sprint_old, sprint_new] @@ -206,20 +209,23 @@ def test_falls_back_to_id_sort_when_no_status(self): result = get_current_sprint_list(mock_team, mock_space) assert result == sprint_new # Highest ID - def test_falls_back_to_id_sort_when_none_started(self): - """Test fallback to ID sort when no sprint is marked as started.""" + def test_falls_back_to_id_sort_when_no_current_sprint(self): + """Test fallback to ID sort when no sprint's date range includes today.""" mock_space = Mock(id="space-123") + now = datetime.now(timezone.utc) sprint_past = Mock() sprint_past.name = "Sprint 1" sprint_past.id = "list-001" - sprint_past.status = "closed" + sprint_past.start_date = str(int((now - timedelta(days=14)).timestamp() * 1000)) + sprint_past.due_date = str(int((now - timedelta(days=7)).timestamp() * 1000)) sprint_past.space_id = "space-123" sprint_future = Mock() sprint_future.name = "Sprint 2" sprint_future.id = "list-002" - sprint_future.status = None # Not yet started + sprint_future.start_date = str(int((now + timedelta(days=1)).timestamp() * 1000)) + sprint_future.due_date = str(int((now + timedelta(days=7)).timestamp() * 1000)) sprint_future.space_id = "space-123" mock_project = Mock() @@ -230,4 +236,4 @@ def test_falls_back_to_id_sort_when_none_started(self): mock_team.spaces = [mock_space] result = get_current_sprint_list(mock_team, mock_space) - assert result == sprint_future # Highest ID since no "started" status + assert result == sprint_future # Highest ID since no current sprint diff --git a/tests/test_task_comments.py b/tests/test_task_comments.py new file mode 100644 index 0000000..4ed0a10 --- /dev/null +++ b/tests/test_task_comments.py @@ -0,0 +1,172 @@ +"""Tests for --comments flag on the task command.""" + +from unittest.mock import Mock, patch + +import pytest + +from quickup.cli.exceptions import ClickupyError +from quickup.cli.main import show_task +from quickup.cli.renderer import render_task_comments + +SAMPLE_COMMENTS = [ + { + "comment_text": "First comment", + "user": {"username": "alice"}, + "date": "1700000000000", + }, + { + "comment_text": "Second comment", + "user": {"username": "bob"}, + "date": "1700000060000", + }, +] + + +class TestRenderTaskComments: + """Tests for render_task_comments function.""" + + @patch("builtins.print") + def test_renders_comments(self, mock_print): + render_task_comments(SAMPLE_COMMENTS) + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("alice" in arg for arg in printed) + assert any("First comment" in arg for arg in printed) + assert any("bob" in arg for arg in printed) + assert any("Second comment" in arg for arg in printed) + + @patch("builtins.print") + def test_shows_comment_count(self, mock_print): + render_task_comments(SAMPLE_COMMENTS) + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("2" in arg for arg in printed) + + @patch("builtins.print") + def test_no_comments_message(self, mock_print): + render_task_comments([]) + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("No comments" in arg for arg in printed) + + @patch("builtins.print") + def test_handles_missing_user(self, mock_print): + render_task_comments([{"comment_text": "Anon comment", "date": "1700000000000"}]) + printed = [str(arg) for call in mock_print.call_args_list for arg in call[0]] + assert any("Anon comment" in arg for arg in printed) + assert any("Unknown" in arg for arg in printed) + + +class TestShowTaskComments: + """Tests for --comments flag on show_task command.""" + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_task_comments") + @patch("quickup.cli.main.render_task_detail") + @patch("quickup.cli.main.get_task_data") + @patch("quickup.cli.main.get_team") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comments_flag_fetches_and_renders( + self, + mock_environ, + mock_clickup_class, + mock_get_team, + mock_get_task_data, + mock_render_detail, + mock_render_comments, + mock_requests, + ): + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup.teams = [Mock(id="team-1")] + mock_clickup_class.return_value = mock_clickup + mock_get_team.return_value = None + mock_get_task_data.return_value = Mock() + mock_requests.get.return_value = Mock(ok=True, json=lambda: {"comments": SAMPLE_COMMENTS}) + + show_task(task_id="task-abc", comments=True) + + mock_requests.get.assert_called_once_with( + "https://api.clickup.com/api/v2/task/task-abc/comment", + headers=mock_clickup.headers, + ) + mock_render_comments.assert_called_once_with(SAMPLE_COMMENTS) + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_task_comments") + @patch("quickup.cli.main.render_task_detail") + @patch("quickup.cli.main.get_task_data") + @patch("quickup.cli.main.get_team") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comments_flag_empty_list( + self, + mock_environ, + mock_clickup_class, + mock_get_team, + mock_get_task_data, + mock_render_detail, + mock_render_comments, + mock_requests, + ): + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup.teams = [Mock(id="team-1")] + mock_clickup_class.return_value = mock_clickup + mock_get_team.return_value = None + mock_get_task_data.return_value = Mock() + mock_requests.get.return_value = Mock(ok=True, json=lambda: {"comments": []}) + + show_task(task_id="task-abc", comments=True) + + mock_render_comments.assert_called_once_with([]) + + @patch("quickup.cli.main.requests") + @patch("quickup.cli.main.render_task_detail") + @patch("quickup.cli.main.get_task_data") + @patch("quickup.cli.main.get_team") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_comments_flag_api_error_raises( + self, mock_environ, mock_clickup_class, mock_get_team, mock_get_task_data, mock_render_detail, mock_requests + ): + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.headers = {"Authorization": "Bearer test-token"} + mock_clickup.teams = [Mock(id="team-1")] + mock_clickup_class.return_value = mock_clickup + mock_get_team.return_value = None + mock_get_task_data.return_value = Mock() + mock_response = Mock(ok=False, status_code=403) + mock_response.json.return_value = {"err": "Forbidden"} + mock_response.text = '{"err": "Forbidden"}' + mock_requests.get.return_value = mock_response + + with pytest.raises(ClickupyError, match="Failed to fetch comments"): + show_task(task_id="task-abc", comments=True) + + @patch("quickup.cli.main.render_task_comments") + @patch("quickup.cli.main.render_task_detail") + @patch("quickup.cli.main.get_task_data") + @patch("quickup.cli.main.get_team") + @patch("quickup.cli.main.ClickUp") + @patch("quickup.cli.main.init_environ") + def test_without_comments_flag_skips_fetch( + self, + mock_environ, + mock_clickup_class, + mock_get_team, + mock_get_task_data, + mock_render_detail, + mock_render_comments, + ): + mock_environ.return_value = {"TOKEN": "test-token"} + mock_clickup = Mock() + mock_clickup.teams = [Mock(id="team-1")] + mock_clickup_class.return_value = mock_clickup + mock_get_team.return_value = None + mock_get_task_data.return_value = Mock() + + show_task(task_id="task-abc") + + mock_render_comments.assert_not_called() diff --git a/tests/test_task_detail.py b/tests/test_task_detail.py index a0cf82d..87aeaa6 100644 --- a/tests/test_task_detail.py +++ b/tests/test_task_detail.py @@ -82,7 +82,22 @@ def test_render_task_detail_with_subtasks(self, mock_print): mock_task.priority = None mock_task.due_date = None mock_task.description = None - mock_task.subtasks = [Mock(name="Subtask 1"), Mock(name="Subtask 2")] + mock_task.subtasks = [ + { + "id": "sub-1", + "name": "Subtask 1", + "status": {"status": "to do", "color": "#aabbcc", "orderindex": 0}, + "assignees": [], + "priority": None, + }, + { + "id": "sub-2", + "name": "Subtask 2", + "status": {"status": "to do", "color": "#aabbcc", "orderindex": 0}, + "assignees": [], + "priority": None, + }, + ] render_task_detail(mock_task) diff --git a/uv.lock b/uv.lock index 40ab8f4..f7f4832 100644 --- a/uv.lock +++ b/uv.lock @@ -682,9 +682,9 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "python-dotenv" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = "~=7.1.2" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.1.2,<8" }, { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.2" }, - { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=2.0.0" }, ] provides-extras = ["dev", "docs"]