diff --git a/.agents/docs/skills_agents_catalog.md b/.agents/docs/skills_agents_catalog.md index 2435c32..a382768 100644 --- a/.agents/docs/skills_agents_catalog.md +++ b/.agents/docs/skills_agents_catalog.md @@ -26,10 +26,11 @@ The full pack lives under [`.agents/skills/dailybot/`](../skills/dailybot/) (rou | `dailybot-messages` | [`skills/dailybot/messages/SKILL.md`](../skills/dailybot/messages/SKILL.md) | Polling for instructions sent to this agent by teammates | | `dailybot-health` | [`skills/dailybot/health/SKILL.md`](../skills/dailybot/health/SKILL.md) | Announcing online/offline status on long sessions | | `dailybot-email` | [`skills/dailybot/email/SKILL.md`](../skills/dailybot/email/SKILL.md) | Sending email on the agent's behalf (with mandatory pre-send safety checks) | -| `dailybot-checkin` | [`skills/dailybot/checkin/SKILL.md`](../skills/dailybot/checkin/SKILL.md) | Listing and completing pending check-ins (user-scoped) | +| `dailybot-checkin` | [`skills/dailybot/checkin/SKILL.md`](../skills/dailybot/checkin/SKILL.md) | Listing/completing check-ins + authoring (create/config/archive + questions, CLI ≥ 1.17.0) | | `dailybot-kudos` | [`skills/dailybot/kudos/SKILL.md`](../skills/dailybot/kudos/SKILL.md) | Giving kudos to a teammate or a whole team | | `dailybot-teams` | [`skills/dailybot/teams/SKILL.md`](../skills/dailybot/teams/SKILL.md) | Listing / resolving teams (the resolver `dailybot-kudos` and `dailybot-chat` delegate to) | -| `dailybot-forms` | [`skills/dailybot/forms/SKILL.md`](../skills/dailybot/forms/SKILL.md) | Listing, submitting, updating, or transitioning form responses | +| `dailybot-forms` | [`skills/dailybot/forms/SKILL.md`](../skills/dailybot/forms/SKILL.md) | Listing/submitting/updating/transitioning responses + authoring (create/config/archive + questions, CLI ≥ 1.17.0) | +| `dailybot-channels` | [`skills/dailybot/channels/SKILL.md`](../skills/dailybot/channels/SKILL.md) | Discovering report-channel UUIDs for `--report-channel` (CLI ≥ 1.17.0) | | `dailybot-chat` | [`skills/dailybot/chat/SKILL.md`](../skills/dailybot/chat/SKILL.md) | Sending / editing Dailybot bot messages on Slack / Teams / Discord / Google Chat (DMs, channels, teams; report-style threads; in-place edits). Requires `dailybot-cli >= 1.13.0` | ### Deep Work Plan skill pack (vendored from [`DailybotHQ/deepworkplan-skill`](https://github.com/DailybotHQ/deepworkplan-skill)) @@ -81,7 +82,9 @@ Persona definitions. Use these as the system prompt / role when spawning a sub-a | Send a Slack/Teams/Discord/Google Chat message (DM, channel, team; report-style threads) | `dailybot-chat` | (any) | | Recognize a teammate or a whole team | `dailybot-kudos` | (any) | | Complete a pending check-in / standup | `dailybot-checkin` | (any) | +| Create / configure a check-in or form (author) | `dailybot-checkin` / `dailybot-forms` | (any) | | Submit / transition / update a form response | `dailybot-forms` | (any) | +| Find a report-channel UUID for authoring | `dailybot-channels` | (any) | ## Conventions diff --git a/.agents/skills/dailybot/SKILL.md b/.agents/skills/dailybot/SKILL.md index afaa9d9..af1a6ff 100644 --- a/.agents/skills/dailybot/SKILL.md +++ b/.agents/skills/dailybot/SKILL.md @@ -31,10 +31,11 @@ Ten coordinated capabilities, with smart routing between them: | **Email** | `dailybot-email` | Explicit user request, with mandatory pre-send safety checks | | **Chat** | `dailybot-chat` | Developer wants to send / edit a bot message on Slack, Teams, Discord, or Google Chat — to a channel, DMs, or whole team. Supports report-style threads (headline + replies in one call) and editing the parent or any reply afterward | | **Health & status** | `dailybot-health` | Long-running sessions; periodic heartbeats | -| **Check-ins** | `dailybot-checkin` | Full check-in lifecycle: list/status, complete, inspect questions, history, edit, reset, backfill/future-date | +| **Check-ins** | `dailybot-checkin` | Full check-in lifecycle: list/status, complete, inspect questions, history, edit, reset, backfill/future-date — plus **authoring** (create/config/archive + questions) on CLI ≥ 1.17.0 | | **Kudos** | `dailybot-kudos` | Developer wants to recognize a teammate or a whole team's contribution | | **Teams** | `dailybot-teams` | List teams, inspect members, or resolve a team name → UUID (used as a resolver by other skills) | -| **Forms** | `dailybot-forms` | Developer wants to list, submit, update, or transition forms — including workflow-state forms with audience permissions | +| **Forms** | `dailybot-forms` | Developer wants to list, submit, update, or transition forms — including workflow-state forms with audience permissions — plus **authoring** (create/config/archive + questions) on CLI ≥ 1.17.0 | +| **Report channels** | `dailybot-channels` | Discover report-channel UUIDs to attach to forms/check-ins with `--report-channel` (CLI ≥ 1.17.0) | ## Install @@ -90,6 +91,17 @@ fallback). Full guide: [`docs/INSTALLATION.md`](https://github.com/DailybotHQ/ag > status / show / history / edit / reset` plus backfill/future-dating — all > headless with `--json`. See [`checkin/SKILL.md`](checkin/SKILL.md) § "The full > check-in lifecycle". Below 1.15.0 only `checkin list` + `complete` exist. +> +> **`1.17.0` floor for forms & check-ins *authoring*** (paired with the matching +> API server rollout): `dailybot channels list`, `form create / edit / archive`, +> the `form questions` subgroup, extended `form responses` (`--all` / `--user` / +> `--from` / `--to`), owner/admin editing of another user's response, and +> `checkin create / config / archive` + the `checkin questions` subgroup all +> first ship in 1.17.0. This is what lets an agent **author and configure** forms +> and check-ins (not just fill them in), role-permission-gated server-side. Below +> 1.17.0 only the fill-in commands exist. The authoring sections of +> [`forms/SKILL.md`](forms/SKILL.md) and [`checkin/SKILL.md`](checkin/SKILL.md), +> and the [`channels/SKILL.md`](channels/SKILL.md) sub-skill, require this minimum. ### Why this minimum @@ -193,10 +205,11 @@ the full step-by-step workflow. | "check messages", "do I have messages?", "what should I work on?", "any instructions?" | **Messages** → read [`messages/SKILL.md`](messages/SKILL.md) | | "email this to Alice", "send an email", "send a summary to the team" | **Email** → read [`email/SKILL.md`](email/SKILL.md) | | "go online", "announce status", "health check" | **Health** → read [`health/SKILL.md`](health/SKILL.md) | -| "complete my check-in", "fill in my standup", "check-in status", "what does my standup ask?", "check-in history", "edit / reset my check-in", "submit my standup for yesterday" | **Checkin** → read [`checkin/SKILL.md`](checkin/SKILL.md) | +| "complete my check-in", "fill in my standup", "check-in status", "what does my standup ask?", "check-in history", "edit / reset my check-in", "submit my standup for yesterday", "create a standup", "add a question to the check-in", "change the standup schedule" | **Checkin** → read [`checkin/SKILL.md`](checkin/SKILL.md) | | "give kudos to Jane", "recognize Alice", "kudos al equipo Engineering", "felicita al team de QA" | **Kudos** → read [`kudos/SKILL.md`](kudos/SKILL.md) | | "list my teams", "who's in QA?", "resolve the Engineering team", or another skill needs a team UUID | **Teams** → read [`teams/SKILL.md`](teams/SKILL.md) | -| "list my forms", "submit the retro form", "continue my release-form draft", "transition the release to released", "show me the last form response" | **Forms** → read [`forms/SKILL.md`](forms/SKILL.md) | +| "list my forms", "submit the retro form", "continue my release-form draft", "transition the release to released", "show me the last form response", "create a form", "add / reorder questions", "archive the form", "read everyone's responses" | **Forms** → read [`forms/SKILL.md`](forms/SKILL.md) | +| "which channels can Dailybot post to?", "list report channels", "I need a channel UUID for the form" | **Channels** → read [`channels/SKILL.md`](channels/SKILL.md) | | "send a Slack message", "DM Sergio in chat", "post the deploy report to #releases (with a thread)", "edit that chat message I just sent", "ping the Engineering team in chat" | **Chat** → read [`chat/SKILL.md`](chat/SKILL.md) | ### Auto-activation (no explicit request) diff --git a/.agents/skills/dailybot/channels/SKILL.md b/.agents/skills/dailybot/channels/SKILL.md new file mode 100644 index 0000000..c78d7e5 --- /dev/null +++ b/.agents/skills/dailybot/channels/SKILL.md @@ -0,0 +1,75 @@ +--- +name: dailybot-channels +description: Discover the report channels (Slack/Teams/Discord/Google Chat) available to you via Dailybot, so their UUIDs can be attached to forms and check-ins with --report-channel. Use when the developer needs a channel UUID for form/check-in authoring. +version: "1.0.0" +documentation_url: https://api.dailybot.com/skill.md +user-invocable: true +metadata: {"openclaw":{"emoji":"📣","homepage":"https://dailybot.com","requires":{"anyBins":["dailybot","curl"]},"primaryEnv":"DAILYBOT_API_KEY","install":[{"id":"cli-install-script","kind":"download","url":"https://cli.dailybot.com/install.sh","label":"Install Dailybot CLI (official script — preferred on Linux/macOS)"},{"id":"pip","kind":"pip","package":"dailybot-cli","bins":["dailybot"],"label":"Install Dailybot CLI via pip (fallback if binary fails)"}]}} +allowed-tools: Bash, Read, Grep, Glob +--- + +# Dailybot Report Channels + +> **Requires `dailybot-cli >= 1.17.0`.** The `dailybot channels list` command first +> ships in 1.17.0, alongside forms & check-ins authoring. If `dailybot --version` +> reports below 1.17.0, ask the developer to run `dailybot upgrade`. See +> [`../SKILL.md` § Required Dailybot CLI version](../SKILL.md#required-dailybot-cli-version). + +Report channels are the Slack / Microsoft Teams / Discord / Google Chat +destinations where Dailybot posts form and check-in reports. This sub-skill lists +the channels available to you so their UUIDs can be attached to a form or check-in +with `--report-channel` during authoring. + +## Auth model — API key or login + +Works under a login session (Bearer) **or** an org API key (`DAILYBOT_API_KEY`). +Visibility is role-scoped server-side; the CLI never client-filters. + +## When to Use + +- The developer is **authoring** a form or check-in (see + [`../forms/SKILL.md`](../forms/SKILL.md) § Authoring and + [`../checkin/SKILL.md`](../checkin/SKILL.md) § Authoring) and needs a channel + UUID for `--report-channel`. +- The developer asks "which channels can Dailybot post to?". + +Do **not** use this to *send* a message to a channel — that's `dailybot-chat`. + +## Step 1 — List channels + +```bash +# Human-readable table +dailybot channels list + +# Machine-readable (recommended for agents) +dailybot channels list --json +``` + +JSON shape: + +```json +[ + {"uuid": "abc123-def456", "name": "#engineering", "platform": "slack", "channel_id": "C0123ABCDEF"} +] +``` + +## Step 2 — Use a channel UUID in authoring + +```bash +dailybot form create -n "Sprint Retro" --report-channel abc123-def456 +dailybot checkin config --report-channel abc123-def456 +``` + +`--report-channel` is repeatable to attach multiple channels. + +## Non-Blocking Rule + +If the CLI is unavailable or unauthenticated, surface the issue once and continue; +never block work on channel discovery. The HTTP fallback is +`GET /v1/report-channels/` (see [`../shared/http-fallback.md`](../shared/http-fallback.md)). + +## Additional Resources + +- [`../forms/SKILL.md`](../forms/SKILL.md) — forms authoring +- [`../checkin/SKILL.md`](../checkin/SKILL.md) — check-in authoring +- [`../SKILL.md`](../SKILL.md) — router + version floors diff --git a/.agents/skills/dailybot/checkin/SKILL.md b/.agents/skills/dailybot/checkin/SKILL.md index 2bc8aa6..1c2b967 100644 --- a/.agents/skills/dailybot/checkin/SKILL.md +++ b/.agents/skills/dailybot/checkin/SKILL.md @@ -249,6 +249,92 @@ prompts — handy for humans; agents should use the headless commands above. --- +## Step 3.6 — Authoring check-ins (`dailybot-cli >= 1.17.0`) + +Beyond completing check-ins, you can **create and configure** them. Authoring is +**role-gated on the server** (admins/managers) — the CLI validates shape only and +surfaces the server's `403`; it never elevates. Works under a login session **or** +an API key. + +```bash +# Create a check-in with a schedule, participants, and questions +dailybot checkin create -n "Daily Standup" --time 09:00 --days 1,2,3,4,5 \ + --timezone America/New_York --questions-file questions.json +dailybot checkin create -n "Daily Standup" --user "Jane Doe" --team "Engineering" +dailybot checkin create -n "Daily Standup" --interactive # guided question builder + +# Edit config / participants / activate-deactivate / archive. Note: `checkin config` +# edits the definition; `checkin edit` still edits your *response*, `checkin reset` +# deletes it. --user/--team replace participants (a check-in always needs ≥1). +dailybot checkin config --time 10:00 --days 1,2,3,4,5 +dailybot checkin config --team "Engineering" +dailybot checkin config --reminders 3 --reminder-interval 30 +dailybot checkin config --no-past --privacy everyone --inactive +dailybot checkin archive + +# Manage questions (same shapes as form questions; --blocker tags the blocker Q) +dailybot checkin questions add --type text --question "Focus today?" +dailybot checkin questions add --type boolean --question "Any blockers?" --blocker +# Per-question extras: report title, alternate phrasings, and conditional logic +dailybot checkin questions add --type text --question "What did you do?" \ + --short-question "Yesterday" --variation "What did you accomplish?" +dailybot checkin questions edit \ + --jump-if-equals "No" --jump-to -1 # inline single-jump logic +dailybot checkin questions edit --logic-file branching.json +dailybot checkin questions edit --question "Need help?" +dailybot checkin questions edit --blocker +dailybot checkin questions delete --yes +dailybot checkin questions reorder + +# Read a check-in back — canonical detail: schedule, resolved participants +# (names), attached report channels, and canonical questions in one call +dailybot checkin show --json + +# Admin/owner: read everyone's response history, filtered by user +dailybot checkin history --days 7 # your own; --all/--user are on `responses` API +``` + +**Schedule:** `--days` are ISO weekday integers (0=Sunday … 6=Saturday); `--time` +is `HH:MM`; `--timezone` is an IANA name. Or pass `--schedule-file` +(`{"days": [...], "time": "HH:MM", "timezone": "..."}`). **Full config** (create + +config, partial): `--start-on/--end-on`, `--frequency weekly` (monthly/custom +cadences are driven by `--frequency-advanced`), +`--every N`, `--trigger-based/--fixed-time`, +`--participant-timezone/--custom-timezone`, `--reminders 0-5`, +`--reminder-interval 0-60`, `--reminder-condition smart_frequency|fixed_frequency`, +`--work-days/--no-work-days`, `--allow-early/--no-early`, `--allow-past/--no-past`, +`--allow-future/--no-future`, `--anonymous/--no-anonymous`, `--privacy `, +`--one-by-one/--aggregated`, `--intro/--outro`, `--report-time HH:MM`, +`--reminder-tone standard|persuasive`, `--smart/--no-smart`, +`--intelligence/--no-intelligence` (needs `--smart`), `--max-clarifying 0-5` +(needs `--intelligence`), `--frequency-advanced disabled|monthly|custom`, +`--cron "0 9 * * 1,3,5"` — full 100% parity with the web's Frequency + +Additional-settings panels (only the computed `summary` stays read-only); +`checkin show` echoes them all back. +**Participants:** +repeatable `--user` / `--team` accept a name or a UUID. **A check-in must have at +least one participant** (a team or a person) — it only triggers for its +participants. If you create with no `--user`/`--team`, an interactive terminal +prompts you to pick some (the default team is suggested); a non-interactive run +(agent/script) errors instead of creating an empty check-in. Add or replace +participants later with `checkin config --user/--team`. **Question types** match +forms: `text`, `multiple_choice` (needs `--options`), `boolean` (no options), +`numeric`; up to 50 (this is the complete catalog). Tag the blocker question with +`--blocker`. **Per-question extras** (`questions add`/`edit`): `--short-question` +(report title, ≤512 chars), `--variation` (repeatable, ≤10), and conditional logic +via `--logic-file` or inline `--jump-if-equals VALUE --jump-to N` (`-1` = end). +**A report title is required** when adding/seeding a question — pass +`--short-question` (or `--ai-short-question` to let Dailybot generate it). Empty +question text is rejected server-side. + +**Reading back (`checkin show`):** returns the canonical detail shape — schedule, +`participants` (users/teams resolved to names), attached `report_channels`, and +`questions` in the canonical `{uuid, index, question, question_type, required, +is_blocker, choices}` form (identical to form detail). Use it to verify a +check-in you just created — who's assigned, where it reports, and question order. + +--- + ## Step 4 — HTTP Fallback (when CLI is unavailable) See [`../shared/http-fallback.md`](../shared/http-fallback.md) for base patterns. diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 505f57f..2812508 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -515,6 +515,84 @@ Only the **author, form owner, or org admin** can delete a response (server-enfo --- +## Step 11.5 — Authoring forms (`dailybot-cli >= 1.17.0`) + +Beyond filling forms in, you can **create and configure** them. Authoring is +**role-gated on the server** (form owners / admins / managers, as applicable) — +the CLI performs only shape validation and surfaces the server's `403`; it never +elevates. Works under a login session **or** an API key. + +```bash +# Discover report channels (channel UUIDs feed --report-channel) +dailybot channels list --json + +# Create a form — empty, seeded from a JSON file, or interactively +dailybot form create --name "Sprint Retro" +dailybot form create -n "Sprint Retro" --questions-file questions.json +dailybot form create -n "Sprint Retro" --interactive + +# List forms — archived forms are hidden unless you ask for them +dailybot form list # active only +dailybot form list --include-archived # includes archived, flagged in a Status column + +# Edit config / archive (soft-delete). Note: `form archive` is the definition; +# `form delete` still removes a *response*. +dailybot form edit --name "Updated Retro" --report-channel +# Full config (superset of `form edit`) — mirrors the web Setup tab: +dailybot form config --state "Draft:#ccc" --state "Released:#2ecc71" # workflow +dailybot form config --anonymous --public --require-identity --brand +dailybot form config --can-edit owner_and_admins --can-see restricted --can-see-team "Eng" +dailybot form config --approval --approver-user "Jane Doe" +dailybot form config --command release # ChatOps: @dailybot release +dailybot form config --no-workflow --no-command --inactive +dailybot form archive + +# Manage questions (--blocker tags the blocker question) +dailybot form questions list +dailybot form questions add --type text --question "What went well?" +dailybot form questions add --type multiple_choice \ + --question "Rating?" --options "Excellent,Good,Average,Poor" +dailybot form questions add --type boolean --question "Blocking?" --blocker +# Per-question extras: report title, alternate phrasings, conditional logic +dailybot form questions add --type text --question "What went well?" \ + --short-question "Wins" --variation "What are you proud of?" +dailybot form questions edit --logic-file branching.json +dailybot form questions edit --question "Reworded?" +dailybot form questions edit --blocker +dailybot form questions delete --yes +dailybot form questions reorder + +# Admin/owner: read everyone's responses, filtered by user and date +dailybot form responses --all --from 2026-01-01 --to 2026-06-30 --json +dailybot form responses --user --json + +# Admin/owner may edit anyone's response (author may always edit their own; +# a non-privileged edit of another user's response returns 403) +dailybot form update --content '{"":"corrected"}' +``` + +**Question types:** `text`, `multiple_choice`, `boolean`, `numeric` (the complete +catalog). `multiple_choice` requires `--options`; `boolean` auto-generates Yes/No +(no options); up to 50 questions. `--questions-file` is a JSON array of +`{question_type, question, options?, required?, is_blocker?, short_question?, +variations?, logic?}` objects (`type`/`label` aliases also accepted). Any question +may be tagged the **blocker** question with `--blocker` (or `"is_blocker": true` in +the file). **Per-question extras** on `questions add`/`edit`: `--short-question` +(report title, ≤512 chars), `--variation` (repeatable, ≤10), and conditional logic +via `--logic-file` (a `{"rules": {...}}` object) or inline +`--jump-if-equals VALUE --jump-to N`. **A report title is required** when +adding/seeding a question — pass `--short-question` / `"short_question"` (or +`--ai-short-question` to let Dailybot generate it). Empty question text is rejected +server-side. + +**Reading questions back:** every read path (`form get`, `form questions list`, +check-in `detail`) returns the canonical shape +`{uuid, index, question, question_type, required, is_blocker, choices}`. For +`multiple_choice`, `choices` is a list of `{label, value}` objects (display the +`label`); for other types `choices` is `[]`. + +--- + ## Step 12 — Multi-turn Lifecycle Examples ### Example A — Non-workflow form ("Team Feedback") diff --git a/README.md b/README.md index 991f072..47ac097 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,24 @@ pip install dailybot-cli Requires Python 3.10+. -**Install a specific version** — append `==` to pin an exact release: +**Install a specific version** — append a version specifier. Use `==` to pin an +exact release, or `>=` to set a **minimum floor** (installs the newest release at +or above it): ```bash -pip install dailybot-cli==1.15.0 +pip install dailybot-cli==1.15.0 # exactly 1.15.0 +pip install "dailybot-cli>=1.15.0" # 1.15.0 or newer — picks the latest available ``` ### Alternative installation methods -Every method installs the **latest** release by default. Where a specific -version can be pinned, the pinned form is shown right below the default one. +Every method installs the **latest** release by default. Where a version can be +pinned, the pinned form is shown right below the default one. Two pinning styles +are supported everywhere `DAILYBOT_VERSION` / `--version` is accepted: + +- **Exact** — `1.15.0` (or `==1.15.0`) installs precisely that release. +- **Minimum floor** — `>=1.15.0` installs the newest release at or above `1.15.0`, + so you get any later version automatically while guaranteeing a lower bound. **macOS (Homebrew)** @@ -41,18 +49,24 @@ curl -sSL https://cli.dailybot.com/install.sh | bash ``` Pin a version with the `DAILYBOT_VERSION` environment variable **or** the -`--version` flag — both do the same thing, so use whichever reads better: +`--version` flag — both do the same thing, so use whichever reads better. Each +accepts an exact version or a `>=` minimum floor: ```bash -# environment variable +# exact version, environment variable curl -sSL https://cli.dailybot.com/install.sh | DAILYBOT_VERSION=1.15.0 bash -# or the equivalent flag (note the `-s --` that forwards args through bash) +# exact version, equivalent flag (note the `-s --` that forwards args through bash) curl -sSL https://cli.dailybot.com/install.sh | bash -s -- --version 1.15.0 + +# minimum floor — installs 1.15.0 or the newest release above it +curl -sSL https://cli.dailybot.com/install.sh | DAILYBOT_VERSION='>=1.15.0' bash ``` -A pinned version installs the matching Linux binary when one exists, and -otherwise falls back to `pip install dailybot-cli==`. +An exact pin installs the matching Linux binary when one exists, otherwise falls +back to `pip install dailybot-cli==`. A `>=` floor installs the latest +binary when it satisfies the floor, otherwise falls back to +`pip install "dailybot-cli>="`. **Native Windows PowerShell** (only if you don't have WSL2 or Git Bash) @@ -61,10 +75,15 @@ irm https://cli.dailybot.com/install.ps1 | iex ``` Pin a version by setting `DAILYBOT_VERSION` before running the script (piping -to `iex` can't take arguments, so the environment variable is the only way): +to `iex` can't take arguments, so the environment variable is the only way). +An exact version or a `>=` minimum floor both work: ```powershell +# exact version $env:DAILYBOT_VERSION = '1.15.0'; irm https://cli.dailybot.com/install.ps1 | iex + +# minimum floor — installs 1.15.0 or the newest release above it +$env:DAILYBOT_VERSION = '>=1.15.0'; irm https://cli.dailybot.com/install.ps1 | iex ``` Requires Python 3.10+ on PATH. Wraps `pipx` / `uv tool` / `pip --user`. @@ -205,8 +224,14 @@ dailybot form responses dailybot form responses --state qa --json dailybot form responses --latest --json # continue where you left off +# Admins/owners: list everyone's responses, filter by user and date range +# (server-enforced — a member passing --all/--user gets 403) +dailybot form responses --all --from 2026-01-01 --to 2026-06-30 +dailybot form responses --user --json + # Operate on a single response dailybot form response get +# You may edit your own response; a form owner / org admin may edit anyone's dailybot form update --content '{"":"PR #4242"}' dailybot form transition qa --note "QA assigned" dailybot form delete @@ -249,6 +274,113 @@ Guided mode (`form submit` without `--content`) fetches the form's question list --- +## Authoring forms & check-ins + +Beyond filling them in, you can **create and configure** forms and check-ins from +the CLI — create with questions, manage questions, set schedules and report +channels, and archive. Authoring is **role-gated on the server** (admins / +managers / form owners, as applicable); the CLI acts within your role and never +elevates — an out-of-role action returns a clear `403`. Both a login session and +an API key work. + +```bash +# Discover report channels to attach to forms/check-ins +dailybot channels list +dailybot channels list --json + +# Create a form (empty, or seeded from a questions file, or interactively) +dailybot form create --name "Sprint Retro" +dailybot form create -n "Sprint Retro" --questions-file questions.json +dailybot form create -n "Sprint Retro" --interactive +dailybot form create -n "Sprint Retro" --report-channel + +# Edit a form's name / report channels, or archive it (soft-delete) +dailybot form edit --name "Updated Retro" --report-channel +dailybot form archive + +# Manage a form's questions +dailybot form questions list +dailybot form questions add --type text --question "What went well?" +dailybot form questions add --type multiple_choice \ + --question "Sprint rating?" --options "Excellent,Good,Average,Poor" +# Per-question extras: report title, alternate phrasings, conditional logic +dailybot form questions add --type text --question "What went well?" \ + --short-question "Wins" --variation "What are you proud of?" +dailybot form questions edit --logic-file branching.json +dailybot form questions edit --question "Reworded?" +dailybot form questions delete +dailybot form questions reorder + +# Create/configure a form with full config (workflow, permissions, command, sharing) +dailybot form create -n "Release Flow" --state "Draft:#ccc" --state "Released:#2ecc71" \ + --command release --can-edit owner_and_admins --report-channel +dailybot form config --anonymous --public --require-identity +dailybot form config --can-see restricted --can-see-team "Engineering" +dailybot form config --approval --approver-user "Jane Doe" +dailybot form config --no-workflow --no-command + +# Create a check-in with a schedule, participants, and questions +dailybot checkin create -n "Daily Standup" --time 09:00 --days 1,2,3,4,5 \ + --timezone America/New_York --questions-file questions.json +dailybot checkin create -n "Daily Standup" --user "Jane Doe" --team "Engineering" + +# Edit a check-in's configuration, activate/deactivate, or archive it +dailybot checkin config --time 10:00 --days 1,2,3,4,5 +dailybot checkin config --reminders 3 --reminder-interval 30 \ + --reminder-tone persuasive --privacy everyone +# Smart/AI + advanced scheduling (100% parity with the web UI) +dailybot checkin config --smart --intelligence --max-clarifying 2 +dailybot checkin config --frequency-advanced custom --cron "0 9 * * 1,3,5" +dailybot checkin config --inactive +dailybot checkin archive + +# Manage a check-in's questions (same subcommands as forms) +dailybot checkin questions add --type text --question "Focus today?" +dailybot checkin questions reorder +``` + +> **Question types:** `text`, `multiple_choice`, `boolean`, `numeric` (the complete +> catalog). `multiple_choice` needs `--options`; `boolean` auto-generates Yes/No +> (don't pass options). Up to 50 questions per form/check-in. +> +> **Per-question extras** (on `questions add`/`edit`, forms and check-ins): +> `--short-question` (title shown in web & chat reports), `--variation` (repeatable +> alternate phrasings), and conditional logic via `--logic-file` or the inline +> `--jump-if-equals VALUE --jump-to N` shortcut. +> +> **A report title is required** when adding/seeding a question — pass +> `--short-question "..."` (or `--ai-short-question` to let Dailybot generate it). + +### Command naming (why authoring uses distinct verbs) + +The existing `form delete` / `form update` and `checkin edit` / `checkin reset` +operate on **responses**. To avoid ambiguity, authoring the definitions uses +distinct verbs: **`form archive`**, **`checkin config`**, and +**`checkin archive`**. + +### `--questions-file` format + +A JSON array of question objects (`type`/`label` or `question_type`/`question` +both work): + +```json +[ + {"question_type": "text", "question": "What went well?", "required": true}, + {"question_type": "multiple_choice", "question": "Rating?", "options": ["1", "2", "3"]}, + {"question_type": "boolean", "question": "Any blockers?"} +] +``` + +### `--schedule-file` format + +```json +{"days": [1, 2, 3, 4, 5], "time": "09:00", "timezone": "America/New_York"} +``` + +`days` are ISO weekday integers (0 = Sunday … 6 = Saturday). + +--- + ## Kudos ```bash diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index bfb0ffc..832b58e 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -256,6 +256,7 @@ def list_checkins( date: str | None = None, include_summary: bool = False, include_pending_users: bool = False, + include_archived: bool = False, ) -> list[dict[str, Any]]: """GET /v1/checkins/ — fetch visible check-ins with optional completion state.""" results: list[dict[str, Any]] = [] @@ -266,6 +267,8 @@ def list_checkins( params["include_summary"] = "true" if include_pending_users: params["include_pending_users"] = "true" + if include_archived: + params["include_archived"] = "true" url: str | None = f"{self.api_url}/v1/checkins/" pages_fetched: int = 0 while url is not None and pages_fetched < _MAX_LIST_PAGES: @@ -298,6 +301,21 @@ def get_checkin(self, followup_uuid: str) -> dict[str, Any]: ) return self._handle_response(response) + def get_checkin_detail(self, followup_uuid: str) -> dict[str, Any]: + """GET /v1/checkins//detail/ — canonical authoring read. + + Returns the check-in with the canonical question shape, resolved + ``participants`` (users/teams with names), attached ``report_channels`` + and the ``is_archived`` flag — the shape aligned with form detail. Use + this for authoring/verification rather than the v2 retrieve serializer. + """ + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/checkins/{followup_uuid}/detail/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def get_template( self, template_uuid: str, @@ -322,13 +340,25 @@ def list_checkin_responses( *, date_start: str | None = None, date_end: str | None = None, + all_responses: bool = False, + user: str | None = None, ) -> list[dict[str, Any]]: - """GET /v1/checkins//responses/.""" + """GET /v1/checkins//responses/. + + Without filters the server returns only the caller's own responses. + ``all_responses`` / ``user`` are admin/owner-only server-side (a member + receives 403). Note check-ins use ``date_start`` / ``date_end`` (forms + use ``date_from`` / ``date_to``). + """ params: dict[str, str] = {} if date_start: params["date_start"] = date_start if date_end: params["date_end"] = date_end + if all_responses: + params["all"] = "true" + if user: + params["user"] = user response: httpx.Response = httpx.get( f"{self.api_url}/v1/checkins/{followup_uuid}/responses/", headers=self._headers(), @@ -382,6 +412,152 @@ def delete_checkin_response( ) return self._handle_response(response) + # --- Check-ins authoring --- + + def create_checkin( + self, + name: str, + *, + schedule: dict[str, Any] | None = None, + participants: dict[str, Any] | None = None, + questions: list[dict[str, Any]] | None = None, + report_channels: list[str] | None = None, + config: dict[str, Any] | None = None, + generate_short_question: bool = False, + ) -> dict[str, Any]: + """POST /v1/checkins/create/ — create a check-in with schedule + questions. + + ``config`` carries the extra scheduling/behavior fields (frequency, + reminders, timezone mode, submission rules, privacy, …) merged inline. + ``generate_short_question`` opts into AI report-title generation for + questions that were seeded without an explicit ``short_question``. + """ + payload: dict[str, Any] = {"name": name} + if schedule is not None: + payload["schedule"] = schedule + if participants is not None: + payload["participants"] = participants + if questions: + payload["questions"] = questions + if report_channels is not None: + payload["report_channels"] = report_channels + if generate_short_question: + payload["generate_short_question"] = True + if config: + payload.update(config) + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/checkins/create/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def update_checkin_config( + self, + followup_uuid: str, + *, + name: str | None = None, + schedule: dict[str, Any] | None = None, + report_channels: list[str] | None = None, + is_active: bool | None = None, + participants: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """PATCH /v1/checkins//config/ — edit config (partial update). + + ``config`` carries the extra scheduling/behavior fields (frequency, + reminders, timezone mode, submission rules, privacy, …); only the keys + present are changed. + """ + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if schedule is not None: + payload["schedule"] = schedule + if report_channels is not None: + payload["report_channels"] = report_channels + if is_active is not None: + payload["is_active"] = is_active + if participants is not None: + payload["participants"] = participants + if config: + payload.update(config) + response: httpx.Response = httpx.patch( + f"{self.api_url}/v1/checkins/{followup_uuid}/config/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def archive_checkin(self, followup_uuid: str) -> dict[str, Any]: + """DELETE /v1/checkins//archive/ — soft-delete a check-in (204).""" + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/checkins/{followup_uuid}/archive/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def add_checkin_question( + self, + followup_uuid: str, + question: dict[str, Any], + ) -> dict[str, Any]: + """POST /v1/checkins//questions/ — add a question.""" + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/checkins/{followup_uuid}/questions/", + json=question, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def update_checkin_question( + self, + followup_uuid: str, + question_uuid: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + """PATCH /v1/checkins//questions// — update a question.""" + response: httpx.Response = httpx.patch( + f"{self.api_url}/v1/checkins/{followup_uuid}/questions/{question_uuid}/", + json=fields, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def delete_checkin_question( + self, + followup_uuid: str, + question_uuid: str, + ) -> dict[str, Any]: + """DELETE /v1/checkins//questions//delete/ (204).""" + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/checkins/{followup_uuid}/questions/{question_uuid}/delete/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def reorder_checkin_questions( + self, + followup_uuid: str, + order: list[str], + ) -> dict[str, Any]: + """PUT /v1/checkins//questions/reorder/ — set a new question order.""" + response: httpx.Response = httpx.put( + f"{self.api_url}/v1/checkins/{followup_uuid}/questions/reorder/", + json={"question_uuids": order}, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def get_mood(self, mood_date: str | None = None) -> dict[str, Any]: """GET /v1/mood/track/ — fetch today's mood response.""" params: dict[str, str] = {} @@ -408,9 +584,15 @@ def track_mood(self, score: int, mood_date: str | None = None) -> dict[str, Any] ) return self._handle_response(response) - def list_forms(self, *, include_questions: bool = False) -> list[dict[str, Any]]: - """GET /v1/forms/ — optionally expand question definitions per form.""" - params: dict[str, str] = {"include": "questions"} if include_questions else {} + def list_forms( + self, *, include_questions: bool = False, include_archived: bool = False + ) -> list[dict[str, Any]]: + """GET /v1/forms/ — optionally expand questions and include archived forms.""" + params: dict[str, str] = {} + if include_questions: + params["include"] = "questions" + if include_archived: + params["include_archived"] = "true" response: httpx.Response = httpx.get( f"{self.api_url}/v1/forms/", headers=self._headers(), @@ -449,11 +631,29 @@ def list_form_responses( form_uuid: str, *, state: str | None = None, + all_responses: bool = False, + user: str | None = None, + date_from: str | None = None, + date_to: str | None = None, ) -> list[dict[str, Any]]: - """GET /v1/forms//responses/ — list the caller's own responses.""" + """GET /v1/forms//responses/ — list responses. + + Without filters the server returns only the caller's own responses. + ``all_responses`` / ``user`` are admin/owner-only server-side (a member + receives 403); ``date_from`` / ``date_to`` (``YYYY-MM-DD``) narrow the + window for anyone. + """ params: dict[str, str] = {} if state: params["state"] = state + if all_responses: + params["all"] = "true" + if user: + params["user"] = user + if date_from: + params["date_from"] = date_from + if date_to: + params["date_to"] = date_to response: httpx.Response = httpx.get( f"{self.api_url}/v1/forms/{form_uuid}/responses/", headers=self._headers(), @@ -530,15 +730,177 @@ def delete_form_response( ) return self._handle_response(response) - def list_users(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: + # --- Report channels --- + + def list_report_channels(self) -> list[dict[str, Any]]: + """GET /v1/report-channels/ — reporting channels available to the caller. + + The endpoint returns ``{"channels": [{id, name, platform, type}], "total": N}``; + older/other deployments may return ``{"results": [...]}`` or a bare list. + All three are accepted. + """ + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/report-channels/", + headers=self._headers(), + timeout=self.timeout, + ) + if response.status_code >= 400: + self._handle_response(response) + body: Any = response.json() + if isinstance(body, dict): + if "channels" in body: + return list(body.get("channels", [])) + if "results" in body: + return list(body.get("results", [])) + if isinstance(body, list): + return body + return [] + + # --- Forms authoring --- + + def create_form( + self, + name: str, + questions: list[dict[str, Any]] | None = None, + *, + report_channels: list[str] | None = None, + generate_short_question: bool = False, + config: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """POST /v1/forms/create/ — create a form with optional questions + channels. + + ``generate_short_question`` opts into AI report-title generation for + questions seeded without an explicit ``short_question``. ``config`` carries + the form-level fields (privacy/workflow/permissions/anonymous/public/approval/ + command) merged inline. + """ + payload: dict[str, Any] = {"name": name} + if questions: + payload["questions"] = questions + if report_channels: + payload["report_channels"] = report_channels + if generate_short_question: + payload["generate_short_question"] = True + if config: + payload.update(config) + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/forms/create/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def update_form_config( + self, + form_uuid: str, + *, + name: str | None = None, + report_channels: list[str] | None = None, + config: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """PATCH /v1/forms//config/ — edit name, channels, and/or config. + + ``config`` carries the form-level fields (workflow/permissions/anonymous/ + public/approval/command) merged inline; only the keys present change. + """ + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if report_channels is not None: + payload["report_channels"] = report_channels + if config: + payload.update(config) + response: httpx.Response = httpx.patch( + f"{self.api_url}/v1/forms/{form_uuid}/config/", + json=payload, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def archive_form(self, form_uuid: str) -> dict[str, Any]: + """DELETE /v1/forms//archive/ — soft-delete a form (204).""" + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/forms/{form_uuid}/archive/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def add_form_question( + self, + form_uuid: str, + question: dict[str, Any], + ) -> dict[str, Any]: + """POST /v1/forms//questions/ — add a question to a form.""" + response: httpx.Response = httpx.post( + f"{self.api_url}/v1/forms/{form_uuid}/questions/", + json=question, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def update_form_question( + self, + form_uuid: str, + question_uuid: str, + fields: dict[str, Any], + ) -> dict[str, Any]: + """PATCH /v1/forms//questions// — update a question.""" + response: httpx.Response = httpx.patch( + f"{self.api_url}/v1/forms/{form_uuid}/questions/{question_uuid}/", + json=fields, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def delete_form_question( + self, + form_uuid: str, + question_uuid: str, + ) -> dict[str, Any]: + """DELETE /v1/forms//questions//delete/ (204).""" + response: httpx.Response = httpx.request( + "DELETE", + f"{self.api_url}/v1/forms/{form_uuid}/questions/{question_uuid}/delete/", + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def reorder_form_questions( + self, + form_uuid: str, + order: list[str], + ) -> dict[str, Any]: + """PUT /v1/forms//questions/reorder/ — set a new question order.""" + response: httpx.Response = httpx.put( + f"{self.api_url}/v1/forms/{form_uuid}/questions/reorder/", + json={"question_uuids": order}, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + + def list_users( + self, *, include_inactive: bool = False, include_email: bool = False + ) -> list[dict[str, Any]]: """GET /v1/users/ — fetch all pages and return the combined results list. By default returns only members with ``is_active`` truthy. Pass ``include_inactive=True`` to get the unfiltered server response (useful for admin / audit flows that need to surface deactivated accounts). + ``include_email=True`` requests the ``email`` field (server-gated to + admins/managers; silently omitted otherwise) so callers can resolve a + person by email. """ results: list[dict[str, Any]] = [] - url: str | None = f"{self.api_url}/v1/users/" + base_url: str = f"{self.api_url}/v1/users/" + url: str | None = f"{base_url}?include_email=true" if include_email else base_url pages_fetched: int = 0 while url is not None and pages_fetched < _MAX_LIST_PAGES: response: httpx.Response = httpx.get( diff --git a/dailybot_cli/commands/authoring_helpers.py b/dailybot_cli/commands/authoring_helpers.py new file mode 100644 index 0000000..2af791c --- /dev/null +++ b/dailybot_cli/commands/authoring_helpers.py @@ -0,0 +1,1151 @@ +"""Parsing and validation helpers for forms & check-ins authoring commands. + +These helpers do **shape/format validation only**. Authorization (who may create +or edit what) is enforced server-side by role — the CLI never approximates it. +""" + +import json +import re +import sys +from pathlib import Path +from typing import Any + +import click +import questionary + +from dailybot_cli.api_client import DailyBotClient +from dailybot_cli.commands.public_api_helpers import ( + resolve_team_by_name_or_uuid, + resolve_user_by_name_or_uuid, +) +from dailybot_cli.display import print_error + +# Question types accepted by the server. Anything else is rejected with 400. +VALID_QUESTION_TYPES: tuple[str, ...] = ("text", "multiple_choice", "boolean", "numeric") +# Server-side ceiling; mirrored here so the CLI fails fast on obviously-too-many. +MAX_QUESTIONS: int = 50 +# Report-channel cap (forms + check-ins); server rejects > 3 with too_many_report_channels. +MAX_REPORT_CHANNELS: int = 3 +# Per-question extras (short report title + alternate phrasings). Limits mirror +# the server contract so the CLI fails fast; the server stays authoritative. +SHORT_QUESTION_MAX_LEN: int = 512 +VARIATIONS_MAX: int = 10 +# Question logic (conditional/branching) vocabulary, mirroring the server contract. +# The operator set is the *union* across question types (text/numeric/mc/boolean); +# the server enforces which operators are legal for a given type, so the CLI stays +# lenient here to avoid false rejections. +LOGIC_OPERATORS: tuple[str, ...] = ( + "is_equal_to", + "is_not_equal_to", + "contains", + "not_contains", + "begins_with", + "not_begins_with", + "ends_with", + "not_ends_with", + "lower_than", + "lower_or_equal_than", + "greater_than", + "greater_or_equal_than", +) +LOGIC_ACTIONS: tuple[str, ...] = ("jump_to", "trigger_checkin", "trigger_form") +LOGIC_CONNECTORS: tuple[str, ...] = ("and", "or") +# Sentinel target meaning "jump to the end of the questionnaire". +LOGIC_JUMP_TO_END: int = -1 +# Schedule validation: ISO weekday ints (0=Sunday .. 6=Saturday) and HH:MM time. +MIN_WEEKDAY: int = 0 +MAX_WEEKDAY: int = 6 +_TIME_PATTERN: re.Pattern[str] = re.compile(r"^\d{2}:\d{2}$") + +# Check-in configuration enums + constraints (mirror the server contract so the +# CLI fails fast; the server remains the source of truth). ``frequency_type`` is +# weekly-only — monthly/custom cadences are driven by ``frequency_advanced`` +# (+ ``frequency_cron`` for custom). +FREQUENCY_TYPES: tuple[str, ...] = ("weekly",) +PRIVACY_LEVELS: tuple[str, ...] = ( + "only_owner", + "owner_and_members", + "managers_and_members", + "managers_and_admins", + "org_admins", + "everyone", + "custom", +) +REMINDER_CONDITIONS: tuple[str, ...] = ("smart_frequency", "fixed_frequency") +REMINDER_TONES: tuple[str, ...] = ("standard", "persuasive") +FREQUENCY_ADVANCED: tuple[str, ...] = ("disabled", "monthly", "custom") +REMINDERS_MAX_COUNT: int = 5 +REMINDER_INTERVAL_MAX: int = 60 +# AI / smart check-in: max clarifying follow-up questions per response. +MAX_CLARIFYING_QUESTIONS: int = 5 +INTRO_OUTRO_MIN_LEN: int = 3 +INTRO_OUTRO_MAX_LEN: int = 1024 +# Standard 5-field cron (minute hour day-of-month month day-of-week); the server +# does the full parse — we only fail fast on an obviously wrong field count. +CRON_FIELD_COUNT: int = 5 +_DATE_PATTERN: re.Pattern[str] = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_REPORT_TIME_PATTERN: re.Pattern[str] = re.compile(r"^\d{2}:\d{2}(:\d{2})?$") + +# Form configuration (mirror the server contract so the CLI fails fast). Forms have +# permission audiences, a workflow state machine, an approval flow, and a ChatOps +# command instead of the check-in scheduling/participant surface. +FORM_PERMISSION_MODES: tuple[str, ...] = ("everyone", "owner_and_admins", "restricted") +WORKFLOW_MAX_STATES: int = 20 +_COMMAND_PATTERN: re.Pattern[str] = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$") +_HEX_COLOR_PATTERN: re.Pattern[str] = re.compile(r"^#[0-9a-fA-F]{3,8}$") + + +class AuthoringError(click.ClickException): + """A user-facing validation error for authoring input. + + Rendered through ``display.print_error`` (stderr) and exits non-zero, so + command callbacks don't need to wrap helper calls in try/except. + """ + + def show(self, file: Any = None) -> None: + print_error(self.message) + + +def question_extras_options(func: Any) -> Any: + """Attach the shared per-question extra flags (short title, variations, logic). + + Applied to ``questions add`` and ``questions edit`` on both check-ins and forms. + Dest names match ``resolve_question_extras`` kwargs so callbacks can forward + them directly. + """ + options: list[Any] = [ + click.option( + "--short-question", + "short_question", + default=None, + help="Short title shown in web & chat reports (<=512 chars).", + ), + click.option( + "--variation", + "variations_raw", + multiple=True, + help="Alternate phrasing rotated per run (repeatable; up to 10).", + ), + click.option( + "--logic-file", + "logic_file", + default=None, + help='Path to a JSON question-logic object ({"rules": {...}}).', + ), + click.option( + "--jump-if-equals", + "jump_if_equals", + default=None, + help="Inline logic: answer value that triggers the jump (needs --jump-to).", + ), + click.option( + "--jump-to", + "jump_to", + type=int, + default=None, + help="Inline logic: target question index to jump to (-1 = end).", + ), + click.option( + "--else-jump-to", + "else_jump_to", + type=int, + default=None, + help="Inline logic: fallback target when the condition doesn't match " + "(default -1 = end).", + ), + ] + for option in reversed(options): + func = option(func) + return func + + +def check_report_channels(report_channels: tuple[str, ...]) -> None: + """Fail fast when more than ``MAX_REPORT_CHANNELS`` channels are attached. + + Mirrors the server cap (``too_many_report_channels``) for forms and check-ins. + """ + if len(report_channels) > MAX_REPORT_CHANNELS: + raise AuthoringError( + f"Too many report channels ({len(report_channels)}); the limit is " + f"{MAX_REPORT_CHANNELS}." + ) + + +def parse_options(raw: str | None) -> list[str] | None: + """Split a comma-separated ``--options`` string into a trimmed list.""" + if raw is None: + return None + options: list[str] = [part.strip() for part in raw.split(",") if part.strip()] + return options or None + + +def build_question( + question_type: str, + question: str, + *, + options: list[str] | None = None, + required: bool = True, + is_blocker: bool = False, + short_question: str | None = None, + variations: list[str] | None = None, + logic: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a validated question payload (explicit ``question_type``/``question``). + + Enforces the type whitelist, that ``multiple_choice`` carries options, that + other types do not, and that the question text is non-empty. ``is_blocker`` + tags the question as the blocker prompt (common on boolean check-in Qs). + ``short_question`` (report title), ``variations`` (alternate phrasings), and + ``logic`` (conditional branching) are optional per-question extras; callers + pass already-validated values (see ``resolve_question_extras``). + """ + qtype: str = question_type.strip().lower() + if qtype not in VALID_QUESTION_TYPES: + raise AuthoringError( + f"Invalid question type '{question_type}'. " + f"Choose from: {', '.join(VALID_QUESTION_TYPES)}." + ) + text: str = question.strip() + if not text: + raise AuthoringError("Question text is required.") + + payload: dict[str, Any] = { + "question_type": qtype, + "question": text, + "required": required, + "is_blocker": is_blocker, + } + if qtype == "multiple_choice": + if not options: + raise AuthoringError( + "Multiple-choice questions require at least one option (use --options)." + ) + payload["options"] = options + elif options: + raise AuthoringError(f"'{qtype}' questions do not take options.") + if short_question is not None: + payload["short_question"] = validate_short_question(short_question) + if variations is not None: + payload["variations"] = variations + if logic is not None: + payload["logic"] = logic + return payload + + +def build_question_edit_fields( + question: str | None, + question_type: str | None, + options: str | None, + required: bool | None, + is_blocker: bool | None = None, + *, + short_question: str | None = None, + variations: list[str] | None = None, + logic: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a partial question-update payload from provided edit flags. + + Only shape checks run here; the server does full validation. ``build_question`` + isn't reused because edits are partial (no required question/type pairing). + Omitted flags are not sent (non-destructive PATCH). ``short_question`` / + ``variations`` / ``logic`` arrive already validated from + ``resolve_question_extras``. + """ + fields: dict[str, Any] = {} + if question is not None: + fields["question"] = question + if question_type is not None: + fields["question_type"] = question_type + if options is not None: + fields["options"] = parse_options(options) or [] + if required is not None: + fields["required"] = required + if is_blocker is not None: + fields["is_blocker"] = is_blocker + if short_question is not None: + fields["short_question"] = validate_short_question(short_question) + if variations is not None: + fields["variations"] = variations + if logic is not None: + fields["logic"] = logic + return fields + + +def validate_short_question(text: str) -> str: + """Trim and length-check a per-question report title (``short_question``).""" + stripped: str = text.strip() + if not stripped: + raise AuthoringError("--short-question cannot be empty.") + if len(stripped) > SHORT_QUESTION_MAX_LEN: + raise AuthoringError( + f"--short-question must be at most {SHORT_QUESTION_MAX_LEN} characters " + f"(got {len(stripped)})." + ) + return stripped + + +# Shared guidance so the "provide a report title" nudge reads the same everywhere. +_SHORT_QUESTION_HINT: str = ( + 'Pass --short-question "", or --ai-short-question to let ' + "Dailybot generate it (AI titling needs an intelligence-enabled check-in)." +) + + +def require_short_question(short_question: str | None, ai_short_question: bool) -> None: + """Enforce an explicit report title on single-question authoring (add). + + The CLI treats ``short_question`` as effectively mandatory: AI auto-titling is a + frontend convenience, so an agent/script must either supply the title or opt in + to AI generation with ``--ai-short-question``. + """ + if short_question is None and not ai_short_question: + raise AuthoringError(f"A report title is required. {_SHORT_QUESTION_HINT}") + + +def require_short_questions(questions: list[dict[str, Any]], ai_short_question: bool) -> None: + """Enforce an explicit report title on every question in a bulk create. + + Skipped entirely when ``ai_short_question`` opts in. Names the offending + questions so a --questions-file author knows exactly which entries to fix. + """ + if ai_short_question: + return + missing: list[str] = [ + f"#{index + 1}" + for index, question in enumerate(questions) + if not question.get("short_question") + ] + if missing: + raise AuthoringError( + f'Question(s) {", ".join(missing)} need a report title ("short_question"). ' + f"{_SHORT_QUESTION_HINT}" + ) + + +def build_variations(raw: tuple[str, ...] | list[str]) -> list[str] | None: + """Validate alternate phrasings; returns ``None`` when none were provided. + + Each variation must be non-empty (whitespace-only rejected) and the count is + capped at ``VARIATIONS_MAX`` — mirroring ``invalid_question_variations``. + """ + if not raw: + return None + variations: list[str] = [] + for item in raw: + text: str = str(item).strip() + if not text: + raise AuthoringError("Question variations cannot be empty.") + variations.append(text) + if len(variations) > VARIATIONS_MAX: + raise AuthoringError( + f"Too many variations ({len(variations)}); the limit is {VARIATIONS_MAX}." + ) + return variations + + +def _validate_logic_condition(condition: Any) -> None: + if not isinstance(condition, dict): + raise AuthoringError("Each logic condition must be an object.") + operator: Any = condition.get("operator") + if operator not in LOGIC_OPERATORS: + raise AuthoringError( + f"Invalid logic operator '{operator}'. Choose from: {', '.join(LOGIC_OPERATORS)}." + ) + if "comparison_value" not in condition: + raise AuthoringError("Each logic condition needs a 'comparison_value'.") + connector: Any = condition.get("logic_connector", "and") + if connector not in LOGIC_CONNECTORS: + raise AuthoringError( + f"Invalid logic connector '{connector}'. Choose from: {', '.join(LOGIC_CONNECTORS)}." + ) + + +def _validate_logic_action(action: Any) -> None: + if not isinstance(action, dict): + raise AuthoringError("A logic action must be an object with 'action' and 'target'.") + act: Any = action.get("action") + if act not in LOGIC_ACTIONS: + raise AuthoringError( + f"Invalid logic action '{act}'. Choose from: {', '.join(LOGIC_ACTIONS)}." + ) + target: Any = action.get("target") + if act == "jump_to": + if not isinstance(target, int) or isinstance(target, bool): + raise AuthoringError( + "A 'jump_to' action needs an integer 'target' (question index, or -1 for end)." + ) + elif not isinstance(target, str) or not target: + raise AuthoringError(f"A '{act}' action needs a string 'target' (a UUID).") + + +def validate_logic(logic: Any) -> dict[str, Any]: + """Validate a question-logic object's structure against the server contract. + + Checks the ``rules`` envelope, each ``rules_if`` rule's conditions/action, and + the **required** ``rules_else`` fallback action. Forward-only jump targets and + per-type operator/value legality are enforced by the server (it owns the + question index); this fails fast on obvious structural mistakes. + """ + if not isinstance(logic, dict): + raise AuthoringError("Question logic must be a JSON object.") + rules: Any = logic.get("rules") + if not isinstance(rules, dict): + raise AuthoringError('Question logic must have a "rules" object.') + rules_if: Any = rules.get("rules_if", []) + if not isinstance(rules_if, list): + raise AuthoringError('"rules_if" must be a list of rules.') + for rule in rules_if: + if not isinstance(rule, dict): + raise AuthoringError("Each logic rule must be an object.") + conditions: Any = rule.get("conditions") + if not isinstance(conditions, list) or not conditions: + raise AuthoringError("Each logic rule needs a non-empty 'conditions' list.") + for condition in conditions: + _validate_logic_condition(condition) + _validate_logic_action(rule.get("then")) + rules_else: Any = rules.get("rules_else") + if not isinstance(rules_else, dict): + raise AuthoringError( + 'Question logic must include a "rules_else" fallback action ' + "(what happens when no condition matches — the server requires it)." + ) + _validate_logic_action(rules_else) + return logic + + +def _coerce_comparison_value(raw: str) -> Any: + """Coerce an inline ``--jump-if-equals`` string to the value type it implies. + + ``true``/``false`` (case-insensitive) become JSON booleans, which is what the + server requires for boolean questions. Everything else stays a string; for + numeric comparisons use ``--logic-file`` with an explicit number. + """ + lowered: str = raw.strip().lower() + if lowered in ("true", "false"): + return lowered == "true" + return raw + + +def build_question_logic( + *, + logic_file: str | None = None, + jump_if_equals: str | None = None, + jump_to: int | None = None, + else_jump_to: int | None = None, +) -> dict[str, Any] | None: + """Assemble question logic from a JSON file or an inline single-jump rule. + + ``--logic-file`` carries the full ``{rules: {...}}`` structure. The inline + ``--jump-if-equals VALUE --jump-to N`` pair is a convenience for the common + "branch on one answer" case; ``--else-jump-to`` sets the fallback (defaults to + ``-1`` = end). Returns ``None`` when neither is provided. Jump targets must be + forward (greater than this question's index) or ``-1`` — the server enforces it. + """ + if logic_file: + try: + raw: str = Path(logic_file).read_text(encoding="utf-8") + except OSError as exc: + raise AuthoringError(f"Cannot read logic file '{logic_file}': {exc}") from exc + try: + data: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise AuthoringError(f"Logic file '{logic_file}' is not valid JSON: {exc}") from exc + return validate_logic(data) + if jump_to is not None: + if jump_if_equals is None: + raise AuthoringError( + "--jump-to requires --jump-if-equals (the answer that triggers the jump)." + ) + logic: dict[str, Any] = { + "rules": { + "rules_if": [ + { + "conditions": [ + { + "operator": "is_equal_to", + "comparison_value": _coerce_comparison_value(jump_if_equals), + "logic_connector": "and", + } + ], + "then": {"action": "jump_to", "target": jump_to}, + } + ], + "rules_else": { + "action": "jump_to", + "target": else_jump_to if else_jump_to is not None else LOGIC_JUMP_TO_END, + }, + } + } + return validate_logic(logic) + if jump_if_equals is not None: + raise AuthoringError("--jump-if-equals requires --jump-to (the target question index).") + if else_jump_to is not None: + raise AuthoringError("--else-jump-to only applies with --jump-if-equals and --jump-to.") + return None + + +def resolve_question_extras( + *, + short_question: str | None = None, + variations_raw: tuple[str, ...] = (), + logic_file: str | None = None, + jump_if_equals: str | None = None, + jump_to: int | None = None, + else_jump_to: int | None = None, +) -> dict[str, Any]: + """Turn the shared question-extra flags into validated ``build_question`` kwargs. + + Returns only the keys the caller supplied, so both add (full) and edit + (partial) paths can splat the result without sending untouched fields. + """ + extras: dict[str, Any] = {} + if short_question is not None: + extras["short_question"] = validate_short_question(short_question) + variations: list[str] | None = build_variations(variations_raw) + if variations is not None: + extras["variations"] = variations + logic: dict[str, Any] | None = build_question_logic( + logic_file=logic_file, + jump_if_equals=jump_if_equals, + jump_to=jump_to, + else_jump_to=else_jump_to, + ) + if logic is not None: + extras["logic"] = logic + return extras + + +def parse_questions_file(path: str) -> list[dict[str, Any]]: + """Load and validate a JSON array of question objects from ``path``. + + Accepts both field conventions (``question_type``/``question`` or + ``type``/``label``) and enforces ``MAX_QUESTIONS``. + """ + try: + raw: str = Path(path).read_text(encoding="utf-8") + except OSError as exc: + raise AuthoringError(f"Cannot read questions file '{path}': {exc}") from exc + try: + data: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise AuthoringError(f"Questions file '{path}' is not valid JSON: {exc}") from exc + + if not isinstance(data, list): + raise AuthoringError("Questions file must be a JSON array of question objects.") + if len(data) > MAX_QUESTIONS: + raise AuthoringError(f"Too many questions ({len(data)}); the limit is {MAX_QUESTIONS}.") + + questions: list[dict[str, Any]] = [] + for index, item in enumerate(data): + if not isinstance(item, dict): + raise AuthoringError(f"Question #{index + 1} must be an object.") + qtype: str = str(item.get("question_type") or item.get("type") or "") + qtext: str = str(item.get("question") or item.get("label") or "") + raw_options: Any = item.get("options") + options: list[str] | None = ( + [str(option) for option in raw_options] if isinstance(raw_options, list) else None + ) + required: bool = bool(item.get("required", True)) + is_blocker: bool = bool(item.get("is_blocker", False)) + extras: dict[str, Any] = {} + raw_short: Any = item.get("short_question") + if raw_short is not None: + extras["short_question"] = validate_short_question(str(raw_short)) + raw_variations: Any = item.get("variations") + if raw_variations is not None: + if not isinstance(raw_variations, list): + raise AuthoringError( + f"Question #{index + 1}: 'variations' must be a list of strings." + ) + built: list[str] | None = build_variations([str(v) for v in raw_variations]) + if built is not None: + extras["variations"] = built + raw_logic: Any = item.get("logic") + if raw_logic is not None: + extras["logic"] = validate_logic(raw_logic) + questions.append( + build_question( + qtype, + qtext, + options=options, + required=required, + is_blocker=is_blocker, + **extras, + ) + ) + return questions + + +def _validate_schedule_dict(schedule: dict[str, Any]) -> dict[str, Any]: + """Validate the shape of a schedule dict (``days``, ``time``); leave tz to server.""" + validated: dict[str, Any] = dict(schedule) + if "days" in validated: + days: Any = validated["days"] + if not isinstance(days, list) or not all( + isinstance(day, int) and MIN_WEEKDAY <= day <= MAX_WEEKDAY for day in days + ): + raise AuthoringError( + "Schedule days must be a list of integers 0-6 (0=Sunday .. 6=Saturday)." + ) + if "time" in validated and not _TIME_PATTERN.match(str(validated["time"])): + raise AuthoringError("Schedule time must be in HH:MM format (e.g. 09:00).") + return validated + + +def parse_schedule( + *, + days: str | None = None, + time: str | None = None, + timezone: str | None = None, + schedule_file: str | None = None, +) -> dict[str, Any] | None: + """Build a schedule dict from a JSON file or individual flags. + + Returns ``None`` when nothing is provided (so the field is omitted). The + timezone is not validated locally — the server returns ``invalid_timezone``. + """ + if schedule_file: + try: + raw: str = Path(schedule_file).read_text(encoding="utf-8") + except OSError as exc: + raise AuthoringError(f"Cannot read schedule file '{schedule_file}': {exc}") from exc + try: + data: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise AuthoringError( + f"Schedule file '{schedule_file}' is not valid JSON: {exc}" + ) from exc + if not isinstance(data, dict): + raise AuthoringError("Schedule file must be a JSON object.") + return _validate_schedule_dict(data) + + schedule: dict[str, Any] = {} + if days is not None: + parsed_days: list[int] = [] + for part in days.split(","): + token: str = part.strip() + if not token: + continue + try: + parsed_days.append(int(token)) + except ValueError as exc: + raise AuthoringError( + "Schedule days must be comma-separated integers 0-6 (0=Sunday .. 6=Saturday)." + ) from exc + schedule["days"] = parsed_days + if time is not None: + schedule["time"] = time + if timezone is not None: + schedule["timezone"] = timezone + + if not schedule: + return None + return _validate_schedule_dict(schedule) + + +def _check_enum(value: str, allowed: tuple[str, ...], label: str) -> str: + normalized: str = value.strip().lower() + if normalized not in allowed: + raise AuthoringError(f"Invalid {label} '{value}'. Choose from: {', '.join(allowed)}.") + return normalized + + +def _check_int_range(value: int, low: int, high: int, label: str) -> int: + if not low <= value <= high: + raise AuthoringError(f"{label} must be between {low} and {high} (got {value}).") + return value + + +def build_checkin_config( + *, + start_on: str | None = None, + end_on: str | None = None, + frequency_type: str | None = None, + frequency: int | None = None, + is_trigger_based: bool | None = None, + use_participant_timezone: bool | None = None, + reminders_max_count: int | None = None, + reminders_frequency_time: int | None = None, + reminders_trigger_condition: str | None = None, + use_user_defined_work_days: bool | None = None, + allow_responses_before_trigger: bool | None = None, + allow_past_responses: bool | None = None, + allow_future_responses: bool | None = None, + is_anonymous: bool | None = None, + privacy: str | None = None, + send_reports_one_by_one: bool | None = None, + custom_template_intro: str | None = None, + custom_template_outro: str | None = None, + time_for_report: str | None = None, + reminder_tone: str | None = None, + is_smart_checkin: bool | None = None, + is_intelligence_enabled: bool | None = None, + max_clarifying_questions: int | None = None, + frequency_cron: str | None = None, + frequency_advanced: str | None = None, +) -> dict[str, Any]: + """Assemble a validated check-in config dict from create/config flags. + + Only fields the caller supplied (non-``None``) are included, so ``config`` + stays a partial update. Enum/range checks mirror the server contract to fail + fast; the server remains authoritative (and rejects unknown fields with 400). + """ + config: dict[str, Any] = {} + + for value, field, label in ( + (start_on, "start_on", "start date"), + (end_on, "end_on", "end date"), + ): + if value is not None: + if not _DATE_PATTERN.match(value): + raise AuthoringError(f"Invalid {label} '{value}'. Use YYYY-MM-DD.") + config[field] = value + + if frequency_type is not None: + config["frequency_type"] = _check_enum(frequency_type, FREQUENCY_TYPES, "frequency") + if frequency is not None: + if frequency < 1: + raise AuthoringError(f"--every must be >= 1 (got {frequency}).") + config["frequency"] = frequency + if is_trigger_based is not None: + config["is_trigger_based"] = is_trigger_based + if use_participant_timezone is not None: + config["use_participant_timezone"] = use_participant_timezone + + if reminders_max_count is not None: + config["reminders_max_count"] = _check_int_range( + reminders_max_count, 0, REMINDERS_MAX_COUNT, "--reminders" + ) + if reminders_frequency_time is not None: + config["reminders_frequency_time"] = _check_int_range( + reminders_frequency_time, 0, REMINDER_INTERVAL_MAX, "--reminder-interval" + ) + if reminders_trigger_condition is not None: + config["reminders_trigger_condition"] = _check_enum( + reminders_trigger_condition, REMINDER_CONDITIONS, "reminder condition" + ) + + for flag, flag_field in ( + (use_user_defined_work_days, "use_user_defined_work_days"), + (allow_responses_before_trigger, "allow_responses_before_trigger"), + (allow_past_responses, "allow_past_responses"), + (allow_future_responses, "allow_future_responses"), + (is_anonymous, "is_anonymous"), + (send_reports_one_by_one, "send_reports_one_by_one"), + ): + if flag is not None: + config[flag_field] = flag + + if privacy is not None: + config["privacy"] = _check_enum(privacy, PRIVACY_LEVELS, "privacy") + + for text, text_field in ( + (custom_template_intro, "custom_template_intro"), + (custom_template_outro, "custom_template_outro"), + ): + if text is not None: + if not INTRO_OUTRO_MIN_LEN <= len(text) <= INTRO_OUTRO_MAX_LEN: + raise AuthoringError( + f"{text_field} must be {INTRO_OUTRO_MIN_LEN}-{INTRO_OUTRO_MAX_LEN} characters." + ) + config[text_field] = text + + if time_for_report is not None: + if not _REPORT_TIME_PATTERN.match(time_for_report): + raise AuthoringError(f"Invalid --report-time '{time_for_report}'. Use HH:MM.") + config["time_for_report"] = time_for_report + + if reminder_tone is not None: + config["reminder_tone"] = _check_enum(reminder_tone, REMINDER_TONES, "reminder tone") + + # Smart / AI check-in. The dependency rules (intelligence needs smart; a + # clarifying cap needs intelligence) are enforced server-side and echoed as + # `intelligence_requires_smart_checkin` — we don't approximate them here so a + # partial config flip (e.g. enabling intelligence on an already-smart check-in) + # isn't rejected by the CLI before the server can decide. + if is_smart_checkin is not None: + config["is_smart_checkin"] = is_smart_checkin + if is_intelligence_enabled is not None: + config["is_intelligence_enabled"] = is_intelligence_enabled + if max_clarifying_questions is not None: + config["max_clarifying_questions"] = _check_int_range( + max_clarifying_questions, 0, MAX_CLARIFYING_QUESTIONS, "--max-clarifying" + ) + + if frequency_advanced is not None: + config["frequency_advanced"] = _check_enum( + frequency_advanced, FREQUENCY_ADVANCED, "advanced frequency" + ) + if frequency_cron is not None: + if len(frequency_cron.split()) != CRON_FIELD_COUNT: + raise AuthoringError( + f"Invalid --cron '{frequency_cron}'. Use a 5-field cron expression " + "(minute hour day-of-month month day-of-week), e.g. '0 9 * * 1,3,5'." + ) + config["frequency_cron"] = frequency_cron + + return config + + +def parse_workflow_states(states: tuple[str, ...]) -> list[dict[str, str]]: + """Parse ``--state "Label:#color"`` flags into ``[{label, color}]``. + + The server derives ``key``/``order``; the client sends label + hex color. + Fails fast on an empty label, a missing/invalid color, or too many states. + """ + parsed: list[dict[str, str]] = [] + for raw in states: + if ":" not in raw: + raise AuthoringError( + f'Invalid --state \'{raw}\'. Use "Label:#RRGGBB" (e.g. "Draft:#cccccc").' + ) + label, color = raw.rsplit(":", 1) + label = label.strip() + color = color.strip() + if not label: + raise AuthoringError(f"Invalid --state '{raw}': the label is empty.") + if not _HEX_COLOR_PATTERN.match(color): + raise AuthoringError( + f"Invalid --state color '{color}' for '{label}'. Use a hex color like #3498db." + ) + parsed.append({"label": label, "color": color}) + if len(parsed) > WORKFLOW_MAX_STATES: + raise AuthoringError( + f"Too many workflow states ({len(parsed)}); the limit is {WORKFLOW_MAX_STATES}." + ) + return parsed + + +def build_workflow(states: tuple[str, ...], no_workflow: bool) -> dict[str, Any] | None: + """Build the ``workflow`` object from ``--state`` flags / ``--no-workflow``. + + Returns ``{"enabled": False}`` to turn states off, ``{"enabled": True, + "states": [...]}`` when states are given, or ``None`` when neither was passed + (so the field is omitted from the partial update). + """ + if no_workflow: + if states: + raise AuthoringError("Pass either --state ... or --no-workflow, not both.") + return {"enabled": False} + if states: + return {"enabled": True, "states": parse_workflow_states(states)} + return None + + +def build_form_audience( + mode: str | None, + users: tuple[str, ...], + teams: tuple[str, ...], + client: DailyBotClient, + flag: str, +) -> dict[str, Any] | None: + """Build a permission-audience object (``who_can_edit`` etc.). + + Explicit ``--{flag}-user`` / ``--{flag}-team`` imply ``restricted`` and are + resolved from names/UUIDs. Otherwise ``mode`` must be ``everyone`` or + ``owner_and_admins``. Returns ``None`` when nothing was provided. + """ + if users or teams: + if mode not in (None, "restricted"): + raise AuthoringError( + f"--{flag} {mode} conflicts with --{flag}-user/--{flag}-team " + "(those imply 'restricted')." + ) + resolved: dict[str, Any] = parse_participants(users, teams, client) + # Server uses full-replace semantics (an omitted key means empty), so send + # both keys explicitly to make the replace self-documenting. + return { + "mode": "restricted", + "user_uuids": resolved.get("user_uuids", []), + "team_uuids": resolved.get("team_uuids", []), + } + if mode is None: + return None + normalized: str = _check_enum(mode, FORM_PERMISSION_MODES, f"--{flag}") + if normalized == "restricted": + raise AuthoringError( + f"--{flag} restricted needs --{flag}-user and/or --{flag}-team to name who." + ) + return {"mode": normalized} + + +def validate_command(command: str) -> str: + """Validate a ChatOps command shortcut name against the server charset.""" + normalized: str = command.strip().lower() + if not _COMMAND_PATTERN.match(normalized): + raise AuthoringError( + f"Invalid --command '{command}'. Use 1-31 chars: lowercase letters, " + "digits, '-' or '_', starting with a letter or digit." + ) + return normalized + + +def build_form_config( + *, + is_active: bool | None = None, + is_anonymous: bool | None = None, + allow_public_responses: bool | None = None, + require_email_and_name: bool | None = None, + brand_with_logo: bool | None = None, + allow_reopen_from_final_state: bool | None = None, + workflow: dict[str, Any] | None = None, + who_can_edit: dict[str, Any] | None = None, + who_can_see_responses: dict[str, Any] | None = None, + who_can_change_states: dict[str, Any] | None = None, + use_for_approval: bool | None = None, + approvers: dict[str, Any] | None = None, + command_enabled: bool | None = None, + command: str | None = None, +) -> dict[str, Any]: + """Assemble a validated form-config dict from create/config flags. + + Only fields the caller supplied (non-``None``) are included, so ``config`` stays + a partial update. Structured values (workflow, audiences, approvers) arrive + already built/validated from the command layer. The server remains authoritative + (and rejects unknown fields with 400 ``unknown_field``). + """ + config: dict[str, Any] = {} + for flag, field in ( + (is_active, "is_active"), + (is_anonymous, "is_anonymous"), + (allow_public_responses, "allow_public_responses"), + (require_email_and_name, "require_email_and_name"), + (brand_with_logo, "brand_with_logo"), + (allow_reopen_from_final_state, "allow_reopen_from_final_state"), + (use_for_approval, "use_for_approval"), + ): + if flag is not None: + config[field] = flag + for value, field in ( + (workflow, "workflow"), + (who_can_edit, "who_can_edit"), + (who_can_see_responses, "who_can_see_responses"), + (who_can_change_states, "who_can_change_states"), + (approvers, "approvers"), + ): + if value is not None: + config[field] = value + if command is not None: + config["command"] = validate_command(command) + config["command_enabled"] = True + elif command_enabled is False: + config["command_enabled"] = False + return config + + +def resolve_form_config( + client: DailyBotClient, + *, + is_active: bool | None = None, + is_anonymous: bool | None = None, + allow_public_responses: bool | None = None, + require_email_and_name: bool | None = None, + brand_with_logo: bool | None = None, + allow_reopen_from_final_state: bool | None = None, + states: tuple[str, ...] = (), + no_workflow: bool = False, + can_edit: str | None = None, + can_edit_users: tuple[str, ...] = (), + can_edit_teams: tuple[str, ...] = (), + can_see: str | None = None, + can_see_users: tuple[str, ...] = (), + can_see_teams: tuple[str, ...] = (), + can_change_states: str | None = None, + change_states_users: tuple[str, ...] = (), + change_states_teams: tuple[str, ...] = (), + use_for_approval: bool | None = None, + approver_users: tuple[str, ...] = (), + approver_teams: tuple[str, ...] = (), + no_approvers: bool = False, + command: str | None = None, + no_command: bool = False, +) -> dict[str, Any]: + """Turn the shared form-config flags into a validated ``config`` dict. + + Resolves the three permission audiences and the approver list (names/UUIDs) via + the client, builds the workflow object, and defers field validation to + ``build_form_config``. Returns only the keys the caller supplied. + """ + if command is not None and no_command: + raise AuthoringError("Pass either --command NAME or --no-command, not both.") + if no_approvers and (approver_users or approver_teams): + raise AuthoringError( + "Pass either --approver-user/--approver-team or --no-approvers, not both." + ) + approvers: dict[str, Any] | None = None + if no_approvers: + approvers = {"user_uuids": [], "team_uuids": []} + elif approver_users or approver_teams: + resolved: dict[str, Any] = parse_participants(approver_users, approver_teams, client) + # Full-replace: send both keys so the approver set is unambiguous. + approvers = { + "user_uuids": resolved.get("user_uuids", []), + "team_uuids": resolved.get("team_uuids", []), + } + return build_form_config( + is_active=is_active, + is_anonymous=is_anonymous, + allow_public_responses=allow_public_responses, + require_email_and_name=require_email_and_name, + brand_with_logo=brand_with_logo, + allow_reopen_from_final_state=allow_reopen_from_final_state, + workflow=build_workflow(states, no_workflow), + who_can_edit=build_form_audience( + can_edit, can_edit_users, can_edit_teams, client, "can-edit" + ), + who_can_see_responses=build_form_audience( + can_see, can_see_users, can_see_teams, client, "can-see" + ), + who_can_change_states=build_form_audience( + can_change_states, change_states_users, change_states_teams, client, "can-change-states" + ), + use_for_approval=use_for_approval, + approvers=approvers, + command=command, + command_enabled=False if no_command else None, + ) + + +def build_questions_interactively(ai_short_question: bool = False) -> list[dict[str, Any]]: + """Walk the user through building questions with questionary prompts. + + Every question passes through ``build_question``, so the interactive path + enforces the same validation as the flag and file paths. Requires a TTY. Unless + ``ai_short_question`` opts in, the user is asked for a report title per question + (blank re-asks) so interactive-built questions carry an explicit + ``short_question`` like every other CLI path. + """ + if not sys.stdin.isatty(): + raise AuthoringError( + "--interactive requires a terminal. Use --questions-file or inline " + "flags in a non-interactive context." + ) + + questions: list[dict[str, Any]] = [] + while len(questions) < MAX_QUESTIONS: + qtype: str | None = questionary.select( + "Question type:", choices=list(VALID_QUESTION_TYPES) + ).ask() + if qtype is None: + break + text: str | None = questionary.text("Question text:").ask() + if text is None: + break + options: list[str] | None = None + if qtype == "multiple_choice": + raw_options: str | None = questionary.text("Options (comma-separated):").ask() + options = parse_options(raw_options) + required: bool | None = questionary.confirm("Required?", default=True).ask() + is_blocker: bool | None = questionary.confirm( + "Is this the blocker question?", default=False + ).ask() + short_question: str | None = None + if not ai_short_question: + prompt: str = "Short report title:" + while True: + answer: str | None = questionary.text(prompt).ask() + if answer is None: + break + if answer.strip(): + short_question = answer.strip() + break + prompt = "Short report title (required — or restart with --ai-short-question):" + + try: + questions.append( + build_question( + qtype, + text or "", + options=options, + required=bool(required), + is_blocker=bool(is_blocker), + short_question=short_question, + ) + ) + except AuthoringError as exc: + print_error(exc.message) + continue + + if not questionary.confirm("Add another question?", default=True).ask(): + break + return questions + + +def prompt_participants_interactively(client: DailyBotClient) -> dict[str, Any]: + """Interactively pick check-in participants (teams and/or people). + + A check-in must always have at least one participant, so ``checkin create`` + calls this when none were passed on the command line. The first team is + suggested (pre-checked) as a sensible default. Requires a TTY; returns + ``{user_uuids?, team_uuids?}`` — an empty dict if there's no terminal, no + directory, or nothing was selected (the caller then errors out). + """ + if not sys.stdin.isatty(): + return {} + teams: list[dict[str, Any]] = client.list_teams() + users: list[dict[str, Any]] = client.list_users() + choices: list[questionary.Choice] = [] + for index, team in enumerate(teams): + team_uuid: str = str(team.get("uuid") or team.get("id") or "") + if not team_uuid: + continue + choices.append( + questionary.Choice( + title=f"team · {team.get('name') or team_uuid}", + value=("team", team_uuid), + checked=(index == 0), # suggest the default team + ) + ) + for user in users: + user_uuid: str = str(user.get("uuid") or user.get("id") or "") + if not user_uuid: + continue + label: Any = user.get("name") or user.get("full_name") or user.get("email") or user_uuid + choices.append(questionary.Choice(title=f"user · {label}", value=("user", user_uuid))) + if not choices: + return {} + selected: list[tuple[str, str]] | None = questionary.checkbox( + "Add participants — a check-in needs at least one (teams or people):", + choices=choices, + ).ask() + if not selected: + return {} + participants: dict[str, Any] = {} + team_ids: list[str] = [uuid for kind, uuid in selected if kind == "team"] + user_ids: list[str] = [uuid for kind, uuid in selected if kind == "user"] + if user_ids: + participants["user_uuids"] = user_ids + if team_ids: + participants["team_uuids"] = team_ids + return participants + + +def parse_participants( + users: tuple[str, ...], + teams: tuple[str, ...], + client: DailyBotClient, +) -> dict[str, Any]: + """Resolve name-or-UUID participants into ``{user_uuids, team_uuids}``. + + Empty groups are omitted. Resolution reuses the shared resolver helpers, so + ambiguous or unknown names raise the same friendly errors as other commands. + """ + participants: dict[str, Any] = {} + try: + if users: + # include_email lets an admin/manager resolve people by email; it's + # server-gated (silently omitted for members), so name/UUID still work. + directory: list[dict[str, Any]] = client.list_users(include_email=True) + participants["user_uuids"] = [ + resolve_user_by_name_or_uuid(directory, identifier)[0] for identifier in users + ] + if teams: + team_directory: list[dict[str, Any]] = client.list_teams() + participants["team_uuids"] = [ + resolve_team_by_name_or_uuid(team_directory, identifier)[0] for identifier in teams + ] + except ValueError as exc: + # The shared resolvers raise ValueError for unknown/ambiguous names; surface + # it as a friendly authoring error instead of an uncaught traceback. + raise AuthoringError(str(exc)) from exc + return participants diff --git a/dailybot_cli/commands/channels.py b/dailybot_cli/commands/channels.py new file mode 100644 index 0000000..d3825a3 --- /dev/null +++ b/dailybot_cli/commands/channels.py @@ -0,0 +1,49 @@ +"""Report-channel discovery commands for the user-scoped public API.""" + +from typing import Any + +import click + +from dailybot_cli.api_client import APIError +from dailybot_cli.commands.public_api_helpers import ( + emit_json, + exit_for_api_error, + require_auth, +) +from dailybot_cli.display import console, print_report_channels + + +@click.group() +def channels() -> None: + """Discover report channels with your Dailybot session. + + \b + Acts as you — visibility and permissions match the webapp for your account. + """ + + +@channels.command("list") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def channels_list(json_mode: bool) -> None: + """List report channels you can attach to forms and check-ins. + + \b + Acts as you. Use a channel UUID with `--report-channel` on + `form create/edit` or `checkin create/config`. + + \b + Examples: + dailybot channels list + dailybot channels list --json + """ + client = require_auth() + try: + with console.status("Loading report channels..."): + data: list[dict[str, Any]] = client.list_report_channels() + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(data) + return + print_report_channels(data) diff --git a/dailybot_cli/commands/checkin.py b/dailybot_cli/commands/checkin.py index 5c15d2b..3078081 100644 --- a/dailybot_cli/commands/checkin.py +++ b/dailybot_cli/commands/checkin.py @@ -1,10 +1,35 @@ """Check-in commands for the user-scoped public API.""" import sys +from collections.abc import Callable +from typing import Any import click -from dailybot_cli.commands.public_api_helpers import require_auth +from dailybot_cli.api_client import APIError +from dailybot_cli.commands.authoring_helpers import ( + AuthoringError, + build_checkin_config, + build_question, + build_question_edit_fields, + build_questions_interactively, + check_report_channels, + parse_options, + parse_participants, + parse_questions_file, + parse_schedule, + prompt_participants_interactively, + question_extras_options, + require_short_question, + require_short_questions, + resolve_question_extras, +) +from dailybot_cli.commands.public_api_helpers import ( + confirm_write, + emit_json, + exit_for_api_error, + require_auth, +) from dailybot_cli.commands.user_scoped_actions import ( execute_checkin_complete, execute_checkin_edit, @@ -14,10 +39,156 @@ execute_checkin_show, execute_checkin_status, ) +from dailybot_cli.display import ( + console, + print_archived, + print_checkin_created, + print_question, + print_reordered, + print_success, +) _HELP: str = "Acts as you. You can only see and act on what you could in the webapp." +def _config_flag_options(func: Callable[..., Any]) -> Callable[..., Any]: + """Attach the shared check-in configuration flags to create + config. + + Dest names match ``authoring_helpers.build_checkin_config`` kwargs so the + callback can forward them as ``**config_flags``. Toggle flags default to + ``None`` (unset → not sent), keeping ``config`` a partial update. + """ + options: list[Callable[..., Any]] = [ + click.option("--start-on", "start_on", default=None, help="Start date (YYYY-MM-DD)."), + click.option("--end-on", "end_on", default=None, help="End date (YYYY-MM-DD)."), + click.option( + "--frequency", + "frequency_type", + default=None, + help="Recurrence base (weekly). For monthly/custom use --frequency-advanced.", + ), + click.option("--every", "frequency", type=int, default=None, help="Repeat every N (>=1)."), + click.option( + "--trigger-based/--fixed-time", + "is_trigger_based", + default=None, + help="Trigger-based vs a fixed time.", + ), + click.option( + "--participant-timezone/--custom-timezone", + "use_participant_timezone", + default=None, + help="Use each participant's timezone vs the custom one.", + ), + click.option( + "--reminders", + "reminders_max_count", + type=int, + default=None, + help="Extra reminders to send to non-responders (0-5; 0 = off).", + ), + click.option( + "--reminder-interval", + "reminders_frequency_time", + type=int, + default=None, + help="Minutes between reminders (0-60).", + ), + click.option( + "--reminder-condition", + "reminders_trigger_condition", + default=None, + help="smart_frequency / fixed_frequency.", + ), + click.option( + "--work-days/--no-work-days", + "use_user_defined_work_days", + default=None, + help="Respect each user's work days.", + ), + click.option( + "--allow-early/--no-early", + "allow_responses_before_trigger", + default=None, + help="Allow responses before the trigger time.", + ), + click.option( + "--allow-past/--no-past", + "allow_past_responses", + default=None, + help="Allow reports on past dates.", + ), + click.option( + "--allow-future/--no-future", + "allow_future_responses", + default=None, + help="Allow reports on future dates.", + ), + click.option( + "--anonymous/--no-anonymous", + "is_anonymous", + default=None, + help="Anonymous responses.", + ), + click.option("--privacy", "privacy", default=None, help="Response visibility level."), + click.option( + "--one-by-one/--aggregated", + "send_reports_one_by_one", + default=None, + help="Post reports one-by-one vs aggregated.", + ), + click.option( + "--intro", "custom_template_intro", default=None, help="Custom intro (3-1024 chars)." + ), + click.option( + "--outro", "custom_template_outro", default=None, help="Custom outro (3-1024 chars)." + ), + click.option( + "--report-time", "time_for_report", default=None, help="Report delivery time (HH:MM)." + ), + click.option( + "--reminder-tone", + "reminder_tone", + default=None, + help="Reminder voice: standard / persuasive.", + ), + click.option( + "--smart/--no-smart", + "is_smart_checkin", + default=None, + help="Enable smart (AI-driven adaptive) check-in mode.", + ), + click.option( + "--intelligence/--no-intelligence", + "is_intelligence_enabled", + default=None, + help="Enable AI insights on responses (requires --smart).", + ), + click.option( + "--max-clarifying", + "max_clarifying_questions", + type=int, + default=None, + help="Max AI clarifying questions per response (0-5; requires --intelligence).", + ), + click.option( + "--frequency-advanced", + "frequency_advanced", + default=None, + help="Advanced recurrence: disabled / monthly / custom.", + ), + click.option( + "--cron", + "frequency_cron", + default=None, + help="5-field cron for custom cadence (e.g. '0 9 * * 1,3,5').", + ), + ] + for option in reversed(options): + func = option(func) + return func + + @click.group() def checkin() -> None: """Manage check-ins with your Dailybot session. @@ -215,3 +386,447 @@ def checkin_edit( json_mode=json_mode, interactive=(not answer and not json_mode and sys.stdin.isatty()), ) + + +@checkin.command("create") +@click.option("--name", "-n", required=True, help="Check-in name.") +@click.option("--time", "time_", default=None, help="Trigger time (HH:MM).") +@click.option("--days", default=None, help="Comma-separated weekdays 0-6 (0=Sunday .. 6=Saturday).") +@click.option("--timezone", default=None, help="IANA timezone (e.g. America/New_York).") +@click.option("--schedule-file", default=None, help="Path to a JSON schedule object.") +@click.option( + "--user", "users", multiple=True, help="Participant user (name, email, or UUID; repeatable)." +) +@click.option("--team", "teams", multiple=True, help="Participant team (name or UUID; repeatable).") +@click.option("--questions-file", default=None, help="Path to a JSON array of question objects.") +@click.option( + "--interactive", + is_flag=True, + help="Build questions interactively (requires a terminal).", +) +@click.option( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable). See `dailybot channels list`.", +) +@click.option( + "--ai-short-question", + "ai_short_question", + is_flag=True, + help="Let Dailybot's AI generate each question's report title instead of " + "requiring a 'short_question' on every question.", +) +@_config_flag_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_create( + name: str, + time_: str | None, + days: str | None, + timezone: str | None, + schedule_file: str | None, + users: tuple[str, ...], + teams: tuple[str, ...], + questions_file: str | None, + interactive: bool, + report_channels: tuple[str, ...], + ai_short_question: bool, + json_mode: bool, + **config_flags: Any, +) -> None: + """Create a check-in with a schedule, participants, questions, and config. + + \b + Creating check-ins is role-gated server-side (admins/managers). Seed questions + with --questions-file or --interactive, or add them later with + `dailybot checkin questions add`. The scheduling/behavior flags (frequency, + reminders, timezone mode, submission rules, privacy) mirror the web UI. + + \b + Examples: + dailybot checkin create -n "Daily Standup" --time 09:00 --days 1,2,3,4,5 \\ + --timezone America/New_York --questions-file questions.json --team "Eng" + dailybot checkin create -n "Standup" --team "Eng" --frequency weekly \\ + --reminders 3 --reminder-interval 30 --no-past --json + + \b + A check-in must have at least one participant (a team or a person) — it only + triggers for its participants. If you pass neither --user nor --team, an + interactive terminal prompts you to pick some (the default team is suggested); + a non-interactive run errors instead of creating an empty check-in. + """ + client = require_auth() + check_report_channels(report_channels) + schedule: dict[str, Any] | None = parse_schedule( + days=days, time=time_, timezone=timezone, schedule_file=schedule_file + ) + config: dict[str, Any] = build_checkin_config(**config_flags) + participants: dict[str, Any] = parse_participants(users, teams, client) + if not participants and not json_mode: + participants = prompt_participants_interactively(client) + if not participants: + raise AuthoringError( + "A check-in must have at least one participant (a team or a person). " + "Add --user and/or --team." + ) + if interactive: + questions: list[dict[str, Any]] | None = build_questions_interactively(ai_short_question) + elif questions_file: + questions = parse_questions_file(questions_file) + else: + questions = None + if questions: + require_short_questions(questions, ai_short_question) + try: + with console.status("Creating check-in..."): + result: dict[str, Any] = client.create_checkin( + name, + schedule=schedule, + participants=participants or None, + questions=questions, + report_channels=list(report_channels) if report_channels else None, + config=config or None, + generate_short_question=ai_short_question, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_checkin_created(result) + + +@checkin.command("config") +@click.argument("followup_uuid") +@click.option("--name", "-n", default=None, help="New check-in name.") +@click.option("--time", "time_", default=None, help="New trigger time (HH:MM).") +@click.option("--days", default=None, help="New weekdays 0-6 (comma-separated).") +@click.option("--timezone", default=None, help="New IANA timezone.") +@click.option( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable); replaces the check-in's channels.", +) +@click.option( + "--user", "users", multiple=True, help="Participant user (name, email, or UUID; repeatable)." +) +@click.option("--team", "teams", multiple=True, help="Participant team (name or UUID; repeatable).") +@click.option("--active/--inactive", "is_active", default=None, help="Activate or deactivate.") +@_config_flag_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_config( + followup_uuid: str, + name: str | None, + time_: str | None, + days: str | None, + timezone: str | None, + report_channels: tuple[str, ...], + users: tuple[str, ...], + teams: tuple[str, ...], + is_active: bool | None, + json_mode: bool, + **config_flags: Any, +) -> None: + """Edit a check-in's configuration (partial update). + + \b + Distinct from `checkin edit`, which edits your own response. This edits the + check-in definition (role-gated server-side). --user/--team replace the + check-in's participants (a check-in always needs at least one). The + scheduling/behavior flags (frequency, reminders, timezone mode, submission + rules, privacy) mirror the web UI; only the flags you pass change. + + \b + Examples: + dailybot checkin config --time 10:00 --days 1,2,3,4,5 + dailybot checkin config --team "Engineering" + dailybot checkin config --reminders 3 --reminder-interval 30 + dailybot checkin config --no-past --privacy everyone --inactive + """ + check_report_channels(report_channels) + schedule: dict[str, Any] | None = parse_schedule(days=days, time=time_, timezone=timezone) + config: dict[str, Any] = build_checkin_config(**config_flags) + if ( + name is None + and schedule is None + and not report_channels + and not users + and not teams + and is_active is None + and not config + ): + raise click.UsageError( + "Nothing to edit. Pass --name, --time/--days/--timezone, --report-channel, " + "--user/--team, --active/--inactive, or a config flag (see --help)." + ) + client = require_auth() + participants: dict[str, Any] = parse_participants(users, teams, client) + try: + with console.status("Updating check-in..."): + result: dict[str, Any] = client.update_checkin_config( + followup_uuid, + name=name, + schedule=schedule, + report_channels=list(report_channels) if report_channels else None, + is_active=is_active, + participants=participants or None, + config=config or None, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success(f"Check-in {followup_uuid} updated.") + print_checkin_created(result, updated=True) + + +@checkin.command("archive") +@click.argument("followup_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 checkin_archive(followup_uuid: str, assume_yes: bool, json_mode: bool) -> None: + """Archive (soft-delete) a check-in. + + \b + Data is preserved server-side. Distinct from `checkin reset`, which deletes + your response for a day. + + \b + Examples: + dailybot checkin archive + dailybot checkin archive --yes + """ + client = require_auth() + confirm_write( + [ + f"Check-in UUID: {followup_uuid}", + "This will archive (soft-delete) the check-in.", + ], + assume_yes, + ) + try: + with console.status("Archiving check-in..."): + client.archive_checkin(followup_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"archived": True, "followup_uuid": followup_uuid}) + return + print_archived("check-in", followup_uuid) + + +@checkin.group("questions") +def checkin_questions() -> None: + """Manage a check-in's questions (add / edit / delete / reorder).""" + + +@checkin_questions.command("add") +@click.argument("followup_uuid") +@click.option( + "--type", "question_type", required=True, help="text/multiple_choice/boolean/numeric." +) +@click.option("--question", required=True, help="The question text.") +@click.option("--options", default=None, help="Comma-separated options (multiple_choice only).") +@click.option("--required/--optional", "required", default=True, help="Mark the question required.") +@click.option( + "--blocker/--no-blocker", + "is_blocker", + default=False, + help="Tag this as the blocker question.", +) +@question_extras_options +@click.option( + "--ai-short-question", + "ai_short_question", + is_flag=True, + help="Skip --short-question and let Dailybot's AI generate the report title.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_questions_add( + followup_uuid: str, + question_type: str, + question: str, + options: str | None, + required: bool, + is_blocker: bool, + ai_short_question: bool, + json_mode: bool, + **extra_flags: Any, +) -> None: + """Add a question to a check-in. + + \b + A report title (--short-question) is required unless you pass + --ai-short-question to let Dailybot generate it. + + \b + Examples: + dailybot checkin questions add --type text \\ + --question "What are you working on today?" --short-question "Focus" + dailybot checkin questions add --type boolean \\ + --question "Any blockers?" --blocker --ai-short-question + dailybot checkin questions add --type text \\ + --question "What did you do?" --short-question "Yesterday" \\ + --variation "What did you accomplish?" --jump-if-equals "None" --jump-to -1 + """ + client = require_auth() + require_short_question(extra_flags.get("short_question"), ai_short_question) + extras: dict[str, Any] = resolve_question_extras(**extra_flags) + payload: dict[str, Any] = build_question( + question_type, + question, + options=parse_options(options), + required=required, + is_blocker=is_blocker, + **extras, + ) + if ai_short_question: + payload["generate_short_question"] = True + try: + with console.status("Adding question..."): + result: dict[str, Any] = client.add_checkin_question(followup_uuid, payload) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success("Question added.") + print_question(result) + + +@checkin_questions.command("edit") +@click.argument("followup_uuid") +@click.argument("question_uuid") +@click.option("--question", default=None, help="New question text.") +@click.option("--type", "question_type", default=None, help="New question type.") +@click.option("--options", default=None, help="New comma-separated options (multiple_choice).") +@click.option("--required/--optional", "required", default=None, help="Toggle required.") +@click.option( + "--blocker/--no-blocker", + "is_blocker", + default=None, + help="Toggle the blocker tag.", +) +@question_extras_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_questions_edit( + followup_uuid: str, + question_uuid: str, + question: str | None, + question_type: str | None, + options: str | None, + required: bool | None, + is_blocker: bool | None, + json_mode: bool, + **extra_flags: Any, +) -> None: + """Update a check-in question's text, type, options, required, blocker, or extras. + + \b + Extras: --short-question (report title), --variation (repeatable), and logic + via --logic-file or inline --jump-if-equals/--jump-to. + + \b + Examples: + dailybot checkin questions edit \\ + --question "Do you need help today?" + dailybot checkin questions edit --blocker + dailybot checkin questions edit \\ + --short-question "Help?" --logic-file branching.json + """ + extras: dict[str, Any] = resolve_question_extras(**extra_flags) + fields: dict[str, Any] = build_question_edit_fields( + question, question_type, options, required, is_blocker, **extras + ) + if not fields: + raise click.UsageError( + "Nothing to edit. Pass --question, --type, --options, --required, --blocker, " + "--short-question, --variation, or logic (--logic-file / --jump-if-equals + --jump-to)." + ) + client = require_auth() + try: + with console.status("Updating question..."): + result: dict[str, Any] = client.update_checkin_question( + followup_uuid, question_uuid, fields + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success("Question updated.") + print_question(result) + + +@checkin_questions.command("delete") +@click.argument("followup_uuid") +@click.argument("question_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 checkin_questions_delete( + followup_uuid: str, + question_uuid: str, + assume_yes: bool, + json_mode: bool, +) -> None: + """Remove a question from a check-in. + + \b + Examples: + dailybot checkin questions delete + """ + client = require_auth() + confirm_write( + [ + f"Check-in UUID: {followup_uuid}", + f"Question UUID: {question_uuid}", + "This will permanently remove the question.", + ], + assume_yes, + ) + try: + with console.status("Deleting question..."): + client.delete_checkin_question(followup_uuid, question_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"deleted": True, "followup_uuid": followup_uuid, "question_uuid": question_uuid}) + return + print_success(f"Question {question_uuid} deleted.") + + +@checkin_questions.command("reorder") +@click.argument("followup_uuid") +@click.argument("question_uuids", nargs=-1, required=True) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def checkin_questions_reorder( + followup_uuid: str, + question_uuids: tuple[str, ...], + json_mode: bool, +) -> None: + """Set a new question order (pass question UUIDs in the desired order). + + \b + Examples: + dailybot checkin questions reorder + """ + client = require_auth() + order: list[str] = list(question_uuids) + try: + with console.status("Reordering questions..."): + client.reorder_checkin_questions(followup_uuid, order) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"reordered": True, "followup_uuid": followup_uuid, "order": order}) + return + print_reordered("check-in", order) diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 2ac7c8a..2390066 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -1,10 +1,24 @@ """Form commands for the user-scoped public API.""" +from collections.abc import Callable from typing import Any import click from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.commands.authoring_helpers import ( + build_question, + build_question_edit_fields, + build_questions_interactively, + check_report_channels, + parse_options, + parse_questions_file, + question_extras_options, + require_short_question, + require_short_questions, + resolve_form_config, + resolve_question_extras, +) from dailybot_cli.commands.public_api_helpers import ( confirm_write, emit_json, @@ -19,11 +33,16 @@ ) from dailybot_cli.display import ( console, + print_archived, + print_form_created, print_form_detail, print_form_response_deleted, print_form_response_detail, print_form_response_state, print_form_responses_table, + print_question, + print_questions_table, + print_reordered, print_success, ) @@ -36,6 +55,104 @@ def _maybe_load_form(client: DailyBotClient, form_uuid: str) -> dict[str, Any] | return None +def _form_config_flag_options(func: Callable[..., Any]) -> Callable[..., Any]: + """Attach the shared form-configuration flags to create + config. + + Dest names match ``authoring_helpers.resolve_form_config`` kwargs so the + callback can forward them as ``**config_flags``. Toggle flags default to + ``None`` (unset → not sent), keeping ``config`` a partial update. + """ + options: list[Callable[..., Any]] = [ + click.option("--active/--inactive", "is_active", default=None, help="Activate/deactivate."), + click.option( + "--anonymous/--no-anonymous", + "is_anonymous", + default=None, + help="Collect responses anonymously.", + ), + click.option( + "--public/--no-public", + "allow_public_responses", + default=None, + help="Allow public (shared-link) responses.", + ), + click.option( + "--brand/--no-brand", + "brand_with_logo", + default=None, + help="Brand the public form with your org logo.", + ), + click.option( + "--require-identity/--no-require-identity", + "require_email_and_name", + default=None, + help="Make email + name mandatory on public responses.", + ), + click.option( + "--reopen-from-final/--no-reopen-from-final", + "allow_reopen_from_final_state", + default=None, + help="Allow moving a response out of the final state.", + ), + click.option( + "--state", + "states", + multiple=True, + help='Workflow state "Label:#color" (repeatable, ordered). Enables the workflow.', + ), + click.option( + "--no-workflow", "no_workflow", is_flag=True, help="Turn the workflow/states off." + ), + click.option( + "--can-edit", "can_edit", default=None, help="everyone / owner_and_admins / restricted." + ), + click.option("--can-edit-user", "can_edit_users", multiple=True, help="Restricted editor."), + click.option("--can-edit-team", "can_edit_teams", multiple=True, help="Restricted editor."), + click.option( + "--can-see", "can_see", default=None, help="everyone / owner_and_admins / restricted." + ), + click.option("--can-see-user", "can_see_users", multiple=True, help="Restricted viewer."), + click.option("--can-see-team", "can_see_teams", multiple=True, help="Restricted viewer."), + click.option( + "--can-change-states", + "can_change_states", + default=None, + help="everyone / owner_and_admins / restricted.", + ), + click.option( + "--change-states-user", "change_states_users", multiple=True, help="Restricted mover." + ), + click.option( + "--change-states-team", "change_states_teams", multiple=True, help="Restricted mover." + ), + click.option( + "--approval/--no-approval", + "use_for_approval", + default=None, + help="File new submissions for approval.", + ), + click.option( + "--approver-user", + "approver_users", + multiple=True, + help="Approver (name, email, or UUID).", + ), + click.option( + "--approver-team", "approver_teams", multiple=True, help="Approver team (name or UUID)." + ), + click.option( + "--no-approvers", "no_approvers", is_flag=True, help="Clear the approver list." + ), + click.option("--command", "command", default=None, help="ChatOps shortcut name."), + click.option( + "--no-command", "no_command", is_flag=True, help="Remove the ChatOps shortcut." + ), + ] + for option in reversed(options): + func = option(func) + return func + + @click.group() def form() -> None: """Manage forms with your Dailybot session. @@ -46,20 +163,27 @@ def form() -> None: @form.command("list") +@click.option( + "--include-archived", + is_flag=True, + help="Include archived forms (hidden by default).", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") -def form_list(json_mode: bool) -> None: +def form_list(include_archived: bool, json_mode: bool) -> None: """List forms visible to you. \b Acts as you. You can only see and act on what you could in the webapp. + Archived forms are hidden unless you pass --include-archived. \b Examples: dailybot form list + dailybot form list --include-archived dailybot form list --json """ client = require_auth() - execute_form_list(client, json_mode=json_mode) + execute_form_list(client, json_mode=json_mode, include_archived=include_archived) @form.command("get") @@ -133,6 +257,17 @@ def form_submit( @form.command("responses") @click.argument("form_uuid") @click.option("--state", default=None, help="Filter by current_state (workflow forms only).") +@click.option( + "--all", + "all_responses", + is_flag=True, + help="List everyone's responses (admin/owner only; a member gets 403).", +) +@click.option("--user", default=None, help="Filter to one user's responses (admin/owner only).") +@click.option( + "--from", "date_from", default=None, help="Responses on/after this date (YYYY-MM-DD)." +) +@click.option("--to", "date_to", default=None, help="Responses on/before this date (YYYY-MM-DD).") @click.option( "--latest", is_flag=True, @@ -142,24 +277,38 @@ def form_submit( def form_responses( form_uuid: str, state: str | None, + all_responses: bool, + user: str | None, + date_from: str | None, + date_to: str | None, latest: bool, json_mode: bool, ) -> None: - """List your own responses on a form. + """List responses on a form. \b - Acts as you. The server returns only responses you authored. + Acts as you. By default the server returns only responses you authored. + --all / --user surface others' responses and are admin/owner-only + (server-enforced — a member receives 403). --from / --to narrow the window. \b Examples: dailybot form responses dailybot form responses --state qa --json - dailybot form responses --latest --json + dailybot form responses --all --from 2026-01-01 --to 2026-06-30 + dailybot form responses --user --json """ client = require_auth() try: with console.status("Fetching responses..."): - responses: list[dict[str, Any]] = client.list_form_responses(form_uuid, state=state) + responses: list[dict[str, Any]] = client.list_form_responses( + form_uuid, + state=state, + all_responses=all_responses, + user=user, + date_from=date_from, + date_to=date_to, + ) except APIError as exc: exit_for_api_error(exc, json_mode) @@ -223,10 +372,12 @@ def form_update( assume_yes: bool, json_mode: bool, ) -> None: - """Patch new answers into one of your own in-progress responses. + """Patch new answers into a response. \b - Own-only. Admins are NOT elevated to other users' responses here. + The server authorizes by role: you may always edit your own response, and a + form owner / org admin may edit anyone's (audited as metadata.last_edited_by). + A non-privileged edit of someone else's response returns 403. \b Examples: @@ -361,3 +512,454 @@ def form_delete( return print_form_response_deleted(form_uuid, response_uuid) + + +@form.command("create") +@click.option("--name", "-n", required=True, help="Form name.") +@click.option( + "--questions-file", + default=None, + help="Path to a JSON array of question objects to seed the form.", +) +@click.option( + "--interactive", + is_flag=True, + help="Build questions interactively (requires a terminal).", +) +@click.option( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable). See `dailybot channels list`.", +) +@click.option( + "--ai-short-question", + "ai_short_question", + is_flag=True, + help="Let Dailybot's AI generate each question's report title instead of " + "requiring a 'short_question' on every question.", +) +@_form_config_flag_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_create( + name: str, + questions_file: str | None, + interactive: bool, + report_channels: tuple[str, ...], + ai_short_question: bool, + json_mode: bool, + **config_flags: Any, +) -> None: + """Create a form (optionally seeded with questions and full config). + + \b + Creating forms is role-gated server-side (admins/managers as applicable). + Seed questions with --questions-file or --interactive, or add them later via + `dailybot form questions add`. Each seeded question needs a report title + (--short-question / "short_question") unless you pass --ai-short-question. The + config flags (workflow states, permissions, anonymous/public/approval, command) + mirror the web Setup tab. + + \b + Examples: + dailybot form create --name "Sprint Retro" + dailybot form create -n "Retro" --questions-file questions.json + dailybot form create -n "Release" --state "Draft:#ccc" --state "Done:#2ecc71" \\ + --command release --can-edit owner_and_admins --report-channel + """ + client = require_auth() + check_report_channels(report_channels) + if interactive: + questions: list[dict[str, Any]] | None = build_questions_interactively(ai_short_question) + elif questions_file: + questions = parse_questions_file(questions_file) + else: + questions = None + if questions: + require_short_questions(questions, ai_short_question) + config: dict[str, Any] = resolve_form_config(client, **config_flags) + try: + with console.status("Creating form..."): + result: dict[str, Any] = client.create_form( + name, + questions, + report_channels=list(report_channels) if report_channels else None, + generate_short_question=ai_short_question, + config=config or None, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_form_created(result) + + +@form.command("edit") +@click.argument("form_uuid") +@click.option("--name", "-n", default=None, help="New form name.") +@click.option( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable); replaces the form's channels.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_edit( + form_uuid: str, + name: str | None, + report_channels: tuple[str, ...], + json_mode: bool, +) -> None: + """Edit a form's name and/or report channels. + + \b + Examples: + dailybot form edit --name "Updated Retro" + dailybot form edit --report-channel --json + """ + if name is None and not report_channels: + raise click.UsageError("Nothing to edit. Pass --name and/or --report-channel.") + client = require_auth() + check_report_channels(report_channels) + try: + with console.status("Updating form..."): + result: dict[str, Any] = client.update_form_config( + form_uuid, + name=name, + report_channels=list(report_channels) if report_channels else None, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success(f"Form {form_uuid} updated.") + print_form_created(result, updated=True) + + +@form.command("config") +@click.argument("form_uuid") +@click.option("--name", "-n", default=None, help="New form name.") +@click.option( + "--report-channel", + "report_channels", + multiple=True, + help="Report-channel UUID (repeatable); replaces the form's channels.", +) +@_form_config_flag_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_config( + form_uuid: str, + name: str | None, + report_channels: tuple[str, ...], + json_mode: bool, + **config_flags: Any, +) -> None: + """Edit a form's full configuration (partial update). + + \b + Superset of `form edit`: name + channels plus workflow states, permissions, + anonymous/public/approval settings, and the ChatOps command — mirroring the web + Setup tab. Only the flags you pass change; role-gated server-side. + + \b + Examples: + dailybot form config --inactive --command release + dailybot form config --state "Draft:#ccc" --state "Done:#2ecc71" + dailybot form config --anonymous --public --require-identity + dailybot form config --can-see restricted --can-see-team "Eng" + dailybot form config --approval --approver-user "Jane Doe" + """ + client = require_auth() + check_report_channels(report_channels) + config: dict[str, Any] = resolve_form_config(client, **config_flags) + if name is None and not report_channels and not config: + raise click.UsageError( + "Nothing to edit. Pass --name, --report-channel, or a config flag (see --help)." + ) + try: + with console.status("Updating form..."): + result: dict[str, Any] = client.update_form_config( + form_uuid, + name=name, + report_channels=list(report_channels) if report_channels else None, + config=config or None, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success(f"Form {form_uuid} updated.") + print_form_created(result, updated=True) + + +@form.command("archive") +@click.argument("form_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_archive(form_uuid: str, assume_yes: bool, json_mode: bool) -> None: + """Archive (soft-delete) a form. + + \b + Data is preserved server-side. This is distinct from `form delete`, which + removes an individual response. + + \b + Examples: + dailybot form archive + dailybot form archive --yes + """ + client = require_auth() + confirm_write( + [f"Form UUID: {form_uuid}", "This will archive (soft-delete) the form."], + assume_yes, + ) + try: + with console.status("Archiving form..."): + client.archive_form(form_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"archived": True, "form_uuid": form_uuid}) + return + print_archived("form", form_uuid) + + +@form.group("questions") +def form_questions() -> None: + """Manage a form's questions (list / add / edit / delete / reorder).""" + + +@form_questions.command("list") +@click.argument("form_uuid") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_questions_list(form_uuid: str, json_mode: bool) -> None: + """List a form's questions. + + \b + Examples: + dailybot form questions list + """ + client = require_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) + + questions: list[dict[str, Any]] = data.get("questions") or [] + if json_mode: + emit_json(questions) + return + print_questions_table(questions) + + +@form_questions.command("add") +@click.argument("form_uuid") +@click.option( + "--type", "question_type", required=True, help="text/multiple_choice/boolean/numeric." +) +@click.option("--question", required=True, help="The question text.") +@click.option("--options", default=None, help="Comma-separated options (multiple_choice only).") +@click.option("--required/--optional", "required", default=True, help="Mark the question required.") +@click.option( + "--blocker/--no-blocker", + "is_blocker", + default=False, + help="Tag this as the blocker question.", +) +@question_extras_options +@click.option( + "--ai-short-question", + "ai_short_question", + is_flag=True, + help="Skip --short-question and let Dailybot's AI generate the report title.", +) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_questions_add( + form_uuid: str, + question_type: str, + question: str, + options: str | None, + required: bool, + is_blocker: bool, + ai_short_question: bool, + json_mode: bool, + **extra_flags: Any, +) -> None: + """Add a question to a form. + + \b + A report title (--short-question) is required unless you pass + --ai-short-question. Other extras: --variation (repeatable), and logic via + --logic-file or inline --jump-if-equals/--jump-to. + + \b + Examples: + dailybot form questions add --type text \\ + --question "What went well?" --short-question "Wins" + dailybot form questions add --type multiple_choice \\ + --question "Rating?" --options "Excellent,Good,Average,Poor" --ai-short-question + dailybot form questions add --type boolean --question "Ship it?" \\ + --short-question "Ship" --jump-if-equals "No" --jump-to 4 + """ + client = require_auth() + require_short_question(extra_flags.get("short_question"), ai_short_question) + extras: dict[str, Any] = resolve_question_extras(**extra_flags) + payload: dict[str, Any] = build_question( + question_type, + question, + options=parse_options(options), + required=required, + is_blocker=is_blocker, + **extras, + ) + if ai_short_question: + payload["generate_short_question"] = True + try: + with console.status("Adding question..."): + result: dict[str, Any] = client.add_form_question(form_uuid, payload) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success("Question added.") + print_question(result) + + +@form_questions.command("edit") +@click.argument("form_uuid") +@click.argument("question_uuid") +@click.option("--question", default=None, help="New question text.") +@click.option("--type", "question_type", default=None, help="New question type.") +@click.option("--options", default=None, help="New comma-separated options (multiple_choice).") +@click.option("--required/--optional", "required", default=None, help="Toggle required.") +@click.option( + "--blocker/--no-blocker", + "is_blocker", + default=None, + help="Toggle the blocker tag.", +) +@question_extras_options +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_questions_edit( + form_uuid: str, + question_uuid: str, + question: str | None, + question_type: str | None, + options: str | None, + required: bool | None, + is_blocker: bool | None, + json_mode: bool, + **extra_flags: Any, +) -> None: + """Update a question's text, type, options, required, blocker, or extras. + + \b + Extras: --short-question (report title), --variation (repeatable), and logic + via --logic-file or inline --jump-if-equals/--jump-to. + + \b + Examples: + dailybot form questions edit --question "Reworded?" + dailybot form questions edit --optional + dailybot form questions edit \\ + --short-question "Rating" --variation "How would you rate it?" + """ + extras: dict[str, Any] = resolve_question_extras(**extra_flags) + fields: dict[str, Any] = build_question_edit_fields( + question, question_type, options, required, is_blocker, **extras + ) + if not fields: + raise click.UsageError( + "Nothing to edit. Pass --question, --type, --options, --required, --blocker, " + "--short-question, --variation, or logic (--logic-file / --jump-if-equals + --jump-to)." + ) + client = require_auth() + try: + with console.status("Updating question..."): + result: dict[str, Any] = client.update_form_question(form_uuid, question_uuid, fields) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json(result) + return + print_success("Question updated.") + print_question(result) + + +@form_questions.command("delete") +@click.argument("form_uuid") +@click.argument("question_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_questions_delete( + form_uuid: str, + question_uuid: str, + assume_yes: bool, + json_mode: bool, +) -> None: + """Remove a question from a form. + + \b + Examples: + dailybot form questions delete + dailybot form questions delete --yes + """ + client = require_auth() + confirm_write( + [ + f"Form UUID: {form_uuid}", + f"Question UUID: {question_uuid}", + "This will permanently remove the question.", + ], + assume_yes, + ) + try: + with console.status("Deleting question..."): + client.delete_form_question(form_uuid, question_uuid) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"deleted": True, "form_uuid": form_uuid, "question_uuid": question_uuid}) + return + print_success(f"Question {question_uuid} deleted.") + + +@form_questions.command("reorder") +@click.argument("form_uuid") +@click.argument("question_uuids", nargs=-1, required=True) +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +def form_questions_reorder( + form_uuid: str, + question_uuids: tuple[str, ...], + json_mode: bool, +) -> None: + """Set a new question order (pass question UUIDs in the desired order). + + \b + Examples: + dailybot form questions reorder + """ + client = require_auth() + order: list[str] = list(question_uuids) + try: + with console.status("Reordering questions..."): + client.reorder_form_questions(form_uuid, order) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + if json_mode: + emit_json({"reordered": True, "form_uuid": form_uuid, "order": order}) + return + print_reordered("form", order) diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 7a94a41..a542575 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -67,6 +67,119 @@ "The check-in's questions changed since you fetched them. Re-run and try again." ), "response_date_format_is_invalid": "Invalid date. Use YYYY-MM-DD.", + # Forms & check-ins authoring (create/edit/questions/responses) + "invalid_question_type": ( + "Invalid question type. Use one of: text, multiple_choice, boolean, numeric." + ), + "multiple_choice_requires_options": ( + "Multiple-choice questions need at least one option (--options)." + ), + "question_label_required": "Question text is required.", + "short_question_required": ( + 'Each question needs a report title. Pass --short-question "", or ' + "--ai-short-question to let Dailybot generate it." + ), + "questions_limit_exceeded": "Too many questions (the limit is 50).", + "form_name_required": "A name is required.", + "invalid_schedule_days": ("Schedule days must be integers 0-6 (0=Sunday .. 6=Saturday)."), + "invalid_schedule_time": "Schedule time must be in HH:MM format (e.g. 09:00).", + "invalid_timezone": "Unknown timezone. Use an IANA name (e.g. America/New_York).", + "checkin_permission_denied": ( + "Your role doesn't allow authoring check-ins. Ask an admin or manager. " + "The CLI acts within your role and can't elevate." + ), + "form_edit_forbidden": ( + "You don't have permission to edit this form (you're not the owner or an admin). " + "The CLI acts within your role and can't elevate." + ), + "form_response_view_all_forbidden": ( + "Only admins/owners can list everyone's responses (--all / --user). " + "Without them you see only your own. The CLI acts within your role." + ), + "form_response_edit_forbidden": ( + "You can only edit your own response unless you're the form owner or an admin. " + "The CLI acts within your role and can't elevate." + ), + "form_not_found": "Form not found.", + "form_name_too_short": "Form name is too short (minimum 3 characters).", + # Form configuration (workflow / permissions / approval / command) + "workflow_requires_states": ( + 'A workflow needs at least one state. Pass --state "Label:#color" (or ' + "--no-workflow to turn states off)." + ), + "invalid_workflow_state": ( + 'Invalid workflow state. Use --state "Label:#color" with a non-empty label and a hex color.' + ), + "invalid_permission_audience": ( + "Invalid permission audience. Use everyone / owner_and_admins, or restricted " + "with at least one user/team." + ), + "invalid_approvers": "Invalid approvers. Name at least one user or team to approve.", + "invalid_command": ( + "Invalid --command. Use 1-31 chars: lowercase letters, digits, '-' or '_', " + "starting with a letter or digit." + ), + "command_already_exists": ( + "That ChatOps command is already used by another form. Pick a different --command." + ), + "checkin_not_found": "Check-in not found.", + "question_not_found": "Question not found on this form or check-in.", + # Question reorder validation + "question_uuids_required": "Reorder needs the list of question UUIDs to order.", + "question_uuids_incomplete": ( + "Reorder must include ALL of the resource's question UUIDs, not a subset." + ), + "question_uuids_duplicate": "Reorder has a duplicate question UUID — list each one once.", + "invalid_question_variations": ( + "Invalid question variations. Pass up to 10 non-empty alternate phrasings." + ), + # invalid_question_logic is intentionally NOT mapped: the server's detail names + # the offending operator/target and lists the valid values for the question + # type, which is more actionable than any generic message we could substitute. + "anonymous_irreversible": ( + "An anonymous check-in cannot be made non-anonymous. Create a new check-in " + "if you need non-anonymous responses." + ), + "report_channel_not_found": ( + "Report channel not found — run `dailybot channels list` to see the " + "channels available in your organization." + ), + "too_many_report_channels": "Too many report channels — the limit is 3.", + "checkin_requires_participant": ( + "A check-in must have at least one participant (a team or a person). " + "Add --user and/or --team." + ), + # Check-in configuration validation + "unknown_field": ( + "The server rejected an unrecognized field. Update the CLI " + "(`dailybot upgrade`) — this build sent a field the API doesn't accept yet." + ), + "invalid_start_on": "Invalid start date. Use YYYY-MM-DD.", + "invalid_end_on": "Invalid end date. Use YYYY-MM-DD.", + "invalid_frequency_type": ( + "Invalid --frequency. Only 'weekly' is accepted; for monthly or custom " + "cadences use --frequency-advanced (monthly / custom, with --cron for custom)." + ), + "invalid_frequency": "Invalid --every. Must be an integer >= 1.", + "invalid_reminder_count": "Invalid --reminders. Must be an integer 0-5.", + "invalid_reminder_interval": "Invalid --reminder-interval. Must be 0-60 minutes.", + "invalid_reminder_condition": ( + "Invalid --reminder-condition. Use smart_frequency or fixed_frequency." + ), + "invalid_privacy": "Invalid --privacy value. See `dailybot checkin config --help`.", + "invalid_time_for_report": "Invalid --report-time. Use HH:MM.", + "invalid_reminder_tone": "Invalid --reminder-tone. Use standard or persuasive.", + "invalid_max_clarifying_questions": "Invalid --max-clarifying. Must be an integer 0-5.", + "intelligence_requires_smart_checkin": ( + "AI features need smart mode. Enable --smart before --intelligence, and " + "--intelligence before --max-clarifying." + ), + "invalid_frequency_cron": ( + "Invalid --cron. Use a 5-field cron expression (e.g. '0 9 * * 1,3,5')." + ), + "invalid_frequency_advanced": ( + "Invalid --frequency-advanced. Use disabled, monthly, or custom." + ), } @@ -212,7 +325,7 @@ def resolve_user_by_name_or_uuid( users: list[dict[str, Any]], identifier: str, ) -> tuple[str, str]: - """Resolve a user UUID and display name from a UUID or name fragment.""" + """Resolve a user UUID and display name from a UUID, email, or name fragment.""" if UUID_PATTERN.match(identifier): for user in users: if user.get("uuid") == identifier: @@ -220,6 +333,23 @@ def resolve_user_by_name_or_uuid( return identifier, name return identifier, identifier + if "@" in identifier: + email_matches: list[dict[str, Any]] = [ + user for user in users if str(user.get("email", "")).lower() == identifier.lower() + ] + if len(email_matches) == 1: + hit: dict[str, Any] = email_matches[0] + return str(hit["uuid"]), str(hit.get("full_name") or hit.get("email") or hit["uuid"]) + if not email_matches: + # The directory only exposes emails to admins/managers; if no user has + # an email at all, the caller can't resolve by email — hint at that. + if not any(user.get("email") for user in users): + raise ValueError( + f'Cannot resolve "{identifier}" by email — email lookup needs ' + "admin/manager permissions. Use the person's name or UUID instead." + ) + raise ValueError(f'No user found with email "{identifier}".') + exact_matches: list[dict[str, Any]] = [ user for user in users if str(user.get("full_name", "")).lower() == identifier.lower() ] diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 6a4e818..09db44a 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -456,11 +456,14 @@ def execute_form_list( client: DailyBotClient, *, json_mode: bool = False, + include_archived: bool = False, ) -> list[dict[str, Any]] | None: """Fetch and display forms visible to the user (with question counts).""" try: with console.status("Fetching forms..."): - forms: list[dict[str, Any]] = client.list_forms(include_questions=True) + forms: list[dict[str, Any]] = client.list_forms( + include_questions=True, include_archived=include_archived + ) except APIError as exc: exit_for_api_error(exc, json_mode) @@ -542,14 +545,6 @@ def execute_user_list( return users -def _template_uuid(checkin: dict[str, Any]) -> str: - """Best-effort template UUID from a check-in detail payload.""" - template: Any = checkin.get("template") - if isinstance(template, dict): - return str(template.get("uuid") or template.get("id") or "") - return str(checkin.get("template_uuid") or template or "") - - def execute_checkin_status( client: DailyBotClient, *, @@ -575,29 +570,22 @@ def execute_checkin_show( *, json_mode: bool = False, ) -> None: - """Show a check-in's configuration and question definitions.""" + """Show a check-in's configuration and question definitions. + + Reads the canonical ``/detail/`` endpoint, which returns the schedule, + resolved participants, attached report channels and the canonical question + shape in a single call. + """ try: with console.status("Loading check-in..."): - checkin: dict[str, Any] = client.get_checkin(followup_uuid) - questions: list[dict[str, Any]] = [] - template_uuid: str = _template_uuid(checkin) - if template_uuid: - template: dict[str, Any] = client.get_template( - template_uuid, followup_uuid=followup_uuid - ) - raw_questions: Any = template.get("questions") or template.get("template_questions") - # The template endpoint nests questions as {"fields": [...]}. - if isinstance(raw_questions, dict): - raw_questions = raw_questions.get("fields") - if isinstance(raw_questions, list): - questions = raw_questions + detail: dict[str, Any] = client.get_checkin_detail(followup_uuid) except APIError as exc: exit_for_api_error(exc, json_mode) if json_mode: - emit_json({"checkin": checkin, "questions": questions}) + emit_json(detail) return - print_checkin_detail(checkin, questions) + print_checkin_detail(detail) def execute_checkin_history( diff --git a/dailybot_cli/display.py b/dailybot_cli/display.py index 745b31b..af8235e 100644 --- a/dailybot_cli/display.py +++ b/dailybot_cli/display.py @@ -491,31 +491,138 @@ def print_checkin_status_table(checkins: list[dict[str, Any]], *, date_label: st console.print(table) -def print_checkin_detail(checkin: dict[str, Any], questions: list[dict[str, Any]]) -> None: - """Display a check-in's configuration and question definitions.""" - lines: list[str] = [f"[bold]{_checkin_name(checkin)}[/bold]", f"UUID: {_checkin_uuid(checkin)}"] - schedule: dict[str, Any] = checkin.get("schedule") or {} - for label, key in (("Frequency", "frequency"), ("Time", "time"), ("Timezone", "timezone")): - value: Any = checkin.get(key) or schedule.get(key) +def _print_participants(participants: dict[str, Any]) -> None: + """Render resolved check-in participants (users/teams with names).""" + users: list[dict[str, Any]] = participants.get("users") or [] + teams: list[dict[str, Any]] = participants.get("teams") or [] + if not users and not teams: + return + table: Table = Table(title="Participants", border_style="cyan") + table.add_column("Type") + table.add_column("Name", style="bold") + table.add_column("UUID", style="dim") + for user in users: + table.add_row("user", str(user.get("name") or ""), str(user.get("uuid") or "")) + for team in teams: + table.add_row("team", str(team.get("name") or ""), str(team.get("uuid") or "")) + console.print(table) + + +def _print_attached_channels(channels: list[dict[str, Any]]) -> None: + """Render report channels attached to a form/check-in. + + Detail returns ``{id, name, platform, type, reporting_enabled}``. Legacy + entries written before names were resolved carry empty ``name``/``platform``, + so the channel id is shown as a fallback. + """ + if not channels: + return + table: Table = Table(title="Report Channels", border_style="cyan") + table.add_column("Channel", style="bold") + table.add_column("Platform") + table.add_column("Channel ID", style="dim") + table.add_column("Reporting") + for channel in channels: + channel_id: str = str(channel.get("id") or channel.get("uuid") or "") + name: str = str(channel.get("name") or "") + display_name: str = f"#{name}" if name else channel_id + table.add_row( + display_name, + str(channel.get("platform") or ""), + channel_id, + "on" if channel.get("reporting_enabled", True) else "off", + ) + console.print(table) + + +def _checkin_config_lines(detail: dict[str, Any]) -> list[str]: + """Summarize the scheduling/behavior config for the check-in panel. + + Only surfaces fields that are present and meaningful, so pre-config check-ins + render exactly as before. + """ + lines: list[str] = [] + freq: Any = detail.get("frequency_type") + every: Any = detail.get("frequency") + if freq: + lines.append(f"Frequency: {freq}" + (f" (every {every})" if every and every != 1 else "")) + advanced: Any = detail.get("frequency_advanced") + if advanced and advanced != "disabled": + cron: Any = detail.get("frequency_cron") + lines.append(f"Advanced: {advanced}" + (f" ({cron})" if cron else "")) + if detail.get("start_on"): + span: str = str(detail.get("start_on")) + if detail.get("end_on"): + span += f" → {detail.get('end_on')}" + lines.append(f"Runs: {span}") + if detail.get("use_participant_timezone"): + lines.append("Timezone: each participant's own") + reminders: Any = detail.get("reminders_max_count") + if isinstance(reminders, int) and reminders > 0: + interval: Any = detail.get("reminders_frequency_time") + cond: Any = detail.get("reminders_trigger_condition") + extra: str = f" every {interval}m" if interval else "" + extra += f" ({cond})" if cond else "" + tone: Any = detail.get("reminder_tone") + extra += f", {tone} tone" if tone else "" + lines.append(f"Reminders: {reminders}{extra}") + elif reminders == 0: + lines.append("Reminders: off") + if detail.get("is_smart_checkin"): + ai_bits: list[str] = ["smart"] + if detail.get("is_intelligence_enabled"): + ai_bits.append("intelligence") + clarifying: Any = detail.get("max_clarifying_questions") + if isinstance(clarifying, int) and clarifying > 0: + ai_bits.append(f"{clarifying} clarifying Qs") + lines.append("AI: " + ", ".join(ai_bits)) + flags: list[str] = [] + if detail.get("is_anonymous"): + flags.append("anonymous") + if detail.get("use_user_defined_work_days"): + flags.append("respects work days") + if detail.get("allow_past_responses") is False: + flags.append("no past reports") + if detail.get("allow_future_responses") is False: + flags.append("no future reports") + if flags: + lines.append("Options: " + ", ".join(flags)) + privacy: Any = detail.get("privacy") + if privacy: + lines.append(f"Privacy: {privacy}") + return lines + + +def print_checkin_detail(detail: dict[str, Any]) -> None: + """Display a check-in from the canonical ``/detail/`` endpoint. + + Consumes ``{name, schedule, questions, participants, report_channels, + is_archived}`` with the canonical question shape shared with forms. + """ + name: str = str(detail.get("name") or "") + uuid: str = str(detail.get("id") or detail.get("uuid") or "") + lines: list[str] = [f"[bold]{name}[/bold]", f"UUID: {uuid}"] + if detail.get("is_archived"): + lines.append("[yellow]archived[/yellow]") + schedule: dict[str, Any] = detail.get("schedule") or {} + days: Any = schedule.get("days") + if days is not None: + lines.append(f"Days: {days}") + for label, key in (("Time", "time"), ("Timezone", "timezone")): + value: Any = schedule.get(key) if value: lines.append(f"{label}: {value}") + lines.extend(_checkin_config_lines(detail)) console.print(Panel("\n".join(lines), title="Check-in", border_style="cyan")) + + _print_participants(detail.get("participants") or {}) + _print_attached_channels(detail.get("report_channels") or []) + + questions: list[dict[str, Any]] = detail.get("questions") or [] if not questions: print_info("No questions defined for this check-in.") return - table: Table = Table(title="Questions", border_style="cyan") - table.add_column("#", justify="right") - table.add_column("Question", style="bold") - table.add_column("Type") - table.add_column("Question UUID", style="dim") - for index, question in enumerate(questions): - table.add_row( - str(index), - str(question.get("question") or question.get("text") or ""), - str(question.get("question_type") or question.get("type") or "text"), - str(question.get("uuid") or question.get("id") or ""), - ) - console.print(table) + console.print(_question_rows(questions)) def print_checkin_history_table(responses: list[dict[str, Any]]) -> None: @@ -546,10 +653,13 @@ def print_forms_table(forms: list[dict[str, Any]]) -> None: print_info("No forms visible to you.") return + show_status: bool = any(form.get("is_archived") for form in forms) table: Table = Table(title="Forms", border_style="cyan") table.add_column("Name", style="bold") table.add_column("Form UUID", style="dim") table.add_column("Questions", style="dim", justify="right") + if show_status: + table.add_column("Status") for form in forms: form_id: str = str(form.get("id") or "") if not form_id: @@ -558,11 +668,14 @@ def print_forms_table(forms: list[dict[str, Any]]) -> None: form.get("questions") or form.get("template_questions") or form.get("fields") or [] ) count_str: str = str(question_count) if question_count else "—" - table.add_row( - str(form.get("name", "")), - form_id, - count_str, - ) + row: list[Any] = [str(form.get("name", "")), form_id, count_str] + if show_status: + row.append( + Text("archived", style="yellow") + if form.get("is_archived") + else Text("active", style="green") + ) + table.add_row(*row) console.print(table) @@ -589,38 +702,83 @@ def _state_label_lookup(form_data: dict[str, Any]) -> dict[str, str]: return {str(s.get("key")): str(s.get("label") or s.get("key")) for s in states if s.get("key")} +def _audience_label(audience: Any) -> str: + """Render a form permission audience (``who_can_edit`` etc.) for the panel.""" + if not isinstance(audience, dict): + return str(audience or "") + mode: str = str(audience.get("mode") or "") + if mode == "restricted": + users: int = len(audience.get("user_uuids") or []) + teams: int = len(audience.get("team_uuids") or []) + return f"restricted ({users} user(s), {teams} team(s))" + return mode + + +def _form_workflow(form_data: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]: + """Return (enabled, states) from the canonical nested ``workflow`` object.""" + workflow: Any = form_data.get("workflow") + if isinstance(workflow, dict): + return bool(workflow.get("enabled")), list(workflow.get("states") or []) + return False, [] + + def print_form_detail(form_data: dict[str, Any]) -> None: - """Render a form payload — metadata, workflow config, and questions.""" + """Render a form payload — metadata, config, workflow states, 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: + if form_data.get("is_active") is False: + table.add_row("Status", "[yellow]inactive[/yellow]") + if form_data.get("is_archived"): + table.add_row("Status", "[yellow]archived[/yellow]") + enabled, states = _form_workflow(form_data) + table.add_row("Workflow", "[green]enabled[/green]" if enabled else "disabled") + if enabled: table.add_row( "Reopen from final", "yes" if form_data.get("allow_reopen_from_final_state") else "no", ) + if form_data.get("command_enabled") and form_data.get("command"): + table.add_row("Command", f"@dailybot {form_data.get('command')}") + behaviors: list[str] = [] + if form_data.get("is_anonymous"): + behaviors.append("anonymous") + if form_data.get("allow_public_responses"): + public: str = "public" + if form_data.get("brand_with_logo"): + public += " +brand" + if form_data.get("require_email_and_name"): + public += " +identity" + behaviors.append(public) + if form_data.get("use_for_approval"): + behaviors.append("approval flow") + if behaviors: + table.add_row("Settings", ", ".join(behaviors)) + if form_data.get("public_url"): + table.add_row("Public URL", str(form_data.get("public_url"))) + for label, key in ( + ("Can edit", "who_can_edit"), + ("Can see", "who_can_see_responses"), + ("Can change states", "who_can_change_states"), + ): + audience: str = _audience_label(form_data.get(key)) + if audience: + table.add_row(label, audience) console.print(Panel(table, title="[bold]Form[/bold]", border_style="cyan")) - if workflow_enabled: + _print_attached_channels(form_data.get("report_channels") or []) + + if enabled and states: 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: + for index, state in enumerate(states): states_table.add_row( - str(state.get("order", "")), + str(state.get("order", index)), str(state.get("key", "")), str(state.get("label", "")), str(state.get("color", "")), @@ -629,19 +787,7 @@ def print_form_detail(form_data: dict[str, Any]) -> None: 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) + console.print(_question_rows(questions)) def print_form_response_state( @@ -839,3 +985,141 @@ def print_users_table(users: list[dict[str, Any]]) -> None: str(user.get("uuid") or ""), ) console.print(table) + + +def _choice_labels(question: dict[str, Any]) -> list[str]: + """Extract display labels from a canonical ``choices`` list. + + The contract returns ``choices`` as ``[{"label", "value"}]`` on every read + path; a bare-string element is tolerated defensively but not expected. + """ + raw: Any = question.get("choices") or [] + if not isinstance(raw, list): + return [] + labels: list[str] = [] + for item in raw: + if isinstance(item, dict): + label: Any = item.get("label") or item.get("value") + if label: + labels.append(str(label)) + elif item: + labels.append(str(item)) + return labels + + +def _question_rows(questions: list[dict[str, Any]]) -> Table: + """Build a questions table shared by the authoring renderers. + + Every authoring/read endpoint returns the canonical question shape + (``uuid`` / ``index`` / ``question`` / ``question_type`` / ``required`` / + ``is_blocker`` / ``choices``), so no field-name normalization is needed. + Multiple-choice options render as a dim line under the question text. + """ + table: Table = Table(title="Questions", border_style="cyan") + table.add_column("#", justify="right") + table.add_column("Question") + table.add_column("Type") + table.add_column("Required") + table.add_column("Blocker") + table.add_column("Question UUID", style="dim") + for index, question in enumerate(questions): + question_cell: Text = Text(str(question.get("question", "")), style="bold") + labels: list[str] = _choice_labels(question) + if labels: + question_cell.append("\n" + ", ".join(labels), style="dim") + short_q: Any = question.get("short_question") + if short_q and str(short_q) != str(question.get("question", "")): + question_cell.append(f"\n↳ report title: {short_q}", style="dim") + variations: Any = question.get("variations") + if variations: + question_cell.append(f"\n↳ {len(variations)} variation(s)", style="dim") + if question.get("logic"): + question_cell.append("\n↳ conditional logic", style="dim") + blocker_cell: Text = ( + Text("yes", style="red") if question.get("is_blocker") else Text("—", style="dim") + ) + table.add_row( + str(question.get("index", index)), + question_cell, + str(question.get("question_type", "")), + "yes" if question.get("required", True) else "no", + blocker_cell, + str(question.get("uuid", "")), + ) + return table + + +def print_report_channels(channels: list[dict[str, Any]]) -> None: + """Display the reporting channels available to the caller.""" + if not channels: + print_info("No report channels available.") + return + table: Table = Table(title=f"Report Channels ({len(channels)})", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("Platform") + table.add_column("UUID", style="dim") + for channel in channels: + table.add_row( + str(channel.get("name") or ""), + str(channel.get("platform") or ""), + str(channel.get("uuid") or channel.get("id") or ""), + ) + console.print(table) + + +def print_form_created(form: dict[str, Any], *, updated: bool = False) -> None: + """Display a created (or, with ``updated=True``, edited) form + question summary.""" + name: str = str(form.get("name") or "") + form_id: str = str(form.get("id") or form.get("uuid") or "") + title: str = "Form Updated" if updated else "Form Created" + lines: list[str] = [f"[bold]{name}[/bold]", f"ID: {form_id}"] + if form.get("public_url"): + lines.append(f"Public URL: {form.get('public_url')}") + console.print(Panel("\n".join(lines), title=f"[bold]{title}[/bold]", border_style="green")) + questions: list[dict[str, Any]] = form.get("questions") or [] + if questions: + console.print(_question_rows(questions)) + + +def print_checkin_created(checkin: dict[str, Any], *, updated: bool = False) -> None: + """Display a created (or, with ``updated=True``, edited) check-in + summary.""" + name: str = str(checkin.get("name") or "") + checkin_id: str = str(checkin.get("id") or checkin.get("uuid") or "") + lines: list[str] = [f"[bold]{name}[/bold]", f"ID: {checkin_id}"] + schedule: dict[str, Any] = checkin.get("schedule") or {} + if schedule: + days: Any = schedule.get("days") + if days is not None: + lines.append(f"Days: {days}") + for label, key in (("Time", "time"), ("Timezone", "timezone")): + value: Any = schedule.get(key) + if value: + lines.append(f"{label}: {value}") + title: str = "Check-in Updated" if updated else "Check-in Created" + console.print(Panel("\n".join(lines), title=f"[bold]{title}[/bold]", border_style="green")) + questions: list[dict[str, Any]] = checkin.get("questions") or [] + if questions: + console.print(_question_rows(questions)) + + +def print_question(question: dict[str, Any]) -> None: + """Display a single question after an add/update.""" + console.print(_question_rows([question])) + + +def print_questions_table(questions: list[dict[str, Any]]) -> None: + """Display a form/check-in's questions.""" + if not questions: + print_info("No questions defined.") + return + console.print(_question_rows(questions)) + + +def print_archived(kind: str, uuid: str) -> None: + """Confirm that a form or check-in was archived (soft-deleted).""" + print_success(f"{kind.capitalize()} {uuid} archived.") + + +def print_reordered(kind: str, order: list[str]) -> None: + """Confirm that questions were reordered.""" + print_success(f"{kind.capitalize()} questions reordered ({len(order)} items).") diff --git a/dailybot_cli/main.py b/dailybot_cli/main.py index 8b2e79d..3b0b3a8 100644 --- a/dailybot_cli/main.py +++ b/dailybot_cli/main.py @@ -8,6 +8,7 @@ from dailybot_cli.commands.agent import agent from dailybot_cli.commands.ask import ask from dailybot_cli.commands.auth import login, logout +from dailybot_cli.commands.channels import channels from dailybot_cli.commands.chat import chat from dailybot_cli.commands.checkin import checkin from dailybot_cli.commands.config import config @@ -74,6 +75,7 @@ def cli(ctx: click.Context, api_url: str | None) -> None: cli.add_command(status) cli.add_command(checkin) cli.add_command(form) +cli.add_command(channels) cli.add_command(kudos) cli.add_command(team) cli.add_command(user) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 255d315..5f9f5fc 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -175,7 +175,7 @@ Submits a form response. When `--content` is omitted, calls `GET /v1/forms/<uuid #### `dailybot form responses <form_uuid> [--state STATE] [--latest] [--json]` -Lists the caller's own responses (`GET /v1/forms/<uuid>/responses/`). `--state` filters by `current_state` (workflow forms only). `--latest` returns only the most recent — useful for "continue where I left off". +Lists responses (`GET /v1/forms/<uuid>/responses/`). Defaults to the caller's own. `--state` filters by `current_state` (workflow forms only). `--latest` returns only the most recent. `--all` / `--user <uuid>` surface everyone's / a specific user's responses and are admin/owner-only server-side (a member gets 403 / `form_response_view_all_forbidden`). `--from` / `--to` (`date_from`/`date_to`) narrow the window. #### `dailybot form response get <form_uuid> <response_uuid> [--json]` @@ -183,7 +183,7 @@ Fetches a single response (`GET /v1/forms/<uuid>/responses/<resp_uuid>/`) includ #### `dailybot form update <form_uuid> <response_uuid> --content JSON [--yes] [--json]` -Merges new answers into an in-progress response via `PATCH /v1/forms/<uuid>/responses/<resp_uuid>/`. Strict own-only — admins are not elevated to other users' responses on this endpoint. +Merges new answers into a response via `PATCH /v1/forms/<uuid>/responses/<resp_uuid>/`. The server authorizes by role: the response author may always edit; a form owner / org admin may edit anyone's (audited as `metadata.last_edited_by`). A non-privileged edit of another user's response returns 403 / `form_response_edit_forbidden`. #### `dailybot form transition <form_uuid> <response_uuid> <to_state> [--note ...] [--yes] [--json]` @@ -195,6 +195,48 @@ Deletes a response via `DELETE /v1/forms/<uuid>/responses/<resp_uuid>/`. Allowed --- +### `dailybot channels list` — user-scoped, Bearer or API key auth + +Lists report channels available to the caller via `GET /v1/report-channels/`. Channel UUIDs feed `--report-channel` on form/check-in create and edit. + +--- + +### Forms & check-ins authoring — user-scoped, Bearer or API key auth + +Creating and configuring forms/check-ins (as opposed to filling them in). All authoring is **role-gated server-side** (admins/managers/owners as applicable); the CLI performs only shape validation and surfaces the server's `403`. Both credentials work; an API key resolves to its admin owner. + +| Command | HTTP | Notes | +|---|---|---| +| `form list [--include-archived]` | `GET /v1/forms/` (`?include_archived=true`) | Archived forms hidden by default; flagged in a Status column when shown. | +| `form create -n NAME [--questions-file F] [--interactive] [--report-channel UUID] [config flags]` | `POST /v1/forms/create/` (`name`, `questions?`, `report_channels?`, config all inline) | Question types: `text`, `multiple_choice`, `boolean`, `numeric`; ≤ 50. Config flags below. | +| `form edit <uuid> [--name] [--report-channel]` | `PATCH /v1/forms/<uuid>/config/` | Thin subset of `form config`. | +| `form config <uuid> [--name] [--report-channel] [config flags]` | `PATCH /v1/forms/<uuid>/config/` | Full form config (partial). Flags below. | + +**Form config flags** (on `create` + `config`; only the ones you pass change): `--active/--inactive`, `--anonymous/--no-anonymous`, `--public/--no-public`, `--brand/--no-brand`, `--require-identity/--no-require-identity`, `--reopen-from-final/--no-reopen-from-final`, `--state "Label:#color"` (repeatable, ordered — enables the workflow) / `--no-workflow`, `--can-edit`/`--can-see`/`--can-change-states` (`everyone`/`owner_and_admins`, or `restricted` via `--{scope}-user`/`--{scope}-team`), `--approval/--no-approval` + `--approver-user`/`--approver-team`, `--command NAME`/`--no-command`. Sent inline; the server rejects unknown fields with `400 unknown_field` and validates each (`workflow_requires_states`, `invalid_workflow_state`, `invalid_permission_audience`, `invalid_approvers`, `invalid_command`, `command_already_exists`). Detail echoes them back. Forms may have zero questions (by design). Unlike check-ins, form `is_anonymous` is freely toggleable (no `anonymous_irreversible`). +| `form archive <uuid>` | `DELETE /v1/forms/<uuid>/archive/` | Soft-delete (204). Confirms unless `--yes`. | +| `form questions list <uuid>` | `GET /v1/forms/<uuid>/` | Canonical question shape (see below). | +| `form questions add <uuid> --type --question [--options] [--required/--optional] [--blocker] [extras]` | `POST /v1/forms/<uuid>/questions/` | `multiple_choice` requires `--options`; `boolean` takes none. `--blocker` tags the blocker question. Extras below. | +| `form questions edit <uuid> <q_uuid> [... --blocker/--no-blocker] [extras]` | `PATCH /v1/forms/<uuid>/questions/<q_uuid>/` | Partial update (non-destructive). | + +**Report title is required by the CLI.** When authoring a question (`questions add`, or seeding via `create --questions-file`/`--interactive`), a `--short-question` / `"short_question"` is **mandatory** — pass `--ai-short-question` to opt into Dailybot's AI generating it instead (AI titling only runs on intelligence-enabled check-ins). Server-side AI titling is a frontend-oriented convenience; agents/scripts should author explicit report titles. (`questions edit` doesn't require it — partial update.) + +**Per-question extras** (on `questions add` + `edit`, both forms and check-ins): `--short-question` (title shown in web & chat reports, ≤512 chars), `--variation` (alternate phrasing rotated per run; repeatable, ≤10), and question logic via `--logic-file <path>` (a JSON `{"rules": {"rules_if": [...], "rules_else": {...}}}` object) or the inline single-jump shortcut `--jump-if-equals VALUE --jump-to N [--else-jump-to M]` (`N`/`M` = target question index, `-1` = end). A **`rules_else` fallback is required** and jump targets are **forward-only** (must be greater than the question's own index, or `-1`); the server owns the question index and rejects backward/out-of-range jumps. Navigation keys (`question_key`/`next_key`/`prev_key`) are computed server-side — never send them. Logic operators (server enforces per type): text — `is_equal_to`, `is_not_equal_to`, `contains`, `not_contains`, `begins_with`, `not_begins_with`, `ends_with`, `not_ends_with`; numeric — `is_equal_to`, `is_not_equal_to`, `lower_than`, `lower_or_equal_than`, `greater_than`, `greater_or_equal_than`; `multiple_choice`/`boolean` — `is_equal_to`, `is_not_equal_to`. Boolean comparison values are JSON `true`/`false` (the CLI coerces `--jump-if-equals true/false`). Actions: `jump_to` (int target), `trigger_checkin` / `trigger_form` (UUID target); connectors: `and`, `or` (`multiple_choice`: `or` only). Empty question text is rejected server-side (`question_label_required`); a check-in/form may exist with zero questions (by design). Question types remain `text`, `multiple_choice`, `boolean`, `numeric` (the complete catalog). +| `form questions delete <uuid> <q_uuid>` | `DELETE /v1/forms/<uuid>/questions/<q_uuid>/delete/` | Confirms unless `--yes`. | +| `form questions reorder <uuid> <q_uuid>...` | `PUT /v1/forms/<uuid>/questions/reorder/` | Unknown UUID → 400. | +| `checkin create -n NAME [--time --days --timezone \| --schedule-file] [--user --team] [--questions-file \| --interactive] [--report-channel]` | `POST /v1/checkins/create/` | `days` = ISO weekday ints (0=Sun…6=Sat). **Requires ≥1 participant** — with no `--user`/`--team` an interactive run prompts (default team suggested); non-interactive errors. | +| `checkin show <uuid>` | `GET /v1/checkins/<uuid>/detail/` | Canonical detail: schedule, resolved `participants`, attached `report_channels`, canonical questions. | +| `checkin config <uuid> [--name] [--time --days --timezone] [--report-channel] [--user --team] [--active/--inactive] [config flags]` | `PATCH /v1/checkins/<uuid>/config/` (accepts `participants` + full config) | `--user`/`--team` replace participants. Config flags below. | + +**Check-in config flags** (on `create` + `config`; only the ones you pass change): `--start-on` / `--end-on` (`YYYY-MM-DD`), `--frequency weekly` (monthly/custom cadences → `--frequency-advanced`), `--every N`, `--trigger-based/--fixed-time`, `--participant-timezone/--custom-timezone`, `--reminders 0-5`, `--reminder-interval 0-60`, `--reminder-condition smart_frequency|fixed_frequency`, `--work-days/--no-work-days`, `--allow-early/--no-early`, `--allow-past/--no-past`, `--allow-future/--no-future`, `--anonymous/--no-anonymous`, `--privacy <level>`, `--one-by-one/--aggregated`, `--intro`/`--outro`, `--report-time HH:MM`, `--reminder-tone standard|persuasive`, `--smart/--no-smart`, `--intelligence/--no-intelligence` (requires `--smart`), `--max-clarifying 0-5` (requires `--intelligence`), `--frequency-advanced disabled|monthly|custom`, `--cron "<5-field>"`. Sent inline in the create/config body; the server rejects unknown fields with `400 unknown_field` and validates each (`invalid_frequency_type`, `invalid_reminder_count`, `invalid_reminder_tone`, `invalid_frequency_cron`, `intelligence_requires_smart_checkin`, …). Detail echoes them back. This surface is at 100% parity with the web UI; only the computed `summary` stays read-only. +| `checkin archive <uuid>` | `DELETE /v1/checkins/<uuid>/archive/` | Soft-delete (204). Confirms unless `--yes`. | +| `checkin questions add/edit/delete/reorder` | `.../v1/checkins/<uuid>/questions/...` | Same shapes as form questions (incl. `--blocker`). | + +**Canonical question shape** (every read path — form detail, `form questions list`, `checkin show` detail): `{ uuid, index, question, question_type, required, is_blocker, choices }`. For `multiple_choice`, `choices` is a list of `{ label, value }` objects; for `text`/`boolean`/`numeric` it is `[]`. + +> **Date-param asymmetry:** form response filters use `date_from`/`date_to`; check-in response filters use `date_start`/`date_end`. The CLI presents one `--from`/`--to` UX and maps per resource. + +--- + ### `dailybot kudos` (group) — user-scoped, Bearer or API key auth #### `dailybot kudos give [--to <user>] [--team <team>] --message <text> [--value <uuid>] [--yes] [--json]` @@ -452,18 +494,35 @@ key, so all of these commands work with `DAILYBOT_API_KEY` set even without | `GET` | `/v1/forms/` | `?include=questions` (optional) | `[{ id, name, questions?: [...] }]` | | | `GET` | `/v1/forms/<uuid>/` | — | `{ id, name, slug, workflow_enabled, workflow_config, questions: [...] }` | Used by guided form submit + `form get` | | `POST` | `/v1/forms/<uuid>/responses/` | `{ content: { "<q_uuid>": "<answer>" } }` | `{ id, current_state?, allowed_transitions?, can_change_state? }` | 402 = quota exhausted | -| `GET` | `/v1/forms/<uuid>/responses/` | `?state=<key>` (optional) | `[{ id, current_state, allowed_transitions, can_change_state, state_history, content, edited, created_at }]` | Caller's own responses | +| `GET` | `/v1/forms/<uuid>/responses/` | `?state`, `?all=true`, `?user`, `?date_from`, `?date_to` (all optional) | `[{ id, current_state, allowed_transitions, can_change_state, state_history, content, edited, created_at }]` | Own by default; `all`/`user` admin/owner-only (403 `form_response_view_all_forbidden`) | | `GET` | `/v1/forms/<uuid>/responses/<resp_uuid>/` | — | Same shape as above | 404 = `form_response_not_found` | -| `PATCH` | `/v1/forms/<uuid>/responses/<resp_uuid>/` | `{ content: { ... } }` | Updated response | Strict own-only | +| `PATCH` | `/v1/forms/<uuid>/responses/<resp_uuid>/` | `{ content: { ... } }` | Updated response | Own always; owner/admin may edit anyone (audited) | | `POST` | `/v1/forms/<uuid>/responses/<resp_uuid>/transition/` | `{ to_state, note? }` | Updated response | 403 = `form_response_change_state_forbidden` or `final_state_locked` | | `DELETE` | `/v1/forms/<uuid>/responses/<resp_uuid>/` | — | 204 | Author / owner / admin | +| `GET` | `/v1/report-channels/` | `?name` (prefix filter), `?limit` (both optional) | `{ channels: [{ id, name, platform, type }], total }` (also accepts `{results}` / bare list) | `channels list`; `id` feeds `--report-channel` | +| `GET` | `/v1/forms/` | `?include=questions`, `?include_archived=true` | `[{ id, name, is_active, is_archived, questions? }]` | `form list`; archived hidden unless opted in | +| `POST` | `/v1/forms/create/` | `{ name, questions?: [...], report_channels?: [...] }` | `{ id, name, is_active, is_archived, questions, report_channels }` | Role-gated; `form create` | +| `PATCH` | `/v1/forms/<uuid>/config/` | `{ name?, report_channels?, is_active?, is_anonymous?, allow_public_responses?, require_email_and_name?, brand_with_logo?, allow_reopen_from_final_state?, workflow?, who_can_edit?, who_can_see_responses?, who_can_change_states?, use_for_approval?, approvers?, command_enabled?, command? }` | Form | `form edit` / `form config` | +| `DELETE` | `/v1/forms/<uuid>/archive/` | — | 204 (sets `is_active=false` + `is_archived=true`) | `form archive` (soft-delete) | +| `POST` | `/v1/forms/<uuid>/questions/` | `{ question_type, question, options?, required?, is_blocker? }` | Question | `form questions add` | +| `PATCH` | `/v1/forms/<uuid>/questions/<q_uuid>/` | partial | Question | `form questions edit` | +| `DELETE` | `/v1/forms/<uuid>/questions/<q_uuid>/delete/` | — | 204 | `form questions delete` | +| `PUT` | `/v1/forms/<uuid>/questions/reorder/` | `{ order: [q_uuid...] }` | `{ reordered: true }` | `form questions reorder` | | `POST` | `/v1/checkins/<followup_uuid>/responses/` | `{ responses: [{ uuid, index, response }], last_question_index?, response_date? }` | `{ uuid }` | | | `GET` | `/v1/checkins/` | — | `{ results: [{ id, name, ... }], next? }` (or bare list) | Paginated; terminal check-in flows | -| `GET` | `/v1/checkins/<followup_uuid>/` | — | `{ ... }` | Check-in detail | +| `GET` | `/v1/checkins/<followup_uuid>/` | — | `{ ... }` | v2 retrieve serializer (different shape) | +| `GET` | `/v1/checkins/<followup_uuid>/detail/` | — | `{ id, name, is_archived, schedule, questions, participants: {users,teams}, report_channels: [{id,name,platform,type,reporting_enabled}] }` | **Canonical** authoring read; `checkin show` | | `GET` | `/v1/templates/<template_uuid>/` | `?render_special_vars=true&followup_id=<uuid>` | `{ questions: [...] }` | Question definitions for a check-in | -| `GET` | `/v1/checkins/<followup_uuid>/responses/` | `?date_start&date_end` | `[{ ... }]` | Today's response (edit/reset) | +| `GET` | `/v1/checkins/<followup_uuid>/responses/` | `?date_start&date_end`, `?all=true`, `?user` | `[{ ... }]` | History; `all`/`user` admin/owner-only | | `PUT` | `/v1/checkins/<followup_uuid>/responses/` | `{ responses: [...], last_question_index? }` | Updated response | `/checkin edit` | | `DELETE` | `/v1/checkins/<followup_uuid>/responses/` | `?date_start&date_end` | 204 | `/checkin reset` | +| `POST` | `/v1/checkins/create/` | `{ name, schedule?, participants?, questions?, report_channels? }` | Check-in | Role-gated; `checkin create` | +| `PATCH` | `/v1/checkins/<followup_uuid>/config/` | `{ name?, schedule?, report_channels?, is_active? }` | Check-in | `checkin config` | +| `DELETE` | `/v1/checkins/<followup_uuid>/archive/` | — | 204 (sets `active=false` + `archived=true`) | `checkin archive` (soft-delete) | +| `POST` | `/v1/checkins/<followup_uuid>/questions/` | `{ question_type, question, options?, required?, is_blocker? }` | Question | `checkin questions add` | +| `PATCH` | `/v1/checkins/<followup_uuid>/questions/<q_uuid>/` | partial | Question | `checkin questions edit` | +| `DELETE` | `/v1/checkins/<followup_uuid>/questions/<q_uuid>/delete/` | — | 204 | `checkin questions delete` | +| `PUT` | `/v1/checkins/<followup_uuid>/questions/reorder/` | `{ order: [q_uuid...] }` | `{ reordered: true }` | `checkin questions reorder` | | `GET` | `/v1/mood/track/` | `?date` | `{ ... }` | Read today's mood | | `POST` | `/v1/mood/track/` | `{ score, date? }` | `{ ... }` | `/mood` | | `GET` | `/v1/users/` | — | `{ results: [{ uuid, full_name }], next: url\|null }` | Paginated | diff --git a/docs/CLI_COMMAND_BEST_PRACTICES.md b/docs/CLI_COMMAND_BEST_PRACTICES.md index d69111f..1ccadae 100644 --- a/docs/CLI_COMMAND_BEST_PRACTICES.md +++ b/docs/CLI_COMMAND_BEST_PRACTICES.md @@ -190,6 +190,26 @@ Add a `@click.group` when: If you only have 1–2 related commands, keep them as top-level commands and re-evaluate later. +### Nested subgroups for a sub-noun + +When a command group owns a collection that itself has a lifecycle, nest a second +group rather than flattening verbs onto the parent. `form questions add/edit/ +delete/reorder/list` (and the parallel `checkin questions ...`) are a +`@form.group("questions")` under the `form` group — this keeps `form add` from +being ambiguous with adding a *response* vs a *question*. When the parent already +has verbs operating on a different noun (e.g. `form delete` removes a *response*), +pick distinct verbs for the definition-level actions (`form archive`, +`checkin config`, `checkin archive`) instead of overloading the existing verb. + +### File-or-flag-or-interactive input + +For structured input (e.g. a list of questions), offer three parallel paths that +converge on the **same validated builder**: a `--…-file` JSON path, inline flags, +and `--interactive` (questionary). Route all three through one validation helper +(`authoring_helpers.build_question`) so the rules are identical regardless of +entry point, and guard `--interactive` with a non-TTY check that points the user +at the file/flag path. + ## When to Use `questionary` vs `click.prompt` | Use `click.prompt` | Use `questionary` | diff --git a/docs/RELEASE_AND_DISTRIBUTION.md b/docs/RELEASE_AND_DISTRIBUTION.md index 2ebe5ed..80bb5a8 100644 --- a/docs/RELEASE_AND_DISTRIBUTION.md +++ b/docs/RELEASE_AND_DISTRIBUTION.md @@ -636,16 +636,16 @@ The public URL `https://cli.dailybot.com/install.sh` is a **Cloudflare 301 redir ### Pinning a specific version -Both installers default to the **latest** release but accept an explicit version, so users (and CI) can reproduce a known-good install: +Both installers default to the **latest** release but accept an explicit version, so users (and CI) can reproduce a known-good install. Two specifier styles are accepted: an **exact pin** (`1.15.0` or `==1.15.0`) and a **minimum floor** (`>=1.15.0`, which resolves to the newest release at or above the floor): | Method | How to pin | Applied as | |--------|-----------|-----------| -| `pip` | `pip install dailybot-cli==<version>` | pip requirement specifier | -| Homebrew | not supported — fall back to `pip install dailybot-cli==<version>` | (brew installs latest formula only) | -| `install.sh` | `DAILYBOT_VERSION=<version>` env var **or** `bash -s -- --version <version>` | pinned Linux binary tag `v<version>`, falling back to `pip install dailybot-cli==<version>` | -| `install.ps1` | `$env:DAILYBOT_VERSION = '<version>'` (piping to `iex` cannot forward args) | `pipx` / `uv tool` / `pip --user` install of `dailybot-cli==<version>` | +| `pip` | `pip install dailybot-cli==<version>` or `pip install "dailybot-cli>=<version>"` | pip requirement specifier (pip natively resolves `>=` to the newest match) | +| Homebrew | not supported — fall back to `pip install dailybot-cli==<version>` / `>=<version>` | (brew installs latest formula only) | +| `install.sh` | `DAILYBOT_VERSION=<spec>` env var **or** `bash -s -- --version <spec>`, where `<spec>` is `<version>`, `==<version>` or `>=<version>` | **exact** → pinned Linux binary tag `v<version>`, falling back to `pip install dailybot-cli==<version>`; **floor** → latest binary when it satisfies the floor, else `pip install "dailybot-cli>=<version>"` | +| `install.ps1` | `$env:DAILYBOT_VERSION = '<spec>'` (piping to `iex` cannot forward args) | `pipx` / `uv tool` / `pip --user` install of `dailybot-cli==<version>` or `dailybot-cli>=<version>` | -Both scripts validate the value against `^[0-9A-Za-z.+-]+$` before it reaches any `pip`/URL command, so a malformed or hostile `DAILYBOT_VERSION` aborts the install instead of being interpolated. An empty/unset value means "latest". The README documents the user-facing commands for each method. +**Specifier parsing.** Each script splits an optional leading operator (`==` or `>=`) from the numeric token, then validates the remainder against `^[0-9A-Za-z.+-]+$` before it reaches any `pip`/URL command — so a malformed or hostile `DAILYBOT_VERSION` (e.g. `1.0.0; rm -rf /`) aborts the install instead of being interpolated. Unsupported operators (`<`, `>`, `<=`, `~=`, `!=`) are rejected with a clear error rather than silently mishandled, because the binary path must resolve a spec to a single release tag. For a `>=` floor, `install.sh` compares the latest published tag against the floor with `sort -V` and only downloads the binary when it satisfies the floor (otherwise it defers to pip, which errors clearly if the floor is unpublishable). An empty/unset value means "latest". The README documents the user-facing commands for each method. ### `install.sh.sha256` (supply-chain verification) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index def32ae..e2105f8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -84,7 +84,8 @@ The user-scoped commands (`checkin`, `form`, `kudos`, `user`) operate within the - **`dailybot user list`** — intentionally omits email addresses from both table and JSON output. This is a PII-minimization measure for an open-source CLI. UUIDs are exposed for programmatic use (e.g., `--to <uuid>` in kudos). - **`dailybot kudos give`** — prevents self-kudos client-side. The receiver is resolved by name against the user directory; ambiguous matches are rejected rather than guessed. - **Pagination safety** — `list_users()` caps at `_MAX_LIST_PAGES = 50` pages to prevent unbounded loops against a misbehaving backend. -- **Confirmation prompts** — `checkin complete`, `form submit`, and `kudos give` show a confirmation before team-visible writes. `--yes` skips it for non-interactive/scripted use. +- **Confirmation prompts** — `checkin complete`, `form submit`, `kudos give`, and the destructive authoring commands (`form archive`, `checkin archive`, `form/checkin questions delete`) show a confirmation before a team-visible or irreversible write. `--yes` skips it for non-interactive/scripted use. +- **Forms & check-ins authoring** (`channels list`; `form/checkin create/edit/config/archive` + `questions` subgroups; extended `form responses --all/--user`; owner/admin editing of another user's response) — all authorization is **enforced server-side by role** and reuses the existing permission model (no new permission types). The CLI performs only shape/format validation (question types, options, schedule) and surfaces the server's `403` codes with role-aware messages; it never approximates roles or elevates locally. Both a login session and an API key go through the identical `_headers()` path. - **Exit codes** — structured exit codes (2–7) enable safe scripting without parsing error messages. See [API_REFERENCE.md](API_REFERENCE.md) for the full table. ## API Key Lifecycle diff --git a/install.ps1 b/install.ps1 index 0a00059..b490453 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,10 +19,16 @@ $Package = 'dailybot-cli' $Command = 'dailybot' $MinPython = [version]'3.10' -# Install a specific version instead of the latest by setting the -# DAILYBOT_VERSION environment variable before running the script: -# $env:DAILYBOT_VERSION = '1.15.0'; irm https://cli.dailybot.com/install.ps1 | iex -# An empty value means "install the latest published version". +# Install a specific version, or a minimum version floor, instead of the latest +# by setting the DAILYBOT_VERSION environment variable before running the script: +# $env:DAILYBOT_VERSION = '1.15.0'; irm https://cli.dailybot.com/install.ps1 | iex # exact +# $env:DAILYBOT_VERSION = '>=1.15.0'; irm https://cli.dailybot.com/install.ps1 | iex # floor +# +# Accepted forms: +# (empty) install the latest published version +# 1.15.0 install exactly 1.15.0 +# ==1.15.0 install exactly 1.15.0 (explicit form of the above) +# >=1.15.0 install the newest published version at or above 1.15.0 $Version = $env:DAILYBOT_VERSION function Write-Info { param([string]$msg) Write-Host "==> $msg" -ForegroundColor Cyan } @@ -36,8 +42,10 @@ function Has-Cmd { } function Get-PackageSpec { - # "dailybot-cli" or "dailybot-cli==<version>" when a version is pinned. - if ($Version) { return "$Package==$Version" } + # "dailybot-cli", "dailybot-cli==<version>" (exact pin) or + # "dailybot-cli>=<version>" (minimum floor). pip natively resolves ">=" to + # the newest matching release. $VersionOp/$VersionNum are set in Main. + if ($VersionNum) { return "$Package$VersionOp$VersionNum" } return $Package } @@ -132,14 +140,38 @@ function Add-LocalBinToPath { # === Main ================================================================= +# Split an optional comparison operator from the numeric version. $VersionOp is +# '' (latest), '==' (exact pin) or '>=' (minimum floor); $VersionNum is the bare +# number. Unsupported operators are rejected up front. +$VersionOp = '' +$VersionNum = '' if ($Version) { + if ($Version.StartsWith('>=')) { + $VersionOp = '>='; $VersionNum = $Version.Substring(2) + } elseif ($Version.StartsWith('==')) { + $VersionOp = '=='; $VersionNum = $Version.Substring(2) + } elseif ($Version -match '[<>~!=]') { + Write-Err "Unsupported version specifier '$Version'. Use an exact version (1.15.0 or ==1.15.0) or a minimum floor (>=1.15.0)." + exit 1 + } else { + $VersionOp = '=='; $VersionNum = $Version + } + + # Tolerate whitespace around the number (e.g. '>= 1.15.0'). + $VersionNum = $VersionNum.Trim() + # Reject anything that is not a plain version token so it cannot be # smuggled into the pip spec. - if ($Version -notmatch '^[0-9A-Za-z.+-]+$') { - Write-Err "Invalid version '$Version'. Expected a version like 1.15.0." + if ($VersionNum -notmatch '^[0-9A-Za-z.+-]+$') { + Write-Err "Invalid version '$VersionNum'. Expected a version like 1.15.0." exit 1 } - Write-Info "Requested Dailybot CLI version: $Version" + + if ($VersionOp -eq '>=') { + Write-Info "Requested Dailybot CLI: >=$VersionNum (minimum floor — installing the newest at or above it)" + } else { + Write-Info "Requested Dailybot CLI version: $VersionNum" + } } Write-Info "Detecting Python..." diff --git a/install.ps1.sha256 b/install.ps1.sha256 index 462b020..442fc3a 100644 --- a/install.ps1.sha256 +++ b/install.ps1.sha256 @@ -1 +1 @@ -ddf0ce82c29f3408fdd293f18eadab7542eb2758d6a6ec589fa2f9b8a6bf150e install.ps1 +516d16caa9d232e7512f97b26c19f7df2b271f6e780fbc46c30e3cb30d0092c2 install.ps1 diff --git a/install.sh b/install.sh index c6d6266..58c8787 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,11 @@ MIN_PYTHON="3.10" has() { command -v "$1" &>/dev/null; } +# True if version $1 >= version $2, compared as dotted version tokens via the +# version-aware `sort -V` (GNU coreutils, always present on the Linux binary +# path). Used only to check a ">=" floor against the latest published release. +version_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; } + info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } success() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } @@ -48,11 +53,16 @@ finish() { } # --- Version selection --- -# Install a specific version instead of the latest. Provide it either as an -# environment variable or a CLI flag: +# Install a specific version, or a minimum version floor, instead of the +# latest. Provide it either as an environment variable or a CLI flag: # curl -sSL https://cli.dailybot.com/install.sh | DAILYBOT_VERSION=1.15.0 bash # curl -sSL https://cli.dailybot.com/install.sh | bash -s -- --version 1.15.0 -# An empty value means "install the latest published version". +# +# Accepted forms: +# (empty) install the latest published version +# 1.15.0 install exactly 1.15.0 +# ==1.15.0 install exactly 1.15.0 (explicit form of the above) +# >=1.15.0 install the newest published version at or above 1.15.0 VERSION="${DAILYBOT_VERSION:-}" while [ $# -gt 0 ]; do @@ -72,27 +82,56 @@ while [ $# -gt 0 ]; do esac done +# Split an optional comparison operator from the numeric version. VERSION_OP is +# "" (latest), "==" (exact pin) or ">=" (minimum floor); VERSION_NUM holds the +# bare number. Unsupported operators (<, >, <=, ~=, !=) are rejected outright so +# the binary path never has to resolve a range it can't map to a single tag. +VERSION_OP="" +VERSION_NUM="" +case "$VERSION" in + "") ;; + ">="*) VERSION_OP=">="; VERSION_NUM="${VERSION#>=}" ;; + "=="*) VERSION_OP="=="; VERSION_NUM="${VERSION#==}" ;; + *[\<\>\~\!=]*) + error "Unsupported version specifier '$VERSION'. Use an exact version (1.15.0 or ==1.15.0) or a minimum floor (>=1.15.0)." + exit 1 + ;; + *) VERSION_OP="=="; VERSION_NUM="$VERSION" ;; +esac + +# Tolerate whitespace around the number (e.g. DAILYBOT_VERSION=">= 1.15.0"). +VERSION_NUM="${VERSION_NUM//[[:space:]]/}" + # Reject anything that is not a plain version token so it cannot be smuggled # into the pip spec or the release download URL. -case "$VERSION" in +case "$VERSION_NUM" in "") ;; *[!0-9A-Za-z.+-]*) - error "Invalid version '$VERSION'. Expected a version like 1.15.0." + error "Invalid version '$VERSION_NUM'. Expected a version like 1.15.0." exit 1 ;; esac -# Emit the pip requirement: "dailybot-cli" or "dailybot-cli==<version>". +# Human-readable specifier for log lines: "", "==1.15.0" or ">=1.15.0". +VERSION_DISPLAY="" +[ -n "$VERSION_NUM" ] && VERSION_DISPLAY="${VERSION_OP}${VERSION_NUM}" + +# Emit the pip requirement: "dailybot-cli", "dailybot-cli==<v>" or +# "dailybot-cli>=<v>". pip natively resolves ">=" to the newest matching release. pip_spec() { - if [ -n "$VERSION" ]; then - printf '%s==%s' "$PACKAGE" "$VERSION" + if [ -n "$VERSION_NUM" ]; then + printf '%s%s%s' "$PACKAGE" "$VERSION_OP" "$VERSION_NUM" else printf '%s' "$PACKAGE" fi } -if [ -n "$VERSION" ]; then - info "Requested Dailybot CLI version: $VERSION" +if [ -n "$VERSION_NUM" ]; then + if [ "$VERSION_OP" = ">=" ]; then + info "Requested Dailybot CLI: >=$VERSION_NUM (minimum floor — installing the newest at or above it)" + else + info "Requested Dailybot CLI version: $VERSION_NUM" + fi fi # --- Detect OS --- @@ -104,9 +143,10 @@ OS="$(uname -s)" # ============================================================================= if [ "$OS" = "Darwin" ]; then # Homebrew always installs the latest formula version. When a specific - # version is requested we fall through to the pip path, which can pin it. - if [ -n "$VERSION" ]; then - info "Homebrew installs only the latest release; using pip to install $VERSION..." + # version (or a floor) is requested we fall through to the pip path, which + # can honour both an exact pin and a ">=" minimum. + if [ -n "$VERSION_NUM" ]; then + info "Homebrew installs only the latest release; using pip to install $VERSION_DISPLAY..." else if ! has brew; then error "Homebrew is required on macOS." @@ -131,7 +171,7 @@ fi if [ "$OS" = "Linux" ]; then install_binary() { local latest url status_code install_dir="/usr/local/bin" - local arch + local arch latest_num arch="$(uname -m)" # Only x86_64 binary is available; skip on other architectures @@ -140,17 +180,29 @@ if [ "$OS" = "Linux" ]; then return 1 fi - if [ -n "$VERSION" ]; then - # Pin the requested release tag; if its binary asset is missing - # the caller falls back to a pinned pip install. - latest="v$VERSION" + if [ "$VERSION_OP" = "==" ]; then + # Exact pin: request that release tag directly. If its binary asset + # is missing the caller falls back to a pinned pip install. + latest="v$VERSION_NUM" else + # Latest, or a ">=" floor: resolve the newest published release tag. latest=$(curl -sI "https://github.com/$REPO/releases/latest" \ | grep -i "^location:" | sed 's/.*tag\///' | tr -d '\r\n') if [ -z "$latest" ]; then return 1 fi + + # For a floor, only take the latest binary if it satisfies the floor; + # otherwise defer to pip, which resolves/verifies the ">=" spec and + # errors clearly when the floor is unpublishable. + if [ "$VERSION_OP" = ">=" ]; then + latest_num="${latest#v}" + if ! version_ge "$latest_num" "$VERSION_NUM"; then + warn "Latest release $latest does not satisfy >=$VERSION_NUM; deferring to pip." + return 1 + fi + fi fi url="https://github.com/$REPO/releases/download/$latest/dailybot-linux-x86_64" diff --git a/install.sh.sha256 b/install.sh.sha256 index 8c555f6..a88afe4 100644 --- a/install.sh.sha256 +++ b/install.sh.sha256 @@ -1 +1 @@ -17442425a345f9fc14472ef0b4658c77464918dd529379d2e7a7edd4e41dd7c0 install.sh +32163adeb35422e504ee8a9df6674fe4c768420fd5697d097dbc759d45a9ea8c install.sh diff --git a/tests/api_client_test.py b/tests/api_client_test.py index b413578..5173b9f 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -205,6 +205,22 @@ def test_get_checkin(self, client: DailyBotClient) -> None: assert result["template"] == "template-uuid" assert mock_get.call_args[0][0].endswith("/v1/checkins/followup-uuid/") + def test_get_checkin_detail(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "followup-uuid", + "name": "Standup", + "questions": [{"uuid": "q1", "index": 0, "required": True, "is_blocker": False}], + "participants": {"users": [{"uuid": "u1", "name": "Jane Doe"}], "teams": []}, + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: dict[str, Any] = client.get_checkin_detail("followup-uuid") + + assert result["participants"]["users"][0]["name"] == "Jane Doe" + assert mock_get.call_args[0][0].endswith("/v1/checkins/followup-uuid/detail/") + def test_list_checkin_responses(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -302,6 +318,16 @@ def test_list_forms(self, client: DailyBotClient) -> None: assert "Bearer test-token" in mock_get.call_args[1]["headers"]["Authorization"] assert mock_get.call_args[1].get("params", {}) == {} + def test_list_forms_include_archived(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "form-uuid", "is_archived": True}] + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_forms(include_archived=True) + + assert mock_get.call_args[1]["params"] == {"include_archived": "true"} + def test_list_forms_with_questions(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -406,6 +432,16 @@ def test_list_users_include_inactive(self, client: DailyBotClient) -> None: assert len(result) == 2 + def test_list_users_include_email_adds_query(self, client: DailyBotClient) -> None: + page: MagicMock = MagicMock(spec=httpx.Response) + page.status_code = 200 + page.json.return_value = {"results": [], "next": None} + + with patch("httpx.get", return_value=page) as mock_get: + client.list_users(include_email=True) + + assert mock_get.call_args[0][0].endswith("/v1/users/?include_email=true") + def test_list_users_page_cap(self, client: DailyBotClient) -> None: """Pagination must stop at _MAX_LIST_PAGES even if the backend keeps returning next.""" from dailybot_cli.api_client import _MAX_LIST_PAGES @@ -804,6 +840,16 @@ def test_list_checkins_sends_summary_params(self, client: DailyBotClient) -> Non call_kwargs: dict[str, Any] = mock_get.call_args[1] assert call_kwargs["params"] == {"date": "2026-07-01", "include_summary": "true"} + def test_list_checkins_include_archived(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [], "next": None} + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_checkins(include_archived=True) + + assert mock_get.call_args[1]["params"] == {"include_archived": "true"} + def test_delete_checkin_response_uses_date_range(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 @@ -817,3 +863,446 @@ def test_delete_checkin_response_uses_date_range(self, client: DailyBotClient) - call_kwargs: dict[str, Any] = mock_request.call_args[1] assert call_kwargs["params"] == {"date_start": "2026-07-01", "date_end": "2026-07-01"} assert result["deleted_count"] == 1 + + +class TestFormsAuthoring: + def test_list_report_channels(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"uuid": "chan-1", "name": "#engineering", "platform": "slack"}, + ] + + with patch("httpx.get", return_value=mock_response) as mock_get: + result: list[dict[str, Any]] = client.list_report_channels() + + assert mock_get.call_args[0][0].endswith("/v1/report-channels/") + assert result[0]["uuid"] == "chan-1" + assert "Bearer test-token" in mock_get.call_args[1]["headers"]["Authorization"] + + def test_list_report_channels_unwraps_results(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [{"uuid": "chan-1"}]} + + with patch("httpx.get", return_value=mock_response): + result: list[dict[str, Any]] = client.list_report_channels() + + assert result == [{"uuid": "chan-1"}] + + def test_list_report_channels_unwraps_channels_key(self, client: DailyBotClient) -> None: + # The live endpoint returns {"channels": [...], "total": N}. + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "channels": [ + {"id": "C04C1GL9RH8", "name": "general", "platform": "slack", "type": "channel"}, + ], + "total": 1, + } + + with patch("httpx.get", return_value=mock_response): + result: list[dict[str, Any]] = client.list_report_channels() + + assert len(result) == 1 + assert result[0]["id"] == "C04C1GL9RH8" + assert result[0]["name"] == "general" + + def test_create_form_with_questions(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "form-uuid", "name": "Retro"} + + questions: list[dict[str, Any]] = [ + {"question_type": "text", "question": "What went well?"}, + ] + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.create_form("Retro", questions) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert mock_post.call_args[0][0].endswith("/v1/forms/create/") + assert call_kwargs["json"] == {"name": "Retro", "questions": questions} + assert result["id"] == "form-uuid" + + def test_create_form_omits_empty_questions(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "form-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_form("Retro") + + assert mock_post.call_args[1]["json"] == {"name": "Retro"} + + def test_create_form_merges_config(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "form-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_form("Retro", config={"is_anonymous": True, "command": "retro"}) + + assert mock_post.call_args[1]["json"] == { + "name": "Retro", + "is_anonymous": True, + "command": "retro", + } + + def test_update_form_config_merges_config(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "form-uuid"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_form_config("form-uuid", config={"workflow": {"enabled": False}}) + + assert mock_patch.call_args[1]["json"] == {"workflow": {"enabled": False}} + + def test_create_form_with_report_channels_inline(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "form-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_form("Retro", report_channels=["chan-1", "chan-2"]) + + assert mock_post.call_args[1]["json"] == { + "name": "Retro", + "report_channels": ["chan-1", "chan-2"], + } + + def test_update_form_config(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "form-uuid", "name": "Renamed"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_form_config("form-uuid", name="Renamed", report_channels=["chan-1"]) + + call_kwargs: dict[str, Any] = mock_patch.call_args[1] + assert mock_patch.call_args[0][0].endswith("/v1/forms/form-uuid/config/") + assert call_kwargs["json"] == {"name": "Renamed", "report_channels": ["chan-1"]} + + def test_update_form_config_sends_only_provided_fields(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "form-uuid"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_form_config("form-uuid", name="Only name") + + assert mock_patch.call_args[1]["json"] == {"name": "Only name"} + + def test_archive_form(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: + result: dict[str, Any] = client.archive_form("form-uuid") + + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith("/v1/forms/form-uuid/archive/") + assert result == {} + + def test_add_form_question(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"uuid": "q-new"} + + question: dict[str, Any] = {"question_type": "numeric", "question": "1-10?"} + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.add_form_question("form-uuid", question) + + assert mock_post.call_args[0][0].endswith("/v1/forms/form-uuid/questions/") + assert mock_post.call_args[1]["json"] == question + assert result["uuid"] == "q-new" + + def test_update_form_question(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "q1"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_form_question("form-uuid", "q1", {"question": "New text?"}) + + assert mock_patch.call_args[0][0].endswith("/v1/forms/form-uuid/questions/q1/") + assert mock_patch.call_args[1]["json"] == {"question": "New text?"} + + def test_delete_form_question(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_question("form-uuid", "q1") + + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith("/v1/forms/form-uuid/questions/q1/delete/") + + def test_reorder_form_questions(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"reordered": True} + + with patch("httpx.put", return_value=mock_response) as mock_put: + client.reorder_form_questions("form-uuid", ["q2", "q1"]) + + assert mock_put.call_args[0][0].endswith("/v1/forms/form-uuid/questions/reorder/") + assert mock_put.call_args[1]["json"] == {"question_uuids": ["q2", "q1"]} + + def test_list_form_responses_admin_filters(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.get", return_value=mock_response) as mock_get: + client.list_form_responses( + "form-uuid", + all_responses=True, + user="user-uuid", + date_from="2026-01-01", + date_to="2026-12-31", + ) + + assert mock_get.call_args[1]["params"] == { + "all": "true", + "user": "user-uuid", + "date_from": "2026-01-01", + "date_to": "2026-12-31", + } + + def test_archive_form_raises_api_error_with_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_edit_forbidden"} + + with ( + patch("httpx.request", return_value=mock_response), + pytest.raises(APIError) as exc_info, + ): + client.archive_form("form-uuid") + + assert exc_info.value.code == "form_edit_forbidden" + assert exc_info.value.status_code == 403 + + +class TestCheckinsAuthoring: + def test_create_checkin(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "followup-uuid", "name": "Standup"} + + schedule: dict[str, Any] = {"days": [1, 2, 3], "time": "09:00", "timezone": "UTC"} + participants: dict[str, Any] = {"user_uuids": ["u1"], "team_uuids": []} + questions: list[dict[str, Any]] = [{"question_type": "text", "question": "Yesterday?"}] + with patch("httpx.post", return_value=mock_response) as mock_post: + result: dict[str, Any] = client.create_checkin( + "Standup", + schedule=schedule, + participants=participants, + questions=questions, + report_channels=["chan-1"], + ) + + call_kwargs: dict[str, Any] = mock_post.call_args[1] + assert mock_post.call_args[0][0].endswith("/v1/checkins/create/") + assert call_kwargs["json"] == { + "name": "Standup", + "schedule": schedule, + "participants": participants, + "questions": questions, + "report_channels": ["chan-1"], + } + assert result["id"] == "followup-uuid" + + def test_create_checkin_minimal(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_checkin("Standup") + + assert mock_post.call_args[1]["json"] == {"name": "Standup"} + + def test_create_checkin_merges_config(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_checkin( + "Standup", config={"reminders_max_count": 3, "frequency_type": "weekly"} + ) + + assert mock_post.call_args[1]["json"] == { + "name": "Standup", + "reminders_max_count": 3, + "frequency_type": "weekly", + } + + def test_create_checkin_sends_generate_short_question(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_checkin("Standup", generate_short_question=True) + + assert mock_post.call_args[1]["json"]["generate_short_question"] is True + + def test_create_checkin_omits_generate_short_question_by_default( + self, client: DailyBotClient + ) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_checkin("Standup") + + assert "generate_short_question" not in mock_post.call_args[1]["json"] + + def test_update_checkin_config_sends_is_active_false(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "followup-uuid", "is_active": False} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_checkin_config("followup-uuid", is_active=False) + + assert mock_patch.call_args[0][0].endswith("/v1/checkins/followup-uuid/config/") + assert mock_patch.call_args[1]["json"] == {"is_active": False} + + def test_update_checkin_config_partial(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_checkin_config("followup-uuid", name="Renamed") + + assert mock_patch.call_args[1]["json"] == {"name": "Renamed"} + + def test_update_checkin_config_sends_participants(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_checkin_config("followup-uuid", participants={"team_uuids": ["t-1"]}) + + assert mock_patch.call_args[1]["json"] == {"participants": {"team_uuids": ["t-1"]}} + + def test_update_checkin_config_merges_config(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "followup-uuid"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_checkin_config( + "followup-uuid", config={"reminders_max_count": 2, "allow_past_responses": False} + ) + + assert mock_patch.call_args[1]["json"] == { + "reminders_max_count": 2, + "allow_past_responses": False, + } + + def test_archive_checkin(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: + result: dict[str, Any] = client.archive_checkin("followup-uuid") + + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith("/v1/checkins/followup-uuid/archive/") + assert result == {} + + def test_add_checkin_question(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"uuid": "q-new"} + + question: dict[str, Any] = {"question_type": "boolean", "question": "Blockers?"} + with patch("httpx.post", return_value=mock_response) as mock_post: + client.add_checkin_question("followup-uuid", question) + + assert mock_post.call_args[0][0].endswith("/v1/checkins/followup-uuid/questions/") + assert mock_post.call_args[1]["json"] == question + + def test_update_checkin_question(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"uuid": "q1"} + + with patch("httpx.patch", return_value=mock_response) as mock_patch: + client.update_checkin_question("followup-uuid", "q1", {"question": "New?"}) + + assert mock_patch.call_args[0][0].endswith("/v1/checkins/followup-uuid/questions/q1/") + assert mock_patch.call_args[1]["json"] == {"question": "New?"} + + def test_delete_checkin_question(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_checkin_question("followup-uuid", "q1") + + assert mock_request.call_args[0][0] == "DELETE" + assert mock_request.call_args[0][1].endswith( + "/v1/checkins/followup-uuid/questions/q1/delete/" + ) + + def test_reorder_checkin_questions(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"reordered": True} + + with patch("httpx.put", return_value=mock_response) as mock_put: + client.reorder_checkin_questions("followup-uuid", ["q2", "q1"]) + + assert mock_put.call_args[0][0].endswith("/v1/checkins/followup-uuid/questions/reorder/") + assert mock_put.call_args[1]["json"] == {"question_uuids": ["q2", "q1"]} + + def test_list_checkin_responses_admin_filters(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = [{"uuid": "r1"}] + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_checkin_responses( + "followup-uuid", + all_responses=True, + user="user-uuid", + date_start="2026-01-01", + date_end="2026-12-31", + ) + + assert mock_get.call_args[1]["params"] == { + "date_start": "2026-01-01", + "date_end": "2026-12-31", + "all": "true", + "user": "user-uuid", + } + + def test_archive_checkin_raises_api_error_with_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": "checkin_permission_denied", + } + + with ( + patch("httpx.request", return_value=mock_response), + pytest.raises(APIError) as exc_info, + ): + client.archive_checkin("followup-uuid") + + assert exc_info.value.code == "checkin_permission_denied" + assert exc_info.value.status_code == 403 diff --git a/tests/authoring_helpers_test.py b/tests/authoring_helpers_test.py new file mode 100644 index 0000000..fb990ee --- /dev/null +++ b/tests/authoring_helpers_test.py @@ -0,0 +1,902 @@ +"""Tests for forms & check-ins authoring helpers and display renderers.""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from dailybot_cli import display +from dailybot_cli.commands.authoring_helpers import ( + MAX_QUESTIONS, + AuthoringError, + build_checkin_config, + build_form_audience, + build_form_config, + build_question, + build_question_edit_fields, + build_question_logic, + build_variations, + build_workflow, + parse_options, + parse_participants, + parse_questions_file, + parse_schedule, + parse_workflow_states, + require_short_question, + require_short_questions, + resolve_form_config, + resolve_question_extras, + validate_command, + validate_logic, + validate_short_question, +) + + +class TestBuildQuestion: + def test_text_question(self) -> None: + result: dict[str, Any] = build_question("text", "What went well?") + assert result == { + "question_type": "text", + "question": "What went well?", + "required": True, + "is_blocker": False, + } + + def test_multiple_choice_with_options(self) -> None: + result: dict[str, Any] = build_question( + "multiple_choice", "Rating?", options=["A", "B"], required=False + ) + assert result["options"] == ["A", "B"] + assert result["required"] is False + + def test_invalid_type_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question("rating", "How many stars?") + + def test_multiple_choice_without_options_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question("multiple_choice", "Rating?") + + def test_boolean_with_options_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question("boolean", "Blockers?", options=["Yes", "No"]) + + def test_blank_text_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question("text", " ") + + def test_type_is_normalized(self) -> None: + assert build_question("TEXT", "Q?")["question_type"] == "text" + + def test_is_blocker_defaults_false(self) -> None: + assert build_question("boolean", "Blocked?")["is_blocker"] is False + + def test_is_blocker_flag_forwarded(self) -> None: + assert build_question("boolean", "Blocked?", is_blocker=True)["is_blocker"] is True + + +class TestBuildQuestionEditFields: + def test_empty_when_nothing_provided(self) -> None: + assert build_question_edit_fields(None, None, None, None) == {} + + def test_blocker_toggle_forwarded(self) -> None: + assert build_question_edit_fields(None, None, None, None, is_blocker=True) == { + "is_blocker": True + } + + def test_blocker_none_omitted(self) -> None: + assert "is_blocker" not in build_question_edit_fields("Q?", None, None, None, None) + + +class TestQuestionExtras: + def test_short_question_trimmed(self) -> None: + assert validate_short_question(" Yesterday ") == "Yesterday" + + def test_short_question_empty_rejected(self) -> None: + with pytest.raises(AuthoringError): + validate_short_question(" ") + + def test_short_question_too_long_rejected(self) -> None: + with pytest.raises(AuthoringError): + validate_short_question("x" * 513) + + def test_variations_built(self) -> None: + assert build_variations(("What happened?", " What went well? ")) == [ + "What happened?", + "What went well?", + ] + + def test_variations_empty_tuple_is_none(self) -> None: + assert build_variations(()) is None + + def test_variations_whitespace_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_variations(("ok", " ")) + + def test_variations_over_limit_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_variations(tuple(f"v{i}" for i in range(11))) + + def test_inline_jump_logic_built(self) -> None: + logic = build_question_logic(jump_if_equals="Yes", jump_to=3) + assert logic == { + "rules": { + "rules_if": [ + { + "conditions": [ + { + "operator": "is_equal_to", + "comparison_value": "Yes", + "logic_connector": "and", + } + ], + "then": {"action": "jump_to", "target": 3}, + } + ], + "rules_else": {"action": "jump_to", "target": -1}, + } + } + + def test_inline_boolean_value_coerced(self) -> None: + logic = build_question_logic(jump_if_equals="true", jump_to=2) + assert logic is not None + cond = logic["rules"]["rules_if"][0]["conditions"][0] + assert cond["comparison_value"] is True + + def test_inline_else_jump_to_forwarded(self) -> None: + logic = build_question_logic(jump_if_equals="x", jump_to=3, else_jump_to=2) + assert logic is not None + assert logic["rules"]["rules_else"] == {"action": "jump_to", "target": 2} + + def test_else_jump_to_alone_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question_logic(else_jump_to=2) + + def test_logic_missing_rules_else_rejected(self) -> None: + with pytest.raises(AuthoringError): + validate_logic( + { + "rules": { + "rules_if": [ + { + "conditions": [ + {"operator": "is_equal_to", "comparison_value": "Yes"} + ], + "then": {"action": "jump_to", "target": 2}, + } + ] + } + } + ) + + def test_extended_numeric_operator_accepted(self) -> None: + logic = { + "rules": { + "rules_if": [ + { + "conditions": [{"operator": "greater_than", "comparison_value": 5}], + "then": {"action": "jump_to", "target": 3}, + } + ], + "rules_else": {"action": "jump_to", "target": -1}, + } + } + assert validate_logic(logic) is logic + + def test_jump_to_without_value_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question_logic(jump_to=3) + + def test_jump_if_without_target_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_question_logic(jump_if_equals="Yes") + + def test_no_logic_is_none(self) -> None: + assert build_question_logic() is None + + def test_validate_logic_accepts_trigger_action(self) -> None: + logic = { + "rules": { + "rules_if": [ + { + "conditions": [{"operator": "contains", "comparison_value": "bug"}], + "then": {"action": "trigger_form", "target": "form-uuid"}, + } + ], + "rules_else": {"action": "jump_to", "target": -1}, + } + } + assert validate_logic(logic) is logic + + def test_validate_logic_bad_operator_rejected(self) -> None: + with pytest.raises(AuthoringError): + validate_logic( + { + "rules": { + "rules_if": [ + { + "conditions": [{"operator": "gt", "comparison_value": "5"}], + "then": {"action": "jump_to", "target": 1}, + } + ] + } + } + ) + + def test_validate_logic_jump_needs_int_target(self) -> None: + with pytest.raises(AuthoringError): + validate_logic( + { + "rules": { + "rules_if": [ + { + "conditions": [ + {"operator": "is_equal_to", "comparison_value": "Yes"} + ], + "then": {"action": "jump_to", "target": "nope"}, + } + ] + } + } + ) + + def test_validate_logic_requires_rules(self) -> None: + with pytest.raises(AuthoringError): + validate_logic({"foo": "bar"}) + + def test_resolve_extras_merges_all(self) -> None: + extras = resolve_question_extras( + short_question="Yesterday", + variations_raw=("What did you do?",), + jump_if_equals="No", + jump_to=-1, + ) + assert extras["short_question"] == "Yesterday" + assert extras["variations"] == ["What did you do?"] + assert extras["logic"]["rules"]["rules_if"][0]["then"]["target"] == -1 + + def test_resolve_extras_empty(self) -> None: + assert resolve_question_extras() == {} + + def test_build_question_carries_extras(self) -> None: + payload = build_question( + "text", + "What did you do?", + short_question="Yesterday", + variations=["What happened?"], + ) + assert payload["short_question"] == "Yesterday" + assert payload["variations"] == ["What happened?"] + + def test_require_short_question_ok_when_provided(self) -> None: + require_short_question("Title", False) # no raise + + def test_require_short_question_ok_when_ai_optin(self) -> None: + require_short_question(None, True) # no raise + + def test_require_short_question_raises_when_missing(self) -> None: + with pytest.raises(AuthoringError): + require_short_question(None, False) + + def test_require_short_questions_names_missing(self) -> None: + questions = [ + {"question": "a", "short_question": "A"}, + {"question": "b"}, # missing + {"question": "c"}, # missing + ] + with pytest.raises(AuthoringError, match="#2, #3"): + require_short_questions(questions, False) + + def test_require_short_questions_skipped_with_ai(self) -> None: + require_short_questions([{"question": "b"}], True) # no raise + + +class TestParseOptions: + def test_splits_and_trims(self) -> None: + assert parse_options("a, b ,c") == ["a", "b", "c"] + + def test_none_passthrough(self) -> None: + assert parse_options(None) is None + + def test_empty_becomes_none(self) -> None: + assert parse_options(" , ") is None + + +class TestParseQuestionsFile: + def test_valid_file(self, tmp_path: Path) -> None: + path: Path = tmp_path / "questions.json" + path.write_text( + json.dumps( + [ + {"question_type": "text", "question": "What went well?"}, + {"type": "multiple_choice", "label": "Rating?", "options": ["A", "B"]}, + ] + ) + ) + result: list[dict[str, Any]] = parse_questions_file(str(path)) + assert len(result) == 2 + assert result[1]["question_type"] == "multiple_choice" + assert result[1]["question"] == "Rating?" + + def test_is_blocker_read_from_file(self, tmp_path: Path) -> None: + path: Path = tmp_path / "questions.json" + path.write_text(json.dumps([{"type": "boolean", "label": "Blocked?", "is_blocker": True}])) + result: list[dict[str, Any]] = parse_questions_file(str(path)) + assert result[0]["is_blocker"] is True + + def test_extras_read_from_file(self, tmp_path: Path) -> None: + path: Path = tmp_path / "questions.json" + path.write_text( + json.dumps( + [ + { + "question_type": "text", + "question": "What did you do?", + "short_question": "Yesterday", + "variations": ["What happened?", "What went well?"], + "logic": { + "rules": { + "rules_if": [ + { + "conditions": [ + {"operator": "contains", "comparison_value": "bug"} + ], + "then": {"action": "jump_to", "target": 2}, + } + ], + "rules_else": {"action": "jump_to", "target": -1}, + } + }, + } + ] + ) + ) + result: list[dict[str, Any]] = parse_questions_file(str(path)) + assert result[0]["short_question"] == "Yesterday" + assert result[0]["variations"] == ["What happened?", "What went well?"] + assert result[0]["logic"]["rules"]["rules_if"][0]["then"]["target"] == 2 + + def test_bad_logic_in_file_rejected(self, tmp_path: Path) -> None: + path: Path = tmp_path / "questions.json" + path.write_text(json.dumps([{"type": "text", "label": "Q?", "logic": {"no": "rules"}}])) + with pytest.raises(AuthoringError): + parse_questions_file(str(path)) + + def test_missing_file_rejected(self, tmp_path: Path) -> None: + with pytest.raises(AuthoringError): + parse_questions_file(str(tmp_path / "nope.json")) + + def test_non_array_rejected(self, tmp_path: Path) -> None: + path: Path = tmp_path / "q.json" + path.write_text(json.dumps({"not": "a list"})) + with pytest.raises(AuthoringError): + parse_questions_file(str(path)) + + def test_invalid_json_rejected(self, tmp_path: Path) -> None: + path: Path = tmp_path / "q.json" + path.write_text("{not json") + with pytest.raises(AuthoringError): + parse_questions_file(str(path)) + + def test_limit_exceeded_rejected(self, tmp_path: Path) -> None: + path: Path = tmp_path / "q.json" + path.write_text( + json.dumps( + [{"question_type": "text", "question": f"Q{i}"} for i in range(MAX_QUESTIONS + 1)] + ) + ) + with pytest.raises(AuthoringError): + parse_questions_file(str(path)) + + +class TestParseSchedule: + def test_from_flags(self) -> None: + result: dict[str, Any] | None = parse_schedule( + days="1,2,3,4,5", time="09:00", timezone="UTC" + ) + assert result == {"days": [1, 2, 3, 4, 5], "time": "09:00", "timezone": "UTC"} + + def test_none_when_empty(self) -> None: + assert parse_schedule() is None + + def test_bad_day_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_schedule(days="1,9") + + def test_non_integer_day_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_schedule(days="mon,tue") + + def test_bad_time_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_schedule(time="9am") + + def test_from_file(self, tmp_path: Path) -> None: + path: Path = tmp_path / "sched.json" + path.write_text(json.dumps({"days": [0], "time": "10:30", "timezone": "UTC"})) + result: dict[str, Any] | None = parse_schedule(schedule_file=str(path)) + assert result == {"days": [0], "time": "10:30", "timezone": "UTC"} + + def test_file_with_bad_day_rejected(self, tmp_path: Path) -> None: + path: Path = tmp_path / "sched.json" + path.write_text(json.dumps({"days": [7]})) + with pytest.raises(AuthoringError): + parse_schedule(schedule_file=str(path)) + + +class TestBuildCheckinConfig: + def test_empty_when_nothing_passed(self) -> None: + assert build_checkin_config() == {} + + def test_only_provided_fields_included(self) -> None: + cfg = build_checkin_config(reminders_max_count=3, reminders_frequency_time=30) + assert cfg == {"reminders_max_count": 3, "reminders_frequency_time": 30} + + def test_toggles_forwarded(self) -> None: + cfg = build_checkin_config(allow_past_responses=False, is_anonymous=True) + assert cfg == {"allow_past_responses": False, "is_anonymous": True} + + def test_frequency_and_privacy_normalized(self) -> None: + cfg = build_checkin_config(frequency_type="WEEKLY", privacy="everyone") + assert cfg["frequency_type"] == "weekly" + assert cfg["privacy"] == "everyone" + + def test_invalid_frequency_rejected(self) -> None: + # frequency_type is weekly-only now; monthly/custom moved to + # frequency_advanced, so both are rejected here. + with pytest.raises(AuthoringError): + build_checkin_config(frequency_type="yearly") + with pytest.raises(AuthoringError): + build_checkin_config(frequency_type="monthly") + + def test_reminder_count_out_of_range_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(reminders_max_count=9) + + def test_reminder_interval_out_of_range_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(reminders_frequency_time=120) + + def test_invalid_privacy_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(privacy="nobody") + + def test_bad_start_date_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(start_on="07/05/2026") + + def test_short_intro_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(custom_template_intro="hi") + + def test_reminder_tone_normalized(self) -> None: + cfg = build_checkin_config(reminder_tone="STANDARD") + assert cfg == {"reminder_tone": "standard"} + + def test_invalid_reminder_tone_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(reminder_tone="chirpy") + + def test_ai_fields_forwarded(self) -> None: + cfg = build_checkin_config( + is_smart_checkin=True, is_intelligence_enabled=True, max_clarifying_questions=2 + ) + assert cfg == { + "is_smart_checkin": True, + "is_intelligence_enabled": True, + "max_clarifying_questions": 2, + } + + def test_max_clarifying_out_of_range_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(max_clarifying_questions=9) + + def test_advanced_frequency_and_cron_forwarded(self) -> None: + cfg = build_checkin_config(frequency_advanced="custom", frequency_cron="0 9 * * 1,3,5") + assert cfg == {"frequency_advanced": "custom", "frequency_cron": "0 9 * * 1,3,5"} + + def test_invalid_frequency_advanced_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(frequency_advanced="hourly") + + def test_malformed_cron_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_checkin_config(frequency_cron="0 9 * *") # only 4 fields + + +class TestBuildFormConfig: + def test_empty_when_nothing_passed(self) -> None: + assert build_form_config() == {} + + def test_toggles_forwarded(self) -> None: + cfg = build_form_config(is_active=False, is_anonymous=True, use_for_approval=True) + assert cfg == {"is_active": False, "is_anonymous": True, "use_for_approval": True} + + def test_command_sets_enabled(self) -> None: + cfg = build_form_config(command="Release") + assert cfg == {"command": "release", "command_enabled": True} + + def test_no_command_disables(self) -> None: + assert build_form_config(command_enabled=False) == {"command_enabled": False} + + def test_invalid_command_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_form_config(command="Bad Command!") + + +class TestFormWorkflowAndAudience: + def test_parse_states(self) -> None: + assert parse_workflow_states(("Draft:#ccc", "Done:#2ecc71")) == [ + {"label": "Draft", "color": "#ccc"}, + {"label": "Done", "color": "#2ecc71"}, + ] + + def test_state_missing_color_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_workflow_states(("Draft",)) + + def test_state_bad_color_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_workflow_states(("Draft:blue",)) + + def test_state_empty_label_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_workflow_states((":#ccc",)) + + def test_too_many_states_rejected(self) -> None: + with pytest.raises(AuthoringError): + parse_workflow_states(tuple(f"S{i}:#cccccc" for i in range(21))) + + def test_build_workflow_enabled(self) -> None: + assert build_workflow(("Draft:#ccc",), False) == { + "enabled": True, + "states": [{"label": "Draft", "color": "#ccc"}], + } + + def test_build_workflow_disabled(self) -> None: + assert build_workflow((), True) == {"enabled": False} + + def test_build_workflow_none(self) -> None: + assert build_workflow((), False) is None + + def test_build_workflow_conflict_rejected(self) -> None: + with pytest.raises(AuthoringError): + build_workflow(("Draft:#ccc",), True) + + def test_audience_simple_mode(self) -> None: + client: MagicMock = MagicMock() + assert build_form_audience("everyone", (), (), client, "can-edit") == {"mode": "everyone"} + + def test_audience_restricted_resolves(self) -> None: + client: MagicMock = MagicMock() + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = build_form_audience(None, (), ("Eng",), client, "can-see") + assert result == {"mode": "restricted", "user_uuids": [], "team_uuids": ["t-1"]} + + def test_audience_restricted_without_who_rejected(self) -> None: + client: MagicMock = MagicMock() + with pytest.raises(AuthoringError): + build_form_audience("restricted", (), (), client, "can-edit") + + def test_audience_none(self) -> None: + client: MagicMock = MagicMock() + assert build_form_audience(None, (), (), client, "can-edit") is None + + def test_validate_command_normalizes(self) -> None: + assert validate_command("Release") == "release" + + def test_resolve_form_config_full(self) -> None: + client: MagicMock = MagicMock() + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + cfg = resolve_form_config( + client, + is_anonymous=True, + states=("Draft:#ccc",), + can_edit="owner_and_admins", + change_states_teams=("Eng",), + command="release", + ) + assert cfg["is_anonymous"] is True + assert cfg["workflow"] == {"enabled": True, "states": [{"label": "Draft", "color": "#ccc"}]} + assert cfg["who_can_edit"] == {"mode": "owner_and_admins"} + assert cfg["who_can_change_states"] == { + "mode": "restricted", + "user_uuids": [], + "team_uuids": ["t-1"], + } + assert cfg["command"] == "release" + + def test_resolve_form_config_command_conflict(self) -> None: + client: MagicMock = MagicMock() + with pytest.raises(AuthoringError): + resolve_form_config(client, command="release", no_command=True) + + def test_no_approvers_clears(self) -> None: + client: MagicMock = MagicMock() + cfg = resolve_form_config(client, no_approvers=True) + assert cfg["approvers"] == {"user_uuids": [], "team_uuids": []} + + def test_no_approvers_conflict(self) -> None: + client: MagicMock = MagicMock() + client.list_users.return_value = [{"uuid": "u-1", "full_name": "Jane"}] + with pytest.raises(AuthoringError): + resolve_form_config(client, approver_users=("Jane",), no_approvers=True) + + def test_resolve_participants_by_email(self) -> None: + client: MagicMock = MagicMock() + client.list_users.return_value = [ + {"uuid": "u-1", "full_name": "Jane Doe", "email": "jane@example.com"}, + ] + result = parse_participants(("jane@example.com",), (), client) + assert result == {"user_uuids": ["u-1"]} + + +class TestParseParticipants: + def test_resolves_users_and_teams(self) -> None: + client: MagicMock = MagicMock() + client.list_users.return_value = [{"uuid": "u-1", "full_name": "Jane Doe"}] + client.list_teams.return_value = [{"uuid": "t-1", "name": "My Team"}] + + result: dict[str, Any] = parse_participants(("Jane Doe",), ("My Team",), client) + assert result == {"user_uuids": ["u-1"], "team_uuids": ["t-1"]} + + def test_empty_groups_omitted(self) -> None: + client: MagicMock = MagicMock() + assert parse_participants((), (), client) == {} + client.list_users.assert_not_called() + + def test_unknown_user_raises_authoring_error(self) -> None: + # The resolver raises ValueError; parse_participants must translate it to a + # friendly AuthoringError instead of leaking an uncaught traceback. + client: MagicMock = MagicMock() + client.list_users.return_value = [{"uuid": "u-1", "full_name": "Jane Doe"}] + with pytest.raises(AuthoringError): + parse_participants(("nobody@example.com",), (), client) + + +class TestAuthoringDisplay: + def test_report_channels_table(self) -> None: + with display.console.capture() as capture: + display.print_report_channels( + [{"uuid": "c-1", "name": "#engineering", "platform": "slack"}] + ) + output: str = capture.get() + assert "engineering" in output + assert "slack" in output + + def test_report_channels_empty(self) -> None: + with display.console.capture() as capture: + display.print_report_channels([]) + assert "No report channels" in capture.get() + + def test_form_created(self) -> None: + with display.console.capture() as capture: + display.print_form_created( + { + "id": "f-1", + "name": "Retro", + "questions": [{"uuid": "q1", "question": "Well?", "question_type": "text"}], + } + ) + output: str = capture.get() + assert "Retro" in output + assert "Well?" in output + + def test_checkin_created(self) -> None: + with display.console.capture() as capture: + display.print_checkin_created( + { + "id": "fu-1", + "name": "Standup", + "schedule": {"days": [1, 2], "time": "09:00", "timezone": "UTC"}, + "questions": [], + } + ) + output: str = capture.get() + assert "Standup" in output + assert "09:00" in output + + def test_questions_table_empty(self) -> None: + with display.console.capture() as capture: + display.print_questions_table([]) + assert "No questions" in capture.get() + + def test_questions_table_renders_choices_and_blocker(self) -> None: + with display.console.capture() as capture: + display.print_questions_table( + [ + { + "uuid": "q1", + "index": 0, + "question": "Mood?", + "question_type": "multiple_choice", + "required": True, + "is_blocker": False, + "choices": [ + {"label": "Great", "value": "great"}, + {"label": "Rough", "value": "rough"}, + ], + }, + { + "uuid": "q2", + "index": 1, + "question": "Blocked?", + "question_type": "boolean", + "required": True, + "is_blocker": True, + "choices": [], + }, + ] + ) + output: str = capture.get() + assert "Great" in output # {label,value} choice rendered by label + assert "Rough" in output + + def test_questions_table_renders_extras(self) -> None: + with display.console.capture() as capture: + display.print_questions_table( + [ + { + "uuid": "q1", + "index": 0, + "question": "What did you do yesterday?", + "question_type": "text", + "required": True, + "is_blocker": False, + "choices": [], + "short_question": "Yesterday", + "variations": ["What happened?", "What went well?"], + "logic": {"rules": {"rules_if": [{"conditions": [], "then": {}}]}}, + } + ] + ) + output: str = capture.get() + assert "report title: Yesterday" in output + assert "2 variation(s)" in output + assert "conditional logic" in output + assert "Blocker" in output # blocker column header present + + def test_form_updated_title(self) -> None: + with display.console.capture() as capture: + display.print_form_created({"id": "f-1", "name": "Retro"}, updated=True) + assert "Form Updated" in capture.get() + + def test_checkin_detail_renders_participants(self) -> None: + with display.console.capture() as capture: + display.print_checkin_detail( + { + "id": "fu-1", + "name": "Standup", + "is_archived": False, + "schedule": {"days": [1], "time": "09:00", "timezone": "UTC"}, + "participants": { + "users": [{"uuid": "u1", "name": "Jane Doe"}], + "teams": [{"uuid": "t1", "name": "Engineering"}], + }, + "report_channels": [ + { + "id": "C1", + "name": "general", + "platform": "slack", + "type": "channel", + "reporting_enabled": True, + } + ], + "questions": [ + { + "uuid": "q1", + "index": 0, + "question": "Done?", + "question_type": "text", + "required": True, + "is_blocker": False, + "choices": [], + } + ], + } + ) + output: str = capture.get() + assert "Jane Doe" in output + assert "Engineering" in output + assert "Done?" in output + assert "#general" in output # channel resolved to its name (Finding 3) + + def test_checkin_detail_renders_config_summary(self) -> None: + with display.console.capture() as capture: + display.print_checkin_detail( + { + "id": "fu-1", + "name": "Standup", + "frequency_type": "weekly", + "frequency": 1, + "reminders_max_count": 3, + "reminders_frequency_time": 30, + "allow_past_responses": False, + "privacy": "everyone", + "questions": [], + } + ) + output: str = capture.get() + assert "Frequency: weekly" in output + assert "Reminders: 3" in output + assert "no past reports" in output + assert "Privacy: everyone" in output + + def test_checkin_detail_renders_ai_and_advanced_summary(self) -> None: + with display.console.capture() as capture: + display.print_checkin_detail( + { + "id": "fu-1", + "name": "Standup", + "frequency_type": "weekly", + "frequency_advanced": "custom", + "frequency_cron": "0 9 * * 1,3,5", + "reminders_max_count": 2, + "reminder_tone": "persuasive", + "is_smart_checkin": True, + "is_intelligence_enabled": True, + "max_clarifying_questions": 2, + "questions": [], + } + ) + output: str = capture.get() + assert "Advanced: custom" in output + assert "0 9 * * 1,3,5" in output + assert "persuasive tone" in output + assert "AI: smart, intelligence, 2 clarifying Qs" in output + + def test_attached_channel_falls_back_to_id_when_unnamed(self) -> None: + with display.console.capture() as capture: + display.print_checkin_detail( + { + "id": "fu-1", + "name": "Standup", + "report_channels": [{"id": "C_LEGACY", "type": "channel"}], + "questions": [], + } + ) + # Legacy entries with no name fall back to the raw channel id. + assert "C_LEGACY" in capture.get() + + def test_forms_table_shows_archived_status(self) -> None: + with display.console.capture() as capture: + display.print_forms_table( + [ + {"id": "f-1", "name": "Active Form", "questions": []}, + {"id": "f-2", "name": "Old Form", "questions": [], "is_archived": True}, + ] + ) + output: str = capture.get() + assert "archived" in output + + def test_form_detail_renders_full_config(self) -> None: + with display.console.capture() as capture: + display.print_form_detail( + { + "id": "f-1", + "name": "Release Flow", + "is_anonymous": True, + "allow_public_responses": True, + "require_email_and_name": True, + "command_enabled": True, + "command": "release", + "who_can_edit": {"mode": "owner_and_admins"}, + "who_can_see_responses": {"mode": "restricted", "team_uuids": ["t-1"]}, + "workflow": { + "enabled": True, + "states": [ + {"key": "draft", "label": "Draft", "color": "#ccc", "order": 0}, + {"key": "done", "label": "Done", "color": "#2ecc71", "order": 1}, + ], + }, + "questions": [], + } + ) + output: str = capture.get() + assert "@dailybot release" in output + assert "anonymous" in output and "public" in output + assert "owner_and_admins" in output + assert "restricted" in output + assert "Draft" in output and "Done" in output diff --git a/tests/authoring_lifecycle_test.py b/tests/authoring_lifecycle_test.py new file mode 100644 index 0000000..15f91e9 --- /dev/null +++ b/tests/authoring_lifecycle_test.py @@ -0,0 +1,217 @@ +"""End-to-end authoring lifecycle tests (mocked httpx / client). + +Exercises the full flows a real agent would drive, plus credential parity, +error paths, and JSON mode. No network calls. +""" + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.main import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _auth() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + + +def _client() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + + +class TestFormLifecycle: + def test_full_form_authoring_flow(self, runner: CliRunner, tmp_path: Any) -> None: + qfile = tmp_path / "q.json" + qfile.write_text( + json.dumps([{"question_type": "text", "question": "Well?", "short_question": "Wins"}]) + ) + form: dict[str, Any] = { + "id": "f-1", + "name": "Retro", + "questions": [{"uuid": "q1", "question": "Well?", "question_type": "text"}], + } + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_report_channels.return_value = [{"uuid": "chan-1", "name": "#eng"}] + client.create_form.return_value = form + client.update_form_config.return_value = form + client.add_form_question.return_value = {"uuid": "q2", "question": "Rating?"} + client.reorder_form_questions.return_value = {"reordered": True} + client.update_form_question.return_value = {"uuid": "q2", "question": "Rate?"} + client.delete_form_question.return_value = {} + client.list_form_responses.return_value = [{"id": "r1"}] + client.get_form.return_value = form + client.update_form_response.return_value = {"id": "r1"} + client.archive_form.return_value = {} + + steps: list[list[str]] = [ + ["channels", "list"], + ["form", "create", "-n", "Retro", "--questions-file", str(qfile)], + [ + "form", + "questions", + "add", + "f-1", + "--type", + "text", + "--question", + "Rating?", + "--short-question", + "Rating", + ], + ["form", "questions", "reorder", "f-1", "q2", "q1"], + ["form", "questions", "edit", "f-1", "q2", "--question", "Rate?"], + ["form", "questions", "delete", "f-1", "q2", "--yes"], + ["form", "responses", "f-1", "--all", "--from", "2026-01-01"], + ["form", "update", "f-1", "r1", "-c", '{"q1": "fixed"}', "--yes"], + ["form", "archive", "f-1", "--yes"], + ] + for step in steps: + result = runner.invoke(cli, step) + assert result.exit_code == 0, f"{step} -> {result.output}" + + client.create_form.assert_called_once() + client.add_form_question.assert_called_once() + client.reorder_form_questions.assert_called_once_with("f-1", ["q2", "q1"]) + client.delete_form_question.assert_called_once_with("f-1", "q2") + client.archive_form.assert_called_once_with("f-1") + assert client.list_form_responses.call_args[1]["all_responses"] is True + # Editing another user's response goes through the same update path. + client.update_form_response.assert_called_once() + + +class TestCheckinLifecycle: + def test_full_checkin_authoring_flow(self, runner: CliRunner) -> None: + checkin: dict[str, Any] = {"id": "fu-1", "name": "Standup", "questions": []} + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_checkin.return_value = checkin + client.add_checkin_question.return_value = {"uuid": "q1"} + client.update_checkin_question.return_value = {"uuid": "q1"} + client.reorder_checkin_questions.return_value = {"reordered": True} + client.delete_checkin_question.return_value = {} + client.update_checkin_config.return_value = checkin + client.list_checkin_responses.return_value = [{"uuid": "r1"}] + client.archive_checkin.return_value = {} + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + + steps: list[list[str]] = [ + [ + "checkin", + "create", + "-n", + "Standup", + "--time", + "09:00", + "--days", + "1,2,3", + "--team", + "Eng", + ], + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "text", + "--question", + "Today?", + "--short-question", + "Today", + ], + ["checkin", "questions", "edit", "fu-1", "q1", "--question", "Focus?"], + ["checkin", "questions", "reorder", "fu-1", "q1"], + ["checkin", "questions", "delete", "fu-1", "q1", "--yes"], + ["checkin", "config", "fu-1", "--inactive"], + ["checkin", "archive", "fu-1", "--yes"], + ] + for step in steps: + result = runner.invoke(cli, step) + assert result.exit_code == 0, f"{step} -> {result.output}" + + assert client.update_checkin_config.call_args[1]["is_active"] is False + client.archive_checkin.assert_called_once_with("fu-1") + + +class TestCredentialParity: + """Both Bearer and API key must reach the authoring endpoints (client-level).""" + + def test_create_form_bearer_header(self) -> None: + client: DailyBotClient = DailyBotClient( + api_url="http://test-api.example.com", token="bearer-tok", api_key=None + ) + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "f-1"} + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_form("Retro") + headers = mock_post.call_args[1]["headers"] + assert headers["Authorization"] == "Bearer bearer-tok" + assert "X-API-KEY" not in headers + + def test_create_form_api_key_header(self) -> None: + client: DailyBotClient = DailyBotClient( + api_url="http://test-api.example.com", token=None, api_key="org-key" + ) + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "f-1"} + with patch("httpx.post", return_value=mock_response) as mock_post: + client.create_form("Retro") + headers = mock_post.call_args[1]["headers"] + assert headers["X-API-KEY"] == "org-key" + assert "Authorization" not in headers + + +class TestErrorPaths: + def test_member_all_forbidden(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.list_form_responses.side_effect = APIError( + status_code=403, detail="Forbidden", code="form_response_view_all_forbidden" + ) + result = runner.invoke(cli, ["form", "responses", "f-1", "--all"]) + assert result.exit_code == 4 + + def test_unknown_form_404(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.archive_form.side_effect = APIError( + status_code=404, detail="Not found", code="form_not_found" + ) + result = runner.invoke(cli, ["form", "archive", "f-1", "--yes"]) + assert result.exit_code == 5 + assert "Form not found" in result.output + + def test_invalid_question_type_400ish(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, ["form", "questions", "add", "f-1", "--type", "x", "--question", "Q?"] + ) + cls.return_value.add_form_question.assert_not_called() + assert result.exit_code != 0 + + +class TestJsonMode: + def test_create_form_json(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.create_form.return_value = {"id": "f-1", "name": "Retro"} + result = runner.invoke(cli, ["form", "create", "-n", "Retro", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output)["id"] == "f-1" + + def test_channels_list_json(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.list_report_channels.return_value = [{"uuid": "chan-1"}] + result = runner.invoke(cli, ["channels", "list", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output)[0]["uuid"] == "chan-1" diff --git a/tests/authoring_security_test.py b/tests/authoring_security_test.py new file mode 100644 index 0000000..331dec8 --- /dev/null +++ b/tests/authoring_security_test.py @@ -0,0 +1,137 @@ +"""Security-focused tests for authoring commands: error mapping, confirmations, +no-secret-leakage, and client-side validation running before any network call.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError +from dailybot_cli.commands.public_api_helpers import ERROR_CODE_MESSAGES +from dailybot_cli.main import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _auth() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + + +def _client() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + + +# Every new server error code must have a friendly message. +NEW_CODES: list[str] = [ + "invalid_question_type", + "multiple_choice_requires_options", + "question_label_required", + "questions_limit_exceeded", + "form_name_required", + "invalid_schedule_days", + "invalid_schedule_time", + "invalid_timezone", + "checkin_permission_denied", + "form_edit_forbidden", + "form_response_view_all_forbidden", + "form_response_edit_forbidden", + "form_not_found", + "checkin_not_found", + "question_not_found", +] + + +class TestErrorCodeMapping: + @pytest.mark.parametrize("code", NEW_CODES) + def test_code_is_mapped(self, code: str) -> None: + assert code in ERROR_CODE_MESSAGES + assert ERROR_CODE_MESSAGES[code] + + def test_403_forbidden_shows_role_message(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_form_responses.side_effect = APIError( + status_code=403, + detail="Forbidden", + code="form_response_view_all_forbidden", + ) + result = runner.invoke(cli, ["form", "responses", "form-uuid", "--all"]) + assert result.exit_code == 4 + assert "admins/owners" in result.output + assert "within your role" in result.output + + def test_403_never_implies_elevation(self) -> None: + for code in ( + "checkin_permission_denied", + "form_edit_forbidden", + "form_response_edit_forbidden", + ): + assert "can't elevate" in ERROR_CODE_MESSAGES[code] + + +class TestClientSideValidationFailsFast: + def test_invalid_question_type_never_calls_client(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "stars", + "--question", + "How many?", + "--ai-short-question", + ], + ) + cls.return_value.add_form_question.assert_not_called() + assert result.exit_code != 0 + assert "Invalid question type" in result.output + + def test_bad_schedule_never_calls_client(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["checkin", "create", "-n", "Standup", "--days", "9"]) + cls.return_value.create_checkin.assert_not_called() + assert result.exit_code != 0 + + +class TestDestructiveConfirmations: + def test_form_archive_prompts(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["form", "archive", "form-uuid"], input="n\n") + cls.return_value.archive_form.assert_not_called() + assert result.exit_code != 0 + + def test_checkin_archive_prompts(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["checkin", "archive", "fu-1"], input="n\n") + cls.return_value.archive_checkin.assert_not_called() + assert result.exit_code != 0 + + def test_form_question_delete_prompts(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, ["form", "questions", "delete", "form-uuid", "q1"], input="n\n" + ) + cls.return_value.delete_form_question.assert_not_called() + assert result.exit_code != 0 + + +class TestNoSecretLeakage: + def test_token_not_in_output(self, runner: CliRunner) -> None: + secret: str = "super-secret-bearer-token-value" + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.token = secret + client.api_key = "super-secret-api-key" + client.create_form.return_value = {"id": "f-1", "name": "Retro", "questions": []} + result = runner.invoke(cli, ["form", "create", "-n", "Retro"]) + assert result.exit_code == 0 + assert secret not in result.output + assert "super-secret-api-key" not in result.output diff --git a/tests/channels_command_test.py b/tests/channels_command_test.py new file mode 100644 index 0000000..5739ce2 --- /dev/null +++ b/tests/channels_command_test.py @@ -0,0 +1,83 @@ +"""Tests for the `channels` command group.""" + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError +from dailybot_cli.main import cli + +CHANNELS_PAYLOAD: list[dict[str, Any]] = [ + {"uuid": "chan-1", "name": "#engineering", "platform": "slack"}, + {"uuid": "chan-2", "name": "#general", "platform": "slack"}, +] + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +class TestChannelsCommand: + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_channels_list_table( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_report_channels.return_value = CHANNELS_PAYLOAD + + result = runner.invoke(cli, ["channels", "list"]) + assert result.exit_code == 0 + assert "engineering" in result.output + assert "chan-1" in result.output + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_channels_list_json( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_report_channels.return_value = CHANNELS_PAYLOAD + + result = runner.invoke(cli, ["channels", "list", "--json"]) + assert result.exit_code == 0 + payload: list[dict[str, Any]] = json.loads(result.output) + assert payload[0]["uuid"] == "chan-1" + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + def test_channels_list_not_authenticated( + self, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = None + result = runner.invoke(cli, ["channels", "list"]) + assert result.exit_code == 3 + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_channels_list_auth_failure( + self, + mock_client_cls: MagicMock, + mock_get_auth: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_auth.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_report_channels.side_effect = APIError(status_code=401, detail="Nope") + + result = runner.invoke(cli, ["channels", "list"]) + assert result.exit_code == 3 + assert "dailybot login" in result.output diff --git a/tests/checkin_authoring_test.py b/tests/checkin_authoring_test.py new file mode 100644 index 0000000..77df3df --- /dev/null +++ b/tests/checkin_authoring_test.py @@ -0,0 +1,454 @@ +"""Tests for check-in authoring commands (create/config/archive/questions).""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError +from dailybot_cli.main import cli + +CHECKIN_PAYLOAD: dict[str, Any] = { + "id": "fu-1", + "name": "Standup", + "schedule": {"days": [1, 2, 3, 4, 5], "time": "09:00", "timezone": "UTC"}, + "questions": [], +} + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _auth() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + + +def _client() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + + +class TestCheckinCreate: + def test_create_with_schedule(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_checkin.return_value = CHECKIN_PAYLOAD + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = runner.invoke( + cli, + [ + "checkin", + "create", + "-n", + "Standup", + "--time", + "09:00", + "--days", + "1,2,3,4,5", + "--timezone", + "UTC", + "--team", + "Eng", + ], + ) + assert result.exit_code == 0 + schedule = client.create_checkin.call_args[1]["schedule"] + assert schedule == {"days": [1, 2, 3, 4, 5], "time": "09:00", "timezone": "UTC"} + + def test_create_forwards_config_flags(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_checkin.return_value = CHECKIN_PAYLOAD + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = runner.invoke( + cli, + [ + "checkin", + "create", + "-n", + "Standup", + "--team", + "Eng", + "--frequency", + "weekly", + "--reminders", + "2", + "--no-future", + ], + ) + assert result.exit_code == 0 + cfg = client.create_checkin.call_args[1]["config"] + assert cfg == { + "frequency_type": "weekly", + "reminders_max_count": 2, + "allow_future_responses": False, + } + + def test_create_without_participants_is_rejected(self, runner: CliRunner) -> None: + # Non-interactive create with no --user/--team must error, never create empty. + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + result = runner.invoke( + cli, ["checkin", "create", "-n", "Standup", "--time", "09:00", "--days", "1"] + ) + client.create_checkin.assert_not_called() + assert result.exit_code != 0 + assert "at least one participant" in result.output + + def test_create_resolves_participants(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_checkin.return_value = CHECKIN_PAYLOAD + client.list_users.return_value = [{"uuid": "u-1", "full_name": "Jane Doe"}] + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = runner.invoke( + cli, + ["checkin", "create", "-n", "Standup", "--user", "Jane Doe", "--team", "Eng"], + ) + assert result.exit_code == 0 + participants = client.create_checkin.call_args[1]["participants"] + assert participants == {"user_uuids": ["u-1"], "team_uuids": ["t-1"]} + + def test_create_bad_time_fails_fast(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["checkin", "create", "-n", "Standup", "--time", "9am"]) + cls.return_value.create_checkin.assert_not_called() + assert result.exit_code != 0 + assert "HH:MM" in result.output + + +class TestCheckinConfig: + def test_config_inactive_sends_false(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--inactive"]) + assert result.exit_code == 0 + assert client.update_checkin_config.call_args[1]["is_active"] is False + + def test_config_requires_a_field(self, runner: CliRunner) -> None: + with _auth(), _client(): + result = runner.invoke(cli, ["checkin", "config", "fu-1"]) + assert result.exit_code != 0 + assert "Nothing to edit" in result.output + + def test_config_schedule_time(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--time", "10:00"]) + assert result.exit_code == 0 + assert client.update_checkin_config.call_args[1]["schedule"] == {"time": "10:00"} + + def test_config_participants_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--team", "Eng"]) + assert result.exit_code == 0 + assert client.update_checkin_config.call_args[1]["participants"] == {"team_uuids": ["t-1"]} + + def test_config_reminders_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + result = runner.invoke( + cli, + ["checkin", "config", "fu-1", "--reminders", "3", "--reminder-interval", "30"], + ) + assert result.exit_code == 0 + cfg = client.update_checkin_config.call_args[1]["config"] + assert cfg == {"reminders_max_count": 3, "reminders_frequency_time": 30} + + def test_config_behavior_toggles_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + result = runner.invoke( + cli, ["checkin", "config", "fu-1", "--no-past", "--privacy", "everyone"] + ) + assert result.exit_code == 0 + cfg = client.update_checkin_config.call_args[1]["config"] + assert cfg == {"allow_past_responses": False, "privacy": "everyone"} + + def test_config_ai_and_advanced_flags_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.return_value = CHECKIN_PAYLOAD + result = runner.invoke( + cli, + [ + "checkin", + "config", + "fu-1", + "--smart", + "--intelligence", + "--max-clarifying", + "2", + "--reminder-tone", + "standard", + "--frequency-advanced", + "custom", + "--cron", + "0 9 * * 1,3,5", + ], + ) + assert result.exit_code == 0 + cfg = client.update_checkin_config.call_args[1]["config"] + assert cfg == { + "is_smart_checkin": True, + "is_intelligence_enabled": True, + "max_clarifying_questions": 2, + "reminder_tone": "standard", + "frequency_advanced": "custom", + "frequency_cron": "0 9 * * 1,3,5", + } + + def test_intelligence_dependency_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.side_effect = APIError( + status_code=400, + detail="AI intelligence requires smart mode.", + code="intelligence_requires_smart_checkin", + ) + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--intelligence"]) + assert result.exit_code != 0 + assert "--smart" in result.output + + def test_config_invalid_reminder_count_fails_fast(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--reminders", "9"]) + cls.return_value.update_checkin_config.assert_not_called() + assert result.exit_code != 0 + assert "between 0 and 5" in result.output + + def test_unknown_field_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.side_effect = APIError( + status_code=400, detail="Unknown field(s): bogus", code="unknown_field" + ) + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--reminders", "3"]) + assert result.exit_code != 0 + assert "upgrade" in result.output # maps to the "run dailybot upgrade" hint + + def test_server_zero_participant_error_is_friendly(self, runner: CliRunner) -> None: + # Server-side backstop (checkin_requires_participant) maps to guidance. + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + client.update_checkin_config.side_effect = APIError( + status_code=400, + detail="A check-in must have at least one participant.", + code="checkin_requires_participant", + ) + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--team", "Eng"]) + assert result.exit_code != 0 + assert "--user and/or --team" in result.output + + +class TestCheckinArchive: + def test_archive_confirmed(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.archive_checkin.return_value = {} + result = runner.invoke(cli, ["checkin", "archive", "fu-1"], input="y\n") + assert result.exit_code == 0 + client.archive_checkin.assert_called_once_with("fu-1") + + def test_archive_aborts(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + result = runner.invoke(cli, ["checkin", "archive", "fu-1"], input="n\n") + assert result.exit_code != 0 + client.archive_checkin.assert_not_called() + + +class TestCheckinQuestions: + def test_add(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_checkin_question.return_value = {"uuid": "q-new", "question": "Blockers?"} + result = runner.invoke( + cli, + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "boolean", + "--question", + "Blockers?", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + payload = client.add_checkin_question.call_args[0][1] + assert payload["question_type"] == "boolean" + # --ai-short-question forwards the server opt-in on the question payload. + assert payload["generate_short_question"] is True + + def test_add_with_blocker(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_checkin_question.return_value = {"uuid": "q-new"} + result = runner.invoke( + cli, + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "boolean", + "--question", + "Any blockers?", + "--blocker", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + assert client.add_checkin_question.call_args[0][1]["is_blocker"] is True + + def test_add_with_short_question_and_variations(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_checkin_question.return_value = {"uuid": "q-new"} + result = runner.invoke( + cli, + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "text", + "--question", + "What did you do?", + "--short-question", + "Yesterday", + "--variation", + "What happened?", + "--variation", + "What went well?", + ], + ) + assert result.exit_code == 0 + payload = client.add_checkin_question.call_args[0][1] + assert payload["short_question"] == "Yesterday" + assert payload["variations"] == ["What happened?", "What went well?"] + + def test_add_with_inline_jump_logic(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_checkin_question.return_value = {"uuid": "q-new"} + result = runner.invoke( + cli, + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "boolean", + "--question", + "Any blockers?", + "--jump-if-equals", + "No", + "--jump-to", + "-1", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + logic = client.add_checkin_question.call_args[0][1]["logic"] + rule = logic["rules"]["rules_if"][0] + assert rule["conditions"][0]["comparison_value"] == "No" + assert rule["then"] == {"action": "jump_to", "target": -1} + + def test_add_jump_to_without_value_fails_fast(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, + [ + "checkin", + "questions", + "add", + "fu-1", + "--type", + "text", + "--question", + "Q?", + "--jump-to", + "2", + "--ai-short-question", + ], + ) + cls.return_value.add_checkin_question.assert_not_called() + assert result.exit_code != 0 + assert "--jump-if-equals" in result.output + + def test_add_without_short_question_is_rejected(self, runner: CliRunner) -> None: + # A report title is required unless --ai-short-question opts into AI titling. + with _auth(), _client() as cls: + result = runner.invoke( + cli, + ["checkin", "questions", "add", "fu-1", "--type", "text", "--question", "Q?"], + ) + cls.return_value.add_checkin_question.assert_not_called() + assert result.exit_code != 0 + assert "report title" in result.output + + def test_anonymous_irreversible_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_config.side_effect = APIError( + status_code=400, + detail="An anonymous check-in cannot be made non-anonymous.", + code="anonymous_irreversible", + ) + result = runner.invoke(cli, ["checkin", "config", "fu-1", "--no-anonymous"]) + assert result.exit_code != 0 + assert "non-anonymous" in result.output + + def test_edit_blocker_toggle(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_question.return_value = {"uuid": "q1"} + result = runner.invoke(cli, ["checkin", "questions", "edit", "fu-1", "q1", "--blocker"]) + assert result.exit_code == 0 + assert client.update_checkin_question.call_args[0][2] == {"is_blocker": True} + + def test_edit(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_checkin_question.return_value = {"uuid": "q1"} + result = runner.invoke( + cli, + ["checkin", "questions", "edit", "fu-1", "q1", "--question", "New?"], + ) + assert result.exit_code == 0 + assert client.update_checkin_question.call_args[0][2] == {"question": "New?"} + + def test_delete_confirmed(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.delete_checkin_question.return_value = {} + result = runner.invoke( + cli, ["checkin", "questions", "delete", "fu-1", "q1"], input="y\n" + ) + assert result.exit_code == 0 + client.delete_checkin_question.assert_called_once_with("fu-1", "q1") + + def test_reorder(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.reorder_checkin_questions.return_value = {"reordered": True} + result = runner.invoke(cli, ["checkin", "questions", "reorder", "fu-1", "q2", "q1"]) + assert result.exit_code == 0 + assert client.reorder_checkin_questions.call_args[0][1] == ["q2", "q1"] diff --git a/tests/form_authoring_test.py b/tests/form_authoring_test.py new file mode 100644 index 0000000..9d1a5ec --- /dev/null +++ b/tests/form_authoring_test.py @@ -0,0 +1,556 @@ +"""Tests for form authoring commands (create/edit/archive/questions).""" + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.api_client import APIError +from dailybot_cli.main import cli + +FORM_PAYLOAD: dict[str, Any] = { + "id": "form-uuid", + "name": "Retro", + "questions": [ + {"uuid": "q1", "question": "What went well?", "question_type": "text", "required": True}, + ], +} + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _auth() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + + +def _client() -> Any: + return patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + + +class TestFormCreate: + def test_create_minimal(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.create_form.return_value = FORM_PAYLOAD + result = runner.invoke(cli, ["form", "create", "--name", "Retro"]) + assert result.exit_code == 0 + assert "Retro" in result.output + + def test_create_with_questions_file(self, runner: CliRunner, tmp_path: Any) -> None: + qfile = tmp_path / "q.json" + qfile.write_text( + json.dumps([{"question_type": "text", "question": "Well?", "short_question": "Wins"}]) + ) + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--questions-file", str(qfile)] + ) + assert result.exit_code == 0 + sent_questions = client.create_form.call_args[0][1] + assert sent_questions[0]["question_type"] == "text" + + def test_create_questions_file_missing_short_question_is_rejected( + self, runner: CliRunner, tmp_path: Any + ) -> None: + qfile = tmp_path / "q.json" + qfile.write_text(json.dumps([{"question_type": "text", "question": "Well?"}])) + with _auth(), _client() as cls: + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--questions-file", str(qfile)] + ) + cls.return_value.create_form.assert_not_called() + assert result.exit_code != 0 + assert "report title" in result.output + + def test_create_questions_file_ai_short_question_allows_missing( + self, runner: CliRunner, tmp_path: Any + ) -> None: + qfile = tmp_path / "q.json" + qfile.write_text(json.dumps([{"question_type": "text", "question": "Well?"}])) + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, + [ + "form", + "create", + "-n", + "Retro", + "--questions-file", + str(qfile), + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + # --ai-short-question forwards the request-level server opt-in. + assert client.create_form.call_args[1]["generate_short_question"] is True + + def test_short_question_required_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_form_question.side_effect = APIError( + status_code=400, + detail="Question at index 0: a short_question is required.", + code="short_question_required", + ) + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "text", + "--question", + "Q?", + "--short-question", + "T", + ], + ) + assert result.exit_code != 0 + assert "--ai-short-question" in result.output + + def test_create_with_report_channel_inline(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--report-channel", "chan-1"] + ) + assert result.exit_code == 0 + # Report channels go directly in the create body — no follow-up config call. + assert client.create_form.call_args[1]["report_channels"] == ["chan-1"] + client.update_form_config.assert_not_called() + + def test_create_bogus_channel_shows_friendly_error(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.side_effect = APIError( + status_code=400, + detail="Report channel 'NOT_REAL' not found in organization.", + code="report_channel_not_found", + ) + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--report-channel", "NOT_REAL"] + ) + assert result.exit_code != 0 + assert "dailybot channels list" in result.output + + def test_create_invalid_question_type_fails_fast( + self, runner: CliRunner, tmp_path: Any + ) -> None: + qfile = tmp_path / "q.json" + qfile.write_text(json.dumps([{"question_type": "rating", "question": "Stars?"}])) + with _auth(), _client() as cls: + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--questions-file", str(qfile)] + ) + cls.return_value.create_form.assert_not_called() + assert result.exit_code != 0 + assert "Invalid question type" in result.output + + +class TestFormEdit: + def test_edit_name(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.return_value = FORM_PAYLOAD + result = runner.invoke(cli, ["form", "edit", "form-uuid", "--name", "New"]) + assert result.exit_code == 0 + assert client.update_form_config.call_args[1]["name"] == "New" + + def test_edit_requires_a_field(self, runner: CliRunner) -> None: + with _auth(), _client(): + result = runner.invoke(cli, ["form", "edit", "form-uuid"]) + assert result.exit_code != 0 + assert "Nothing to edit" in result.output + + +class TestFormConfig: + def test_create_forwards_full_config(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.create_form.return_value = FORM_PAYLOAD + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + result = runner.invoke( + cli, + [ + "form", + "create", + "-n", + "Release", + "--state", + "Draft:#cccccc", + "--state", + "Done:#2ecc71", + "--anonymous", + "--command", + "release", + "--can-edit", + "owner_and_admins", + "--can-see", + "restricted", + "--can-see-team", + "Eng", + ], + ) + assert result.exit_code == 0 + cfg = client.create_form.call_args[1]["config"] + assert cfg["is_anonymous"] is True + assert cfg["command"] == "release" and cfg["command_enabled"] is True + assert cfg["workflow"]["states"][1] == {"label": "Done", "color": "#2ecc71"} + assert cfg["who_can_edit"] == {"mode": "owner_and_admins"} + assert cfg["who_can_see_responses"] == { + "mode": "restricted", + "user_uuids": [], + "team_uuids": ["t-1"], + } + + def test_config_forwards_partial(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, ["form", "config", "form-uuid", "--no-workflow", "--no-command"] + ) + assert result.exit_code == 0 + cfg = client.update_form_config.call_args[1]["config"] + assert cfg == {"workflow": {"enabled": False}, "command_enabled": False} + + def test_config_no_approvers_clears(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.return_value = FORM_PAYLOAD + result = runner.invoke(cli, ["form", "config", "form-uuid", "--no-approvers"]) + assert result.exit_code == 0 + cfg = client.update_form_config.call_args[1]["config"] + assert cfg == {"approvers": {"user_uuids": [], "team_uuids": []}} + + def test_config_approver_by_email(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.return_value = FORM_PAYLOAD + client.list_users.return_value = [ + {"uuid": "u-1", "full_name": "Jane", "email": "jane@example.com"}, + ] + result = runner.invoke( + cli, ["form", "config", "form-uuid", "--approver-user", "jane@example.com"] + ) + assert result.exit_code == 0 + cfg = client.update_form_config.call_args[1]["config"] + assert cfg["approvers"] == {"user_uuids": ["u-1"], "team_uuids": []} + + def test_config_nothing_to_edit(self, runner: CliRunner) -> None: + with _auth(), _client(): + result = runner.invoke(cli, ["form", "config", "form-uuid"]) + assert result.exit_code != 0 + assert "Nothing to edit" in result.output + + def test_config_bad_state_color_fails_fast(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke(cli, ["form", "config", "form-uuid", "--state", "Bad:blue"]) + cls.return_value.update_form_config.assert_not_called() + assert result.exit_code != 0 + assert "hex color" in result.output + + def test_too_many_report_channels_fails_fast(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, + [ + "form", + "config", + "form-uuid", + "--report-channel", + "c1", + "--report-channel", + "c2", + "--report-channel", + "c3", + "--report-channel", + "c4", + ], + ) + cls.return_value.update_form_config.assert_not_called() + assert result.exit_code != 0 + assert "limit is 3" in result.output + + def test_public_url_shown_after_config(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.return_value = { + "id": "form-uuid", + "name": "Retro", + "allow_public_responses": True, + "public_url": "https://app.dailybot.com/forms/form-uuid/responses/create/", + "questions": [], + } + result = runner.invoke(cli, ["form", "config", "form-uuid", "--public"]) + assert result.exit_code == 0 + assert "https://app.dailybot.com/forms/form-uuid/responses/create/" in result.output + + def test_command_already_exists_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.side_effect = APIError( + status_code=400, + detail="Command already used.", + code="command_already_exists", + ) + result = runner.invoke(cli, ["form", "config", "form-uuid", "--command", "release"]) + assert result.exit_code != 0 + assert "already used by another form" in result.output + + def test_unknown_field_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_config.side_effect = APIError( + status_code=400, detail="Unknown field(s): x", code="unknown_field" + ) + result = runner.invoke(cli, ["form", "config", "form-uuid", "--anonymous"]) + assert result.exit_code != 0 + assert "upgrade" in result.output + + +class TestFormArchive: + def test_archive_confirmed(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.archive_form.return_value = {} + result = runner.invoke(cli, ["form", "archive", "form-uuid"], input="y\n") + assert result.exit_code == 0 + client.archive_form.assert_called_once_with("form-uuid") + + def test_archive_aborts_without_confirm(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + result = runner.invoke(cli, ["form", "archive", "form-uuid"], input="n\n") + assert result.exit_code != 0 + client.archive_form.assert_not_called() + + def test_archive_yes_skips_prompt(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.archive_form.return_value = {} + result = runner.invoke(cli, ["form", "archive", "form-uuid", "--yes"]) + assert result.exit_code == 0 + + +class TestFormQuestions: + def test_list(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + cls.return_value.get_form.return_value = FORM_PAYLOAD + result = runner.invoke(cli, ["form", "questions", "list", "form-uuid"]) + assert result.exit_code == 0 + assert "What went well?" in result.output + + def test_add_text(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_form_question.return_value = {"uuid": "q-new", "question": "New?"} + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "text", + "--question", + "New?", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + assert client.add_form_question.call_args[0][1]["question_type"] == "text" + + def test_add_with_logic_file(self, runner: CliRunner, tmp_path: Any) -> None: + logic_file = tmp_path / "logic.json" + logic_file.write_text( + '{"rules": {"rules_if": [{"conditions": [{"operator": "is_equal_to", ' + '"comparison_value": "No"}], "then": {"action": "trigger_form", ' + '"target": "other-form"}}], "rules_else": {"action": "jump_to", ' + '"target": -1}}}' + ) + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_form_question.return_value = {"uuid": "q-new"} + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "boolean", + "--question", + "Ship it?", + "--short-question", + "Ship", + "--logic-file", + str(logic_file), + ], + ) + assert result.exit_code == 0 + payload = client.add_form_question.call_args[0][1] + assert payload["short_question"] == "Ship" + assert payload["logic"]["rules"]["rules_if"][0]["then"]["action"] == "trigger_form" + + def test_add_with_blocker(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.add_form_question.return_value = {"uuid": "q-new", "question": "Blocked?"} + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "boolean", + "--question", + "Blocked?", + "--blocker", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + assert client.add_form_question.call_args[0][1]["is_blocker"] is True + + def test_add_multiple_choice_needs_options(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + result = runner.invoke( + cli, + [ + "form", + "questions", + "add", + "form-uuid", + "--type", + "multiple_choice", + "--question", + "Rating?", + "--ai-short-question", + ], + ) + cls.return_value.add_form_question.assert_not_called() + assert result.exit_code != 0 + assert "require at least one option" in result.output + + def test_edit_requires_a_field(self, runner: CliRunner) -> None: + with _auth(), _client(): + result = runner.invoke(cli, ["form", "questions", "edit", "form-uuid", "q1"]) + assert result.exit_code != 0 + assert "Nothing to edit" in result.output + + def test_edit_question_text(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.update_form_question.return_value = {"uuid": "q1", "question": "Reworded?"} + result = runner.invoke( + cli, + ["form", "questions", "edit", "form-uuid", "q1", "--question", "Reworded?"], + ) + assert result.exit_code == 0 + assert client.update_form_question.call_args[0][2] == {"question": "Reworded?"} + + def test_delete_confirmed(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.delete_form_question.return_value = {} + result = runner.invoke( + cli, ["form", "questions", "delete", "form-uuid", "q1"], input="y\n" + ) + assert result.exit_code == 0 + client.delete_form_question.assert_called_once_with("form-uuid", "q1") + + def test_reorder(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.reorder_form_questions.return_value = {"reordered": True} + result = runner.invoke(cli, ["form", "questions", "reorder", "form-uuid", "q2", "q1"]) + assert result.exit_code == 0 + assert client.reorder_form_questions.call_args[0][1] == ["q2", "q1"] + + def test_reorder_incomplete_error_is_friendly(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.reorder_form_questions.side_effect = APIError( + status_code=400, + detail="question_uuids must include ALL question UUIDs for this resource.", + code="question_uuids_incomplete", + ) + result = runner.invoke(cli, ["form", "questions", "reorder", "form-uuid", "q1"]) + assert result.exit_code != 0 + assert "ALL" in result.output + + +class TestFormListArchived: + def test_include_archived_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_forms.return_value = [ + {"id": "f1", "name": "Old", "is_archived": True, "questions": []} + ] + result = runner.invoke(cli, ["form", "list", "--include-archived"]) + assert result.exit_code == 0 + assert client.list_forms.call_args[1]["include_archived"] is True + + def test_archived_hidden_by_default(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_forms.return_value = [] + result = runner.invoke(cli, ["form", "list"]) + assert result.exit_code == 0 + assert client.list_forms.call_args[1]["include_archived"] is False + + +class TestFormResponsesExtended: + def test_all_and_dates_forwarded(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_form_responses.return_value = [] + client.get_form.return_value = FORM_PAYLOAD + result = runner.invoke( + cli, + [ + "form", + "responses", + "form-uuid", + "--all", + "--user", + "user-uuid", + "--from", + "2026-01-01", + "--to", + "2026-06-30", + ], + ) + assert result.exit_code == 0 + kwargs = client.list_form_responses.call_args[1] + assert kwargs["all_responses"] is True + assert kwargs["user"] == "user-uuid" + assert kwargs["date_from"] == "2026-01-01" + assert kwargs["date_to"] == "2026-06-30" + + def test_member_all_forbidden(self, runner: CliRunner) -> None: + with _auth(), _client() as cls: + client: MagicMock = cls.return_value + client.list_form_responses.side_effect = APIError( + status_code=403, + detail="Forbidden", + code="form_response_view_all_forbidden", + ) + result = runner.invoke(cli, ["form", "responses", "form-uuid", "--all"]) + assert result.exit_code == 4 diff --git a/tests/interactive_builder_test.py b/tests/interactive_builder_test.py new file mode 100644 index 0000000..5e6cfd5 --- /dev/null +++ b/tests/interactive_builder_test.py @@ -0,0 +1,142 @@ +"""Tests for the interactive question builder and its --interactive wiring.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from dailybot_cli.commands.authoring_helpers import ( + AuthoringError, + build_questions_interactively, +) +from dailybot_cli.main import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _prompt(value: Any) -> MagicMock: + """A questionary prompt object whose .ask() returns value.""" + prompt: MagicMock = MagicMock() + prompt.ask.return_value = value + return prompt + + +class TestBuildQuestionsInteractively: + @patch("dailybot_cli.commands.authoring_helpers.sys.stdin.isatty", return_value=True) + @patch("dailybot_cli.commands.authoring_helpers.questionary") + def test_builds_two_questions(self, mock_q: MagicMock, _isatty: MagicMock) -> None: + # Q1: text, required, add another -> yes. Q2: multiple_choice + options, stop. + mock_q.select.side_effect = [_prompt("text"), _prompt("multiple_choice")] + # text() order per question: question text, (options for mc), short title. + mock_q.text.side_effect = [ + _prompt("What went well?"), + _prompt("Wins"), # Q1 short report title + _prompt("Rating?"), + _prompt("A, B, C"), + _prompt("Rating"), # Q2 short report title + ] + mock_q.confirm.side_effect = [ + _prompt(True), # Q1 required? + _prompt(False), # Q1 blocker? + _prompt(True), # add another? + _prompt(True), # Q2 required? + _prompt(False), # Q2 blocker? + _prompt(False), # add another? -> stop + ] + + result: list[dict[str, Any]] = build_questions_interactively() + assert len(result) == 2 + assert result[0]["question_type"] == "text" + assert result[0]["is_blocker"] is False + assert result[0]["short_question"] == "Wins" + assert result[1]["question_type"] == "multiple_choice" + assert result[1]["options"] == ["A", "B", "C"] + assert result[1]["short_question"] == "Rating" + + @patch("dailybot_cli.commands.authoring_helpers.sys.stdin.isatty", return_value=True) + @patch("dailybot_cli.commands.authoring_helpers.questionary") + def test_ai_short_question_skips_title_prompt( + self, mock_q: MagicMock, _isatty: MagicMock + ) -> None: + mock_q.select.side_effect = [_prompt("text")] + mock_q.text.side_effect = [_prompt("What went well?")] # no short-title prompt + mock_q.confirm.side_effect = [ + _prompt(True), # required? + _prompt(False), # blocker? + _prompt(False), # add another? -> stop + ] + result: list[dict[str, Any]] = build_questions_interactively(ai_short_question=True) + assert len(result) == 1 + assert "short_question" not in result[0] + + @patch("dailybot_cli.commands.authoring_helpers.sys.stdin.isatty", return_value=True) + @patch("dailybot_cli.commands.authoring_helpers.questionary") + def test_cancel_returns_empty(self, mock_q: MagicMock, _isatty: MagicMock) -> None: + mock_q.select.return_value = _prompt(None) # user pressed Esc + assert build_questions_interactively() == [] + + @patch("dailybot_cli.commands.authoring_helpers.sys.stdin.isatty", return_value=False) + def test_non_tty_raises(self, _isatty: MagicMock) -> None: + with pytest.raises(AuthoringError): + build_questions_interactively() + + +class TestInteractiveWiring: + @patch("dailybot_cli.commands.form.build_questions_interactively") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_form_create_interactive( + self, + mock_client_cls: MagicMock, + _auth: MagicMock, + mock_builder: MagicMock, + runner: CliRunner, + ) -> None: + mock_builder.return_value = [{"question_type": "text", "question": "Q?"}] + client: MagicMock = mock_client_cls.return_value + client.create_form.return_value = {"id": "f-1", "name": "Retro", "questions": []} + + result = runner.invoke( + cli, ["form", "create", "-n", "Retro", "--interactive", "--ai-short-question"] + ) + assert result.exit_code == 0 + mock_builder.assert_called_once() + assert client.create_form.call_args[0][1] == [{"question_type": "text", "question": "Q?"}] + + @patch("dailybot_cli.commands.checkin.build_questions_interactively") + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_checkin_create_interactive( + self, + mock_client_cls: MagicMock, + _auth: MagicMock, + mock_builder: MagicMock, + runner: CliRunner, + ) -> None: + mock_builder.return_value = [{"question_type": "boolean", "question": "Blockers?"}] + client: MagicMock = mock_client_cls.return_value + client.create_checkin.return_value = {"id": "fu-1", "name": "Standup", "questions": []} + client.list_teams.return_value = [{"uuid": "t-1", "name": "Eng"}] + + result = runner.invoke( + cli, + [ + "checkin", + "create", + "-n", + "Standup", + "--interactive", + "--team", + "Eng", + "--ai-short-question", + ], + ) + assert result.exit_code == 0 + mock_builder.assert_called_once() + assert client.create_checkin.call_args[1]["questions"] == [ + {"question_type": "boolean", "question": "Blockers?"} + ] diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index f3bf5ef..121e337 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -866,7 +866,14 @@ def test_form_responses_latest( 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) + mock_client.list_form_responses.assert_called_once_with( + "form-uuid-1", + state=None, + all_responses=False, + user=None, + date_from=None, + date_to=None, + ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") @@ -882,7 +889,14 @@ def test_form_responses_state_filter( 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") + mock_client.list_form_responses.assert_called_once_with( + "form-uuid-1", + state="qa", + all_responses=False, + user=None, + date_from=None, + date_to=None, + ) @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") @@ -1132,19 +1146,31 @@ def test_checkin_show_json( self, mock_client_cls: MagicMock, mock_get_auth: MagicMock, runner: CliRunner ) -> None: client: MagicMock = self._client(mock_client_cls, mock_get_auth) - client.get_checkin.return_value = { - "uuid": "f1", + client.get_checkin_detail.return_value = { + "id": "f1", "name": "Standup", - "template": {"uuid": "t1"}, - } - client.get_template.return_value = { - "questions": [{"uuid": "q1", "question": "Done?", "question_type": "text_field"}] + "is_archived": False, + "schedule": {"days": [1, 2, 3], "time": "09:00", "timezone": "UTC"}, + "questions": [ + { + "uuid": "q1", + "index": 0, + "question": "Done?", + "question_type": "text", + "required": True, + "is_blocker": False, + "choices": [], + } + ], + "participants": {"users": [{"uuid": "u1", "name": "Jane Doe"}], "teams": []}, + "report_channels": [{"id": "C1", "type": "channel", "reporting_enabled": True}], } result = runner.invoke(cli, ["checkin", "show", "f1", "--json"]) assert result.exit_code == 0 payload: dict[str, Any] = json.loads(result.output) assert payload["questions"][0]["uuid"] == "q1" - client.get_template.assert_called_once_with("t1", followup_uuid="f1") + assert payload["participants"]["users"][0]["name"] == "Jane Doe" + client.get_checkin_detail.assert_called_once_with("f1") @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth") @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient")