From d9dd99c4055fea1614b0a42f43888e4f53834b0a Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:06:56 +0000 Subject: [PATCH 1/7] feat(cli): add label and featured commands for CORE-2362 Expose /v1/labels/ CRUD and /v1/me/featured/ list/set/batch via dailybot label and dailybot featured with table output, error mapping, and unit tests. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 121 ++++++++++ dailybot_cli/commands/featured.py | 149 ++++++++++++ dailybot_cli/commands/label.py | 251 ++++++++++++++++++++ dailybot_cli/commands/public_api_helpers.py | 6 + dailybot_cli/display.py | 36 +++ dailybot_cli/main.py | 4 + tests/labels_featured_commands_test.py | 65 +++++ 7 files changed, 632 insertions(+) create mode 100644 dailybot_cli/commands/featured.py create mode 100644 dailybot_cli/commands/label.py create mode 100644 tests/labels_featured_commands_test.py diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index e3db0e1..58b28d6 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -1789,6 +1789,127 @@ def mark_agent_messages_read( ) return self._handle_response(response) + # --- Organization Labels (/v1/labels/) --- + + def get_labels_entitlement(self) -> dict[str, Any]: + """GET /v1/labels/entitlement/ — org Labels feature flags for the caller.""" + response: httpx.Response = self._request("GET", f"{self.api_url}/v1/labels/entitlement/") + return self._handle_response(response) + + def list_labels( + self, + *, + search: str | None = None, + is_archived: bool = False, + limit: int = 20, + offset: int = 0, + ) -> dict[str, Any]: + """GET /v1/labels/ — paginated org Labels (limit/offset).""" + params: dict[str, Any] = { + "limit": max(1, min(limit, 100)), + "offset": max(0, offset), + "is_archived": is_archived, + } + if search: + params["search"] = search + response: httpx.Response = self._request( + "GET", f"{self.api_url}/v1/labels/", params=params + ) + return self._handle_response(response) + + def get_label(self, label_uuid: str) -> dict[str, Any]: + """GET /v1/labels// — one organization Label.""" + response: httpx.Response = self._request( + "GET", f"{self.api_url}/v1/labels/{label_uuid}/" + ) + return self._handle_response(response) + + def create_label( + self, + *, + name: str, + color: str | None = None, + description: str | None = None, + ) -> dict[str, Any]: + """POST /v1/labels/ — create an organization Label.""" + body: dict[str, Any] = {"name": name} + if color is not None: + body["color"] = color + if description is not None: + body["description"] = description + response: httpx.Response = self._request( + "POST", f"{self.api_url}/v1/labels/", json=body + ) + return self._handle_response(response) + + def update_label(self, label_uuid: str, body: dict[str, Any]) -> dict[str, Any]: + """PATCH /v1/labels// — update an organization Label.""" + response: httpx.Response = self._request( + "PATCH", f"{self.api_url}/v1/labels/{label_uuid}/", json=body + ) + return self._handle_response(response) + + def delete_label(self, label_uuid: str) -> None: + """DELETE /v1/labels// — hard-delete (elevated only).""" + response: httpx.Response = self._request( + "DELETE", f"{self.api_url}/v1/labels/{label_uuid}/" + ) + if response.status_code == 204: + return + self._handle_response(response) + + def archive_label(self, label_uuid: str) -> dict[str, Any]: + """POST /v1/labels//archive/ — archive a Label.""" + response: httpx.Response = self._request( + "POST", f"{self.api_url}/v1/labels/{label_uuid}/archive/" + ) + return self._handle_response(response) + + # --- Private Featured stars (/v1/me/featured/) --- + + def list_featured(self, *, entity_type: str) -> dict[str, Any]: + """GET /v1/me/featured/?entity_type= — list Featured entity UUIDs for the caller.""" + response: httpx.Response = self._request( + "GET", + f"{self.api_url}/v1/me/featured/", + params={"entity_type": entity_type}, + ) + return self._handle_response(response) + + def set_featured( + self, + entity_type: str, + entity_uuid: str, + *, + featured: bool, + ) -> dict[str, Any]: + """PUT /v1/me/featured/{entity_type}/{uuid}/ — toggle Featured for one entity.""" + response: httpx.Response = self._request( + "PUT", + f"{self.api_url}/v1/me/featured/{entity_type}/{entity_uuid}/", + json={"featured": featured}, + ) + return self._handle_response(response) + + def batch_featured( + self, + *, + entity_type: str, + entity_uuids: list[str], + featured: bool, + ) -> dict[str, Any]: + """POST /v1/me/featured/batch/ — batch feature/unfeature entities.""" + response: httpx.Response = self._request( + "POST", + f"{self.api_url}/v1/me/featured/batch/", + json={ + "entity_type": entity_type, + "entity_uuids": entity_uuids, + "featured": featured, + }, + ) + return self._handle_response(response) + # --- Agent registration endpoints --- def get_registration_challenge(self) -> dict[str, Any]: diff --git a/dailybot_cli/commands/featured.py b/dailybot_cli/commands/featured.py new file mode 100644 index 0000000..db59ee8 --- /dev/null +++ b/dailybot_cli/commands/featured.py @@ -0,0 +1,149 @@ +"""Private Featured star commands (/v1/me/featured/).""" + +from typing import Any + +import click + +from dailybot_cli.api_client import APIError +from dailybot_cli.commands.public_api_helpers import ( + emit_json, + exit_for_api_error, + require_auth, +) +from dailybot_cli.display import console, print_featured_summary, print_success + +FEATURED_ENTITY_TYPES: tuple[str, ...] = ("forms", "automations", "checkins") + + +def _parse_entity_type(ctx: click.Context, param: click.Parameter, value: str) -> str: + normalized: str = value.strip().lower() + if normalized not in FEATURED_ENTITY_TYPES: + raise click.BadParameter( + f"entity_type must be one of: {', '.join(FEATURED_ENTITY_TYPES)}" + ) + return normalized + + +@click.group() +def featured() -> None: + """Manage your private Featured stars on Forms, Automations, and Check-ins. + + \b + Featured is per-user (not org-shared). Not gated by paid Labels. + """ + + +@featured.command("list") +@click.option( + "--entity-type", + "entity_type", + required=True, + callback=_parse_entity_type, + help="Entity type: forms, automations, or checkins.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def featured_list(entity_type: str, json_mode: bool) -> None: + """List Featured entity UUIDs for one entity type.""" + client = require_auth() + try: + with console.status("Fetching featured items..."): + data: dict[str, Any] = client.list_featured(entity_type=entity_type) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_featured_summary(data) + + +@featured.command("set") +@click.option( + "--entity-type", + "entity_type", + required=True, + callback=_parse_entity_type, + help="Entity type: forms, automations, or checkins.", +) +@click.argument("entity_uuid") +@click.option( + "--featured/--unfeatured", + default=True, + help="Star (default) or unstar the entity.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def featured_set( + entity_type: str, + entity_uuid: str, + featured: bool, + json_mode: bool, +) -> None: + """Toggle Featured for one entity.""" + client = require_auth() + try: + with console.status("Updating featured state..."): + data: dict[str, Any] = client.set_featured( + entity_type, + entity_uuid, + featured=featured, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + state: str = "featured" if data.get("is_featured", featured) else "unfeatured" + print_success(f"Marked {entity_uuid} as {state} ({entity_type}).") + + +@featured.command("batch") +@click.option( + "--entity-type", + "entity_type", + required=True, + callback=_parse_entity_type, + help="Entity type: forms, automations, or checkins.", +) +@click.option( + "--uuids", + required=True, + help="Comma-separated entity UUIDs.", +) +@click.option( + "--featured/--unfeatured", + default=True, + help="Feature (default) or unfeature all listed entities.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def featured_batch( + entity_type: str, + uuids: str, + featured: bool, + json_mode: bool, +) -> None: + """Batch feature or unfeature multiple entities.""" + entity_uuids: list[str] = [part.strip() for part in uuids.split(",") if part.strip()] + if not entity_uuids: + raise click.UsageError("--uuids must contain at least one UUID.") + + client = require_auth() + try: + with console.status("Updating featured items..."): + data: dict[str, Any] = client.batch_featured( + entity_type=entity_type, + entity_uuids=entity_uuids, + featured=featured, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + updated: int = len(data.get("updated_entity_uuids", entity_uuids)) + state: str = "featured" if featured else "unfeatured" + print_success(f"Marked {updated} {entity_type} item(s) as {state}.") diff --git a/dailybot_cli/commands/label.py b/dailybot_cli/commands/label.py new file mode 100644 index 0000000..8a216bb --- /dev/null +++ b/dailybot_cli/commands/label.py @@ -0,0 +1,251 @@ +"""Organization Labels commands (/v1/labels/).""" + +from typing import Any + +import click + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + emit_json, + enforce_plan_access, + exit_for_api_error, + require_auth, +) +from dailybot_cli.display import ( + console, + print_detail_panel, + print_labels_table, + print_success, +) + +_LABEL_FIELDS: list[tuple[str, str]] = [ + ("Name", "name"), + ("UUID", "uuid"), + ("Color", "color"), + ("Description", "description"), + ("Archived", "is_archived"), + ("Usage (total)", "usage_total"), +] + + +def _usage_total(label: dict[str, Any]) -> dict[str, Any]: + usage: dict[str, Any] = label.get("usage") or {} + enriched: dict[str, Any] = dict(label) + enriched["usage_total"] = usage.get("total", 0) + return enriched + + +@click.group() +def label() -> None: + """Manage organization Labels (paid Feature.LABELS). + + \b + Shared taxonomy for Forms, Automations, and Check-ins. Acts as you — + permissions match the web app (members create; elevated roles manage all). + """ + + +@label.command("entitlement") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_entitlement(json_mode: bool) -> None: + """Show Labels entitlement flags for your org.""" + enforce_plan_access("label_entitlement", json_mode=json_mode) + client = require_auth() + try: + with console.status("Checking Labels entitlement..."): + data: dict[str, Any] = client.get_labels_entitlement() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_detail_panel( + "Labels entitlement", + data, + [ + ("Entitled", "entitled"), + ("Can create", "can_create"), + ("Can manage all", "can_manage_all"), + ("Can hard delete", "can_hard_delete"), + ("Guest", "is_guest"), + ], + ) + + +@label.command("list") +@click.option("--search", default=None, help="Case-insensitive name search.") +@click.option("--archived", is_flag=True, help="Include archived Labels.") +@click.option("--limit", default=20, type=click.IntRange(1, 100), show_default=True) +@click.option("--offset", default=0, type=click.IntRange(0, 100000), show_default=True) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_list( + search: str | None, + archived: bool, + limit: int, + offset: int, + json_mode: bool, +) -> None: + """List organization Labels.""" + enforce_plan_access("label_list", json_mode=json_mode) + client = require_auth() + try: + with console.status("Fetching labels..."): + data: dict[str, Any] = client.list_labels( + search=search, + is_archived=archived, + limit=limit, + offset=offset, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + results: list[dict[str, Any]] = data.get("results", []) + print_labels_table(results) + count: int | None = data.get("count") + if count is not None: + console.print(f"[dim]{count} total[/dim]") + + +@label.command("get") +@click.argument("label_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_get(label_uuid: str, json_mode: bool) -> None: + """Get one Label by UUID.""" + enforce_plan_access("label_get", json_mode=json_mode) + client = require_auth() + try: + with console.status("Loading label..."): + data: dict[str, Any] = client.get_label(label_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_detail_panel("Label", _usage_total(data), _LABEL_FIELDS) + + +@label.command("create") +@click.option("--name", required=True, help="Label name (unique per org).") +@click.option("--color", default=None, help="Hex color (e.g. #4A90E2).") +@click.option("--description", default=None, help="Optional description.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_create( + name: str, + color: str | None, + description: str | None, + json_mode: bool, +) -> None: + """Create an organization Label.""" + enforce_plan_access("label_create", json_mode=json_mode) + client = require_auth() + try: + with console.status("Creating label..."): + data: dict[str, Any] = client.create_label( + name=name, + color=color, + description=description, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_success(f"Created label '{data.get('name', name)}' ({data.get('uuid', '')}).") + + +@label.command("update") +@click.argument("label_uuid") +@click.option("--name", default=None, help="New name.") +@click.option("--color", default=None, help="New hex color.") +@click.option("--description", default=None, help="New description (empty string clears).") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_update( + label_uuid: str, + name: str | None, + color: str | None, + description: str | None, + json_mode: bool, +) -> None: + """Update an organization Label.""" + enforce_plan_access("label_update", json_mode=json_mode) + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if color is not None: + body["color"] = color + if description is not None: + body["description"] = description + if not body: + raise click.UsageError("Pass at least one of --name, --color, or --description.") + + client = require_auth() + try: + with console.status("Updating label..."): + data: dict[str, Any] = client.update_label(label_uuid, body) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_success(f"Updated label '{data.get('name', label_uuid)}'.") + + +@label.command("archive") +@click.argument("label_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_archive(label_uuid: str, json_mode: bool) -> None: + """Archive a Label (idempotent).""" + enforce_plan_access("label_archive", json_mode=json_mode) + client = require_auth() + try: + with console.status("Archiving label..."): + data: dict[str, Any] = client.archive_label(label_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + print_success(f"Archived label '{data.get('name', label_uuid)}'.") + + +@label.command("delete") +@click.argument("label_uuid") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_delete(label_uuid: str, yes: bool, json_mode: bool) -> None: + """Hard-delete a Label (elevated only; fails when in use).""" + enforce_plan_access("label_delete", json_mode=json_mode) + client: DailyBotClient = require_auth() + + if not yes and not json_mode: + if not click.confirm( + f"Permanently delete label {label_uuid}? This cannot be undone.", + default=False, + ): + raise SystemExit(0) + + try: + with console.status("Deleting label..."): + client.delete_label(label_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"deleted": True, "uuid": label_uuid}) + return + + print_success(f"Deleted label {label_uuid}.") diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 04551f0..d2cea8d 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -115,6 +115,12 @@ "Your role doesn't allow authoring check-ins. Ask an admin or manager. " "The CLI acts within your role and can't elevate." ), + "paid_plan_required": "Organization Labels require a paid plan with Feature.LABELS enabled.", + "feature_not_available": "Organization Labels are not available on this plan.", + "guest_not_allowed": "Guests cannot manage organization Labels.", + "duplicate_name": "A label with this name already exists.", + "archived_label_not_assignable": "This label is archived and cannot be assigned.", + "permission_denied": "You don't have permission for this Labels action.", "form_edit_forbidden": ( "You don't have permission to edit this form (you're not the owner or an admin). " "The CLI acts within your role and can't elevate." diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 0948985..ae9ab39 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -170,6 +170,42 @@ def print_kudos_wall_of_fame(data: dict[str, Any]) -> None: ) +def print_labels_table(labels: list[dict[str, Any]]) -> None: + """Render a compact table of organization Labels.""" + if not labels: + console.print("[dim]No labels found.[/dim]") + return + table: Table = Table(title="Labels") + table.add_column("Name", style="cyan") + table.add_column("UUID", style="dim", no_wrap=True) + table.add_column("Color") + table.add_column("Usage", justify="right") + table.add_column("Archived", justify="center") + for label in labels: + usage: dict[str, Any] = label.get("usage") or {} + archived: str = "[green]yes[/green]" if label.get("is_archived") else "[dim]no[/dim]" + table.add_row( + str(label.get("name", "—")), + str(label.get("uuid", "—")), + str(label.get("color", "—")), + str(usage.get("total", 0)), + archived, + ) + console.print(table) + + +def print_featured_summary(data: dict[str, Any]) -> None: + """Render Featured UUID list for one entity type.""" + entity_type: str = str(data.get("entity_type", "—")) + uuids: list[str] = [str(value) for value in data.get("entity_uuids", [])] + console.print(f"[bold]Featured {entity_type}[/bold] ({len(uuids)})") + if not uuids: + console.print("[dim]None starred yet.[/dim]") + return + for uuid in uuids: + console.print(f" • {uuid}") + + def print_workflows_table(workflows: list[dict[str, Any]]) -> None: """Render a compact table of workflows (name, trigger, active, runs).""" if not workflows: diff --git a/dailybot_cli/main.py b/dailybot_cli/main.py index 6954a6c..47b60a6 100644 --- a/dailybot_cli/main.py +++ b/dailybot_cli/main.py @@ -14,12 +14,14 @@ from dailybot_cli.commands.config import config from dailybot_cli.commands.conversation import conversation from dailybot_cli.commands.env import env +from dailybot_cli.commands.featured import featured from dailybot_cli.commands.form import form from dailybot_cli.commands.hook import hook from dailybot_cli.commands.identity import me, org from dailybot_cli.commands.interactive import run_interactive from dailybot_cli.commands.interactive_chat import interactive from dailybot_cli.commands.kudos import kudos +from dailybot_cli.commands.label import label from dailybot_cli.commands.status import status from dailybot_cli.commands.team import team from dailybot_cli.commands.uninstall import uninstall @@ -121,6 +123,8 @@ def cli(ctx: click.Context, api_url: str | None, app_url: str | None) -> None: cli.add_command(me) cli.add_command(org) cli.add_command(workflow) +cli.add_command(label) +cli.add_command(featured) cli.add_command(agent) cli.add_command(chat) cli.add_command(conversation) diff --git a/tests/labels_featured_commands_test.py b/tests/labels_featured_commands_test.py new file mode 100644 index 0000000..b622d23 --- /dev/null +++ b/tests/labels_featured_commands_test.py @@ -0,0 +1,65 @@ +"""Tests for label and featured commands.""" + +from typing import Any +from unittest.mock import MagicMock + +from click.testing import CliRunner + +from dailybot_cli.main import cli + + +def _client(monkeypatch: Any) -> MagicMock: + client = MagicMock() + monkeypatch.setattr("dailybot_cli.commands.public_api_helpers.get_agent_auth", lambda: "tok") + monkeypatch.setattr( + "dailybot_cli.commands.public_api_helpers.DailyBotClient", lambda *a, **k: client + ) + return client + + +def test_label_list_renders(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.list_labels.return_value = { + "count": 1, + "results": [ + { + "name": "Release", + "uuid": "lbl-1", + "color": "#4A90E2", + "usage": {"total": 2}, + "is_archived": False, + } + ], + } + result = CliRunner().invoke(cli, ["label", "list"]) + assert result.exit_code == 0 + assert "Release" in result.output + + +def test_featured_list_json(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.list_featured.return_value = { + "entity_type": "forms", + "entity_uuids": ["form-1", "form-2"], + } + result = CliRunner().invoke( + cli, + ["featured", "list", "--entity-type", "forms", "--json"], + ) + assert result.exit_code == 0 + assert "form-1" in result.output + + +def test_featured_set_calls_client(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.set_featured.return_value = { + "entity_type": "checkins", + "entity_uuid": "chk-1", + "is_featured": True, + } + result = CliRunner().invoke( + cli, + ["featured", "set", "chk-1", "--entity-type", "checkins", "--json"], + ) + assert result.exit_code == 0 + client.set_featured.assert_called_once_with("checkins", "chk-1", featured=True) From 398257e63057f6004e9d156c131f60cd2ad2a3e4 Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:06 +0000 Subject: [PATCH 2/7] feat(cli): forward Labels/Featured enrichment on list APIs Add dashboard enrichment query params for forms, workflows, and check-ins with serialization tests for CORE-2362. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 44 ++++++++++++++++ tests/dashboard_enrichment_list_perf_test.py | 55 ++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/dashboard_enrichment_list_perf_test.py diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 58b28d6..97efa5b 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -106,6 +106,23 @@ def _merge_list_query( return params +def _merge_dashboard_enrichment_query( + params: dict[str, Any], + *, + labels: list[str] | None = None, + featured: bool | None = None, + prioritize_featured: bool | None = None, +) -> dict[str, Any]: + """Merge Labels / Featured dashboard enrichment query params.""" + if labels: + params["labels"] = ",".join(labels) + if featured is not None: + params["featured"] = "true" if featured else "false" + if prioritize_featured is not None: + params["prioritize_featured"] = "true" if prioritize_featured else "false" + return params + + def _fill_meta(meta: dict[str, Any] | None, result: "PaginatedResult") -> None: """Populate a caller-provided meta dict with pagination totals, if given.""" if meta is not None: @@ -640,6 +657,9 @@ def list_checkins( fetch_all: bool = True, limit: int | None = None, meta: dict[str, Any] | None = None, + labels: list[str] | None = None, + featured: bool | None = None, + prioritize_featured: bool | None = None, ) -> list[dict[str, Any]]: """GET /v1/checkins/ — fetch visible check-ins with optional search/paging.""" params: dict[str, Any] = {} @@ -652,6 +672,12 @@ def list_checkins( if include_archived: params["include_archived"] = "true" _merge_list_query(params, search=search, start_date=start_date, end_date=end_date) + _merge_dashboard_enrichment_query( + params, + labels=labels, + featured=featured, + prioritize_featured=prioritize_featured, + ) result: PaginatedResult = self._paginated_get( f"{self.api_url}/v1/checkins/", params=params, @@ -963,6 +989,9 @@ def list_forms( fetch_all: bool = True, limit: int | None = None, meta: dict[str, Any] | None = None, + labels: list[str] | None = None, + featured: bool | None = None, + prioritize_featured: bool | None = None, ) -> list[dict[str, Any]]: """GET /v1/forms/ — optionally expand questions, search, and page. @@ -990,6 +1019,12 @@ def list_forms( if is_ascend: params["is_ascend"] = "true" _merge_list_query(params, search=search, start_date=start_date, end_date=end_date) + _merge_dashboard_enrichment_query( + params, + labels=labels, + featured=featured, + prioritize_featured=prioritize_featured, + ) result: PaginatedResult = self._paginated_get( f"{self.api_url}/v1/forms/", params=params, @@ -1442,10 +1477,19 @@ def list_workflows( fetch_all: bool = True, limit: int | None = None, meta: dict[str, Any] | None = None, + labels: list[str] | None = None, + featured: bool | None = None, + prioritize_featured: bool | None = None, ) -> list[dict[str, Any]]: """GET /v1/workflows/ — list workflows (plan-gated feature).""" params: dict[str, Any] = {} _merge_list_query(params, search=search, start_date=start_date, end_date=end_date) + _merge_dashboard_enrichment_query( + params, + labels=labels, + featured=featured, + prioritize_featured=prioritize_featured, + ) result: PaginatedResult = self._paginated_get( f"{self.api_url}/v1/workflows/", params=params, diff --git a/tests/dashboard_enrichment_list_perf_test.py b/tests/dashboard_enrichment_list_perf_test.py new file mode 100644 index 0000000..3826fd2 --- /dev/null +++ b/tests/dashboard_enrichment_list_perf_test.py @@ -0,0 +1,55 @@ +"""Tests for dashboard enrichment query params on list endpoints (CORE-2362).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from dailybot_cli.api_client import DailyBotClient, _merge_dashboard_enrichment_query + + +def test_merge_dashboard_enrichment_query_serializes_csv_and_booleans() -> None: + params: dict = {} + _merge_dashboard_enrichment_query( + params, + labels=["uuid-a", "uuid-b"], + featured=True, + prioritize_featured=False, + ) + assert params == { + "labels": "uuid-a,uuid-b", + "featured": "true", + "prioritize_featured": "false", + } + + +@patch.object(DailyBotClient, "_paginated_get") +@patch.object(DailyBotClient, "_request") +def test_list_forms_forwards_enrichment_params( + _request: MagicMock, + paginated_get: MagicMock, +) -> None: + paginated_get.return_value = MagicMock(results=[], count=0, next=None, previous=None) + client = DailyBotClient(api_url="https://api.test", token="tok") + client.list_forms( + labels=["label-1", "label-2"], + featured=True, + prioritize_featured=True, + fetch_all=False, + page=1, + ) + call_kwargs = paginated_get.call_args.kwargs + assert call_kwargs["params"]["labels"] == "label-1,label-2" + assert call_kwargs["params"]["featured"] == "true" + assert call_kwargs["params"]["prioritize_featured"] == "true" + + +@patch.object(DailyBotClient, "_paginated_get") +@patch.object(DailyBotClient, "_request") +def test_list_workflows_forwards_enrichment_params( + _request: MagicMock, + paginated_get: MagicMock, +) -> None: + paginated_get.return_value = MagicMock(results=[], count=0, next=None, previous=None) + client = DailyBotClient(api_url="https://api.test", token="tok") + client.list_workflows(labels=["label-1"], fetch_all=False, page=1) + assert paginated_get.call_args.kwargs["params"]["labels"] == "label-1" From b444cd325c13d4762b169a4c15a5b86924320fdb Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:35:56 +0000 Subject: [PATCH 3/7] fix(cli): satisfy ruff on label delete confirm and unused pytest import Co-authored-by: Cursor --- dailybot_cli/commands/label.py | 11 +++++------ tests/dashboard_enrichment_list_perf_test.py | 2 -- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/dailybot_cli/commands/label.py b/dailybot_cli/commands/label.py index 8a216bb..c846a69 100644 --- a/dailybot_cli/commands/label.py +++ b/dailybot_cli/commands/label.py @@ -231,12 +231,11 @@ def label_delete(label_uuid: str, yes: bool, json_mode: bool) -> None: enforce_plan_access("label_delete", json_mode=json_mode) client: DailyBotClient = require_auth() - if not yes and not json_mode: - if not click.confirm( - f"Permanently delete label {label_uuid}? This cannot be undone.", - default=False, - ): - raise SystemExit(0) + if not yes and not json_mode and not click.confirm( + f"Permanently delete label {label_uuid}? This cannot be undone.", + default=False, + ): + raise SystemExit(0) try: with console.status("Deleting label..."): diff --git a/tests/dashboard_enrichment_list_perf_test.py b/tests/dashboard_enrichment_list_perf_test.py index 3826fd2..80dc322 100644 --- a/tests/dashboard_enrichment_list_perf_test.py +++ b/tests/dashboard_enrichment_list_perf_test.py @@ -2,8 +2,6 @@ from unittest.mock import MagicMock, patch -import pytest - from dailybot_cli.api_client import DailyBotClient, _merge_dashboard_enrichment_query From ea9fc9c52a387b5bdaec0b2fc0fd93891426a3a2 Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:11:46 -0500 Subject: [PATCH 4/7] refactor(cli): streamline API request formatting and improve error handling in label commands - Simplified the formatting of API request calls in `api_client.py` for better readability. - Enhanced error handling in `_parse_entity_type` function in `featured.py` by consolidating the raise statement. - Improved readability of the confirmation prompt in `label.py` for label deletion. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 12 +++--------- dailybot_cli/commands/featured.py | 4 +--- dailybot_cli/commands/label.py | 10 +++++++--- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 97efa5b..9dfda10 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -1856,16 +1856,12 @@ def list_labels( } if search: params["search"] = search - response: httpx.Response = self._request( - "GET", f"{self.api_url}/v1/labels/", params=params - ) + response: httpx.Response = self._request("GET", f"{self.api_url}/v1/labels/", params=params) return self._handle_response(response) def get_label(self, label_uuid: str) -> dict[str, Any]: """GET /v1/labels// — one organization Label.""" - response: httpx.Response = self._request( - "GET", f"{self.api_url}/v1/labels/{label_uuid}/" - ) + response: httpx.Response = self._request("GET", f"{self.api_url}/v1/labels/{label_uuid}/") return self._handle_response(response) def create_label( @@ -1881,9 +1877,7 @@ def create_label( body["color"] = color if description is not None: body["description"] = description - response: httpx.Response = self._request( - "POST", f"{self.api_url}/v1/labels/", json=body - ) + response: httpx.Response = self._request("POST", f"{self.api_url}/v1/labels/", json=body) return self._handle_response(response) def update_label(self, label_uuid: str, body: dict[str, Any]) -> dict[str, Any]: diff --git a/dailybot_cli/commands/featured.py b/dailybot_cli/commands/featured.py index db59ee8..3b77406 100644 --- a/dailybot_cli/commands/featured.py +++ b/dailybot_cli/commands/featured.py @@ -18,9 +18,7 @@ def _parse_entity_type(ctx: click.Context, param: click.Parameter, value: str) -> str: normalized: str = value.strip().lower() if normalized not in FEATURED_ENTITY_TYPES: - raise click.BadParameter( - f"entity_type must be one of: {', '.join(FEATURED_ENTITY_TYPES)}" - ) + raise click.BadParameter(f"entity_type must be one of: {', '.join(FEATURED_ENTITY_TYPES)}") return normalized diff --git a/dailybot_cli/commands/label.py b/dailybot_cli/commands/label.py index c846a69..269c669 100644 --- a/dailybot_cli/commands/label.py +++ b/dailybot_cli/commands/label.py @@ -231,9 +231,13 @@ def label_delete(label_uuid: str, yes: bool, json_mode: bool) -> None: enforce_plan_access("label_delete", json_mode=json_mode) client: DailyBotClient = require_auth() - if not yes and not json_mode and not click.confirm( - f"Permanently delete label {label_uuid}? This cannot be undone.", - default=False, + if ( + not yes + and not json_mode + and not click.confirm( + f"Permanently delete label {label_uuid}? This cannot be undone.", + default=False, + ) ): raise SystemExit(0) From 8eae33744ab3b391e6be93b1dcbc8c5dc7c6b315 Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:32:56 -0500 Subject: [PATCH 5/7] fix(tests): normalize output for profile checks in env commands tests - Updated assertions in `env_commands_test.py` and `repo_env_test.py` to join whitespace in captured output, ensuring consistent matching for profile status messages. --- tests/env_commands_test.py | 3 ++- tests/repo_env_test.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/env_commands_test.py b/tests/env_commands_test.py index 047664c..70cb993 100644 --- a/tests/env_commands_test.py +++ b/tests/env_commands_test.py @@ -260,7 +260,8 @@ def test_empty_profiles_list_shows_hint(self, runner: CliRunner, chdir_tmp: Path result = runner.invoke(cli, ["env", "show"]) assert result.exit_code == 0 - assert "no active profile" in result.output.lower() + # Rich wraps long tmp paths, so join whitespace before matching. + assert "no active profile" in " ".join(result.output.split()).lower() result = runner.invoke(cli, ["env", "list"]) assert result.exit_code == 0 diff --git a/tests/repo_env_test.py b/tests/repo_env_test.py index f82857f..2cdb43b 100644 --- a/tests/repo_env_test.py +++ b/tests/repo_env_test.py @@ -299,7 +299,7 @@ def test_disabled_non_bool_warns_and_stays_active( assert result["disabled"] is False active: dict[str, Any] | None = get_active_env_profile(chdir_tmp) assert active is not None and active["name"] == "x" - captured: str = capsys.readouterr().out + captured: str = " ".join(capsys.readouterr().out.split()) assert "must be a JSON boolean" in captured assert "ACTIVE" in captured From 44e7f8f08ffc97c5080fc2abec5d829871bf8701 Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:19:48 +0000 Subject: [PATCH 6/7] feat(cli): assign organization Labels on forms, check-ins, and workflows Give the CLI the same replace-set picker as the web app, plus batch add/remove, and document it for agents. Co-authored-by: Cursor --- .agents/skills/dailybot/SKILL.md | 4 +- .agents/skills/dailybot/labels/SKILL.md | 69 +++++++++++ dailybot_cli/api_client.py | 50 ++++++++ dailybot_cli/commands/label.py | 154 ++++++++++++++++++++++++ dailybot_cli/display.py | 7 ++ tests/labels_featured_commands_test.py | 82 +++++++++++++ 6 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/dailybot/labels/SKILL.md diff --git a/.agents/skills/dailybot/SKILL.md b/.agents/skills/dailybot/SKILL.md index d137738..6897d1a 100644 --- a/.agents/skills/dailybot/SKILL.md +++ b/.agents/skills/dailybot/SKILL.md @@ -51,7 +51,7 @@ machine — permissions, consent guarantees, and a self-audit you can run — is ## What it does -Thirteen coordinated capabilities, with smart routing between them: +Fourteen coordinated capabilities, with smart routing between them: | Capability | Sub-skill | When it fires | |------------|-----------|---------------| @@ -67,6 +67,7 @@ Thirteen coordinated capabilities, with smart routing between them: | **Teams** | `dailybot-teams` | List teams, inspect members, resolve a team name → UUID (used as a resolver by other skills) — **plus account context**: `dailybot me` (who am I / role), `dailybot org` (which org), and `dailybot user get` (one user's profile) | | **Forms** | `dailybot-forms` | List, submit, update, or transition forms — including workflow-state forms with audience permissions (`form list` is now **org-scoped** by default, with `--mine` to narrow to your own; list + responses support pagination / search / date filters) — **plus authoring**: create/configure a form (workflow states, permissions, anonymous/public/approval, ChatOps command) and manage its questions | | **Workflows** | `dailybot-workflow` | Developer wants to **read or trigger** the org's workflows — `workflow list` (paginated/searchable, with `--filter api_trigger`), `workflow get`, and `workflow trigger` (fire an API-triggerable workflow with an optional JSON payload). Creating/editing workflows is web-app only. Plan-gated | +| **Labels** | `dailybot-labels` | Create org Labels and assign them to forms, check-ins, and workflows (`label assign` / `label batch`) — same chip picker as the web app | | **Report channels** | `dailybot-channels` | Discover report-channel UUIDs to attach to forms/check-ins with `--report-channel` | | **Per-repo API keys** | `dailybot-env` | Configure `.dailybot/env.json` — an **opt-in, gitignored** file that carries API keys + URLs for one or more environments (live, local, staging) so the developer can be "logged into different orgs in different repos". `dailybot env add / use / show / list / remove / off / on`. Pack baseline (`>= 3.8.0`) | @@ -242,6 +243,7 @@ the full step-by-step workflow. | "list / search / browse my forms (or kudos, or workflows) with pagination", "only the first N", "since last week", "grep for retro" | The matching sub-skill — all share [`shared/list-query-and-errors.md`](shared/list-query-and-errors.md) for the query flags | | "who am I?", "what's my role?", "which org am I in?", "show a user's profile" | **Teams** → read [`teams/SKILL.md`](teams/SKILL.md) § Step 4.5 (`me` / `org` / `user get`) | | "browse kudos", "kudos I received / gave", "org kudos stats", "who's on the wall of fame?" | **Kudos** → read [`kudos/SKILL.md`](kudos/SKILL.md) § Browsing kudos | +| "add a label to a form / check-in / workflow", "tag this standup with Sprint", "assign organization labels" | **Labels** → read [`labels/SKILL.md`](labels/SKILL.md) | | "list my workflows", "show workflows", "what's in workflow X?" | **Workflows** → read [`workflow/SKILL.md`](workflow/SKILL.md) | | "trigger the deploy workflow", "fire automation X", "run workflow ``", "trigger workflow with payload" | **Workflows** → read [`workflow/SKILL.md`](workflow/SKILL.md) § Step 4 (Trigger) | | "which channels can Dailybot post to?", "list report channels", "I need a channel UUID for the form / check-in" | **Channels** → read [`channels/SKILL.md`](channels/SKILL.md) | diff --git a/.agents/skills/dailybot/labels/SKILL.md b/.agents/skills/dailybot/labels/SKILL.md new file mode 100644 index 0000000..2c5517c --- /dev/null +++ b/.agents/skills/dailybot/labels/SKILL.md @@ -0,0 +1,69 @@ +--- +name: dailybot-labels +description: Create organization Labels and attach them to forms, check-ins, and workflows (automations) via the Dailybot CLI — same taxonomy as the web settings page and row chip picker. Use when the developer wants to add, replace, or clear Labels on those entities, or to create/list Labels. +--- + +# Dailybot Labels + +Organization Labels are a **shared taxonomy** (not private Featured stars). The +web picker on a form / check-in / automation row is a **replace-set**. Match +that with `dailybot label assign`. Bulk add/remove uses `dailybot label batch`. + +Requires CLI from the personalized-labels branch (commands `label assign` and +`label batch`). Confirm with `dailybot label assign --help`. + +## Auth + +Same session as the rest of the CLI (`dailybot login` or `DAILYBOT_API_KEY`). +Point at staging with `--api-url https://staging-api.dailybot.com` when testing +there. Server entitlement (`dailybot label entitlement`) is the source of +truth — do not assume a paid plan is required. + +## Create and list Labels + +```bash +dailybot label list +dailybot label create --name "Sprint" --color "#4A90E2" +``` + +Copy UUIDs from `label list` (or `--json`). + +## Attach to one entity (web picker parity) + +```bash +# Forms +dailybot label assign --type forms --label + +# Check-ins +dailybot label assign --type checkins --label + +# Workflows / Automations (same API; --type automations is an alias) +dailybot label assign --type workflows --label +``` + +Repeat `--label` (or comma-separate) for several Labels. The list **replaces** +whatever was on the entity. + +```bash +dailybot label assign --type forms --clear +``` + +## Bulk add / remove + +```bash +dailybot label batch --type forms --uuids , --label --mode add +dailybot label batch --type checkins --uuids --label --mode remove +dailybot label batch --type workflows --uuids --label --mode replace +``` + +`--mode` is `add` (default), `remove`, or `replace`. + +## After authoring + +`dailybot form create` / `checkin create` do **not** take `--labels`. Create +first, then `label assign` with the new UUID from `--json`. + +## Not this skill + +Private stars → `dailybot featured`. Workflow *states* named "labels" on a +form response → `dailybot-forms` transitions. diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 9dfda10..e80c176 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -123,6 +123,20 @@ def _merge_dashboard_enrichment_query( return params +def _label_entity_collection(entity_type: str) -> str: + """Map CLI entity type (including web 'automations') to the public API path.""" + normalized: str = entity_type.strip().lower() + if normalized in {"automations", "workflows", "workflow"}: + return "workflows" + if normalized in {"forms", "form"}: + return "forms" + if normalized in {"checkins", "checkin", "check-ins"}: + return "checkins" + raise ValueError( + f"entity type must be one of: forms, checkins, workflows (got {entity_type!r})" + ) + + def _fill_meta(meta: dict[str, Any] | None, result: "PaginatedResult") -> None: """Populate a caller-provided meta dict with pagination totals, if given.""" if meta is not None: @@ -1903,6 +1917,42 @@ def archive_label(self, label_uuid: str) -> dict[str, Any]: ) return self._handle_response(response) + def assign_entity_labels( + self, + entity_type: str, + entity_uuid: str, + label_uuids: list[str], + ) -> dict[str, Any]: + """POST /v1/{forms|checkins|workflows}/{uuid}/labels/ — replace-set Labels.""" + collection: str = _label_entity_collection(entity_type) + response: httpx.Response = self._request( + "POST", + f"{self.api_url}/v1/{collection}/{entity_uuid}/labels/", + json={"label_uuids": label_uuids}, + ) + return self._handle_response(response) + + def batch_entity_labels( + self, + *, + entity_type: str, + entity_uuids: list[str], + label_uuids: list[str], + mode: str, + ) -> dict[str, Any]: + """POST /v1/{forms|checkins|workflows}/labels/batch/ — add/remove/replace.""" + collection: str = _label_entity_collection(entity_type) + response: httpx.Response = self._request( + "POST", + f"{self.api_url}/v1/{collection}/labels/batch/", + json={ + "entity_uuids": entity_uuids, + "label_uuids": label_uuids, + "mode": mode, + }, + ) + return self._handle_response(response) + # --- Private Featured stars (/v1/me/featured/) --- def list_featured(self, *, entity_type: str) -> dict[str, Any]: diff --git a/dailybot_cli/commands/label.py b/dailybot_cli/commands/label.py index 269c669..b1c5319 100644 --- a/dailybot_cli/commands/label.py +++ b/dailybot_cli/commands/label.py @@ -14,10 +14,39 @@ from dailybot_cli.display import ( console, print_detail_panel, + print_label_assignment, print_labels_table, print_success, ) +_LABEL_ENTITY_TYPES: tuple[str, ...] = ("forms", "checkins", "workflows", "automations") +_BATCH_MODES: tuple[str, ...] = ("add", "remove", "replace") + + +def _parse_label_entity_type( + _ctx: click.Context, _param: click.Parameter, value: str +) -> str: + normalized: str = value.strip().lower() + if normalized not in _LABEL_ENTITY_TYPES: + raise click.BadParameter( + "must be one of: forms, checkins, workflows (automations is an alias)" + ) + if normalized == "automations": + return "workflows" + return normalized + + +def _parse_label_uuid_list(values: tuple[str, ...]) -> list[str]: + uuids: list[str] = [] + for raw in values: + for part in raw.split(","): + token: str = part.strip() + if token: + uuids.append(token) + # Preserve order, drop duplicates. + return list(dict.fromkeys(uuids)) + + _LABEL_FIELDS: list[tuple[str, str]] = [ ("Name", "name"), ("UUID", "uuid"), @@ -252,3 +281,128 @@ def label_delete(label_uuid: str, yes: bool, json_mode: bool) -> None: return print_success(f"Deleted label {label_uuid}.") + + +@label.command("assign") +@click.argument("entity_uuid") +@click.option( + "--type", + "entity_type", + required=True, + callback=_parse_label_entity_type, + help="Entity type: forms, checkins, or workflows (automations alias).", +) +@click.option( + "--label", + "label_refs", + multiple=True, + help="Label UUID to attach (repeatable, or comma-separated). Replace-set.", +) +@click.option( + "--clear", + is_flag=True, + help="Remove every Label from this entity (replace with an empty set).", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_assign( + entity_uuid: str, + entity_type: str, + label_refs: tuple[str, ...], + clear: bool, + json_mode: bool, +) -> None: + """Replace the Labels on one form, check-in, or workflow (web picker parity). + + \b + Same as the chip picker in the Dailybot web app: the list you pass becomes + the full set. Use --clear to detach every Label. Add or remove on many + items at once with `label batch`. + """ + label_uuids: list[str] = _parse_label_uuid_list(label_refs) + if clear and label_uuids: + raise click.UsageError("Pass either --label or --clear, not both.") + if not clear and not label_uuids: + raise click.UsageError("Pass at least one --label, or --clear to detach all.") + + client = require_auth() + try: + with console.status("Updating labels..."): + data: dict[str, Any] = client.assign_entity_labels( + entity_type, + entity_uuid, + label_uuids, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + attached: list[dict[str, Any]] = data.get("labels") or [] + print_success(f"Updated labels on {entity_type} {data.get('uuid', entity_uuid)}.") + print_label_assignment(str(data.get("uuid", entity_uuid)), attached) + + +@label.command("batch") +@click.option( + "--type", + "entity_type", + required=True, + callback=_parse_label_entity_type, + help="Entity type: forms, checkins, or workflows (automations alias).", +) +@click.option( + "--uuids", + required=True, + help="Comma-separated form, check-in, or workflow UUIDs.", +) +@click.option( + "--label", + "label_refs", + multiple=True, + help="Label UUID (repeatable, or comma-separated).", +) +@click.option( + "--mode", + type=click.Choice(_BATCH_MODES, case_sensitive=False), + default="add", + show_default=True, + help="add / remove / replace Labels on every listed entity.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def label_batch( + entity_type: str, + uuids: str, + label_refs: tuple[str, ...], + mode: str, + json_mode: bool, +) -> None: + """Add, remove, or replace Labels on many forms, check-ins, or workflows.""" + entity_uuids: list[str] = [part.strip() for part in uuids.split(",") if part.strip()] + if not entity_uuids: + raise click.UsageError("--uuids must contain at least one UUID.") + label_uuids: list[str] = _parse_label_uuid_list(label_refs) + if not label_uuids: + raise click.UsageError("Pass at least one --label.") + + client = require_auth() + try: + with console.status("Updating labels..."): + data: dict[str, Any] = client.batch_entity_labels( + entity_type=entity_type, + entity_uuids=entity_uuids, + label_uuids=label_uuids, + mode=mode.lower(), + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + + updated: int = int(data.get("updated_count") or len(entity_uuids)) + print_success( + f"{mode.lower()} {len(label_uuids)} label(s) on {updated} {entity_type} item(s)." + ) diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index ae9ab39..a9b1553 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -170,6 +170,13 @@ def print_kudos_wall_of_fame(data: dict[str, Any]) -> None: ) +def print_label_assignment(entity_uuid: str, labels: list[dict[str, Any]]) -> None: + """Render the Labels currently attached to one form, check-in, or workflow.""" + names: list[str] = [str(item.get("name") or item.get("uuid") or "—") for item in labels] + attached: str = ", ".join(names) if names else "(none)" + console.print(f"[bold]{entity_uuid}[/bold] → {attached}") + + def print_labels_table(labels: list[dict[str, Any]]) -> None: """Render a compact table of organization Labels.""" if not labels: diff --git a/tests/labels_featured_commands_test.py b/tests/labels_featured_commands_test.py index b622d23..65552cb 100644 --- a/tests/labels_featured_commands_test.py +++ b/tests/labels_featured_commands_test.py @@ -50,6 +50,88 @@ def test_featured_list_json(monkeypatch: Any) -> None: assert "form-1" in result.output +def test_label_assign_replace_set(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.assign_entity_labels.return_value = { + "uuid": "form-1", + "labels": [{"uuid": "lbl-1", "name": "Sprint", "color": "#4A90E2"}], + } + result = CliRunner().invoke( + cli, + [ + "label", + "assign", + "form-1", + "--type", + "forms", + "--label", + "lbl-1", + "--json", + ], + ) + assert result.exit_code == 0, result.output + client.assign_entity_labels.assert_called_once_with( + "forms", + "form-1", + ["lbl-1"], + ) + + +def test_label_assign_clear(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.assign_entity_labels.return_value = {"uuid": "chk-1", "labels": []} + result = CliRunner().invoke( + cli, + ["label", "assign", "chk-1", "--type", "checkins", "--clear"], + ) + assert result.exit_code == 0, result.output + client.assign_entity_labels.assert_called_once_with("checkins", "chk-1", []) + + +def test_label_assign_maps_automations_to_workflows(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.assign_entity_labels.return_value = {"uuid": "wf-1", "labels": []} + result = CliRunner().invoke( + cli, + ["label", "assign", "wf-1", "--type", "automations", "--clear", "--json"], + ) + assert result.exit_code == 0, result.output + client.assign_entity_labels.assert_called_once_with("workflows", "wf-1", []) + + +def test_label_batch_add(monkeypatch: Any) -> None: + client = _client(monkeypatch) + client.batch_entity_labels.return_value = { + "mode": "add", + "updated_count": 2, + "entity_uuids": ["a", "b"], + "labels": [{"uuid": "lbl-1", "name": "Sprint"}], + } + result = CliRunner().invoke( + cli, + [ + "label", + "batch", + "--type", + "forms", + "--uuids", + "a,b", + "--label", + "lbl-1", + "--mode", + "add", + "--json", + ], + ) + assert result.exit_code == 0, result.output + client.batch_entity_labels.assert_called_once_with( + entity_type="forms", + entity_uuids=["a", "b"], + label_uuids=["lbl-1"], + mode="add", + ) + + def test_featured_set_calls_client(monkeypatch: Any) -> None: client = _client(monkeypatch) client.set_featured.return_value = { From 0dc1e646d9e6493ce1b5a141abeb6b35bd8313eb Mon Sep 17 00:00:00 2001 From: AndresMpa <39841241+AndresMpa@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:28:10 +0000 Subject: [PATCH 7/7] fix(cli): format label assign commands for ruff --- dailybot_cli/commands/label.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dailybot_cli/commands/label.py b/dailybot_cli/commands/label.py index b1c5319..ffb79a3 100644 --- a/dailybot_cli/commands/label.py +++ b/dailybot_cli/commands/label.py @@ -23,9 +23,7 @@ _BATCH_MODES: tuple[str, ...] = ("add", "remove", "replace") -def _parse_label_entity_type( - _ctx: click.Context, _param: click.Parameter, value: str -) -> str: +def _parse_label_entity_type(_ctx: click.Context, _param: click.Parameter, value: str) -> str: normalized: str = value.strip().lower() if normalized not in _LABEL_ENTITY_TYPES: raise click.BadParameter( @@ -403,6 +401,4 @@ def label_batch( return updated: int = int(data.get("updated_count") or len(entity_uuids)) - print_success( - f"{mode.lower()} {len(label_uuids)} label(s) on {updated} {entity_type} item(s)." - ) + print_success(f"{mode.lower()} {len(label_uuids)} label(s) on {updated} {entity_type} item(s).")