diff --git a/AGENTS.md b/AGENTS.md index 0a51f90..fcbfd13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,10 +32,10 @@ **Dailybot CLI** is a Python command-line tool that bridges **humans** and **agents** with the [Dailybot](https://www.dailybot.com) platform. It provides: -- **For humans** — email-OTP login, viewing pending check-ins, submitting structured/free-text updates, interactive TUI mode. -- **For agents (AI assistants, CI jobs, deploy scripts, bots)** — progress reports, milestone tracking, agent health, webhook registration, agent-to-agent messaging, transactional email, and standalone agent registration (creates an org without a human Dailybot account). +- **For humans** — email-OTP login, viewing pending check-ins, submitting structured/free-text updates, **filling out forms** (one-shot or driven through a workflow state machine: `pre_release → qa → code_review → ready_to_release → released`), **browsing teams** (role-scoped server-side), **giving kudos to users or whole teams**, interactive TUI mode. +- **For agents (AI assistants, CI jobs, deploy scripts, bots)** — progress reports, milestone tracking, agent health, webhook registration, agent-to-agent messaging, transactional email, standalone agent registration (creates an org without a human Dailybot account), **and the full forms-response lifecycle** (`get / responses / response get / update / transition / delete`) so an agent can drive any form — including workflow-enabled ones — end-to-end after `dailybot login`. -It talks exclusively to the Dailybot HTTP API under `/v1/cli/*` and `/v1/agent*/*` endpoints. There is no local database; all state is either in `~/.config/dailybot/` (credentials, agent profiles, config) or fetched from the API. +It talks exclusively to the Dailybot HTTP API under `/v1/cli/*`, `/v1/agent*/*`, `/v1/forms/*`, `/v1/teams/*`, `/v1/kudos/`, `/v1/users/`, and `/v1/checkins/*` endpoints. There is no local database; all state is either in `~/.config/dailybot/` (credentials, agent profiles, config) or fetched from the API. **Stack:** Python 3.10+, [Click](https://click.palletsprojects.com/) 8.3+, [httpx](https://www.python-httpx.org/) 0.28+, [questionary](https://questionary.readthedocs.io/) 2.1+, [rich](https://rich.readthedocs.io/) 15+. Tested with `pytest`. Built and packaged with `setuptools`; distributed via PyPI, Homebrew tap (`dailybothq/tap`), a PyInstaller-built Linux x86_64 binary, and a PowerShell installer (`install.ps1`) that wraps `pipx`/`uv`/`pip` for native Windows users. @@ -55,6 +55,15 @@ dailybot_cli/ # Source package ├── status.py # pending check-ins + --auth status ├── update.py # submit human check-in update ├── config.py # get/set/remove stored settings (api_key) + ├── checkin.py # `checkin` group: list / complete (user-scoped) + ├── form.py # `form` group: list / get / submit / responses / + │ # response get / update / transition / delete + ├── team.py # `team` group: list / get (server-scoped by role) + ├── kudos.py # `kudos give` (to a user, a team, or both) + ├── user.py # `user list` (org directory) + ├── public_api_helpers.py # require_bearer_auth, exit_for_api_error, + │ # ERROR_CODE_MESSAGES, resolve_user_/team_by_name_or_uuid + ├── user_scoped_actions.py # shared handlers for checkin + form (used by CLI + TUI) ├── interactive.py # questionary-based TUI when run with no args ├── version.py # `dailybot version` — install info + PyPI update check ├── upgrade.py # `dailybot upgrade` — auto-detect install method + self-update @@ -63,8 +72,12 @@ dailybot_cli/ # Source package # update, health, webhook, message, email tests/ # pytest suite (file naming: *_test.py) -├── api_client_test.py # HTTP client mocking -├── commands_test.py # Click CliRunner invocations +├── api_client_test.py # HTTP client mocking (all DailyBotClient methods) +├── commands_test.py # Click CliRunner — agent + interactive + auth flows +├── public_api_commands_test.py # Click CliRunner — checkin, form (lifecycle), +│ # team, kudos, user (all Bearer-token paths) +├── form_question_types_test.py # guided-prompt type classifier +├── repo_profile_test.py # `.dailybot/profile.json` resolution └── config_test.py # Config/credential file management .github/workflows/release.yml # Tag-triggered: PyPI + Linux binary + Homebrew diff --git a/README.md b/README.md index 4d99974..1275f51 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ dailybot checkin complete -a 0="Done" --yes --json # List all forms visible to you (includes question count) dailybot form list +# Get a form's full payload (questions + workflow states + permissions) +dailybot form get + # Submit a form — guided mode (prompts each question by label and type) dailybot form submit @@ -143,11 +146,36 @@ dailybot form submit \ --content '{"":"Great week!", "":"No blockers"}' \ --yes +# List your own responses on a form +dailybot form responses +dailybot form responses --state qa --json +dailybot form responses --latest --json # continue where you left off + +# Operate on a single response +dailybot form response get +dailybot form update --content '{"":"PR #4242"}' +dailybot form transition qa --note "QA assigned" +dailybot form delete + # Machine-readable JSON output dailybot form list --json dailybot form submit --content '{"":"Yes"}' --yes --json ``` +### Workflow-enabled forms + +When a form has `workflow_enabled: true`, every response carries a workflow-state surface that the CLI prints after every mutating call: + +| Field | Meaning | +|---|---| +| `current_state` | The effective current state. | +| `allowed_transitions` | List of `{to_state, label}` for every reachable next state. | +| `can_change_state` | Whether the caller is in the form's `state_change_permission` audience. | +| `allow_reopen_from_final_state` | Form-level — `false` (default) means the terminal state is sticky. | +| `state_history` | Append-only audit trail of every transition. | + +The form's `state_change_permission` audience is the sole gate for `dailybot form transition` — there is no response-author short-circuit. If you're not in the audience, the API returns 403 with `code: form_response_change_state_forbidden`. + Guided mode (`form submit` without `--content`) fetches the form's question list from the API and prompts each question one by one, with type-aware inputs: | Question type | Prompt | @@ -170,9 +198,15 @@ Guided mode (`form submit` without `--content`) fetches the form's question list ## Kudos ```bash -# Give kudos — receiver resolved by full name against your org directory +# Give kudos to a user — receiver resolved by full name against your org directory dailybot kudos give --to "Jane Doe" --message "Shipped the auth refactor cleanly, great work!" +# Give kudos to an entire team (resolved against GET /v1/teams/) +dailybot kudos give --team "Engineering" --message "Shipped flawlessly" + +# Combine both — single message goes to one user and a whole team +dailybot kudos give --to "Alice" --team "QA" --message "Both nailed it" + # Resolve by UUID instead dailybot kudos give --to --message "Thanks for the PR review." --yes @@ -183,13 +217,16 @@ dailybot kudos give --to "Jane Doe" --message "Great!" --value --with-members --json ``` -The table shows **Name** and **User UUID**. You can copy a UUID directly into `dailybot kudos give --to ` for precise targeting. +The `user list` table shows **Name** and **User UUID**. You can copy a UUID directly into `dailybot kudos give --to ` for precise targeting. + +`team list` is **role-scoped server-side**: org admins see all teams in the organization; members see only the teams they belong to. The CLI never client-filters — it shows the server response verbatim. If `dailybot kudos give --team "X"` errors with a "not visible to you" message, the team either doesn't exist or you're not a member. --- ## User-scoped exit codes -All user-scoped commands (`checkin`, `form`, `kudos`, `user`) use structured exit codes for scripting: +All user-scoped commands (`checkin`, `form`, `kudos`, `user`, `team`) use structured exit codes for scripting: | Code | Meaning | |------|---------| | `0` | Success | -| `2` | Invalid input (bad format, ambiguous receiver) | +| `2` | Invalid input (bad format, ambiguous receiver, 400 from server) | | `3` | Not logged in — run `dailybot login` | -| `4` | Permission denied (403), self-kudos, or daily kudos limit reached | -| `5` | Form response quota exhausted | +| `4` | Permission denied (403), self-kudos, daily kudos limit, or `final_state_locked` | +| `5` | Resource not found (404) or form response quota exhausted (402) | | `6` | Rate limited — wait and retry | | `7` | User declined the confirmation prompt | +`--json` output for any 4xx surfaces both `detail` (server-provided text) and the structured `code` field — chat-agent consumers can pattern-match on the code (`form_response_change_state_forbidden`, `final_state_locked`, `form_response_not_found`, `no_valid_team`, etc.) without parsing prose. + ## For agents Any software agent — AI coding assistants, CI jobs, deploy scripts, bots — can report activity through the CLI. This lets teams get visibility into what automated processes are doing, alongside human updates. Dailybot interconnects agents and humans with work analysis, progress reports, observability, and automations. @@ -491,19 +539,27 @@ Replies to agent emails land as messages retrievable via `dailybot agent message | Command | Description | |---------|-------------| | `dailybot form list` | List forms visible to you (includes question count) | +| `dailybot form get ` | Show the form's full payload (questions + workflow states) | | `dailybot form submit ` | Submit a form (guided prompts or `--content` JSON) | +| `dailybot form responses ` | List your own responses on a form (`--state`, `--latest`) | +| `dailybot form response get ` | Show a single response (state + history + answers) | +| `dailybot form update ` | Patch new answers into an in-progress response | +| `dailybot form transition ` | Advance a response through the workflow | +| `dailybot form delete ` | Delete a response (author / owner / admin) | ### Kudos | Command | Description | |---------|-------------| -| `dailybot kudos give` | Give kudos to a teammate by name or UUID | +| `dailybot kudos give` | Give kudos to a user (`--to`), a team (`--team`), or both | ### Team | Command | Description | |---------|-------------| | `dailybot user list` | List all members in your organization | +| `dailybot team list` | List teams visible to you (scoped server-side by role) | +| `dailybot team get ` | Show a team; add `--with-members` for the member list | ### Agent commands diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index c4a12d9..91cb3c6 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -12,9 +12,10 @@ class APIError(Exception): """Raised when the API returns a non-success response.""" - def __init__(self, status_code: int, detail: str) -> None: + def __init__(self, status_code: int, detail: str, code: str | None = None) -> None: self.status_code: int = status_code self.detail: str = detail + self.code: str | None = code super().__init__(f"API error {status_code}: {detail}") @@ -63,14 +64,18 @@ def _agent_headers(self) -> dict[str, str]: def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """Parse API response and raise on errors.""" if response.status_code >= 400: + code: str | None = None try: body: dict[str, Any] = response.json() detail: str = body.get("detail", body.get("error", str(body))) + raw_code: Any = body.get("code") + if isinstance(raw_code, str): + code = raw_code except Exception: detail = response.text or f"HTTP {response.status_code}" if response.status_code in (401, 403) and self._agent_auth_mode == "bearer": detail = "Session expired. Run 'dailybot login' to re-authenticate." - raise APIError(status_code=response.status_code, detail=detail) + raise APIError(status_code=response.status_code, detail=detail, code=code) if response.status_code == 204: return {} return response.json() @@ -218,6 +223,92 @@ def submit_form_response( ) return self._handle_response(response) + def list_form_responses( + self, + form_uuid: str, + *, + state: str | None = None, + ) -> list[dict[str, Any]]: + """GET /v1/forms//responses/ — list the caller's own responses.""" + params: dict[str, str] = {} + if state: + params["state"] = state + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/forms/{form_uuid}/responses/", + headers=self._headers(), + params=params, + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict) and "results" in body: + return list(body.get("results", [])) + if isinstance(body, list): + return body + return [] + + def get_form_response( + self, + form_uuid: str, + response_uuid: str, + ) -> dict[str, Any]: + """GET /v1/forms//responses//""" + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/forms/{form_uuid}/responses/{response_uuid}/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def update_form_response( + self, + form_uuid: str, + response_uuid: str, + content: dict[str, Any], + ) -> dict[str, Any]: + """PATCH /v1/forms//responses//""" + response: httpx.Response = httpx.patch( + f"{self.api_url}/v1/forms/{form_uuid}/responses/{response_uuid}/", + json={"content": content}, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def transition_form_response( + self, + form_uuid: str, + response_uuid: str, + to_state: str, + note: str | None = None, + ) -> dict[str, Any]: + """POST /v1/forms//responses//transition/""" + payload: dict[str, Any] = {"to_state": to_state} + if note: + payload["note"] = note + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/forms/{form_uuid}/responses/{response_uuid}/transition/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def delete_form_response( + self, + form_uuid: str, + response_uuid: str, + ) -> dict[str, Any]: + """DELETE /v1/forms//responses//""" + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/forms/{form_uuid}/responses/{response_uuid}/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def list_users(self) -> list[dict[str, Any]]: """GET /v1/users/ — fetch all pages and return the combined results list.""" results: list[dict[str, Any]] = [] @@ -239,15 +330,21 @@ def list_users(self) -> list[dict[str, Any]]: def give_kudos( self, - receivers: list[str], content: str, + user_uuid_receivers: list[str] | None = None, + team_uuid_receivers: list[str] | None = None, company_value: str | None = None, ) -> dict[str, Any]: - """POST /v1/kudos/""" - payload: dict[str, Any] = { - "receivers": receivers, - "content": content, - } + """POST /v1/kudos/ + + At least one of ``user_uuid_receivers`` or ``team_uuid_receivers`` must + be non-empty — the backend rejects an empty receiver set. + """ + payload: dict[str, Any] = {"content": content} + if user_uuid_receivers: + payload["user_uuid_receivers"] = user_uuid_receivers + if team_uuid_receivers: + payload["team_uuid_receivers"] = team_uuid_receivers if company_value: payload["company_value"] = company_value response: httpx.Response = httpx.post( @@ -258,6 +355,56 @@ def give_kudos( ) return self._handle_response(response) + def list_teams(self) -> list[dict[str, Any]]: + """GET /v1/teams/ — server scopes results by role (admin sees all, member sees own).""" + results: list[dict[str, Any]] = [] + url: str | None = f"{self.api_url}/v1/teams/" + pages_fetched: int = 0 + while url is not None and pages_fetched < _MAX_LIST_PAGES: + response: httpx.Response = httpx.get( + url, + headers=self._headers(), + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict) and "results" in body: + results.extend(body.get("results", [])) + url = body.get("next") + elif isinstance(body, list): + results.extend(body) + url = None + else: + url = None + pages_fetched += 1 + return results + + def get_team(self, team_uuid: str) -> dict[str, Any]: + """GET /v1/teams//""" + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/teams/{team_uuid}/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def list_team_members(self, team_uuid: str) -> list[dict[str, Any]]: + """GET /v1/teams//members/""" + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/teams/{team_uuid}/members/", + headers=self._headers(), + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict) and "results" in body: + return list(body.get("results", [])) + if isinstance(body, list): + return body + return [] + # --- Agent endpoints --- def submit_agent_report( diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index ed944c3..d140ffb 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -1,13 +1,39 @@ """Form commands for the user-scoped public API.""" +from typing import Any + import click -from dailybot_cli.commands.public_api_helpers import require_bearer_auth +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + confirm_write, + emit_json, + exit_for_api_error, + require_bearer_auth, +) from dailybot_cli.commands.user_scoped_actions import ( execute_form_list, execute_form_submit, + parse_form_content_json, resolve_form_content, ) +from dailybot_cli.display import ( + console, + print_form_detail, + print_form_response_deleted, + print_form_response_detail, + print_form_response_state, + print_form_responses_table, + print_success, +) + + +def _maybe_load_form(client: DailyBotClient, form_uuid: str) -> dict[str, Any] | None: + """Return form metadata if accessible; swallow errors (UI-only enrichment).""" + try: + return client.get_form(form_uuid) + except APIError: + return None @click.group() @@ -36,6 +62,33 @@ def form_list(json_mode: bool) -> None: execute_form_list(client, json_mode=json_mode) +@form.command("get") +@click.argument("form_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_get(form_uuid: str, json_mode: bool) -> None: + """Get a form's full payload (questions + workflow states + permissions). + + \b + Acts as you. You can only see and act on what you could in the webapp. + + \b + Examples: + dailybot form get + dailybot form get --json + """ + client = require_bearer_auth() + try: + with console.status("Loading form..."): + data: dict[str, Any] = client.get_form(form_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + print_form_detail(data) + + @form.command("submit") @click.argument("form_uuid") @click.option( @@ -75,3 +128,236 @@ def form_submit( assume_yes=assume_yes, json_mode=json_mode, ) + + +@form.command("responses") +@click.argument("form_uuid") +@click.option("--state", default=None, help="Filter by current_state (workflow forms only).") +@click.option( + "--latest", + is_flag=True, + help="Return only the most recent response (continue where you left off).", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_responses( + form_uuid: str, + state: str | None, + latest: bool, + json_mode: bool, +) -> None: + """List your own responses on a form. + + \b + Acts as you. The server returns only responses you authored. + + \b + Examples: + dailybot form responses + dailybot form responses --state qa --json + dailybot form responses --latest --json + """ + client = require_bearer_auth() + try: + with console.status("Fetching responses..."): + responses: list[dict[str, Any]] = client.list_form_responses(form_uuid, state=state) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if latest: + responses = responses[:1] if responses else [] + + if json_mode: + emit_json(responses) + return + + form_data: dict[str, Any] | None = _maybe_load_form(client, form_uuid) + print_form_responses_table(form_uuid, responses, form_data) + + +@form.group("response") +def form_response() -> None: + """Operate on individual form responses (get / update / transition / delete).""" + + +@form_response.command("get") +@click.argument("form_uuid") +@click.argument("response_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_response_get(form_uuid: str, response_uuid: str, json_mode: bool) -> None: + """Get a single response (state, allowed transitions, history, content). + + \b + Examples: + dailybot form response get + dailybot form response get --json + """ + client = require_bearer_auth() + try: + with console.status("Loading response..."): + data: dict[str, Any] = client.get_form_response(form_uuid, response_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + form_data: dict[str, Any] | None = _maybe_load_form(client, form_uuid) + print_form_response_detail(data, form_data) + + +@form.command("update") +@click.argument("form_uuid") +@click.argument("response_uuid") +@click.option( + "--content", + "-c", + required=True, + help="JSON map of question UUID to new answer. Shallow-merged into the response.", +) +@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_update( + form_uuid: str, + response_uuid: str, + content: str, + assume_yes: bool, + json_mode: bool, +) -> None: + """Patch new answers into one of your own in-progress responses. + + \b + Own-only. Admins are NOT elevated to other users' responses here. + + \b + Examples: + dailybot form update \\ + --content '{"": "PR #4242"}' + dailybot form update -c '{...}' --yes --json + """ + client = require_bearer_auth() + content_map: dict[str, Any] = parse_form_content_json(content) + + summary_lines: list[str] = [ + f"Form UUID: {form_uuid}", + f"Response UUID: {response_uuid}", + "Updates:", + ] + for question_uuid, answer in content_map.items(): + summary_lines.append(f" {question_uuid}: {answer}") + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Updating response..."): + result: dict[str, Any] = client.update_form_response( + form_uuid=form_uuid, + response_uuid=response_uuid, + content=content_map, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + + print_success(f"Response {response_uuid} updated.") + form_data: dict[str, Any] | None = _maybe_load_form(client, form_uuid) + print_form_response_state(result, form_data) + + +@form.command("transition") +@click.argument("form_uuid") +@click.argument("response_uuid") +@click.argument("to_state") +@click.option("--note", default=None, help="Optional transition note for the audit trail.") +@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_transition( + form_uuid: str, + response_uuid: str, + to_state: str, + note: str | None, + assume_yes: bool, + json_mode: bool, +) -> None: + """Advance a response to a new workflow state. + + \b + The form's state_change_permission audience is the sole gate — there is no + response-author short-circuit. If you're not in the audience the API will + return 403 / form_response_change_state_forbidden. + + \b + Examples: + dailybot form transition qa --note "QA assigned" + dailybot form transition released --json + """ + client = require_bearer_auth() + + summary_lines: list[str] = [ + f"Form UUID: {form_uuid}", + f"Response UUID: {response_uuid}", + f"To state: {to_state}", + ] + if note: + summary_lines.append(f"Note: {note}") + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Transitioning response..."): + result: dict[str, Any] = client.transition_form_response( + form_uuid=form_uuid, + response_uuid=response_uuid, + to_state=to_state, + note=note, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + + print_success(f"Response {response_uuid} transitioned to '{to_state}'.") + form_data: dict[str, Any] | None = _maybe_load_form(client, form_uuid) + print_form_response_state(result, form_data) + + +@form.command("delete") +@click.argument("form_uuid") +@click.argument("response_uuid") +@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_delete( + form_uuid: str, + response_uuid: str, + assume_yes: bool, + json_mode: bool, +) -> None: + """Delete one of your responses (or any response, if you're owner / admin). + + \b + Examples: + dailybot form delete + dailybot form delete --yes + """ + client = require_bearer_auth() + + summary_lines: list[str] = [ + f"Form UUID: {form_uuid}", + f"Response UUID: {response_uuid}", + "This will permanently delete the response.", + ] + confirm_write(summary_lines, assume_yes) + + try: + with console.status("Deleting response..."): + client.delete_form_response(form_uuid=form_uuid, response_uuid=response_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"deleted": True, "form_uuid": form_uuid, "response_uuid": response_uuid}) + return + + print_form_response_deleted(form_uuid, response_uuid) diff --git a/dailybot_cli/commands/interactive.py b/dailybot_cli/commands/interactive.py index 2ce81e9..12d0d15 100644 --- a/dailybot_cli/commands/interactive.py +++ b/dailybot_cli/commands/interactive.py @@ -318,10 +318,9 @@ def _give_kudos(client: DailyBotClient) -> None: try: execute_kudos_give( client, - receiver_uuid, - receiver_name, message, - current_uuid, + user_receivers=[(receiver_uuid, receiver_name)], + current_uuid=current_uuid, assume_yes=True, ) except SystemExit: diff --git a/dailybot_cli/commands/kudos.py b/dailybot_cli/commands/kudos.py index 02f259d..3eb371a 100644 --- a/dailybot_cli/commands/kudos.py +++ b/dailybot_cli/commands/kudos.py @@ -13,6 +13,7 @@ exit_for_api_error, get_current_user_uuid, require_bearer_auth, + resolve_team_by_name_or_uuid, resolve_user_by_name_or_uuid, ) from dailybot_cli.display import ( @@ -24,29 +25,57 @@ def execute_kudos_give( client: DailyBotClient, - receiver_uuid: str, - receiver_name: str, message: str, - current_uuid: str | None, *, + user_receivers: list[tuple[str, str]] | None = None, + team_receivers: list[tuple[str, str]] | None = None, + current_uuid: str | None = None, value: str | None = None, assume_yes: bool = False, json_mode: bool = False, + receiver_uuid: str | None = None, + receiver_name: str | None = None, ) -> None: - """Send kudos after the receiver has already been resolved.""" - if current_uuid and receiver_uuid == current_uuid: - error_message: str = "You cannot give kudos to yourself." + """Send kudos with already-resolved user / team receivers. + + ``receiver_uuid`` / ``receiver_name`` are a legacy single-user shortcut kept + for interactive mode; new callers should use ``user_receivers`` / + ``team_receivers`` lists of (uuid, display_name) pairs. + """ + users: list[tuple[str, str]] = list(user_receivers or []) + teams: list[tuple[str, str]] = list(team_receivers or []) + + if receiver_uuid: + users.append((receiver_uuid, receiver_name or receiver_uuid)) + + if current_uuid: + for user_uuid, _name in users: + if user_uuid == current_uuid: + error_message: str = "You cannot give kudos to yourself." + if json_mode: + emit_json({"error": error_message, "status": 403}) + else: + print_error(error_message) + raise SystemExit(EXIT_PERMISSION_DENIED) + + if not users and not teams: + error_message = "At least one --to or --team receiver is required." if json_mode: - emit_json({"error": error_message, "status": 403}) + emit_json({"error": error_message, "status": 0}) else: print_error(error_message) - raise SystemExit(EXIT_PERMISSION_DENIED) - - summary_lines: list[str] = [ - f"To: {receiver_name}", - f"Receiver UUID: {receiver_uuid}", - f"Message: {message}", - ] + raise SystemExit(EXIT_USAGE_ERROR) + + summary_lines: list[str] = [] + if users: + summary_lines.append("Users:") + for user_uuid, name in users: + summary_lines.append(f" - {name} ({user_uuid})") + if teams: + summary_lines.append("Teams:") + for team_uuid, team_name in teams: + summary_lines.append(f" - {team_name} ({team_uuid})") + summary_lines.append(f"Message: {message}") if value: summary_lines.append(f"Company value: {value}") @@ -55,8 +84,9 @@ def execute_kudos_give( try: with console.status("Sending kudos..."): result: dict[str, Any] = client.give_kudos( - receivers=[receiver_uuid], content=message, + user_uuid_receivers=[u for u, _ in users] or None, + team_uuid_receivers=[t for t, _ in teams] or None, company_value=value, ) except APIError as exc: @@ -66,7 +96,8 @@ def execute_kudos_give( emit_json(result) return - print_kudos_result(receiver_name, result) + label_parts: list[str] = [name for _, name in users] + [f"team {name}" for _, name in teams] + print_kudos_result(", ".join(label_parts) or "receiver", result) @click.group() @@ -83,8 +114,14 @@ def kudos() -> None: "--to", "-t", "receiver", - required=True, - help="Receiver full name or UUID (resolved via GET /v1/users/).", + default=None, + help="User full name or UUID (resolved via GET /v1/users/).", +) +@click.option( + "--team", + "team_identifier", + default=None, + help="Team name or UUID (resolved via GET /v1/teams/).", ) @click.option( "--message", @@ -100,31 +137,54 @@ def kudos() -> None: @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.") @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") def kudos_give( - receiver: str, + receiver: str | None, + team_identifier: str | None, message: str, value: str | None, assume_yes: bool, json_mode: bool, ) -> None: - """Give kudos to a teammate. + """Give kudos to a teammate, a team, or both. \b Acts as you. You can only see and act on what you could in the webapp. - Receivers are resolved by name against your organization directory — never guessed. + Receivers are resolved by name against your organization directory — never + guessed. Teams are scoped by your role server-side (admins see all teams, + members see only their own). \b Examples: dailybot kudos give --to "Jane Doe" --message "Great release work!" - dailybot kudos give --to --message "Thanks!" --yes + dailybot kudos give --team "Engineering" --message "Shipped flawlessly" + dailybot kudos give --to "Alice" --team "QA" --message "Both nailed it" """ + if not receiver and not team_identifier: + message_err: str = "At least one of --to or --team is required." + if json_mode: + emit_json({"error": message_err, "status": 0}) + else: + print_error(message_err) + raise SystemExit(EXIT_USAGE_ERROR) + client = require_bearer_auth() + user_receivers: list[tuple[str, str]] = [] + team_receivers: list[tuple[str, str]] = [] + current_uuid: str | None = None + try: - with console.status("Resolving receiver..."): - users: list[dict[str, Any]] = client.list_users() - receiver_uuid, receiver_name = resolve_user_by_name_or_uuid(users, receiver) - current_uuid: str | None = get_current_user_uuid(client) + if receiver: + with console.status("Resolving user receiver..."): + users: list[dict[str, Any]] = client.list_users() + user_uuid, user_name = resolve_user_by_name_or_uuid(users, receiver) + user_receivers.append((user_uuid, user_name)) + current_uuid = get_current_user_uuid(client) + if team_identifier: + with console.status("Resolving team receiver..."): + teams: list[dict[str, Any]] = client.list_teams() + team_uuid, team_name = resolve_team_by_name_or_uuid(teams, team_identifier) + team_receivers.append((team_uuid, team_name)) except APIError as exc: exit_for_api_error(exc, json_mode) except ValueError as exc: @@ -136,10 +196,10 @@ def kudos_give( execute_kudos_give( client, - receiver_uuid, - receiver_name, message, - current_uuid, + user_receivers=user_receivers, + team_receivers=team_receivers, + current_uuid=current_uuid, value=value, assume_yes=assume_yes, json_mode=json_mode, diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 839a52b..846fdf8 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -19,10 +19,39 @@ EXIT_USAGE_ERROR: int = 2 EXIT_NOT_AUTHENTICATED: int = 3 EXIT_PERMISSION_DENIED: int = 4 +EXIT_NOT_FOUND: int = 5 EXIT_QUOTA_EXHAUSTED: int = 5 EXIT_RATE_LIMITED: int = 6 EXIT_USER_ABORTED: int = 7 +# Server-side error codes from {detail, code} responses. Kept here so command +# handlers and tests share a single source of truth. +ERROR_CODE_MESSAGES: dict[str, str] = { + "form_response_change_state_forbidden": ( + "You don't have permission to change the state of this submission. " + "The form's audience may restrict transitions to specific users / teams. " + "Ask the form owner." + ), + "final_state_locked": ( + "This response is in the final workflow state and the form doesn't allow " + "reopening. Ask the form owner to enable `allow_reopen_from_final_state`." + ), + "form_response_delete_forbidden": ( + "You can't delete this response (you're not the author, form owner, or org admin)." + ), + "user_can_not_see_form_responses": ( + "You don't have permission to read responses on this form." + ), + "form_response_not_found": "Response not found.", + "form_does_not_exists": "Form not found.", + "payload_too_large": "Payload too large.", + "no_valid_team": "Team not found. Check `dailybot team list`.", + "no_valid_users": ( + "Empty receiver set — `--to` or `--team` must resolve to at least one valid receiver." + ), + "no_users_found": "Some users not found or duplicated.", +} + class InteractiveAbort(Exception): """Raised when the user cancels an interactive prompt (e.g. Esc).""" @@ -55,12 +84,18 @@ def emit_json_error(message: str, status: int) -> None: def exit_for_api_error(exc: APIError, json_mode: bool) -> NoReturn: """Map API failures to user-facing messages and process exit codes.""" + code: str | None = getattr(exc, "code", None) + mapped_message: str | None = ERROR_CODE_MESSAGES.get(code) if code else None + if exc.status_code == 401: message: str = "Session expired. Run: dailybot login" exit_code: int = EXIT_NOT_AUTHENTICATED elif exc.status_code == 403: - message = exc.detail + message = mapped_message or exc.detail exit_code = EXIT_PERMISSION_DENIED + elif exc.status_code == 404: + message = mapped_message or exc.detail + exit_code = EXIT_NOT_FOUND elif exc.status_code == 402: message = "Form response quota exhausted for your organization." exit_code = EXIT_QUOTA_EXHAUSTED @@ -70,12 +105,20 @@ def exit_for_api_error(exc: APIError, json_mode: bool) -> NoReturn: elif exc.status_code == 429: message = "Rate limit exceeded (60 requests/minute). Wait and try again." exit_code = EXIT_RATE_LIMITED + elif exc.status_code == 400: + message = mapped_message or exc.detail + exit_code = EXIT_USAGE_ERROR else: - message = exc.detail + message = mapped_message or exc.detail exit_code = 1 if json_mode: - emit_json_error(message, exc.status_code) + payload: dict[str, Any] = {"error": message, "status": exc.status_code} + if code: + payload["code"] = code + if exc.detail and exc.detail != message: + payload["detail"] = exc.detail + emit_json(payload) else: print_error(message) raise SystemExit(exit_code) @@ -170,6 +213,50 @@ def resolve_user_by_name_or_uuid( raise ValueError(f'No user found matching "{identifier}".') +def resolve_team_by_name_or_uuid( + teams: list[dict[str, Any]], + identifier: str, +) -> tuple[str, str]: + """Resolve a team UUID and name from a UUID or case-insensitive name match. + + The scoping is enforced server-side by GET /v1/teams/ — admins see all org + teams, members see only their own. The CLI never client-filters. + """ + if UUID_PATTERN.match(identifier): + for team in teams: + if str(team.get("uuid")) == identifier: + name: str = str(team.get("name") or identifier) + return identifier, name + return identifier, identifier + + target: str = identifier.lower() + exact_matches: list[dict[str, Any]] = [ + team for team in teams if str(team.get("name", "")).lower() == target + ] + if len(exact_matches) == 1: + match: dict[str, Any] = exact_matches[0] + return str(match["uuid"]), str(match.get("name") or match["uuid"]) + if len(exact_matches) > 1: + names: str = ", ".join(str(team.get("name", "")) for team in exact_matches) + raise ValueError(f'Ambiguous team "{identifier}". Matches: {names}') + + partial_matches: list[dict[str, Any]] = [ + team for team in teams if target in str(team.get("name", "")).lower() + ] + if len(partial_matches) == 1: + match = partial_matches[0] + return str(match["uuid"]), str(match.get("name") or match["uuid"]) + if len(partial_matches) > 1: + names = ", ".join(str(team.get("name", "")) for team in partial_matches) + raise ValueError(f'Ambiguous team "{identifier}". Matches: {names}') + + raise ValueError( + f"No team named '{identifier}' visible to you. You may not be a member, " + "or it doesn't exist. Org admins see all teams; members see only their " + "own. Run `dailybot team list` to confirm." + ) + + def get_current_user_uuid(client: DailyBotClient) -> str | None: """Return the authenticated user's UUID from auth status, if available.""" data: dict[str, Any] = client.auth_status() diff --git a/dailybot_cli/commands/team.py b/dailybot_cli/commands/team.py new file mode 100644 index 0000000..ed07eca --- /dev/null +++ b/dailybot_cli/commands/team.py @@ -0,0 +1,116 @@ +"""Team directory commands for the user-scoped public API.""" + +from typing import Any + +import click + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + EXIT_USAGE_ERROR, + emit_json, + exit_for_api_error, + print_error, + require_bearer_auth, + resolve_team_by_name_or_uuid, +) +from dailybot_cli.display import ( + console, + print_team_detail, + print_teams_table, +) + + +def _resolve_team_arg( + client: DailyBotClient, + identifier: str, + *, + json_mode: bool, +) -> tuple[str, str]: + """Return (team_uuid, team_name) from a UUID or name. Exits on failure.""" + try: + teams: list[dict[str, Any]] = client.list_teams() + return resolve_team_by_name_or_uuid(teams, identifier) + except APIError as exc: + exit_for_api_error(exc, json_mode) + except ValueError as exc: + if json_mode: + emit_json({"error": str(exc), "status": 0}) + else: + print_error(str(exc)) + raise SystemExit(EXIT_USAGE_ERROR) from exc + + +@click.group() +def team() -> None: + """Browse teams with your Dailybot session. + + \b + Acts as you — visibility is scoped server-side by your role. + Admins see all org teams; members see only their own. + """ + + +@team.command("list") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def team_list(json_mode: bool) -> None: + """List teams visible to you. + + \b + Visibility is enforced server-side by GET /v1/teams/. The CLI shows the + server's response verbatim — no client-side filter. + + \b + Examples: + dailybot team list + dailybot team list --json + """ + client = require_bearer_auth() + try: + with console.status("Fetching teams..."): + teams: list[dict[str, Any]] = client.list_teams() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(teams) + return + print_teams_table(teams) + + +@team.command("get") +@click.argument("team_identifier") +@click.option( + "--with-members", + is_flag=True, + help="Also fetch and display the team's member list.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def team_get(team_identifier: str, with_members: bool, json_mode: bool) -> None: + """Get a single team by UUID or name (case-insensitive). + + \b + Examples: + dailybot team get + dailybot team get "Engineering" + dailybot team get "Engineering" --with-members --json + """ + client = require_bearer_auth() + team_uuid, _team_name = _resolve_team_arg(client, team_identifier, json_mode=json_mode) + + try: + with console.status("Loading team..."): + data: dict[str, Any] = client.get_team(team_uuid) + members: list[dict[str, Any]] | None = None + if with_members: + members = client.list_team_members(team_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + payload: dict[str, Any] = dict(data) + if members is not None: + payload["members"] = members + emit_json(payload) + return + + print_team_detail(data, members) diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index c5b5fd8..9e3006c 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -414,6 +414,250 @@ def print_kudos_result(receiver_name: str, data: dict[str, Any]) -> None: print_info(f"Kudos ID: {kudos_id}") +def _state_label_lookup(form_data: dict[str, Any]) -> dict[str, str]: + """Map workflow state key → human label using the form's workflow_config.""" + config: Any = form_data.get("workflow_config") or {} + states: Any = config.get("states") if isinstance(config, dict) else [] + if not isinstance(states, list): + return {} + return {str(s.get("key")): str(s.get("label") or s.get("key")) for s in states if s.get("key")} + + +def print_form_detail(form_data: dict[str, Any]) -> None: + """Render a form payload — metadata, workflow config, and questions.""" + table: Table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="bold") + table.add_column() + table.add_row("Name", str(form_data.get("name", ""))) + table.add_row("UUID", str(form_data.get("id", ""))) + slug: str = str(form_data.get("slug", "") or "") + if slug: + table.add_row("Slug", slug) + workflow_enabled: bool = bool(form_data.get("workflow_enabled")) + table.add_row("Workflow", "[green]enabled[/green]" if workflow_enabled else "disabled") + if workflow_enabled: + table.add_row( + "Reopen from final", + "yes" if form_data.get("allow_reopen_from_final_state") else "no", + ) + console.print(Panel(table, title="[bold]Form[/bold]", border_style="cyan")) + + if workflow_enabled: + states_table: Table = Table(title="Workflow States", border_style="cyan") + states_table.add_column("Order", style="dim", justify="right") + states_table.add_column("Key", style="bold") + states_table.add_column("Label") + states_table.add_column("Color", style="dim") + config: Any = form_data.get("workflow_config") or {} + states: list[dict[str, Any]] = ( + list(config.get("states", [])) if isinstance(config, dict) else [] + ) + for state in states: + states_table.add_row( + str(state.get("order", "")), + str(state.get("key", "")), + str(state.get("label", "")), + str(state.get("color", "")), + ) + console.print(states_table) + + questions: list[dict[str, Any]] = list(form_data.get("questions", []) or []) + if questions: + q_table: Table = Table(title="Questions", border_style="cyan") + q_table.add_column("#", justify="right", style="dim") + q_table.add_column("UUID", style="dim") + q_table.add_column("Question", style="bold") + q_table.add_column("Type", style="dim") + for index, question in enumerate(questions, start=1): + q_table.add_row( + str(index), + str(question.get("uuid") or question.get("id") or ""), + str(question.get("question") or question.get("text") or ""), + str(question.get("question_type") or question.get("type") or ""), + ) + console.print(q_table) + + +def print_form_response_state( + data: dict[str, Any], form_data: dict[str, Any] | None = None +) -> None: + """Render the workflow-state surface of a form response after a mutation.""" + response_id: str = str(data.get("id") or data.get("uuid") or "") + current_state: str = str(data.get("current_state") or "") + state_history: list[dict[str, Any]] = list(data.get("state_history") or []) + previous_state: str = "" + last_note: str = "" + if state_history: + last_entry: dict[str, Any] = state_history[-1] + previous_state = str(last_entry.get("from_state") or "") + last_note = str(last_entry.get("note") or "") + + allowed: list[dict[str, Any]] = list(data.get("allowed_transitions") or []) + can_change: Any = data.get("can_change_state") + + labels: dict[str, str] = _state_label_lookup(form_data or {}) + + def label_for(key: str) -> str: + return labels.get(key, key) if key else "" + + table: Table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="bold") + table.add_column() + table.add_row("Response", response_id) + + state_cell: str = label_for(current_state) or current_state or "(none)" + if previous_state and previous_state != current_state: + state_cell = ( + f"{state_cell} [dim](from {label_for(previous_state) or previous_state})[/dim]" + ) + table.add_row("Current state", state_cell) + + if last_note: + table.add_row("Note", last_note) + + if allowed: + next_states: str = ", ".join( + str(entry.get("label") or label_for(str(entry.get("to_state") or ""))) + for entry in allowed + ) + table.add_row("Next states", f"{next_states} [dim](use `dailybot form transition`)[/dim]") + + if can_change is not None: + table.add_row("You can change", "yes" if can_change else "no") + + console.print(Panel(table, title="[bold]Form Response[/bold]", border_style="green")) + + +def print_form_responses_table( + form_uuid: str, + responses: list[dict[str, Any]], + form_data: dict[str, Any] | None = None, +) -> None: + """Render the response list for a form.""" + if not responses: + print_info(f"No responses on form {form_uuid}.") + return + + labels: dict[str, str] = _state_label_lookup(form_data or {}) + table: Table = Table(title="Form Responses", border_style="cyan") + table.add_column("Response UUID", style="dim") + table.add_column("Current state") + table.add_column("Edited") + table.add_column("Created", style="dim") + for response in responses: + state_key: str = str(response.get("current_state") or "") + state_display: str = labels.get(state_key, state_key) if state_key else "—" + edited_flag: bool = bool(response.get("edited")) + table.add_row( + str(response.get("id") or response.get("uuid") or ""), + state_display, + "yes" if edited_flag else "no", + str(response.get("created_at") or ""), + ) + console.print(table) + + +def print_form_response_detail( + data: dict[str, Any], + form_data: dict[str, Any] | None = None, +) -> None: + """Render a single response payload — workflow surface + answers + history.""" + print_form_response_state(data, form_data) + + content: Any = data.get("content") + if isinstance(content, dict) and content: + ans_table: Table = Table(title="Answers", border_style="cyan") + ans_table.add_column("Question UUID", style="dim") + ans_table.add_column("Answer") + for question_uuid, answer in content.items(): + ans_table.add_row(str(question_uuid), str(answer)) + console.print(ans_table) + + history: list[dict[str, Any]] = list(data.get("state_history") or []) + if history: + labels: dict[str, str] = _state_label_lookup(form_data or {}) + hist_table: Table = Table(title="State History", border_style="dim") + hist_table.add_column("When", style="dim") + hist_table.add_column("From") + hist_table.add_column("To") + hist_table.add_column("Actor") + hist_table.add_column("Note") + for entry in history: + from_key: str = str(entry.get("from_state") or "") + to_key: str = str(entry.get("to_state") or "") + hist_table.add_row( + str(entry.get("at") or ""), + labels.get(from_key, from_key) if from_key else "—", + labels.get(to_key, to_key) if to_key else "—", + str(entry.get("actor_name") or ""), + str(entry.get("note") or ""), + ) + console.print(hist_table) + + +def print_form_response_deleted(form_uuid: str, response_uuid: str) -> None: + """Confirmation after a successful response delete.""" + print_success(f"Response {response_uuid} deleted (form {form_uuid}).") + + +def print_teams_table(teams: list[dict[str, Any]]) -> None: + """Display teams visible to the caller.""" + if not teams: + print_info("No teams visible to you. Org admins see all teams; members see only their own.") + return + + table: Table = Table(title="Teams", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("Team UUID", style="dim") + table.add_column("Members", justify="right") + table.add_column("Active") + for team in teams: + active_value: Any = team.get("active") + active_display: str = "yes" if active_value or active_value is None else "no" + members_count: Any = team.get("members_count") + if members_count is None: + members_raw: Any = team.get("members") or team.get("memberships") + members_count = len(members_raw) if isinstance(members_raw, list) else "—" + table.add_row( + str(team.get("name") or team.get("uuid") or ""), + str(team.get("uuid") or ""), + str(members_count), + active_display, + ) + console.print(table) + + +def print_team_detail(team: dict[str, Any], members: list[dict[str, Any]] | None = None) -> None: + """Display a single team with optional member list.""" + table: Table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="bold") + table.add_column() + table.add_row("Name", str(team.get("name") or "")) + table.add_row("UUID", str(team.get("uuid") or "")) + active_value: Any = team.get("active") + if active_value is not None: + table.add_row("Active", "yes" if active_value else "no") + if team.get("is_default") is not None: + table.add_row("Default team", "yes" if team.get("is_default") else "no") + console.print(Panel(table, title="[bold]Team[/bold]", border_style="cyan")) + + if members is not None: + if not members: + print_info("No members on this team.") + return + m_table: Table = Table(title="Members", border_style="cyan") + m_table.add_column("Name", style="bold") + m_table.add_column("User UUID", style="dim") + m_table.add_column("Email", style="dim") + for member in members: + m_table.add_row( + str(member.get("full_name") or member.get("name") or member.get("uuid") or ""), + str(member.get("uuid") or ""), + str(member.get("email") or ""), + ) + console.print(m_table) + + def print_users_table(users: list[dict[str, Any]]) -> None: """Display organization members in a table.""" if not users: diff --git a/dailybot_cli/main.py b/dailybot_cli/main.py index 74a3d20..f101377 100644 --- a/dailybot_cli/main.py +++ b/dailybot_cli/main.py @@ -13,6 +13,7 @@ from dailybot_cli.commands.interactive import run_interactive from dailybot_cli.commands.kudos import kudos from dailybot_cli.commands.status import status +from dailybot_cli.commands.team import team from dailybot_cli.commands.uninstall import uninstall from dailybot_cli.commands.update import update from dailybot_cli.commands.upgrade import upgrade @@ -70,6 +71,7 @@ def cli(ctx: click.Context, api_url: str | None) -> None: cli.add_command(checkin) cli.add_command(form) cli.add_command(kudos) +cli.add_command(team) cli.add_command(user) cli.add_command(agent) cli.add_command(config) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a2be7b6..e8ba7af 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -100,6 +100,10 @@ Interactive path: prompts each question using type-aware inputs (text, numeric, Lists forms visible to the user. Calls `GET /v1/forms/?include=questions` to include question definitions. +#### `dailybot form get [--json]` + +Fetches a form's full payload via `GET /v1/forms//` — questions, workflow states, and permissions surface (`workflow_enabled`, `workflow_config.states`, `state_change_permission`, `view_reports_permission`, `edit_permission`, `allow_reopen_from_final_state`). + #### `dailybot form submit [--content JSON] [--yes] [--json]` Submits a form response. When `--content` is omitted, calls `GET /v1/forms//` to load questions and prompts each one interactively with type-aware inputs. @@ -110,23 +114,46 @@ Submits a form response. When `--content` is omitted, calls `GET /v1/forms/ [--state STATE] [--latest] [--json]` + +Lists the caller's own responses (`GET /v1/forms//responses/`). `--state` filters by `current_state` (workflow forms only). `--latest` returns only the most recent — useful for "continue where I left off". + +#### `dailybot form response get [--json]` + +Fetches a single response (`GET /v1/forms//responses//`) including `current_state`, `allowed_transitions`, `can_change_state`, and `state_history`. A 404 returns `{code: form_response_not_found}` (the API never leaks existence to callers without read permission). + +#### `dailybot form update --content JSON [--yes] [--json]` + +Merges new answers into an in-progress response via `PATCH /v1/forms//responses//`. Strict own-only — admins are not elevated to other users' responses on this endpoint. + +#### `dailybot form transition [--note ...] [--yes] [--json]` + +Advances a response to `to_state` via `POST /v1/forms//responses//transition/`. The form's `state_change_permission` audience is the sole gate — there is no response-author short-circuit. `--note` is recorded on the audit trail. 403 / `final_state_locked` fires when the response is in the final state and the form's `allow_reopen_from_final_state` is `false`. + +#### `dailybot form delete [--yes] [--json]` + +Deletes a response via `DELETE /v1/forms//responses//`. Allowed for the response author, the form owner, or an org admin (403 / `form_response_delete_forbidden` otherwise). + --- ### `dailybot kudos` (group) — user-scoped, Bearer auth -#### `dailybot kudos give --to --message [--value ] [--yes] [--json]` +#### `dailybot kudos give [--to ] [--team ] --message [--value ] [--yes] [--json]` -Gives kudos to a teammate. Resolves receivers by name (exact then partial match) against `GET /v1/users/`. +Gives kudos to a user, a team, or both. Users are resolved by name (exact then partial match) against `GET /v1/users/`. Teams are resolved by name against `GET /v1/teams/` (server-scoped by role). At least one of `--to` / `--team` is required. + +The POST `/v1/kudos/` payload uses `user_uuid_receivers` and `team_uuid_receivers` (both list-of-string fields); the backend manager expands team UUIDs into their active members and excludes the caller, so giving kudos to a team you belong to is valid. | Flag | Short | Notes | |------|-------|-------| -| `--to` | `-t` | Receiver full name or UUID. Required. | +| `--to` | `-t` | User full name or UUID. Optional when `--team` is provided. | +| `--team` | | Team name or UUID. Optional when `--to` is provided. | | `--message` | `-m` | Kudos message. Required. | | `--value` | | Optional company value UUID. | | `--yes` | `-y` | Skip confirmation prompt. | | `--json` | | Machine-readable JSON output. | -Self-kudos is rejected client-side (exit code 4). Ambiguous name matches return exit code 2. +Self-kudos via `--to` is rejected client-side (exit code 4). Ambiguous name matches return exit code 2. --- @@ -138,20 +165,49 @@ Lists organization members. Calls `GET /v1/users/` with automatic pagination (ca --- +### `dailybot team` (group) — user-scoped, Bearer auth + +#### `dailybot team list [--json]` + +Lists teams visible to the caller via `GET /v1/teams/`. **Visibility is scoped server-side**: admins see all org teams, members see only their own (via `teammembership_set`). The CLI never client-filters — it renders the server response verbatim. + +#### `dailybot team get [--with-members] [--json]` + +Fetches a team via `GET /v1/teams//`. A name argument is resolved to UUID by calling `GET /v1/teams/` first (case-insensitive; ambiguous matches exit 2). `--with-members` adds a second call to `GET /v1/teams//members/`. + +--- + ### User-scoped exit codes -All user-scoped commands (`checkin`, `form`, `kudos`, `user`) share these exit codes: +All user-scoped commands (`checkin`, `form`, `kudos`, `user`, `team`) share these exit codes: | Code | Constant | Meaning | |------|----------|---------| | `0` | — | Success | -| `2` | `EXIT_USAGE_ERROR` | Invalid input | +| `2` | `EXIT_USAGE_ERROR` | Invalid input / 400 from server | | `3` | `EXIT_NOT_AUTHENTICATED` | Not logged in | -| `4` | `EXIT_PERMISSION_DENIED` | Forbidden / self-kudos / daily limit | -| `5` | `EXIT_QUOTA_EXHAUSTED` | Form quota (402) | +| `4` | `EXIT_PERMISSION_DENIED` | Forbidden, self-kudos, daily limit, `final_state_locked` | +| `5` | `EXIT_NOT_FOUND` / `EXIT_QUOTA_EXHAUSTED` | 404 from server, or form quota (402) | | `6` | `EXIT_RATE_LIMITED` | Rate limited (429) | | `7` | `EXIT_USER_ABORTED` | Confirmation declined | +`--json` output for any 4xx includes `error`, `status`, and (when present) `code` + `detail` — pattern-match on `code` rather than prose. + +Server-side `code` values mapped to messages (see `ERROR_CODE_MESSAGES` in `commands/public_api_helpers.py`): + +| Code | Surfaced as | +|------|-------------| +| `form_response_change_state_forbidden` | 403 → exit 4 | +| `final_state_locked` | 403 → exit 4 | +| `form_response_delete_forbidden` | 403 → exit 4 | +| `user_can_not_see_form_responses` | 403 → exit 4 | +| `form_response_not_found` | 404 → exit 5 | +| `form_does_not_exists` | 404 → exit 5 | +| `payload_too_large` | 400 → exit 2 | +| `no_valid_team` | 400 → exit 2 | +| `no_valid_users` | 400 → exit 2 | +| `no_users_found` | 400 → exit 2 | + --- ### `dailybot agent` (group) @@ -246,11 +302,19 @@ All endpoints are POSTed to `{api_url}/v1/...`. The default `api_url` is `https: | Method | Path | Request | Response | Notes | |--------|------|---------|----------|-------| | `GET` | `/v1/forms/` | `?include=questions` (optional) | `[{ id, name, questions?: [...] }]` | | -| `GET` | `/v1/forms//` | — | `{ id, name, questions: [{ uuid, question, question_type, choices? }] }` | Used by guided form submit | -| `POST` | `/v1/forms//responses/` | `{ content: { "": "" } }` | `{ uuid }` | 402 = quota exhausted | +| `GET` | `/v1/forms//` | — | `{ id, name, slug, workflow_enabled, workflow_config, questions: [...] }` | Used by guided form submit + `form get` | +| `POST` | `/v1/forms//responses/` | `{ content: { "": "" } }` | `{ id, current_state?, allowed_transitions?, can_change_state? }` | 402 = quota exhausted | +| `GET` | `/v1/forms//responses/` | `?state=` (optional) | `[{ id, current_state, allowed_transitions, can_change_state, state_history, content, edited, created_at }]` | Caller's own responses | +| `GET` | `/v1/forms//responses//` | — | Same shape as above | 404 = `form_response_not_found` | +| `PATCH` | `/v1/forms//responses//` | `{ content: { ... } }` | Updated response | Strict own-only | +| `POST` | `/v1/forms//responses//transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | +| `DELETE` | `/v1/forms//responses//` | — | 204 | Author / owner / admin | | `POST` | `/v1/checkins//responses/` | `{ responses: [{ uuid, index, response }], last_question_index?, response_date? }` | `{ uuid }` | | | `GET` | `/v1/users/` | — | `{ results: [{ uuid, full_name }], next: url\|null }` | Paginated | -| `POST` | `/v1/kudos/` | `{ receivers: [""], content, company_value? }` | `{ uuid }` | 406 = daily limit | +| `GET` | `/v1/teams/` | — | `{ results: [{ uuid, name, active, members_count, is_default }], next? }` | Server-scoped: admins see all, members see own | +| `GET` | `/v1/teams//` | — | `{ uuid, name, active, ... }` | Same scoping | +| `GET` | `/v1/teams//members/` | — | `[{ uuid, full_name, email }]` | Members of a team the caller can see | +| `POST` | `/v1/kudos/` | `{ content, user_uuid_receivers?: [...], team_uuid_receivers?: [...], company_value? }` | `{ uuid }` | At least one receiver list required; 406 = daily limit | ### Agent (X-API-KEY *or* Bearer) diff --git a/docs/CLI_COMMAND_BEST_PRACTICES.md b/docs/CLI_COMMAND_BEST_PRACTICES.md index 169a446..c476029 100644 --- a/docs/CLI_COMMAND_BEST_PRACTICES.md +++ b/docs/CLI_COMMAND_BEST_PRACTICES.md @@ -60,6 +60,8 @@ Anything that doesn't fit this shape is either: Pick the same short alias every time you re-use the same long name. If you need a new short alias, check the list above first to avoid collisions inside the same command. +**Short aliases are scoped per command, not globally unique.** `-m` legitimately means `--milestone` on `dailybot agent update` and `--message` on `dailybot kudos give`; `-c` is `--co-authors` on `agent update` and `--content` on `form submit / update`. Click resolves each command's flags independently, so there is no parsing conflict — the rule is "consistent **inside** a group". When extending an existing command, prefer the alias the surrounding subcommands already use rather than introducing a new one. + ### Help Text Style - One-line summary: imperative, capitalized, no trailing period. diff --git a/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md b/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md index 77e13ab..e2b4c14 100644 --- a/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md +++ b/docs/DISPLAY_OUTPUT_BEST_PRACTICES.md @@ -59,9 +59,16 @@ Don't reinvent panels and tables in command code. The existing helpers cover mos | `print_update_result(data)` | Update receipt with attached follow-ups | | `print_users_table(users)` | Team members table (Name + UUID, no email) | | `print_forms_table(forms)` | Forms table (Name + UUID + Questions count) | +| `print_form_detail(form_data)` | Form payload panel + workflow-states table + questions table | | `print_checkin_list(checkins)` | Pending check-ins table with question count | | `print_kudos_result(name, data)` | Kudos sent confirmation panel | | `print_form_submit_result(data)` | Form submission confirmation panel | +| `print_form_response_state(data, form_data)` | Workflow-state panel printed after `submit` / `update` / `transition`: current state, previous state, note, next reachable states, "you can change" | +| `print_form_responses_table(form_uuid, responses, form_data)` | Response list table — UUID, current_state (humanized), edited, created_at | +| `print_form_response_detail(data, form_data)` | Single-response view — workflow surface + answers table + state-history table | +| `print_form_response_deleted(form_uuid, response_uuid)` | Delete confirmation | +| `print_teams_table(teams)` | Teams table (Name + UUID + Members + Active). Empty-state message surfaces the role-scoping rule | +| `print_team_detail(team, members)` | Team panel; with `--with-members` adds a members table | | `print_checkin_complete_result(data)` | Check-in completion confirmation panel | If you need a new shape, add a helper here rather than building it inline. diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index f10b3ad..0b763b3 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -35,9 +35,17 @@ It is a **public, open-source product** distributed through PyPI, Homebrew, and | List pending check-ins | `dailybot checkin list` | Pending follow-ups for today with question details | | Complete a check-in | `dailybot checkin complete ` | Interactive or `--answer` flags; type-aware prompts | | List forms | `dailybot form list` | All visible forms with question count | +| Get a form | `dailybot form get ` | Full payload: questions, workflow states, permissions | | Submit a form | `dailybot form submit ` | Guided per-question prompts or `--content` JSON | -| Give kudos | `dailybot kudos give --to "Name"` | Resolves receiver by name or UUID; team-visible | -| List team members | `dailybot user list` | Name + UUID; emails not exposed | +| List own responses | `dailybot form responses [--state] [--latest]` | Resume "where you left off" | +| Get one response | `dailybot form response get ` | Includes `current_state`, `allowed_transitions`, `state_history` | +| Update a response | `dailybot form update --content '{...}'` | Strict own-only; patches new answers in | +| Transition state | `dailybot form transition [--note]` | Gated by form's `state_change_permission` audience | +| Delete a response | `dailybot form delete ` | Author / form owner / org admin | +| Give kudos | `dailybot kudos give --to "Name" \| --team "Engineering"` | Both flags optional; either or both. Receivers go in `user_uuid_receivers` / `team_uuid_receivers` | +| List org members | `dailybot user list` | Name + UUID; emails not exposed | +| List teams | `dailybot team list` | Role-scoped server-side (admin sees all, member sees own) | +| Get one team | `dailybot team get [--with-members]` | Name resolves to UUID; `--with-members` fetches the roster | | Interactive TUI | `dailybot` (no args) | Grouped menu: Check-ins, Forms, Team, Session | ### Agent-Facing diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 41655c6..4a33720 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -26,18 +26,34 @@ pytest -s # don't capture stdout (debugging) ``` tests/ ├── __init__.py # empty, just makes the dir importable -├── api_client_test.py # DailyBotClient + APIError +├── api_client_test.py # DailyBotClient + APIError (every HTTP method) ├── commands_test.py # Click commands via CliRunner (auth, agent, interactive) ├── config_test.py # ~/.config/dailybot/ file management -├── public_api_commands_test.py # User-scoped commands (checkin, form, kudos, user) -└── form_question_types_test.py # Type-aware form prompt logic +├── public_api_commands_test.py # User-scoped commands: checkin, form (full +│ # lifecycle — get/responses/update/transition/delete), +│ # team (list/get), kudos (--to / --team / both), user +├── form_question_types_test.py # Type-aware form prompt logic +├── repo_profile_test.py # `.dailybot/profile.json` resolution +├── agent_init_test.py # `dailybot agent init` wizard +└── uninstall_test.py # install-method detection + remove paths ``` When adding a new module, mirror it in `tests/`. New test files **MUST** end in `_test.py`. ### User-scoped command tests -The user-scoped commands (`checkin`, `form`, `kudos`, `user`) are tested in `public_api_commands_test.py`. The pattern follows the same approach as `commands_test.py` but patches `dailybot_cli.commands.public_api_helpers.get_token` and `dailybot_cli.commands.public_api_helpers.DailyBotClient` (since the auth resolution for these commands goes through `require_bearer_auth()`). +The user-scoped commands (`checkin`, `form`, `team`, `kudos`, `user`) are tested in `public_api_commands_test.py`. The pattern follows the same approach as `commands_test.py` but patches `dailybot_cli.commands.public_api_helpers.get_token` and `dailybot_cli.commands.public_api_helpers.DailyBotClient` (since the auth resolution for these commands goes through `require_bearer_auth()`). + +**Forms-lifecycle coverage expectations.** New `form` subcommands (`get`, `responses`, `response get`, `update`, `transition`, `delete`) must include: + +1. **Happy path** — assert the client method is called with the right args, and (for mutating calls) that the workflow surface is rendered after success. +2. **Error path** for every server `code` the command can surface. At minimum: + - `form transition` → `form_response_change_state_forbidden` (403, exit 4) **and** `final_state_locked` (403, exit 4). + - `form delete` → `form_response_delete_forbidden` (403, exit 4). + - `form response get` / `form update` → `form_response_not_found` (404, exit 5). +3. **JSON mode** — assert `--json` emits the `code` and `detail` fields alongside `status`, so chat-agent consumers can pattern-match without parsing prose. + +**Teams + team-kudos coverage:** `team list` / `team get` exercise the new resolver; `kudos give --team` and `--to + --team` must assert that the POST payload uses `user_uuid_receivers` / `team_uuid_receivers` (the legacy `receivers` key MUST NOT appear). ## Mocking HTTP diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 936f05a..057c7cd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -85,6 +85,69 @@ You're running the non-interactive step 2 (`--code --org=`) without having - Per-org hourly throttle. Wait an hour or rotate to a different agent profile if you have a higher-quota one configured. +## Forms & Workflow + +### `dailybot form transition` → 403 / `form_response_change_state_forbidden` + +- The form's `state_change_permission` audience excludes you. **There is no response-author short-circuit** — even on your own response, only users in the audience can transition. Ask the form owner to add you (or your team) to the audience, or to transition on your behalf. +- Inspect the response: `dailybot form response get ` and look at `can_change_state` (`false` means the server already told you). + +### `dailybot form transition` → 403 / `final_state_locked` + +- The response is in the workflow's **final state** and the form has `allow_reopen_from_final_state: false` (the default). Once `released` (or whatever the final state is), the response is sticky. +- Resolution: ask the form owner to enable reopening, or create a fresh response with `dailybot form submit`. + +### `dailybot form update` → 404 / `form_response_not_found` + +- The response UUID doesn't exist, **or** it belongs to another user. `update` is strict own-only; admins are *not* elevated to other users' responses on this endpoint (unlike `delete`). + +### `dailybot form delete` → 403 / `form_response_delete_forbidden` + +- You're not the response author, the form owner, or an org admin. No CLI workaround — ask one of them to delete it. + +### `dailybot form responses --latest` returns nothing + +- You haven't submitted a response on this form yet. The endpoint scopes by author server-side. `form list` shows all forms visible to you; `form responses` only shows responses you authored. + +### My form response renders as one wall of text in the webapp + +- The webapp renders Markdown. The CLI sends `--content` answers verbatim — if you submit `"line 1 line 2 line 3"`, that's what's stored. Embed real `\n` newlines (and Markdown `**bold**`, `# Heading`, `- bullet`, fenced code blocks, tables, etc.) inside each answer string. The CLI doesn't auto-format. + +### Form-response Markdown: supported subset + +The form-response webapp renders a **constrained Markdown subset**. When authoring `--content` for `form submit` / `form update`, stick to: + +- **Single-level headings only** — use `# Heading`. **Do NOT use `##` or `###`** — only one title level is supported; nested heading levels render as plain text. To group sub-sections, use a `# Heading` followed by paragraphs / lists. +- **Real line breaks** — embed actual `\n` characters between paragraphs and list items. Two `\n` for a paragraph break, one `\n` between bullet rows. A single literal newline in a JSON string is `"\n"`. +- **Inline:** `**bold**`, `*italic*`, `` `code` ``, `[link](url)`. +- **Blocks:** `- bullet` lists, `1. numbered` lists, fenced ``` ```code``` ``` blocks, Markdown `|` tables. + +Quick smoke-check from the CLI side after a `submit` / `update`: + +```bash +dailybot form response get --json | jq -r '.content[]' | head -20 +``` + +If the printed output has real line breaks (not `\n` literals) and uses `#` rather than `##` / `###`, the webapp will render it correctly. + +## Teams & Kudos + +### `dailybot team list` shows fewer teams than I expect + +- Visibility is **server-scoped by role**. Org admins see all org teams; members see only the teams they belong to (via `teammembership_set`). This is not a bug. If you should be in a team but aren't, ask an admin to add you — the CLI never client-filters. + +### `dailybot kudos give --team "X"` → "No team named 'X' visible to you" + +- The team either doesn't exist or you're not a member (and you're not an admin). Run `dailybot team list` to see exactly what the server returns for your role. + +### Can I give kudos to my own team? + +- Yes — the backend `kudos_manager` expands a team UUID into its active members and **excludes the caller**. So `kudos give --team "MyTeam"` where you belong to MyTeam is valid; you credit your teammates, not yourself. + +### `kudos give` → 400 / `no_valid_users` or `no_valid_team` + +- At least one of `--to` / `--team` must resolve to a valid receiver. Empty receiver lists are rejected server-side. Double-check the names with `dailybot user list` and `dailybot team list`. + ## Output / Display ### `dailybot agent message list | jq ...` fails with "parse error" diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 80cc57d..94823de 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -241,17 +241,149 @@ def test_give_kudos(self, client: DailyBotClient) -> None: with patch("httpx.post", return_value=mock_response) as mock_post: result: dict[str, Any] = client.give_kudos( - receivers=["user-uuid"], content="Great work!", + user_uuid_receivers=["user-uuid"], company_value="value-uuid", ) call_kwargs: dict[str, Any] = mock_post.call_args[1] - assert call_kwargs["json"]["receivers"] == ["user-uuid"] + assert call_kwargs["json"]["user_uuid_receivers"] == ["user-uuid"] + assert "team_uuid_receivers" not in call_kwargs["json"] assert call_kwargs["json"]["company_value"] == "value-uuid" assert "by_dailybot" not in call_kwargs["json"] + assert "receivers" not in call_kwargs["json"] assert result["uuid"] == "kudos-uuid" + def test_give_kudos_team(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "kudos-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.give_kudos( + content="Team shipped it", + user_uuid_receivers=["user-uuid"], + team_uuid_receivers=["team-uuid"], + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert call_kwargs["json"]["user_uuid_receivers"] == ["user-uuid"] + assert call_kwargs["json"]["team_uuid_receivers"] == ["team-uuid"] + + def test_list_teams(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": [{"uuid": "team-1", "name": "General"}], + "next": None, + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + teams: list[dict[str, Any]] = client.list_teams() + + assert mock_get.call_count == 1 + assert teams == [{"uuid": "team-1", "name": "General"}] + + def test_get_team(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "team-1", "name": "General"} + + with patch("httpx.get", return_value=mock_response) as mock_get: + data: dict[str, Any] = client.get_team("team-1") + + assert mock_get.call_args[0][0].endswith("/v1/teams/team-1/") + assert data["name"] == "General" + + def test_list_team_members(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"uuid": "user-1", "full_name": "Jane"}] + + with patch("httpx.get", return_value=mock_response) as mock_get: + members: list[dict[str, Any]] = client.list_team_members("team-1") + + assert mock_get.call_args[0][0].endswith("/v1/teams/team-1/members/") + assert members[0]["full_name"] == "Jane" + + def test_list_form_responses(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"id": "r1", "current_state": "qa"}, + ] + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_form_responses("form-uuid", state="qa") + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"] == {"state": "qa"} + assert result[0]["current_state"] == "qa" + + def test_get_form_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "r1", "current_state": "pre_release"} + + with patch("httpx.get", return_value=mock_response) as mock_get: + data: dict[str, Any] = client.get_form_response("form-uuid", "r1") + + assert mock_get.call_args[0][0].endswith("/v1/forms/form-uuid/responses/r1/") + assert data["id"] == "r1" + + def test_update_form_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "r1"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_form_response("form-uuid", "r1", {"q-uuid": "Updated"}) + + call_kwargs: dict[str, Any] = mock_patch.call_args[1] + assert call_kwargs["json"]["content"] == {"q-uuid": "Updated"} + + def test_transition_form_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "r1", "current_state": "qa"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.transition_form_response("form-uuid", "r1", "qa", note="QA assigned") + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert mock_post.call_args[0][0].endswith("/v1/forms/form-uuid/responses/r1/transition/") + assert call_kwargs["json"] == {"to_state": "qa", "note": "QA assigned"} + + def test_delete_form_response(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 204 + mock_response.json.return_value = {} + + with patch("httpx.request", return_value=mock_response) as mock_request: + client.delete_form_response("form-uuid", "r1") + + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith("/v1/forms/form-uuid/responses/r1/") + + def test_api_error_carries_code(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 403 + mock_response.json.return_value = { + "detail": "Forbidden", + "code": "form_response_change_state_forbidden", + } + + from dailybot_cli.api_client import APIError as _APIError + + with patch("httpx.post", return_value=mock_response): + try: + client.transition_form_response("form-uuid", "r1", "qa") + except _APIError as exc: + assert exc.code == "form_response_change_state_forbidden" + assert exc.status_code == 403 + return + raise AssertionError("APIError not raised") + class TestDailyBotClientAgent: def test_submit_agent_report(self, client: DailyBotClient) -> None: diff --git a/tests/commands_test.py b/tests/commands_test.py index d07ef92..1b5d066 100644 --- a/tests/commands_test.py +++ b/tests/commands_test.py @@ -1077,9 +1077,10 @@ def test_interactive_give_kudos_picks_teammate( result = runner.invoke(cli, []) assert result.exit_code == 0 mock_execute_kudos.assert_called_once() - assert mock_execute_kudos.call_args.args[1] == "peer-uuid" - assert mock_execute_kudos.call_args.args[3] == "Great work!" - assert mock_execute_kudos.call_args.kwargs["assume_yes"] is True + call_args = mock_execute_kudos.call_args + assert call_args.args[1] == "Great work!" + assert call_args.kwargs["user_receivers"] == [("peer-uuid", "Jane Doe")] + assert call_args.kwargs["assume_yes"] is True class TestAgentCommand: diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index b6646ac..1dbc523 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -1,7 +1,7 @@ """Tests for user-scoped public API commands (checkin, form, kudos).""" import json -from typing import Any +from typing import Any, ClassVar from unittest.mock import MagicMock, patch import pytest @@ -463,8 +463,9 @@ def test_kudos_give_success( ) assert result.exit_code == 0 mock_client.give_kudos.assert_called_once_with( - receivers=["user-uuid-1"], content="Great work!", + user_uuid_receivers=["user-uuid-1"], + team_uuid_receivers=None, company_value=None, ) @@ -563,3 +564,484 @@ def test_kudos_give_ambiguous_receiver( ], ) assert result.exit_code == 2 + + @patch("dailybot_cli.commands.kudos.get_current_user_uuid") + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_kudos_give_team_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "Engineering"}, + ] + mock_client.give_kudos.return_value = {"uuid": "kudos-uuid"} + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--team", + "Engineering", + "--message", + "Shipped flawlessly", + "--yes", + ], + ) + assert result.exit_code == 0 + mock_client.give_kudos.assert_called_once_with( + content="Shipped flawlessly", + user_uuid_receivers=None, + team_uuid_receivers=["team-uuid-1"], + company_value=None, + ) + + @patch("dailybot_cli.commands.kudos.get_current_user_uuid") + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_kudos_give_user_and_team( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + mock_current_uuid: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_current_uuid.return_value = "self-uuid" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [ + {"uuid": "user-uuid-1", "full_name": "Alice"}, + ] + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "QA"}, + ] + mock_client.give_kudos.return_value = {"uuid": "kudos-uuid"} + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--to", + "Alice", + "--team", + "QA", + "--message", + "Both nailed it", + "--yes", + ], + ) + assert result.exit_code == 0 + mock_client.give_kudos.assert_called_once_with( + content="Both nailed it", + user_uuid_receivers=["user-uuid-1"], + team_uuid_receivers=["team-uuid-1"], + company_value=None, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_kudos_give_unseen_team( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "Engineering"}, + ] + + result = runner.invoke( + cli, + [ + "kudos", + "give", + "--team", + "Marketing", + "--message", + "Nice", + "--yes", + ], + ) + assert result.exit_code == 2 + assert "Marketing" in result.output or "Marketing" in result.stderr_bytes.decode() + mock_client.give_kudos.assert_not_called() + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_kudos_give_requires_to_or_team( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + + result = runner.invoke( + cli, + ["kudos", "give", "--message", "Nice", "--yes"], + ) + assert result.exit_code == 2 + + +class TestTeamCommand: + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_team_list_success( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "General", "active": True, "members_count": 12}, + ] + + result = runner.invoke(cli, ["team", "list"]) + assert result.exit_code == 0 + assert "General" in result.output + assert "team-uuid-1" in result.output + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_team_list_json( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "General"}, + ] + + result = runner.invoke(cli, ["team", "list", "--json"]) + assert result.exit_code == 0 + payload: list[dict[str, Any]] = json.loads(result.output) + assert payload[0]["uuid"] == "team-uuid-1" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_team_get_by_name( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_teams.return_value = [ + {"uuid": "team-uuid-1", "name": "Engineering"}, + ] + mock_client.get_team.return_value = {"uuid": "team-uuid-1", "name": "Engineering"} + + result = runner.invoke(cli, ["team", "get", "Engineering"]) + assert result.exit_code == 0 + mock_client.get_team.assert_called_once_with("team-uuid-1") + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_team_get_with_members( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + team_uuid: str = "13883b5b-7066-47aa-ad0c-63bbc89eb986" + mock_client.list_teams.return_value = [ + {"uuid": team_uuid, "name": "Engineering"}, + ] + mock_client.get_team.return_value = {"uuid": team_uuid, "name": "Engineering"} + mock_client.list_team_members.return_value = [ + {"uuid": "u1", "full_name": "Jane"}, + ] + + result = runner.invoke(cli, ["team", "get", team_uuid, "--with-members", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["members"][0]["full_name"] == "Jane" + + +class TestFormLifecycle: + FORM_PAYLOAD: ClassVar[dict[str, Any]] = { + "id": "form-uuid-1", + "name": "Code Release", + "slug": "code-release-form", + "workflow_enabled": True, + "workflow_config": { + "states": [ + {"key": "pre_release", "label": "Pre Release", "order": 0}, + {"key": "qa", "label": "QA", "order": 1}, + {"key": "released", "label": "Released", "order": 2}, + ] + }, + "questions": [ + {"uuid": "q-1", "question": "PR?", "question_type": "text"}, + ], + } + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_get( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_form.return_value = self.FORM_PAYLOAD + + result = runner.invoke(cli, ["form", "get", "form-uuid-1", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["slug"] == "code-release-form" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_responses_latest( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_form_responses.return_value = [ + {"id": "r1", "current_state": "qa"}, + {"id": "r0", "current_state": "pre_release"}, + ] + + result = runner.invoke(cli, ["form", "responses", "form-uuid-1", "--latest", "--json"]) + assert result.exit_code == 0 + payload: list[dict[str, Any]] = json.loads(result.output) + assert len(payload) == 1 + assert payload[0]["id"] == "r1" + mock_client.list_form_responses.assert_called_once_with("form-uuid-1", state=None) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_responses_state_filter( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_form_responses.return_value = [] + + result = runner.invoke(cli, ["form", "responses", "form-uuid-1", "--state", "qa", "--json"]) + assert result.exit_code == 0 + mock_client.list_form_responses.assert_called_once_with("form-uuid-1", state="qa") + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_response_get( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_form_response.return_value = { + "id": "r1", + "current_state": "qa", + "content": {"q-1": "PR #4242"}, + } + + result = runner.invoke(cli, ["form", "response", "get", "f1", "r1", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["id"] == "r1" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_response_get_not_found( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.get_form_response.side_effect = APIError( + 404, "Form response not found.", code="form_response_not_found" + ) + + result = runner.invoke(cli, ["form", "response", "get", "f1", "r1", "--json"]) + assert result.exit_code == 5 + payload: dict[str, Any] = json.loads(result.output) + assert payload["code"] == "form_response_not_found" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_update( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.update_form_response.return_value = { + "id": "r1", + "current_state": "qa", + "allowed_transitions": [{"to_state": "released", "label": "Released"}], + } + + result = runner.invoke( + cli, + [ + "form", + "update", + "f1", + "r1", + "--content", + '{"q-1": "PR #4242"}', + "--yes", + "--json", + ], + ) + assert result.exit_code == 0 + mock_client.update_form_response.assert_called_once_with( + form_uuid="f1", + response_uuid="r1", + content={"q-1": "PR #4242"}, + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_transition( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.transition_form_response.return_value = { + "id": "r1", + "current_state": "qa", + "allowed_transitions": [], + "can_change_state": True, + "state_history": [ + {"from_state": "pre_release", "to_state": "qa", "note": "QA assigned"} + ], + } + + result = runner.invoke( + cli, + [ + "form", + "transition", + "f1", + "r1", + "qa", + "--note", + "QA assigned", + "--yes", + "--json", + ], + ) + assert result.exit_code == 0 + mock_client.transition_form_response.assert_called_once_with( + form_uuid="f1", + response_uuid="r1", + to_state="qa", + note="QA assigned", + ) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_transition_forbidden( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.transition_form_response.side_effect = APIError( + 403, + "You don't have permission", + code="form_response_change_state_forbidden", + ) + + result = runner.invoke(cli, ["form", "transition", "f1", "r1", "qa", "--yes", "--json"]) + assert result.exit_code == 4 + payload: dict[str, Any] = json.loads(result.output) + assert payload["code"] == "form_response_change_state_forbidden" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_transition_final_state_locked( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.transition_form_response.side_effect = APIError( + 403, "Locked", code="final_state_locked" + ) + + result = runner.invoke( + cli, ["form", "transition", "f1", "r1", "released", "--yes", "--json"] + ) + assert result.exit_code == 4 + payload: dict[str, Any] = json.loads(result.output) + assert payload["code"] == "final_state_locked" + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_delete( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.delete_form_response.return_value = {} + + result = runner.invoke(cli, ["form", "delete", "f1", "r1", "--yes", "--json"]) + assert result.exit_code == 0 + payload: dict[str, Any] = json.loads(result.output) + assert payload["deleted"] is True + mock_client.delete_form_response.assert_called_once_with(form_uuid="f1", response_uuid="r1") + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_delete_forbidden( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.delete_form_response.side_effect = APIError( + 403, "Forbidden", code="form_response_delete_forbidden" + ) + + result = runner.invoke(cli, ["form", "delete", "f1", "r1", "--yes", "--json"]) + assert result.exit_code == 4 + payload: dict[str, Any] = json.loads(result.output) + assert payload["code"] == "form_response_delete_forbidden"